//! `aura nodes new` (#241): sibling node-crate scaffold + [nodes] attach. use std::path::Path; use std::process::Command; fn temp_cwd(tag: &str) -> std::path::PathBuf { let d = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-nodes-{tag}")); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d } fn aura(args: &[&str], cwd: &Path) -> std::process::Output { Command::new(env!("CARGO_BIN_EXE_aura")) .args(args) .current_dir(cwd) .output() .expect("spawn aura") } /// Property (#258): a test sandbox must not outlive its run — `temp_cwd` must /// hand back a run-STABLE path that lives off the `env::temp_dir()` tmpfs, so /// its own pre-create `remove_dir_all` reclaims the previous run instead of /// stranding one `aura-nodes-*` directory per test-binary invocation. /// /// The helper today keys the name on `std::process::id()`, so every run mints a /// new path the wipe can never match: >50 000 leaked dirs filled a 16 GiB tmpfs /// to 100%. The in-repo remedy is the knob-lab scaffold's (commit 84e1075, sibling /// `project_sweep_campaign.rs`): a fixed name under `CARGO_TARGET_TMPDIR`. This /// pins that property at one representative site — the assertion reads the path /// value only, so it is deterministic and does not depend on any external state. #[test] fn temp_cwd_sandbox_does_not_leak_under_env_temp_dir() { let sandbox = temp_cwd("leakguard"); // Read the path value, then reclaim immediately so a red run of this very // test does not itself add to the leak it describes. let path = sandbox.clone(); let _ = std::fs::remove_dir_all(&sandbox); let pid = std::process::id().to_string(); assert!( !path.to_string_lossy().contains(&pid), "sandbox name embeds the pid ({pid}), so no later run's pre-create wipe \ can reclaim it — one directory leaks per run: {}", path.display(), ); assert!( !path.starts_with(std::env::temp_dir()), "sandbox lives under env::temp_dir() — the tmpfs the >50k-dir leak filled: {}", path.display(), ); } #[test] fn nodes_new_scaffolds_a_sibling_crate_and_attaches_it() { let cwd = temp_cwd("attach"); assert!(aura(&["new", "lab"], &cwd).status.success()); let proj = cwd.join("lab"); let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../.."; let out = aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj); assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); let stdout = String::from_utf8_lossy(&out.stdout); assert!(stdout.contains("lab_nodes"), "names the namespace: {stdout}"); let crate_dir = cwd.join("lab-nodes"); assert!(crate_dir.join("Cargo.toml").is_file()); assert!(crate_dir.join("src/lib.rs").is_file()); assert!(!crate_dir.join("Aura.toml").exists(), "a node crate is not a project"); let toml = std::fs::read_to_string(proj.join("Aura.toml")).unwrap(); assert!(toml.contains("[nodes]"), "{toml}"); assert!(toml.contains("../lab-nodes"), "{toml}"); } /// Property (#243): a freshly scaffolded node crate stamps resolvable /// provenance too — `aura nodes new` follows the same `git init` with an /// initial commit as `aura new` (`scaffold_node_crate`, `scaffold.rs`), so /// `git rev-parse HEAD` resolves from the crate's very first run rather than /// leaving HEAD unborn. This half of #243's property runs and is asserted /// separately from `new_outside_a_work_tree_leaves_a_resolvable_head` /// (`project_new.rs`), which covers the `aura new` project half only. #[test] fn nodes_new_leaves_a_resolvable_head() { let cwd = temp_cwd("resolvable-head"); assert!(aura(&["new", "lab"], &cwd).status.success()); let proj = cwd.join("lab"); let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../.."; let out = aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj); assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); let crate_dir = cwd.join("lab-nodes"); let head = Command::new("git") .args(["rev-parse", "HEAD"]) .current_dir(&crate_dir) .output() .expect("run git rev-parse HEAD"); assert!( head.status.success(), "fresh node-crate scaffold has no HEAD (`git rev-parse HEAD` failed): {}", String::from_utf8_lossy(&head.stderr) ); } /// Property (#241): `--namespace` sets the vocabulary prefix independently /// of the crate's own (directory) name — a crate can be named anything, but /// the blueprints that reference its nodes address them under the namespace /// the flag chose, never a name silently derived from the crate name. This /// is exercised transitively by the big sweep/campaign fixture /// (`project_sweep_campaign.rs`, `--namespace knob_lab` on a `knob-lab-nodes` /// crate) but a failure there would not cleanly bisect to this property; this /// test isolates it. #[test] fn nodes_new_namespace_flag_overrides_crate_derived_default() { let cwd = temp_cwd("ns-override"); assert!(aura(&["new", "lab"], &cwd).status.success()); let proj = cwd.join("lab"); let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../.."; let out = aura( &["nodes", "new", "weird-crate-name", "--namespace", "custom_ns", "--engine-path", &engine], &proj, ); assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); let stdout = String::from_utf8_lossy(&out.stdout); assert!(stdout.contains("custom_ns"), "reports the override namespace: {stdout}"); assert!( !stdout.contains("weird_crate_name"), "must not fall back to the crate-derived default namespace: {stdout}" ); let lib = std::fs::read_to_string(cwd.join("weird-crate-name/src/lib.rs")).unwrap(); assert!( lib.contains("custom_ns::Scale") && lib.contains("namespace: \"custom_ns\""), "the generated vocabulary is registered under the override namespace: {lib}" ); assert!( !lib.contains("weird_crate_name"), "the crate-derived name must not leak into the vocabulary: {lib}" ); } /// Property (#241): the single-crate attach guard checks for the *presence* /// of a `[nodes]` section in `Aura.toml`, not merely a non-empty `crates` /// list — a hand-edited `Aura.toml` carrying an empty `[nodes]` section still /// refuses a second attach. Without this, `aura nodes new` would scaffold a /// crate that `append_nodes_pointer` then refuses to wire in, leaving an /// orphaned crate on disk. #[test] fn nodes_new_refuses_when_nodes_section_present_but_crates_empty() { let cwd = temp_cwd("empty-section"); assert!(aura(&["new", "lab"], &cwd).status.success()); let proj = cwd.join("lab"); let toml_path = proj.join("Aura.toml"); let mut toml = std::fs::read_to_string(&toml_path).unwrap(); toml.push_str("\n[nodes]\ncrates = []\n"); std::fs::write(&toml_path, toml).unwrap(); let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../.."; let out = aura(&["nodes", "new", "x-nodes", "--engine-path", &engine], &proj); assert_eq!(out.status.code(), Some(1)); assert!( String::from_utf8_lossy(&out.stderr).contains("already attached"), "{}", String::from_utf8_lossy(&out.stderr) ); assert!( !cwd.join("x-nodes").exists(), "the guard must fire before scaffolding the crate (no orphaned crate on disk)" ); } #[test] fn a_second_nodes_new_refuses_single_crate() { let cwd = temp_cwd("second"); assert!(aura(&["new", "lab"], &cwd).status.success()); let proj = cwd.join("lab"); let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../.."; assert!(aura(&["nodes", "new", "a-nodes", "--engine-path", &engine], &proj).status.success()); let out = aura(&["nodes", "new", "b-nodes", "--engine-path", &engine], &proj); assert_eq!(out.status.code(), Some(1)); assert!( String::from_utf8_lossy(&out.stderr).contains("already attached"), "{}", String::from_utf8_lossy(&out.stderr) ); } #[test] fn nodes_new_outside_a_project_refuses() { let cwd = temp_cwd("outside"); let out = aura(&["nodes", "new", "x-nodes"], &cwd); assert_eq!(out.status.code(), Some(1)); assert!( String::from_utf8_lossy(&out.stderr).contains("needs a project"), "{}", String::from_utf8_lossy(&out.stderr) ); } /// The full role-2 loop: scaffold, build, and the project resolves the /// crate's namespace end to end (vocabulary listing names the node). #[test] fn attached_crate_namespace_resolves_after_build() { let cwd = temp_cwd("resolve"); assert!(aura(&["new", "lab"], &cwd).status.success()); let proj = cwd.join("lab"); let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../.."; assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success()); let build = Command::new("cargo") .arg("build") .current_dir(cwd.join("lab-nodes")) .output() .expect("cargo build"); assert!(build.status.success(), "{}", String::from_utf8_lossy(&build.stderr)); let out = aura(&["graph", "introspect", "--vocabulary"], &proj); assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); assert!( String::from_utf8_lossy(&out.stdout).contains("lab_nodes::Scale"), "{}", String::from_utf8_lossy(&out.stdout) ); } /// E2E (#278): loading an attached node crate whose `src/` is newer than its /// built dylib emits exactly one `aura: warning: ` class-marked line naming /// the stale dylib, and still succeeds (the run proceeds with the existing /// dylib rather than refusing) — staleness is a continuing-run warning, never /// a fault. A freshly built dylib (no touched source since) emits none. Real /// `cargo build` + a real `libloading::Library::new` load, exercising /// `aura_runner::project::load_crate`'s actual call site rather than the pure /// `stale_warning` helper alone. #[test] fn attached_crate_load_warns_when_source_outruns_the_built_dylib() { let cwd = temp_cwd("stale-warn"); assert!(aura(&["new", "lab"], &cwd).status.success()); let proj = cwd.join("lab"); let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../.."; assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success()); let crate_root = cwd.join("lab-nodes"); let build = Command::new("cargo").arg("build").current_dir(&crate_root).output().expect("cargo build"); assert!(build.status.success(), "{}", String::from_utf8_lossy(&build.stderr)); // Fresh build, untouched source: no staleness warning. let fresh = aura(&["graph", "introspect", "--vocabulary"], &proj); assert!(fresh.status.success(), "stderr: {}", String::from_utf8_lossy(&fresh.stderr)); assert!( !String::from_utf8_lossy(&fresh.stderr).contains("may be stale"), "a freshly built dylib is not stale: {}", String::from_utf8_lossy(&fresh.stderr) ); // Bump `src/lib.rs`'s mtime a full minute past "now" (comfortably past the // dylib's just-completed build time) without rebuilding — the exact // "source outran the dylib, no rebuild yet" shape `stale_warning` guards. let lib_rs = crate_root.join("src/lib.rs"); let f = std::fs::File::open(&lib_rs).expect("open lib.rs"); f.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(60)) .expect("bump lib.rs mtime past the dylib's build time"); let stale = aura(&["graph", "introspect", "--vocabulary"], &proj); assert!( stale.status.success(), "staleness is a warning, not a refusal: {}", String::from_utf8_lossy(&stale.stderr) ); let stderr = String::from_utf8_lossy(&stale.stderr); assert_eq!( stderr.matches("may be stale").count(), 1, "exactly one staleness warning: {stderr}" ); assert!(stderr.contains("aura: warning: "), "carries the warning class marker (#278): {stderr}"); assert!(stderr.contains("run `cargo build` to refresh"), "names the remedy: {stderr}"); } /// Property (#316 self-description): the scaffold's own seed node ships a /// `doc:` line that itself passes the C29 shape gate (`aura_core::doc_gate`) /// — non-empty and not a bare restatement of the type name. This exact text /// is what a new extension author copy-pastes as the very first node of /// their crate; if the scaffold's own doc failed the gate it would teach the /// wrong pattern (an alibi doc) to every project built from it, and nothing /// else would catch that — `NodeSchema.doc` is required at compile time /// (E0063 without it) but its *shape* is never checked by the compiler. #[test] fn nodes_new_scaffold_doc_passes_the_doc_gate() { let cwd = temp_cwd("doc-gate"); assert!(aura(&["new", "lab"], &cwd).status.success()); let proj = cwd.join("lab"); let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../.."; assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success()); let lib = std::fs::read_to_string(cwd.join("lab-nodes/src/lib.rs")).unwrap(); let doc_line = lib .lines() .find(|l| l.trim_start().starts_with("doc:")) .unwrap_or_else(|| panic!("scaffold must carry a doc: field: {lib}")); let doc_text = doc_line .split_once('"') .and_then(|(_, rest)| rest.rsplit_once('"')) .map(|(text, _)| text) .unwrap_or_else(|| panic!("doc: field must be a string literal: {doc_line}")); aura_core::doc_gate("lab_nodes::Scale", doc_text).unwrap_or_else(|f| { panic!("scaffold's own doc line fails its own gate: {f:?} — {doc_text:?}") }); }