feat(cli): aura nodes new — node-crate scaffold + attach (#241 T5-T6)
- New clap family `aura nodes new <name> [--engine-path] [--namespace]`: scaffolds a node crate as a SIBLING of the project root (Cargo.toml with the aura-core path-dep, the parameterized Scale starter node, roster macro, crate-scoped .gitignore + CLAUDE.md, own git repo) and appends the `[nodes] crates = ["../<name>"]` pointer to the project's Aura.toml. Refuses outside a project, on any existing [nodes] section (incl. an empty crates array), and on an existing destination; joins the load-phase bypass beside `aura new` (a scaffolder must work in an unbuilt tree). - The old single-tier scaffold() is gone; the crate templates live on in scaffold_node_crate; dead-code bridges removed with their callers. - tests/project_nodes.rs: attach + refusals + namespace override + the full role-2 loop (scaffold, cargo build, vocabulary resolves <ns>::Scale end to end). - tests/project_sweep_campaign.rs fixture reworked to the two-tier shape (aura new + aura nodes new --namespace knob_lab); the two campaign acceptance tests are byte-unchanged and green. refs #241
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
//! `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 = std::env::temp_dir().join(format!("aura-nodes-{tag}-{}", std::process::id()));
|
||||
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")
|
||||
}
|
||||
|
||||
#[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 (#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)
|
||||
);
|
||||
}
|
||||
@@ -32,8 +32,11 @@ const GER40_TO_MS: &str = "1727740799999";
|
||||
/// The OPEN blueprint: the scaffold's SMA-cross bias graph, but its terminal
|
||||
/// node is the project's OWN `knob_lab::Scale` (named `gain`), left UNBOUND — so
|
||||
/// `factor` is the one open sweep axis (`scaled_signal.gain.factor` wrapped /
|
||||
/// `gain.factor` raw). `knob_lab` is the snake namespace `aura new knob-lab`
|
||||
/// derives; `Scale` is the starter node every scaffold emits (#183).
|
||||
/// `gain.factor` raw). Two-tier fixture (#241): `aura new knob-lab` scaffolds
|
||||
/// the data-only project, and a sibling `knob-lab-nodes` node crate (attached
|
||||
/// via `aura nodes new ... --namespace knob_lab`) supplies the `knob_lab`
|
||||
/// vocabulary namespace — `Scale` is the starter node every node-crate
|
||||
/// scaffold emits (#183).
|
||||
const OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"scaled_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":"knob_lab::Scale","name":"gain"}}],"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"}]}}"#;
|
||||
|
||||
/// The minimal executable process pipeline (one sweep stage) — the campaign
|
||||
@@ -52,10 +55,14 @@ fn aura_in(dir: &Path, args: &[&str]) -> Output {
|
||||
.expect("run aura")
|
||||
}
|
||||
|
||||
/// Scaffold `knob-lab` once via `aura new`, cargo-build its cdylib, and drop the
|
||||
/// OPEN blueprint into `blueprints/scaled_open.json`. Returns the project dir.
|
||||
/// `OnceLock` so the expensive scaffold+build happens once per test binary; the
|
||||
/// project's path-deps resolve into this checkout (the baked engine root), like
|
||||
/// Scaffold `knob-lab` once via the two-tier project path (#241): a data-only
|
||||
/// `aura new` project, a sibling `knob-lab-nodes` node crate attached via
|
||||
/// `aura nodes new` (with `--namespace knob_lab` overriding the crate-derived
|
||||
/// default so the blueprint's `knob_lab::` prefix stays byte-identical), then
|
||||
/// `cargo build` the node crate. Drops the OPEN blueprint into
|
||||
/// `blueprints/scaled_open.json`. Returns the project dir. `OnceLock` so the
|
||||
/// expensive scaffold+build happens once per test binary; the node crate's
|
||||
/// path-dep resolves into this checkout (the baked engine root), like
|
||||
/// `project_new.rs`.
|
||||
fn built_scale_project() -> &'static PathBuf {
|
||||
static BUILT: OnceLock<PathBuf> = OnceLock::new();
|
||||
@@ -72,14 +79,34 @@ fn built_scale_project() -> &'static PathBuf {
|
||||
);
|
||||
let proj = base.join("knob-lab");
|
||||
|
||||
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
|
||||
let nodes_new = aura_in(
|
||||
&proj,
|
||||
&[
|
||||
"nodes",
|
||||
"new",
|
||||
"knob-lab-nodes",
|
||||
"--namespace",
|
||||
"knob_lab",
|
||||
"--engine-path",
|
||||
&engine,
|
||||
],
|
||||
);
|
||||
assert!(
|
||||
nodes_new.status.success(),
|
||||
"aura nodes new knob-lab-nodes failed: {}",
|
||||
String::from_utf8_lossy(&nodes_new.stderr)
|
||||
);
|
||||
let nodes_crate = base.join("knob-lab-nodes");
|
||||
|
||||
let build = Command::new("cargo")
|
||||
.arg("build")
|
||||
.current_dir(&proj)
|
||||
.current_dir(&nodes_crate)
|
||||
.output()
|
||||
.expect("cargo build the scaffolded project");
|
||||
.expect("cargo build the scaffolded node crate");
|
||||
assert!(
|
||||
build.status.success(),
|
||||
"scaffolded project build failed:\n{}",
|
||||
"scaffolded node crate build failed:\n{}",
|
||||
String::from_utf8_lossy(&build.stderr)
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user