diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 7cfebf3..db711ea 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -3760,6 +3760,8 @@ enum Command { Runs(RunsCmd), /// Reproduce a recorded family by content id. Reproduce(ReproduceCmd), + /// Scaffold a new research project crate. + New(NewCmd), } #[derive(Args)] @@ -3774,6 +3776,19 @@ struct ChartCmd { panels: bool, } +#[derive(Args)] +struct NewCmd { + /// Directory / crate name to create. + name: String, + /// Engine checkout the project depends on (path deps). Default: the + /// engine root this binary was built from. + #[arg(long)] + engine_path: Option, + /// Vocabulary namespace (default: the name with dashes as underscores). + #[arg(long)] + namespace: Option, +} + #[derive(Args)] struct GraphCmd { #[command(subcommand)] @@ -4348,6 +4363,31 @@ fn dispatch_reproduce(a: ReproduceCmd, env: &project::Env) { reproduce_family(&a.id, env); } +fn dispatch_new(a: NewCmd, _env: &project::Env) { + let cwd = std::env::current_dir().unwrap_or_else(|e| { + eprintln!("aura: {e}"); + std::process::exit(1); + }); + let spec = scaffold::scaffold_spec( + &a.name, + a.engine_path.as_deref(), + a.namespace.as_deref(), + &cwd, + ) + .unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(2); + }); + scaffold::scaffold(&spec).unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(1); + }); + println!( + "created project `{}` (namespace `{}`)", + spec.name, spec.namespace + ); +} + /// `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) { @@ -4637,18 +4677,24 @@ fn main() { libc::signal(libc::SIGPIPE, libc::SIG_DFL); } let cli = Cli::parse(); - let env = match std::env::current_dir() - .ok() - .and_then(|d| project::discover_from(&d)) - { - Some(root) => match project::load(&root, cli.release) { - Ok(p) => project::Env::with_project(p), - Err(e) => { - eprintln!("aura: {e}"); - std::process::exit(1); - } - }, - None => project::Env::std(), + // `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(_)) { + project::Env::std() + } else { + match std::env::current_dir() + .ok() + .and_then(|d| project::discover_from(&d)) + { + Some(root) => match project::load(&root, cli.release) { + Ok(p) => project::Env::with_project(p), + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(1); + } + }, + None => project::Env::std(), + } }; match cli.command { Command::Run(a) => dispatch_run(a, &env), @@ -4660,6 +4706,7 @@ fn main() { Command::Mc(a) => dispatch_mc(a, &env), Command::Runs(a) => dispatch_runs(a, &env), Command::Reproduce(a) => dispatch_reproduce(a, &env), + Command::New(a) => dispatch_new(a, &env), } } diff --git a/crates/aura-cli/tests/project_new.rs b/crates/aura-cli/tests/project_new.rs new file mode 100644 index 0000000..1ea0fcd --- /dev/null +++ b/crates/aura-cli/tests/project_new.rs @@ -0,0 +1,161 @@ +//! E2E: the `aura new` scaffolder (cycle 0103). Proves the full authoring +//! loop — scaffold → cargo build → aura run — with zero hand-editing, plus +//! the refusal contract. Scaffolded projects live in per-test temp dirs; +//! their path-deps resolve into this checkout (the baked engine root). + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn aura(args: &[&str], cwd: &Path) -> Output { + Command::new(env!("CARGO_BIN_EXE_aura")) + .args(args) + .current_dir(cwd) + .output() + .expect("run aura") +} + +fn tmp(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("aura-new-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn new_scaffold_builds_and_runs_the_authoring_loop() { + let base = tmp("loop"); + let out = aura(&["new", "demo-lab"], &base); + assert!( + out.status.success(), + "aura new failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let proj = base.join("demo-lab"); + for f in [ + "Cargo.toml", + "Aura.toml", + ".gitignore", + "src/lib.rs", + "blueprints/signal.json", + "CLAUDE.md", + ] { + assert!(proj.join(f).is_file(), "missing scaffolded file {f}"); + } + let build = Command::new("cargo") + .arg("build") + .current_dir(&proj) + .output() + .expect("cargo build scaffolded project"); + assert!( + build.status.success(), + "scaffolded build failed:\n{}", + String::from_utf8_lossy(&build.stderr) + ); + let a = aura(&["run", "blueprints/signal.json"], &proj); + assert!( + a.status.success(), + "run failed: {}", + String::from_utf8_lossy(&a.stderr) + ); + let b = aura(&["run", "blueprints/signal.json"], &proj); + assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)"); + let v: serde_json::Value = serde_json::from_slice(&a.stdout).expect("report is JSON"); + assert_eq!(v["manifest"]["project"]["namespace"], "demo_lab"); + let introspect = aura(&["graph", "introspect", "--vocabulary"], &proj); + assert!(introspect.status.success()); + let text = String::from_utf8_lossy(&introspect.stdout); + assert!(text.contains("demo_lab::Identity"), "project id listed"); + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn new_refuses_an_existing_destination() { + let base = tmp("exists"); + std::fs::create_dir_all(base.join("taken")).unwrap(); + let out = aura(&["new", "taken"], &base); + assert_eq!(out.status.code(), Some(1), "runtime refusal"); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("already exists"), "stderr: {err}"); + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn new_rejects_invalid_names_as_usage_faults() { + let base = tmp("badname"); + for bad in ["bad/name", "9lives"] { + let out = aura(&["new", bad], &base); + assert_eq!(out.status.code(), Some(2), "usage fault for {bad}"); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("invalid project name"), "stderr: {err}"); + } + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn new_refuses_a_missing_engine_path_with_hint() { + let base = tmp("noengine"); + let out = aura(&["new", "x", "--engine-path", "/nonexistent-engine"], &base); + assert_eq!(out.status.code(), Some(1), "runtime refusal"); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("--engine-path"), "stderr: {err}"); + let _ = std::fs::remove_dir_all(&base); +} + +/// Property: an explicit `--namespace` override actually reaches the +/// runtime vocabulary (not just the `ScaffoldSpec` struct field checked at +/// the unit layer) — a scaffolded, built, run project registers its node +/// under the caller-chosen namespace, and the manifest records it too. +#[test] +fn new_namespace_override_reaches_the_built_vocabulary() { + let base = tmp("customns"); + let out = aura(&["new", "demo-lab", "--namespace", "custom_ns"], &base); + assert!( + out.status.success(), + "aura new failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let proj = base.join("demo-lab"); + let build = Command::new("cargo") + .arg("build") + .current_dir(&proj) + .output() + .expect("cargo build scaffolded project"); + assert!( + build.status.success(), + "scaffolded build failed:\n{}", + String::from_utf8_lossy(&build.stderr) + ); + let run = aura(&["run", "blueprints/signal.json"], &proj); + assert!( + run.status.success(), + "run failed: {}", + String::from_utf8_lossy(&run.stderr) + ); + let v: serde_json::Value = serde_json::from_slice(&run.stdout).expect("report is JSON"); + assert_eq!(v["manifest"]["project"]["namespace"], "custom_ns"); + let introspect = aura(&["graph", "introspect", "--vocabulary"], &proj); + assert!(introspect.status.success()); + let text = String::from_utf8_lossy(&introspect.stdout); + assert!(text.contains("custom_ns::Identity"), "overridden namespace listed: {text}"); + assert!(!text.contains("demo_lab::Identity"), "default-derived namespace must not leak"); + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn new_works_inside_an_unbuilt_project_tree() { + // Scaffold once (unbuilt), then scaffold AGAIN from inside that tree: + // the load-bypass guard must keep aura new from trying to load the + // enclosing project's (absent) dylib. + let base = tmp("nested"); + let out = aura(&["new", "outer"], &base); + assert!(out.status.success()); + let inner_cwd = base.join("outer"); + let out = aura(&["new", "inner"], &inner_cwd); + assert!( + out.status.success(), + "aura new inside an unbuilt project tree must succeed: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(inner_cwd.join("inner/Cargo.toml").is_file()); + let _ = std::fs::remove_dir_all(&base); +}