diff --git a/crates/aura-cli/src/project.rs b/crates/aura-cli/src/project.rs index f916eff..ad86bd0 100644 --- a/crates/aura-cli/src/project.rs +++ b/crates/aura-cli/src/project.rs @@ -25,6 +25,8 @@ use std::time::SystemTime; pub struct AuraToml { #[serde(default)] pub paths: AuraPaths, + #[serde(default)] + pub nodes: AuraNodes, } #[derive(Debug, Default, PartialEq, serde::Deserialize)] @@ -37,6 +39,15 @@ pub struct AuraPaths { pub runs: Option, } +/// 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, +} + #[derive(Debug)] pub enum ProjectError { Toml(PathBuf, String), @@ -46,6 +57,8 @@ pub enum ProjectError { NotAProjectDylib(PathBuf), Incompatible { what: &'static str, dylib: String, host: String }, Charter(String), + MultiCrate(usize), + NodesPointer { pointer: String, inner: Box }, } impl fmt::Display for ProjectError { @@ -73,25 +86,41 @@ impl fmt::Display for ProjectError { the host's toolchain/aura-core" ), 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` -/// is leaked (load-and-hold): the fn pointers are valid for 'static. +/// A discovered project. `native` is present only when a node crate is +/// loaded (the crate's `Library` is leaked — load-and-hold, 'static). +#[derive(Debug)] pub struct ProjectEnv { pub root: PathBuf, pub toml: AuraToml, + pub commit: Option, + pub native: Option, +} + +/// The loaded node crate: vocabulary + identity stamps. +#[derive(Debug)] +pub struct NativeEnv { pub namespace: String, pub dylib_sha256: String, - pub commit: Option, resolver: fn(&str) -> Option, type_id_list: &'static [&'static str], } impl ProjectEnv { fn resolve(&self, type_id: &str) -> Option { - (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`). pub fn type_ids(&self) -> Vec<&'static str> { let mut out: Vec<&'static str> = Vec::new(); - if let Some(p) = &self.project { - out.extend_from_slice(p.type_id_list); + if let Some(n) = self.project.as_ref().and_then(|p| p.native.as_ref()) { + out.extend_from_slice(n.type_id_list); } out.extend_from_slice(std_vocabulary_types()); out @@ -159,8 +188,8 @@ impl Env { pub fn provenance(&self) -> Option { self.project.as_ref().map(|p| ProjectProvenance { - namespace: p.namespace.clone(), - dylib_sha256: p.dylib_sha256.clone(), + namespace: p.native.as_ref().map(|n| n.namespace.clone()), + dylib_sha256: p.native.as_ref().map(|n| n.dylib_sha256.clone()), commit: p.commit.clone(), }) } @@ -185,6 +214,17 @@ fn parse_aura_toml(root: &Path) -> Result { 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 { + parse_aura_toml(root) +} + /// Derive the cdylib artifact path from `cargo metadata` output (pure — /// unit-testable on a canned JSON document). fn artifact_from_metadata( @@ -440,15 +480,41 @@ fn newest_source_mtime(root: &Path) -> Option { 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 { 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 { + let dylib_path = artifact_path(crate_root, release)?; if !dylib_path.is_file() { return Err(ProjectError::ArtifactMissing(dylib_path)); } 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) { eprintln!("{warning}"); @@ -492,15 +558,7 @@ pub fn load(root: &Path, release: bool) -> Result { let type_id_list = (desc.type_ids)(); check_charter(&namespace, type_id_list, &|t| resolver(t))?; - Ok(ProjectEnv { - root: root.to_path_buf(), - toml, - namespace, - dylib_sha256, - commit: project_commit(root), - resolver, - type_id_list, - }) + Ok(NativeEnv { namespace, dylib_sha256, resolver, type_id_list }) } #[cfg(test)] @@ -519,6 +577,56 @@ mod tests { 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] fn aura_toml_parses_empty_partial_and_unknown_keys() { let t: AuraToml = toml::from_str("").unwrap(); diff --git a/crates/aura-cli/tests/fixtures/dataonly-project/Aura.toml b/crates/aura-cli/tests/fixtures/dataonly-project/Aura.toml new file mode 100644 index 0000000..0f38e41 --- /dev/null +++ b/crates/aura-cli/tests/fixtures/dataonly-project/Aura.toml @@ -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. diff --git a/crates/aura-cli/tests/fixtures/dataonly-project/signal.json b/crates/aura-cli/tests/fixtures/dataonly-project/signal.json new file mode 100644 index 0000000..d4f9598 --- /dev/null +++ b/crates/aura-cli/tests/fixtures/dataonly-project/signal.json @@ -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"}]}} diff --git a/crates/aura-cli/tests/fixtures/multicrate-project/Aura.toml b/crates/aura-cli/tests/fixtures/multicrate-project/Aura.toml new file mode 100644 index 0000000..99df924 --- /dev/null +++ b/crates/aura-cli/tests/fixtures/multicrate-project/Aura.toml @@ -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"] diff --git a/crates/aura-cli/tests/fixtures/nested-nodes-project/Aura.toml b/crates/aura-cli/tests/fixtures/nested-nodes-project/Aura.toml new file mode 100644 index 0000000..63c7d59 --- /dev/null +++ b/crates/aura-cli/tests/fixtures/nested-nodes-project/Aura.toml @@ -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"] diff --git a/crates/aura-cli/tests/fixtures/nested-nodes-project/nodecrate/.gitignore b/crates/aura-cli/tests/fixtures/nested-nodes-project/nodecrate/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/crates/aura-cli/tests/fixtures/nested-nodes-project/nodecrate/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/crates/aura-cli/tests/fixtures/nested-nodes-project/nodecrate/Cargo.toml b/crates/aura-cli/tests/fixtures/nested-nodes-project/nodecrate/Cargo.toml new file mode 100644 index 0000000..e99e69f --- /dev/null +++ b/crates/aura-cli/tests/fixtures/nested-nodes-project/nodecrate/Cargo.toml @@ -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] diff --git a/crates/aura-cli/tests/fixtures/nested-nodes-project/nodecrate/src/lib.rs b/crates/aura-cli/tests/fixtures/nested-nodes-project/nodecrate/src/lib.rs new file mode 100644 index 0000000..4946f33 --- /dev/null +++ b/crates/aura-cli/tests/fixtures/nested-nodes-project/nodecrate/src/lib.rs @@ -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 { + 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 { + 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, +} diff --git a/crates/aura-cli/tests/fixtures/nested-nodes-project/pointer_signal.json b/crates/aura-cli/tests/fixtures/nested-nodes-project/pointer_signal.json new file mode 100644 index 0000000..bdf3086 --- /dev/null +++ b/crates/aura-cli/tests/fixtures/nested-nodes-project/pointer_signal.json @@ -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"}]}} diff --git a/crates/aura-cli/tests/project_load.rs b/crates/aura-cli/tests/project_load.rs index e7a764e..df1e0ca 100644 --- a/crates/aura-cli/tests/project_load.rs +++ b/crates/aura-cli/tests/project_load.rs @@ -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] fn missing_artifact_refuses_with_build_hint() { // A minimal never-built project: valid cargo metadata, no dylib on disk. diff --git a/crates/aura-engine/src/report.rs b/crates/aura-engine/src/report.rs index c27b8d6..b8a6f2d 100644 --- a/crates/aura-engine/src/report.rs +++ b/crates/aura-engine/src/report.rs @@ -65,12 +65,16 @@ pub struct RunManifest { } /// 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)] pub struct ProjectProvenance { /// The project's vocabulary namespace (from the descriptor). - pub namespace: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace: Option, /// 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, /// The project repo's HEAD (+ `-dirty`), when derivable. #[serde(default, skip_serializing_if = "Option::is_none")] pub commit: Option, @@ -344,8 +348,8 @@ mod tests { assert!(!out.contains("\"project\"")); // Some(..) must round-trip. let p = ProjectProvenance { - namespace: "demo".into(), - dylib_sha256: "ab".repeat(32), + namespace: Some("demo".into()), + dylib_sha256: Some("ab".repeat(32)), commit: None, }; let m2 = RunManifest { project: Some(p.clone()), ..m }; @@ -354,6 +358,31 @@ mod tests { 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] fn family_selection_round_trips_on_the_manifest() { let sel = FamilySelection {