feat(cli): aura new — CLI wiring, load-bypass guard, authoring-loop e2e (0103 tasks 2-4)
Command::New + NewCmd + dispatch_new (usage faults exit 2, runtime refusals exit 1, the C14 partition); the main() guard exempts aura new from the eager project load so scaffolding works inside unbuilt trees. E2e (tests/project_new.rs, 6 tests): scaffold -> cargo build -> aura run twice byte-identical with manifest.project.namespace stamped; introspect lists the project type beside std; refusals for existing destination (1), invalid names (2), missing engine path with --engine-path hint (1); aura new inside an unbuilt project tree succeeds (the bypass proof); namespace override reaches the built vocabulary (an additive e2e beyond the plan, kept). Verification: cargo build --workspace clean; cargo test --workspace 873 passed / 0 failed; clippy -D warnings clean. refs #180
This commit is contained in:
+59
-12
@@ -3760,6 +3760,8 @@ enum Command {
|
|||||||
Runs(RunsCmd),
|
Runs(RunsCmd),
|
||||||
/// Reproduce a recorded family by content id.
|
/// Reproduce a recorded family by content id.
|
||||||
Reproduce(ReproduceCmd),
|
Reproduce(ReproduceCmd),
|
||||||
|
/// Scaffold a new research project crate.
|
||||||
|
New(NewCmd),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
@@ -3774,6 +3776,19 @@ struct ChartCmd {
|
|||||||
panels: bool,
|
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<std::path::PathBuf>,
|
||||||
|
/// Vocabulary namespace (default: the name with dashes as underscores).
|
||||||
|
#[arg(long)]
|
||||||
|
namespace: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
struct GraphCmd {
|
struct GraphCmd {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
@@ -4348,6 +4363,31 @@ fn dispatch_reproduce(a: ReproduceCmd, env: &project::Env) {
|
|||||||
reproduce_family(&a.id, 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
|
/// `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.
|
/// first-positional is an existing `.json`, else the built-in `--strategy` grid sweep.
|
||||||
fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
|
fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
|
||||||
@@ -4637,18 +4677,24 @@ fn main() {
|
|||||||
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
|
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
|
||||||
}
|
}
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let env = match std::env::current_dir()
|
// `aura new` scaffolds; it must not require a loadable project even
|
||||||
.ok()
|
// when invoked inside one (e.g. an unbuilt tree).
|
||||||
.and_then(|d| project::discover_from(&d))
|
let env = if matches!(cli.command, Command::New(_)) {
|
||||||
{
|
project::Env::std()
|
||||||
Some(root) => match project::load(&root, cli.release) {
|
} else {
|
||||||
Ok(p) => project::Env::with_project(p),
|
match std::env::current_dir()
|
||||||
Err(e) => {
|
.ok()
|
||||||
eprintln!("aura: {e}");
|
.and_then(|d| project::discover_from(&d))
|
||||||
std::process::exit(1);
|
{
|
||||||
}
|
Some(root) => match project::load(&root, cli.release) {
|
||||||
},
|
Ok(p) => project::Env::with_project(p),
|
||||||
None => project::Env::std(),
|
Err(e) => {
|
||||||
|
eprintln!("aura: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => project::Env::std(),
|
||||||
|
}
|
||||||
};
|
};
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Run(a) => dispatch_run(a, &env),
|
Command::Run(a) => dispatch_run(a, &env),
|
||||||
@@ -4660,6 +4706,7 @@ fn main() {
|
|||||||
Command::Mc(a) => dispatch_mc(a, &env),
|
Command::Mc(a) => dispatch_mc(a, &env),
|
||||||
Command::Runs(a) => dispatch_runs(a, &env),
|
Command::Runs(a) => dispatch_runs(a, &env),
|
||||||
Command::Reproduce(a) => dispatch_reproduce(a, &env),
|
Command::Reproduce(a) => dispatch_reproduce(a, &env),
|
||||||
|
Command::New(a) => dispatch_new(a, &env),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user