feat(cli,engine): data-only project tier at the load boundary (#241 T1-T2)

- ProjectProvenance namespace/dylib_sha256 widen to Option (Tier-1
  additive serde): a data-only run stamps commit-only provenance; old
  string-field manifests keep parsing.
- The load boundary tier-selects: a [nodes] pointer list in Aura.toml
  (one entry honored; MultiCrate / pointer-context refusals) loads the
  pointed-at crate; a root Cargo.toml stays the pre-#241 native project,
  unchanged; neither means data-only (std vocabulary, project-local
  runs/, both-tier provenance).
- ProjectEnv splits into directory facts + Option<NativeEnv>; the crate
  pipeline (metadata -> artifact -> staleness -> ABI -> charter)
  relocates byte-identical into load_crate.
- e2e fixtures: dataonly-project, multicrate-project,
  nested-nodes-project (a pointer crate resolves and stamps its
  namespace end to end).

refs #241
This commit is contained in:
2026-07-12 14:42:57 +02:00
parent 43a427bfed
commit 1f6b55a8e1
11 changed files with 386 additions and 24 deletions
+128 -20
View File
@@ -25,6 +25,8 @@ use std::time::SystemTime;
pub struct AuraToml { pub struct AuraToml {
#[serde(default)] #[serde(default)]
pub paths: AuraPaths, pub paths: AuraPaths,
#[serde(default)]
pub nodes: AuraNodes,
} }
#[derive(Debug, Default, PartialEq, serde::Deserialize)] #[derive(Debug, Default, PartialEq, serde::Deserialize)]
@@ -37,6 +39,15 @@ pub struct AuraPaths {
pub runs: Option<PathBuf>, pub runs: Option<PathBuf>,
} }
/// Node-crate pointers (C17: static context). Paths resolve relative to the
/// project root (`Path::join`, so absolute paths pass through). The format
/// carries a list; this iteration loads exactly one entry and refuses more.
#[derive(Debug, Default, PartialEq, serde::Deserialize)]
pub struct AuraNodes {
#[serde(default)]
pub crates: Vec<String>,
}
#[derive(Debug)] #[derive(Debug)]
pub enum ProjectError { pub enum ProjectError {
Toml(PathBuf, String), Toml(PathBuf, String),
@@ -46,6 +57,8 @@ pub enum ProjectError {
NotAProjectDylib(PathBuf), NotAProjectDylib(PathBuf),
Incompatible { what: &'static str, dylib: String, host: String }, Incompatible { what: &'static str, dylib: String, host: String },
Charter(String), Charter(String),
MultiCrate(usize),
NodesPointer { pointer: String, inner: Box<ProjectError> },
} }
impl fmt::Display for ProjectError { impl fmt::Display for ProjectError {
@@ -73,25 +86,41 @@ impl fmt::Display for ProjectError {
the host's toolchain/aura-core" the host's toolchain/aura-core"
), ),
Self::Charter(e) => write!(f, "project vocabulary rejected: {e}"), Self::Charter(e) => write!(f, "project vocabulary rejected: {e}"),
Self::MultiCrate(n) => write!(
f,
"multi-crate loading is not yet supported (found {n} entries \
in [nodes].crates — only one is accepted)"
),
Self::NodesPointer { pointer, inner } => write!(
f,
"node crate at `{pointer}` (from [nodes] in Aura.toml): {inner}"
),
} }
} }
} }
/// A successfully loaded project. The `Library` behind `resolver`/`type_ids` /// A discovered project. `native` is present only when a node crate is
/// is leaked (load-and-hold): the fn pointers are valid for 'static. /// loaded (the crate's `Library` is leaked load-and-hold, 'static).
#[derive(Debug)]
pub struct ProjectEnv { pub struct ProjectEnv {
pub root: PathBuf, pub root: PathBuf,
pub toml: AuraToml, pub toml: AuraToml,
pub commit: Option<String>,
pub native: Option<NativeEnv>,
}
/// The loaded node crate: vocabulary + identity stamps.
#[derive(Debug)]
pub struct NativeEnv {
pub namespace: String, pub namespace: String,
pub dylib_sha256: String, pub dylib_sha256: String,
pub commit: Option<String>,
resolver: fn(&str) -> Option<PrimitiveBuilder>, resolver: fn(&str) -> Option<PrimitiveBuilder>,
type_id_list: &'static [&'static str], type_id_list: &'static [&'static str],
} }
impl ProjectEnv { impl ProjectEnv {
fn resolve(&self, type_id: &str) -> Option<PrimitiveBuilder> { fn resolve(&self, type_id: &str) -> Option<PrimitiveBuilder> {
(self.resolver)(type_id) self.native.as_ref().and_then(|n| (n.resolver)(type_id))
} }
} }
@@ -121,8 +150,8 @@ impl Env {
/// project std type ids, project first (for `introspect --vocabulary`). /// project std type ids, project first (for `introspect --vocabulary`).
pub fn type_ids(&self) -> Vec<&'static str> { pub fn type_ids(&self) -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new(); let mut out: Vec<&'static str> = Vec::new();
if let Some(p) = &self.project { if let Some(n) = self.project.as_ref().and_then(|p| p.native.as_ref()) {
out.extend_from_slice(p.type_id_list); out.extend_from_slice(n.type_id_list);
} }
out.extend_from_slice(std_vocabulary_types()); out.extend_from_slice(std_vocabulary_types());
out out
@@ -159,8 +188,8 @@ impl Env {
pub fn provenance(&self) -> Option<ProjectProvenance> { pub fn provenance(&self) -> Option<ProjectProvenance> {
self.project.as_ref().map(|p| ProjectProvenance { self.project.as_ref().map(|p| ProjectProvenance {
namespace: p.namespace.clone(), namespace: p.native.as_ref().map(|n| n.namespace.clone()),
dylib_sha256: p.dylib_sha256.clone(), dylib_sha256: p.native.as_ref().map(|n| n.dylib_sha256.clone()),
commit: p.commit.clone(), commit: p.commit.clone(),
}) })
} }
@@ -185,6 +214,17 @@ fn parse_aura_toml(root: &Path) -> Result<AuraToml, ProjectError> {
toml::from_str(&text).map_err(|e| ProjectError::Toml(path, e.to_string())) toml::from_str(&text).map_err(|e| ProjectError::Toml(path, e.to_string()))
} }
/// Read a project's `Aura.toml` (for scaffold-side tooling that must not
/// trigger a crate load, e.g. `aura nodes new`).
///
/// `dead_code` is allowed here only until that consumer lands — remove the
/// allow with the first caller, at which point genuine unuse must surface
/// again.
#[allow(dead_code)]
pub fn read_aura_toml(root: &Path) -> Result<AuraToml, ProjectError> {
parse_aura_toml(root)
}
/// Derive the cdylib artifact path from `cargo metadata` output (pure — /// Derive the cdylib artifact path from `cargo metadata` output (pure —
/// unit-testable on a canned JSON document). /// unit-testable on a canned JSON document).
fn artifact_from_metadata( fn artifact_from_metadata(
@@ -440,15 +480,41 @@ fn newest_source_mtime(root: &Path) -> Option<SystemTime> {
newest newest
} }
/// Locate, load (load-and-hold), verify, and charter-check the project dylib. /// Discover the project's tier and load it. Data-only (no [nodes], no root
/// crate) loads nothing; a [nodes] pointer or a root Cargo.toml routes into
/// the unchanged crate pipeline (`load_crate`).
pub fn load(root: &Path, release: bool) -> Result<ProjectEnv, ProjectError> { pub fn load(root: &Path, release: bool) -> Result<ProjectEnv, ProjectError> {
let toml = parse_aura_toml(root)?; let toml = parse_aura_toml(root)?;
let dylib_path = artifact_path(root, release)?; let native = match toml.nodes.crates.as_slice() {
[] => {
if root.join("Cargo.toml").is_file() {
Some(load_crate(root, release)?)
} else {
None
}
}
[pointer] => Some(load_crate(&root.join(pointer), release).map_err(|e| {
ProjectError::NodesPointer { pointer: pointer.clone(), inner: Box::new(e) }
})?),
many => return Err(ProjectError::MultiCrate(many.len())),
};
Ok(ProjectEnv {
root: root.to_path_buf(),
toml,
commit: project_commit(root),
native,
})
}
/// Locate, load (load-and-hold), verify, and charter-check a node crate's
/// dylib.
fn load_crate(crate_root: &Path, release: bool) -> Result<NativeEnv, ProjectError> {
let dylib_path = artifact_path(crate_root, release)?;
if !dylib_path.is_file() { if !dylib_path.is_file() {
return Err(ProjectError::ArtifactMissing(dylib_path)); return Err(ProjectError::ArtifactMissing(dylib_path));
} }
if let Ok(dylib_mtime) = std::fs::metadata(&dylib_path).and_then(|m| m.modified()) if let Ok(dylib_mtime) = std::fs::metadata(&dylib_path).and_then(|m| m.modified())
&& let Some(source_mtime) = newest_source_mtime(root) && let Some(source_mtime) = newest_source_mtime(crate_root)
&& let Some(warning) = stale_warning(&dylib_path, dylib_mtime, source_mtime) && let Some(warning) = stale_warning(&dylib_path, dylib_mtime, source_mtime)
{ {
eprintln!("{warning}"); eprintln!("{warning}");
@@ -492,15 +558,7 @@ pub fn load(root: &Path, release: bool) -> Result<ProjectEnv, ProjectError> {
let type_id_list = (desc.type_ids)(); let type_id_list = (desc.type_ids)();
check_charter(&namespace, type_id_list, &|t| resolver(t))?; check_charter(&namespace, type_id_list, &|t| resolver(t))?;
Ok(ProjectEnv { Ok(NativeEnv { namespace, dylib_sha256, resolver, type_id_list })
root: root.to_path_buf(),
toml,
namespace,
dylib_sha256,
commit: project_commit(root),
resolver,
type_id_list,
})
} }
#[cfg(test)] #[cfg(test)]
@@ -519,6 +577,56 @@ mod tests {
std::fs::remove_dir_all(&tmp).unwrap(); std::fs::remove_dir_all(&tmp).unwrap();
} }
#[test]
fn aura_toml_parses_a_nodes_pointer_list() {
let t: AuraToml = toml::from_str("[nodes]\ncrates = [\"../my-nodes\"]").unwrap();
assert_eq!(t.nodes.crates, vec!["../my-nodes".to_string()]);
let t: AuraToml = toml::from_str("").unwrap();
assert!(t.nodes.crates.is_empty());
}
/// #241: an Aura.toml with neither a [nodes] pointer nor a root crate is
/// the data-only tier — it loads with no native env, resolves std-only,
/// and stamps commit-only provenance.
#[test]
fn data_only_project_loads_without_a_crate() {
let tmp = std::env::temp_dir().join(format!("aura-dataonly-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("Aura.toml"), "[paths]\nruns = \"runs\"\n").unwrap();
let p = load(&tmp, false).expect("data-only load");
assert!(p.native.is_none());
let env = Env::with_project(p);
assert!(env.resolve("SMA").is_some());
assert!(env.resolve("demo::Identity").is_none());
assert_eq!(env.runs_root(), tmp.join("runs"));
let prov = env.provenance().expect("a data-only project is a project");
assert!(prov.namespace.is_none());
assert!(prov.dylib_sha256.is_none());
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn two_nodes_pointers_refuse_with_multi_crate() {
let tmp = std::env::temp_dir().join(format!("aura-multi-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"a\", \"b\"]\n").unwrap();
let e = load(&tmp, false).unwrap_err();
assert!(e.to_string().contains("multi-crate loading is not yet supported"), "{e}");
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn a_broken_nodes_pointer_names_the_pointer() {
let tmp = std::env::temp_dir().join(format!("aura-badptr-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"../nope\"]\n").unwrap();
let e = load(&tmp, false).unwrap_err();
let msg = e.to_string();
assert!(msg.contains("../nope"), "{msg}");
assert!(msg.contains("[nodes]"), "{msg}");
std::fs::remove_dir_all(&tmp).ok();
}
#[test] #[test]
fn aura_toml_parses_empty_partial_and_unknown_keys() { fn aura_toml_parses_empty_partial_and_unknown_keys() {
let t: AuraToml = toml::from_str("").unwrap(); let t: AuraToml = toml::from_str("").unwrap();
@@ -0,0 +1,2 @@
# data-only fixture (#241): neither [nodes] nor a root Cargo.toml — the
# project has no node crate to load at all, only static context.
@@ -0,0 +1 @@
{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
@@ -0,0 +1,5 @@
# multi-crate fixture (#241): two [nodes].crates entries — loading more than
# one node crate is not yet supported and must refuse before any file I/O
# on the run's blueprint argument.
[nodes]
crates = ["a", "b"]
@@ -0,0 +1,5 @@
# nested-nodes fixture (#241): the node crate lives at a [nodes] pointer, NOT
# at the project root (no root Cargo.toml here) — the load boundary must
# resolve the crate through the pointer, not assume root-collocation.
[nodes]
crates = ["nodecrate"]
@@ -0,0 +1,2 @@
/target
Cargo.lock
@@ -0,0 +1,16 @@
[package]
name = "nodecrate"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
aura-core = { path = "../../../../../aura-core" }
# Standalone workspace root: never a member of the engine workspace
# (docs/project-layout.md, the empty-[workspace] note) — matches the
# demo-project fixture's convention.
[workspace]
@@ -0,0 +1,76 @@
//! Fixture node crate for the #241 [nodes] pointer e2e: lives one level
//! BELOW its Aura.toml (`../Aura.toml` points at this dir via
//! `[nodes].crates`), unlike the demo-project fixture which collocates the
//! crate at the project root. One pass-through node under the `ptr`
//! namespace, exported via the descriptor macro.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// One-input f64 pass-through. Emits `None` until its input has a value.
pub struct Identity {
out: [Cell; 1],
}
impl Identity {
pub fn new() -> Self {
Self { out: [Cell::from_f64(0.0)] }
}
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"ptr::Identity",
NodeSchema {
inputs: vec![PortSpec {
kind: ScalarKind::F64,
firing: Firing::Any,
name: "value".into(),
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
},
|_| Box::new(Identity::new()),
)
}
}
impl Default for Identity {
fn default() -> Self {
Self::new()
}
}
impl Node for Identity {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None;
}
self.out[0] = Cell::from_f64(w[0]);
Some(&self.out)
}
fn label(&self) -> String {
"ptr::Identity".to_string()
}
}
fn vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
match type_id {
"ptr::Identity" => Some(Identity::builder()),
_ => None,
}
}
fn type_ids() -> &'static [&'static str] {
&["ptr::Identity"]
}
aura_core::aura_project! {
namespace: "ptr",
vocabulary: vocabulary,
type_ids: type_ids,
}
@@ -0,0 +1 @@
{"format_version":1,"blueprint":{"name":"pointer_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"ptr::Identity"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}}
+117
View File
@@ -248,6 +248,123 @@ fn stale_dylib_warns_naming_both_timestamps_but_still_runs() {
); );
} }
fn dataonly_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/dataonly-project")
}
fn multicrate_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/multicrate-project")
}
fn nested_nodes_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/nested-nodes-project")
}
/// Property (#241, tier selection at the load boundary): a project whose
/// `Aura.toml` has neither a `[nodes]` pointer nor a root `Cargo.toml` is the
/// data-only tier — `aura run` resolves entirely against the std vocabulary
/// (no crate to load, no dylib to build) and still succeeds. The widened
/// `ProjectProvenance` (Tier-1 optional fields) must reflect that: the
/// manifest's `project` object carries no `namespace`/`dylib_sha256` (there is
/// no loaded crate to stamp) while still being present as an object (a project
/// WAS discovered — `Aura.toml` exists).
#[test]
fn data_only_project_runs_and_stamps_commit_only_provenance() {
let dir = dataonly_dir();
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "signal.json"])
.current_dir(&dir)
.output()
.expect("spawn aura run");
assert!(
out.status.success(),
"data-only run failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("run report is JSON");
let p = &v["manifest"]["project"];
assert!(p.is_object(), "a project WAS discovered (Aura.toml exists): {p}");
assert!(
p.get("namespace").is_none(),
"no crate was loaded, so namespace must be absent, not null: {p}"
);
assert!(
p.get("dylib_sha256").is_none(),
"no crate was loaded, so dylib_sha256 must be absent, not null: {p}"
);
assert!(
p["commit"].as_str().is_some_and(|s| !s.is_empty()),
"a data-only project still stamps its own repo commit: {p}"
);
}
/// Property (#241, tier selection refuses ambiguity): more than one entry
/// under `[nodes].crates` refuses at the real CLI entrypoint — before any
/// subcommand-specific file I/O runs (main.rs resolves the project `Env`
/// ahead of dispatch) — naming both the count and the `[nodes]` source, not a
/// raw parse/index panic.
#[test]
fn multi_crate_pointer_refuses_end_to_end() {
let dir = multicrate_dir();
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "x.json"])
.current_dir(&dir)
.output()
.expect("spawn aura run");
assert_eq!(
out.status.code(),
Some(1),
"a multi-crate [nodes] config is a runtime refusal (exit 1): {:?}",
out.status
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("multi-crate loading is not yet supported"),
"stderr must name the cause: {err}"
);
}
/// Property (#241, [nodes] pointer indirection): the node crate need not be
/// collocated with `Aura.toml` at the project root — a single `[nodes].crates`
/// entry names a relative path to load instead. Driven through the REAL load
/// path (cargo metadata + libloading + charter, exactly like the root-crate
/// case in `project_run_manifest_carries_provenance`), not a pure unit call:
/// `aura run` from the project root resolves a node type that only the
/// pointed-to crate exports, and the manifest's provenance stamps that
/// crate's namespace.
#[test]
fn nodes_pointer_crate_resolves_and_stamps_its_namespace() {
let dir = nested_nodes_dir();
let crate_dir = dir.join("nodecrate");
let build = Command::new("cargo")
.arg("build")
.current_dir(&crate_dir)
.output()
.expect("spawn cargo build for the nodecrate fixture");
assert!(
build.status.success(),
"nodecrate fixture build failed:\n{}",
String::from_utf8_lossy(&build.stderr)
);
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "pointer_signal.json"])
.current_dir(&dir)
.output()
.expect("spawn aura run");
assert!(
out.status.success(),
"pointer-crate run failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("run report is JSON");
assert_eq!(
v["manifest"]["project"]["namespace"], "ptr",
"provenance names the POINTED-TO crate's namespace, not the project root: {v}"
);
}
#[test] #[test]
fn missing_artifact_refuses_with_build_hint() { fn missing_artifact_refuses_with_build_hint() {
// A minimal never-built project: valid cargo metadata, no dylib on disk. // A minimal never-built project: valid cargo metadata, no dylib on disk.
+33 -4
View File
@@ -65,12 +65,16 @@ pub struct RunManifest {
} }
/// Provenance of the project cdylib a run was resolved through (C16/C18). /// Provenance of the project cdylib a run was resolved through (C16/C18).
/// `namespace`/`dylib_sha256` are present only when a node crate is loaded
/// (native tier); a data-only project stamps only its commit.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ProjectProvenance { pub struct ProjectProvenance {
/// The project's vocabulary namespace (from the descriptor). /// The project's vocabulary namespace (from the descriptor).
pub namespace: String, #[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
/// SHA-256 (hex) of the loaded dylib file's bytes. /// SHA-256 (hex) of the loaded dylib file's bytes.
pub dylib_sha256: String, #[serde(default, skip_serializing_if = "Option::is_none")]
pub dylib_sha256: Option<String>,
/// The project repo's HEAD (+ `-dirty`), when derivable. /// The project repo's HEAD (+ `-dirty`), when derivable.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>, pub commit: Option<String>,
@@ -344,8 +348,8 @@ mod tests {
assert!(!out.contains("\"project\"")); assert!(!out.contains("\"project\""));
// Some(..) must round-trip. // Some(..) must round-trip.
let p = ProjectProvenance { let p = ProjectProvenance {
namespace: "demo".into(), namespace: Some("demo".into()),
dylib_sha256: "ab".repeat(32), dylib_sha256: Some("ab".repeat(32)),
commit: None, commit: None,
}; };
let m2 = RunManifest { project: Some(p.clone()), ..m }; let m2 = RunManifest { project: Some(p.clone()), ..m };
@@ -354,6 +358,31 @@ mod tests {
assert_eq!(back.project, Some(p)); assert_eq!(back.project, Some(p));
} }
#[test]
fn runmanifest_project_field_commit_only_omits_namespace_and_sha() {
// #241: a data-only project (no node crate loaded) stamps a
// commit-only ProjectProvenance; namespace/dylib_sha256 must stay
// absent from the bytes (byte-stability of the native-tier shape)
// and the commit-only value must still round-trip.
let p = ProjectProvenance {
namespace: None,
dylib_sha256: None,
commit: Some("deadbeef".into()),
};
let m = RunManifest {
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
seed: 0, broker: "b".into(), selection: None, instrument: None,
topology_hash: None,
project: Some(p.clone()),
};
let s = serde_json::to_string(&m).expect("serializes");
assert!(!s.contains("\"namespace\""), "namespace omitted when None: {s}");
assert!(!s.contains("\"dylib_sha256\""), "dylib_sha256 omitted when None: {s}");
assert!(s.contains("\"commit\":\"deadbeef\""), "commit present: {s}");
let back: RunManifest = serde_json::from_str(&s).expect("round-trips");
assert_eq!(back.project, Some(p));
}
#[test] #[test]
fn family_selection_round_trips_on_the_manifest() { fn family_selection_round_trips_on_the_manifest() {
let sel = FamilySelection { let sel = FamilySelection {