feat(cli): aura new scaffold module — validation, templates, emit (0103 task 1)
Token-replace templates (Cargo.toml/Aura.toml/.gitignore/lib.rs/ signal.json/CLAUDE.md) parameterized by name/namespace/engine root; argv validation (usage exit-2 class), refusals before first write (existing destination, missing engine root with --engine-path hint), best-effort git init. Unit tests: validation table, snake derivation, engine-root default, token-free rendering, refusal pair — 5/5 green. Verified against the fixed plan (derive(Debug) — see the plan-fix commit); CLI wiring follows as task 2. refs #180
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
mod render;
|
||||
mod graph_construct;
|
||||
mod project;
|
||||
mod scaffold;
|
||||
use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series};
|
||||
|
||||
use aura_core::{zip_params, Cell, Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
//! `aura new` — scaffold a research project crate (cycle 0103, C16/C17).
|
||||
//!
|
||||
//! Emits the cycle-0102 fixture shape parameterized by name/namespace: a
|
||||
//! cdylib crate with path-deps on the engine checkout (the baked default or
|
||||
//! `--engine-path`), a paths-only `Aura.toml`, a sample node + runnable
|
||||
//! blueprint, and a project `CLAUDE.md`. The scaffolder never builds and
|
||||
//! never loads (the author builds; `aura new` bypasses the project-load
|
||||
//! phase in `main()`).
|
||||
//!
|
||||
//! Templates are raw strings with `__NS__` / `__NAME__` / `__NAME_SNAKE__` /
|
||||
//! `__ENGINE__` tokens substituted by plain `.replace()` — no format-string
|
||||
//! brace escaping in Rust template bodies.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ScaffoldSpec {
|
||||
pub name: String,
|
||||
pub namespace: String,
|
||||
pub engine_root: PathBuf,
|
||||
pub target_dir: PathBuf,
|
||||
}
|
||||
|
||||
fn valid_name(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& !s.starts_with(|c: char| c.is_ascii_digit())
|
||||
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
fn valid_namespace(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& !s.starts_with(|c: char| c.is_ascii_digit())
|
||||
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
/// The name→namespace / name→blueprint-name mapping: dashes become
|
||||
/// underscores (mirrors cargo's crate-name normalization).
|
||||
pub fn snake(name: &str) -> String {
|
||||
name.replace('-', "_")
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn default_engine_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(2)
|
||||
.expect("aura-cli manifest dir has two ancestors")
|
||||
.to_path_buf()
|
||||
}
|
||||
|
||||
/// Validate the argv-level inputs. `Err` is a usage fault — the caller
|
||||
/// exits 2 (C14 partition).
|
||||
pub fn scaffold_spec(
|
||||
name: &str,
|
||||
engine_path: Option<&Path>,
|
||||
namespace: Option<&str>,
|
||||
cwd: &Path,
|
||||
) -> Result<ScaffoldSpec, String> {
|
||||
if !valid_name(name) {
|
||||
return Err(format!(
|
||||
"invalid project name `{name}` (allowed: letters, digits, `_`, `-`; no leading digit)"
|
||||
));
|
||||
}
|
||||
let namespace = match namespace {
|
||||
Some(ns) if !valid_namespace(ns) => {
|
||||
return Err(format!(
|
||||
"invalid namespace `{ns}` (allowed: letters, digits, `_`; no leading digit)"
|
||||
));
|
||||
}
|
||||
Some(ns) => ns.to_string(),
|
||||
None => snake(name),
|
||||
};
|
||||
Ok(ScaffoldSpec {
|
||||
name: name.to_string(),
|
||||
namespace,
|
||||
engine_root: engine_path
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(default_engine_root),
|
||||
target_dir: cwd.join(name),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- templates
|
||||
|
||||
const CARGO_TOML: &str = r#"[package]
|
||||
name = "__NAME__"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
aura-core = { path = "__ENGINE__/crates/aura-core" }
|
||||
|
||||
# Standalone workspace root: never a member of an enclosing workspace.
|
||||
[workspace]
|
||||
"#;
|
||||
|
||||
const AURA_TOML: &str = r#"# Static project context only (C17); paths only.
|
||||
[paths]
|
||||
runs = "runs"
|
||||
"#;
|
||||
|
||||
const GITIGNORE: &str = "/target\nCargo.lock\n/runs\n";
|
||||
|
||||
const LIB_RS: &str = r#"//! __NAME__ — an aura research project: node logic + the exported vocabulary.
|
||||
//!
|
||||
//! Every node type this crate provides is registered in `vocabulary()` /
|
||||
//! `type_ids()` under the `__NS__::` namespace prefix; the `aura` host loads
|
||||
//! this cdylib per invocation and merges it with the std vocabulary.
|
||||
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder,
|
||||
ScalarKind,
|
||||
};
|
||||
|
||||
/// One-input f64 pass-through. Emits `None` until its input has a value.
|
||||
pub struct Identity {
|
||||
out: [Cell; 1],
|
||||
}
|
||||
|
||||
impl Identity {
|
||||
pub fn new() -> Self {
|
||||
Self { out: [Cell::from_f64(0.0)] }
|
||||
}
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::new(
|
||||
"__NS__::Identity",
|
||||
NodeSchema {
|
||||
inputs: vec![PortSpec {
|
||||
kind: ScalarKind::F64,
|
||||
firing: Firing::Any,
|
||||
name: "value".into(),
|
||||
}],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
},
|
||||
|_| Box::new(Identity::new()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Identity {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Node for Identity {
|
||||
fn lookbacks(&self) -> Vec<usize> {
|
||||
vec![1]
|
||||
}
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||
let w = ctx.f64_in(0);
|
||||
if w.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.out[0] = Cell::from_f64(w[0]);
|
||||
Some(&self.out)
|
||||
}
|
||||
fn label(&self) -> String {
|
||||
"__NS__::Identity".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
|
||||
match type_id {
|
||||
"__NS__::Identity" => Some(Identity::builder()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn type_ids() -> &'static [&'static str] {
|
||||
&["__NS__::Identity"]
|
||||
}
|
||||
|
||||
aura_core::aura_project! {
|
||||
namespace: "__NS__",
|
||||
vocabulary: vocabulary,
|
||||
type_ids: type_ids,
|
||||
}
|
||||
"#;
|
||||
|
||||
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__::Identity"}}],"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: &str = r#"# __NAME__ — an aura research project
|
||||
|
||||
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.
|
||||
"#;
|
||||
|
||||
fn render(template: &str, spec: &ScaffoldSpec) -> String {
|
||||
template
|
||||
.replace("__NAME_SNAKE__", &snake(&spec.name))
|
||||
.replace("__NAME__", &spec.name)
|
||||
.replace("__NS__", &spec.namespace)
|
||||
.replace("__ENGINE__", &spec.engine_root.display().to_string())
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- emit
|
||||
|
||||
/// Emit the project. `Err` is a runtime refusal — the caller exits 1 (C14
|
||||
/// partition). All refusals fire before the first write.
|
||||
pub fn scaffold(spec: &ScaffoldSpec) -> Result<(), String> {
|
||||
if spec.target_dir.exists() {
|
||||
return Err(format!(
|
||||
"destination `{}` already exists",
|
||||
spec.target_dir.display()
|
||||
));
|
||||
}
|
||||
if !spec.engine_root.join("crates/aura-core").is_dir() {
|
||||
return Err(format!(
|
||||
"engine checkout not found at `{}` — pass --engine-path <engine checkout>",
|
||||
spec.engine_root.display()
|
||||
));
|
||||
}
|
||||
let write = |rel: &str, body: String| -> Result<(), String> {
|
||||
let path = spec.target_dir.join(rel);
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("creating `{}`: {e}", dir.display()))?;
|
||||
}
|
||||
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("src/lib.rs", render(LIB_RS, spec))?;
|
||||
write("blueprints/signal.json", render(SIGNAL_JSON, spec))?;
|
||||
write("CLAUDE.md", render(CLAUDE_MD, spec))?;
|
||||
// Best-effort git init: a warning, never an error (cargo-new mirror).
|
||||
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)"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn spec_for(name: &str, ns: Option<&str>) -> ScaffoldSpec {
|
||||
scaffold_spec(name, None, ns, Path::new("/tmp/x")).expect("valid spec")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_and_namespace_validation_table() {
|
||||
assert!(scaffold_spec("ger40-lab", None, None, Path::new("/t")).is_ok());
|
||||
assert!(scaffold_spec("x_1", None, None, Path::new("/t")).is_ok());
|
||||
for bad in ["", "bad/name", "9lives", "a b", "ä"] {
|
||||
let e = scaffold_spec(bad, None, None, Path::new("/t")).unwrap_err();
|
||||
assert!(e.contains("invalid project name"), "{bad}: {e}");
|
||||
}
|
||||
for bad_ns in ["", "with-dash", "9x", "a::b"] {
|
||||
let e = scaffold_spec("ok", None, Some(bad_ns), Path::new("/t")).unwrap_err();
|
||||
assert!(e.contains("invalid namespace"), "{bad_ns}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_defaults_to_snake_name() {
|
||||
assert_eq!(spec_for("ger40-lab", None).namespace, "ger40_lab");
|
||||
assert_eq!(spec_for("ger40-lab", Some("ger40")).namespace, "ger40");
|
||||
assert_eq!(snake("a-b-c"), "a_b_c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_engine_root_is_this_checkout() {
|
||||
assert!(default_engine_root().join("crates/aura-core").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates_render_without_leftover_tokens() {
|
||||
let spec = spec_for("demo-lab", None);
|
||||
for t in [CARGO_TOML, AURA_TOML, GITIGNORE, LIB_RS, SIGNAL_JSON, CLAUDE_MD] {
|
||||
let out = render(t, &spec);
|
||||
assert!(!out.contains("__"), "unsubstituted token in: {out}");
|
||||
}
|
||||
let lib = render(LIB_RS, &spec);
|
||||
assert!(lib.contains("\"demo_lab::Identity\""));
|
||||
assert!(lib.contains("namespace: \"demo_lab\""));
|
||||
let json = render(SIGNAL_JSON, &spec);
|
||||
assert!(json.contains("\"type\":\"demo_lab::Identity\""));
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaffold_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();
|
||||
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();
|
||||
assert!(e.contains("--engine-path"), "{e}");
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user