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:
@@ -2455,6 +2455,8 @@ enum Command {
|
||||
Reproduce(ReproduceCmd),
|
||||
/// Scaffold a new research project crate.
|
||||
New(NewCmd),
|
||||
/// Scaffold and attach node crates (native-node development).
|
||||
Nodes(NodesCmd),
|
||||
/// Validate, introspect, and register process documents (methodology).
|
||||
Process(research_docs::ProcessCmd),
|
||||
/// Validate, introspect, and register campaign documents (experiment intent).
|
||||
@@ -2479,6 +2481,30 @@ struct NewCmd {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct NodesCmd {
|
||||
#[command(subcommand)]
|
||||
command: NodesCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum NodesCommand {
|
||||
/// Scaffold a node crate beside this project and attach it via [nodes].
|
||||
New(NodesNewCmd),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct NodesNewCmd {
|
||||
/// Crate name; also the default namespace (dashes become underscores).
|
||||
name: String,
|
||||
/// Engine checkout for the crate's aura-core path-dep.
|
||||
#[arg(long)]
|
||||
engine_path: Option<std::path::PathBuf>,
|
||||
/// Vocabulary namespace override.
|
||||
#[arg(long)]
|
||||
namespace: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
#[command(args_conflicts_with_subcommands = true)]
|
||||
struct GraphCmd {
|
||||
@@ -3332,6 +3358,70 @@ fn dispatch_new(a: NewCmd, _env: &project::Env) {
|
||||
);
|
||||
}
|
||||
|
||||
fn dispatch_nodes(cmd: NodesCmd) {
|
||||
match cmd.command {
|
||||
NodesCommand::New(a) => dispatch_nodes_new(a),
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_nodes_new(a: NodesNewCmd) {
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|e| {
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let Some(root) = project::discover_from(&cwd) else {
|
||||
eprintln!(
|
||||
"aura: `aura nodes new` needs a project (no Aura.toml found up from {})",
|
||||
cwd.display()
|
||||
);
|
||||
std::process::exit(1);
|
||||
};
|
||||
let toml = match project::read_aura_toml(&root) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
// Mirror `append_nodes_pointer`'s textual `[nodes]`-section check (not just
|
||||
// `crates.is_empty()`) so a `[nodes]` section with an empty crates array
|
||||
// is caught here too, before scaffold_node_crate writes a crate that
|
||||
// append_nodes_pointer would then refuse to attach.
|
||||
let already_attached = !toml.nodes.crates.is_empty()
|
||||
|| std::fs::read_to_string(root.join("Aura.toml")).is_ok_and(|t| t.contains("[nodes]"));
|
||||
if already_attached {
|
||||
eprintln!("aura: a node crate is already attached (multi-crate loading is not yet supported)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let parent = root.parent().unwrap_or(&root).to_path_buf();
|
||||
let spec = match scaffold::scaffold_spec(
|
||||
&a.name,
|
||||
a.engine_path.as_deref(),
|
||||
a.namespace.as_deref(),
|
||||
&parent,
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(m) => {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
if let Err(m) = scaffold::scaffold_node_crate(&spec) {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
if let Err(m) = scaffold::append_nodes_pointer(&root, &format!("../{}", a.name)) {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!(
|
||||
"created node crate \"../{}\" (namespace \"{}\") and attached it to {}",
|
||||
a.name,
|
||||
spec.namespace,
|
||||
root.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
/// `aura sweep`: loaded-blueprint by-name axis sweep (or `--list-axes` probe) when the
|
||||
/// first-positional is an existing `.json`, else the built-in `--strategy` grid sweep.
|
||||
fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
|
||||
@@ -3701,9 +3791,9 @@ fn main() {
|
||||
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
|
||||
}
|
||||
let cli = Cli::parse();
|
||||
// `aura new` scaffolds; it must not require a loadable project even
|
||||
// when invoked inside one (e.g. an unbuilt tree).
|
||||
let env = if matches!(cli.command, Command::New(_)) {
|
||||
// `aura new`/`aura nodes new` scaffold; they must not require a
|
||||
// loadable project even when invoked inside one (e.g. an unbuilt tree).
|
||||
let env = if matches!(cli.command, Command::New(_) | Command::Nodes(_)) {
|
||||
project::Env::std()
|
||||
} else {
|
||||
match std::env::current_dir()
|
||||
@@ -3731,6 +3821,7 @@ fn main() {
|
||||
Command::Runs(a) => dispatch_runs(a, &env),
|
||||
Command::Reproduce(a) => dispatch_reproduce(a, &env),
|
||||
Command::New(a) => dispatch_new(a, &env),
|
||||
Command::Nodes(a) => dispatch_nodes(a),
|
||||
Command::Process(a) => research_docs::process_cmd(a, &env),
|
||||
Command::Campaign(a) => research_docs::campaign_cmd(a, &env),
|
||||
}
|
||||
|
||||
@@ -216,11 +216,6 @@ fn parse_aura_toml(root: &Path) -> Result<AuraToml, ProjectError> {
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
//! project-load phase in `main()` sees a plain data-only project just like
|
||||
//! any other).
|
||||
//!
|
||||
//! The crate-scaffold templates (`ScaffoldSpec`/`scaffold`/`scaffold_spec`,
|
||||
//! cycle 0103, C16/C17) survive for the native-node tier: `aura nodes new`
|
||||
//! (a follow-up task) attaches a node crate beside a project.
|
||||
//! The crate-scaffold templates (`ScaffoldSpec`/`scaffold_node_crate`/
|
||||
//! `scaffold_spec`, cycle 0103, C16/C17) serve the native-node tier: `aura
|
||||
//! nodes new` attaches a node crate beside a project.
|
||||
//!
|
||||
//! Templates are raw strings with `__NS__` / `__NAME__` / `__NAME_SNAKE__` /
|
||||
//! `__ENGINE__` tokens substituted by plain `.replace()` — no format-string
|
||||
@@ -44,7 +44,8 @@ pub fn snake(name: &str) -> String {
|
||||
|
||||
/// The compile-time default engine root: two ancestors above aura-cli's
|
||||
/// manifest dir (`<root>/crates/aura-cli` → `<root>`). Valid on the box the
|
||||
/// binary was built on; `scaffold` refuses when it no longer exists.
|
||||
/// binary was built on; `scaffold_node_crate` refuses when it no longer
|
||||
/// exists.
|
||||
pub fn default_engine_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
@@ -73,7 +74,6 @@ pub fn project_scaffold_spec(name: &str, cwd: &Path) -> Result<ProjectScaffoldSp
|
||||
|
||||
/// Validate the argv-level inputs. `Err` is a usage fault — the caller
|
||||
/// exits 2 (C14 partition).
|
||||
#[allow(dead_code)] // #241: `aura nodes new` (next task) becomes the caller
|
||||
pub fn scaffold_spec(
|
||||
name: &str,
|
||||
engine_path: Option<&Path>,
|
||||
@@ -128,7 +128,7 @@ runs = "runs"
|
||||
# data = "/path/to/archive" # the recorded-data root; defaults to the built-in path
|
||||
"#;
|
||||
|
||||
const GITIGNORE: &str = "/target\nCargo.lock\n/runs\n";
|
||||
const GITIGNORE_CRATE: &str = "/target\nCargo.lock\n";
|
||||
|
||||
const LIB_RS: &str = r#"//! __NAME__ — an aura research project: node logic + the exported vocabulary.
|
||||
//!
|
||||
@@ -204,18 +204,17 @@ aura_core::aura_project! {
|
||||
}
|
||||
"#;
|
||||
|
||||
const SIGNAL_JSON: &str = r#"{"format_version":1,"blueprint":{"name":"__NAME_SNAKE___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":"__NS__::Scale","bound":[{"pos":0,"name":"factor","kind":"F64","value":{"F64":1.0}}]}}],"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"}]}}"#;
|
||||
const CLAUDE_MD_CRATE: &str = r#"# __NAME__ — an aura node crate
|
||||
|
||||
const CLAUDE_MD: &str = r#"# __NAME__ — an aura research project
|
||||
Native node logic for an aura research project (extension-dev role). This
|
||||
crate is attached to its project via `[nodes]` in the project's `Aura.toml`.
|
||||
|
||||
This crate is an aura project: a cdylib of node/strategy blueprints the
|
||||
`aura` host loads during research (see the engine's docs/project-layout.md).
|
||||
|
||||
- Build: `cargo build` (the next `aura` invocation loads the fresh dylib)
|
||||
- Run: `aura run blueprints/signal.json` (from anywhere inside this dir)
|
||||
- Nodes live in `src/`; each is registered in `vocabulary()`/`type_ids()`
|
||||
under the `__NS__::` namespace prefix.
|
||||
- Topology is data (`blueprints/*.json`), node logic is Rust.
|
||||
- Build: `cargo build` (the next `aura` invocation in the project loads the
|
||||
fresh dylib)
|
||||
- Each node is registered in `vocabulary()`/`type_ids()` under the
|
||||
`__NS__::` namespace prefix; blueprints reference nodes by that id.
|
||||
- This crate has no data-plane dependencies — nodes are pure stream
|
||||
transformers over read-only input windows.
|
||||
"#;
|
||||
|
||||
const GITIGNORE_PROJECT: &str = "/runs\n";
|
||||
@@ -290,15 +289,12 @@ pub fn scaffold_project(spec: &ProjectScaffoldSpec) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emit the project. `Err` is a runtime refusal — the caller exits 1 (C14
|
||||
/// partition). All refusals fire before the first write.
|
||||
#[allow(dead_code)] // #241: `aura nodes new` (next task) becomes the caller
|
||||
pub fn scaffold(spec: &ScaffoldSpec) -> Result<(), String> {
|
||||
/// Emit a node crate (#241): the native half of the old single-tier scaffold
|
||||
/// — Cargo.toml + src/lib.rs + crate-scoped .gitignore + CLAUDE.md, no
|
||||
/// Aura.toml and no blueprints/ (those belong to the research project).
|
||||
pub fn scaffold_node_crate(spec: &ScaffoldSpec) -> Result<(), String> {
|
||||
if spec.target_dir.exists() {
|
||||
return Err(format!(
|
||||
"destination `{}` already exists",
|
||||
spec.target_dir.display()
|
||||
));
|
||||
return Err(format!("destination `{}` already exists", spec.target_dir.display()));
|
||||
}
|
||||
if !spec.engine_root.join("crates/aura-core").is_dir() {
|
||||
return Err(format!(
|
||||
@@ -315,11 +311,9 @@ pub fn scaffold(spec: &ScaffoldSpec) -> Result<(), String> {
|
||||
std::fs::write(&path, body).map_err(|e| format!("writing `{}`: {e}", path.display()))
|
||||
};
|
||||
write("Cargo.toml", render(CARGO_TOML, spec))?;
|
||||
write("Aura.toml", render(AURA_TOML, spec))?;
|
||||
write(".gitignore", render(GITIGNORE, spec))?;
|
||||
write(".gitignore", GITIGNORE_CRATE.to_string())?;
|
||||
write("src/lib.rs", render(LIB_RS, spec))?;
|
||||
write("blueprints/signal.json", render(SIGNAL_JSON, spec))?;
|
||||
write("CLAUDE.md", render(CLAUDE_MD, spec))?;
|
||||
write("CLAUDE.md", render(CLAUDE_MD_CRATE, spec))?;
|
||||
// Best-effort git init: a warning, never an error (cargo-new mirror).
|
||||
// Also the cargo-new mirror: inside an existing work tree the init is
|
||||
// SKIPPED silently — a nested .git is a gitlink/submodule hazard when the
|
||||
@@ -327,17 +321,29 @@ pub fn scaffold(spec: &ScaffoldSpec) -> Result<(), String> {
|
||||
if inside_git_work_tree(&spec.target_dir) {
|
||||
return Ok(());
|
||||
}
|
||||
match std::process::Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(&spec.target_dir)
|
||||
.output()
|
||||
{
|
||||
match std::process::Command::new("git").arg("init").current_dir(&spec.target_dir).output() {
|
||||
Ok(o) if o.status.success() => {}
|
||||
_ => eprintln!("aura: warning: git init failed (project created without a repo)"),
|
||||
_ => eprintln!("aura: warning: git init failed (node crate created without a repo)"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append the `[nodes]` pointer to a project's Aura.toml. Refuses when a
|
||||
/// `[nodes]` section already exists (single-crate iteration).
|
||||
pub fn append_nodes_pointer(project_root: &Path, pointer: &str) -> Result<(), String> {
|
||||
let path = project_root.join("Aura.toml");
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("reading `{}`: {e}", path.display()))?;
|
||||
if text.contains("[nodes]") {
|
||||
return Err(
|
||||
"Aura.toml already has a [nodes] section (multi-crate loading is not yet supported)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
std::fs::write(&path, format!("{text}\n[nodes]\ncrates = [\"{pointer}\"]\n"))
|
||||
.map_err(|e| format!("writing `{}`: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Whether `dir` already sits inside a git work tree (`git rev-parse`, the
|
||||
/// same oracle cargo-new consults). A missing git binary or any failure reads
|
||||
/// as "not in a work tree" — the init stays best-effort either way.
|
||||
@@ -388,16 +394,13 @@ mod tests {
|
||||
fn templates_render_without_leftover_tokens() {
|
||||
// Crate-template set (native tier, via `ScaffoldSpec`).
|
||||
let spec = spec_for("demo-lab", None);
|
||||
for t in [CARGO_TOML, AURA_TOML, GITIGNORE, LIB_RS, SIGNAL_JSON, CLAUDE_MD] {
|
||||
for t in [CARGO_TOML, GITIGNORE_CRATE, LIB_RS, CLAUDE_MD_CRATE] {
|
||||
let out = render(t, &spec);
|
||||
assert!(!out.contains("__"), "unsubstituted token in: {out}");
|
||||
}
|
||||
let lib = render(LIB_RS, &spec);
|
||||
assert!(lib.contains("\"demo_lab::Scale\""));
|
||||
assert!(lib.contains("namespace: \"demo_lab\""));
|
||||
let json = render(SIGNAL_JSON, &spec);
|
||||
assert!(json.contains("\"type\":\"demo_lab::Scale\""));
|
||||
assert!(json.contains("\"name\":\"demo_lab_signal\""));
|
||||
let cargo = render(CARGO_TOML, &spec);
|
||||
assert!(cargo.contains("name = \"demo-lab\""));
|
||||
assert!(cargo.contains("/crates/aura-core"));
|
||||
@@ -418,16 +421,13 @@ mod tests {
|
||||
/// Property (#183, fieldtest F2 gap): the scaffold's own starter node teaches
|
||||
/// the bound-param build closure. Its rendered `src/lib.rs` declares a param in
|
||||
/// the node schema and reads a bound value in the build closure (not the
|
||||
/// param-less `|_|` Identity the template shipped before), and the rendered
|
||||
/// starter blueprint binds `factor` on the project's own node — so `aura run`
|
||||
/// exercises the bound-param path with zero hand-editing. (The scaffold→build→
|
||||
/// run e2e in tests/project_new.rs re-validates the swap end to end; this pins
|
||||
/// the copyable template content a newcomer learns from directly.)
|
||||
/// param-less `|_|` Identity the template shipped before) — the copyable
|
||||
/// template content a newcomer learns from directly.
|
||||
#[test]
|
||||
fn starter_node_declares_and_reads_a_bound_param() {
|
||||
let spec = spec_for("demo-lab", None);
|
||||
let lib = render(LIB_RS, &spec);
|
||||
// (a) the node schema declares a bindable param...
|
||||
// the node schema declares a bindable param...
|
||||
assert!(
|
||||
lib.contains("ParamSpec"),
|
||||
"starter node must declare a param in its schema, not `params: vec![]`: {lib}"
|
||||
@@ -437,13 +437,6 @@ mod tests {
|
||||
lib.contains("p[0].f64()"),
|
||||
"starter build closure must consume a bound param (p[0].f64()), not `|_|`: {lib}"
|
||||
);
|
||||
// (b) the starter blueprint binds `factor` on the project's own node, so
|
||||
// `aura run` drives the bound-param path out of the box.
|
||||
let json = render(SIGNAL_JSON, &spec);
|
||||
assert!(
|
||||
json.contains("\"name\":\"factor\""),
|
||||
"starter blueprint must bind the `factor` param on the project node: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #241: the data-only starter blueprints reference std vocabulary only —
|
||||
@@ -476,18 +469,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaffold_refuses_existing_dir_and_missing_engine() {
|
||||
fn scaffold_node_crate_refuses_existing_dir_and_missing_engine() {
|
||||
let tmp = std::env::temp_dir().join(format!("aura-scaf-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
// existing destination
|
||||
let mut spec = scaffold_spec("x", None, None, &tmp).unwrap();
|
||||
std::fs::create_dir_all(tmp.join("x")).unwrap();
|
||||
let e = scaffold(&spec).unwrap_err();
|
||||
let e = scaffold_node_crate(&spec).unwrap_err();
|
||||
assert!(e.contains("already exists"), "{e}");
|
||||
// missing engine root
|
||||
std::fs::remove_dir_all(tmp.join("x")).unwrap();
|
||||
spec.engine_root = PathBuf::from("/nonexistent-engine");
|
||||
let e = scaffold(&spec).unwrap_err();
|
||||
let e = scaffold_node_crate(&spec).unwrap_err();
|
||||
assert!(e.contains("--engine-path"), "{e}");
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
@@ -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