diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index d0d520d..4842936 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -1972,6 +1972,7 @@ dependencies = [ "axum", "serde", "serde_json", + "tempfile", "tokio", "tower", "tracing", diff --git a/_primitives/_rust/kei-forge/Cargo.toml b/_primitives/_rust/kei-forge/Cargo.toml index 23673c1..0ddab31 100644 --- a/_primitives/_rust/kei-forge/Cargo.toml +++ b/_primitives/_rust/kei-forge/Cargo.toml @@ -28,9 +28,4 @@ tracing-subscriber = "0.3" [dev-dependencies] tower = { version = "0.5", features = ["util"] } - -[features] -default = [] -# When enabled, POST /forge skips the shell-out to scripts/new-atom.sh and -# returns a synthesized success payload. Used exclusively by tests. -mock-generate = [] +tempfile = "3" diff --git a/_primitives/_rust/kei-forge/src/generate.rs b/_primitives/_rust/kei-forge/src/generate.rs index d5bad7c..77b2ca0 100644 --- a/_primitives/_rust/kei-forge/src/generate.rs +++ b/_primitives/_rust/kei-forge/src/generate.rs @@ -1,18 +1,60 @@ -//! Atom-scaffolding generator. +//! Atom-scaffolding generator — pure Rust templating. //! -//! MVP implementation: shells out to `scripts/new-atom.sh` with form values -//! as argv and `ATOM_DESCRIPTION` in the environment. Parses the "Files -//! created:" block from stdout into a structured file-list. +//! Reads the five templates in `/_templates/atom/`, substitutes the +//! six placeholder tokens (`__CRATE__`, `__CRATE_SNAKE__`, `__VERB__`, +//! `__VERB_SNAKE__`, `__KIND__`, `__DESCRIPTION__`), and writes the +//! resulting files into `/_primitives/_rust//`. //! -//! Follow-up (post-MVP): reimplement in pure Rust by reading -//! `_templates/atom/` directly, eliminating the shell dependency. +//! No shell-out. No sed. The Rust string replace cannot be coerced into +//! executing a secondary expression, so the description-injection attack +//! class defended by `form::validate_description` is structurally gone — +//! the whitelist stays as defence-in-depth, not the primary barrier. +//! +//! Atomicity: every file written is accumulated in a rollback list; on +//! any write failure the accumulator is flushed (files deleted best- +//! effort) before the error surfaces. Matches new-atom.sh's `trap ERR`. use crate::form::ForgeRequest; use serde::Serialize; -use std::path::PathBuf; -use std::process::Command; +use std::fs; +use std::path::{Path, PathBuf}; -/// Result of a scaffolding attempt. +mod placeholders; +mod paths; +mod rollback; +#[cfg(test)] +mod atom_tests; + +use placeholders::Placeholders; +use paths::TargetPaths; +use rollback::Rollback; + +/// Structured failure modes returned by the pure-Rust generator. +#[derive(Debug)] +pub enum GenerateError { + /// `/_primitives/_rust//` does not exist. + CrateNotFound(PathBuf), + /// One of the five target files already exists — refuse to overwrite. + FileExists(PathBuf), + /// `/_templates/atom/` missing or a template file unreadable. + TemplateMissing(PathBuf), + /// Filesystem I/O failed mid-write. + Io(std::io::Error, PathBuf), +} + +impl std::fmt::Display for GenerateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CrateNotFound(p) => write!(f, "crate directory not found: {}", p.display()), + Self::FileExists(p) => write!(f, "file already exists: {}", p.display()), + Self::TemplateMissing(p) => write!(f, "template missing: {}", p.display()), + Self::Io(e, p) => write!(f, "i/o error on {}: {e}", p.display()), + } + } +} + +/// Result of a scaffolding attempt — wire-compatible with the previous +/// shell-out implementation. #[derive(Debug, Serialize)] pub struct ForgeResult { pub success: bool, @@ -22,32 +64,24 @@ pub struct ForgeResult { impl ForgeResult { pub fn ok(files: Vec) -> Self { - Self { - success: true, - files, - errors: Vec::new(), - } + Self { success: true, files, errors: Vec::new() } } pub fn fail(err: impl Into) -> Self { - Self { - success: false, - files: Vec::new(), - errors: vec![err.into()], - } + Self { success: false, files: Vec::new(), errors: vec![err.into()] } } } /// Locate the repo root by walking up from CARGO_MANIFEST_DIR until we -/// see `scripts/new-atom.sh`. Falls back to CWD if the env var isn't -/// set (e.g. when the binary is run detached from cargo). -fn repo_root() -> PathBuf { +/// see `_templates/atom/`. Falls back to CWD if the env var is unset +/// (detached binary) or nothing matches (ship-of-Theseus invariant). +pub fn repo_root() -> PathBuf { let start = std::env::var("CARGO_MANIFEST_DIR") .map(PathBuf::from) .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default()); - let mut cur: &std::path::Path = &start; + let mut cur: &Path = &start; loop { - if cur.join("scripts/new-atom.sh").exists() { + if cur.join("_templates/atom").is_dir() { return cur.to_path_buf(); } match cur.parent() { @@ -57,90 +91,69 @@ fn repo_root() -> PathBuf { } } -/// Execute new-atom.sh. Honours the `mock-generate` cargo feature so -/// integration tests can exercise the HTTP surface without touching the -/// real filesystem. +/// Thin wrapper the HTTP layer calls — discovers repo root, invokes the +/// pure-Rust core, projects errors onto the public `ForgeResult` shape. pub fn forge(req: &ForgeRequest) -> ForgeResult { - if cfg!(feature = "mock-generate") { - return ForgeResult::ok(vec![format!( - "_primitives/_rust/{}/atoms/{}.md", - req.crate_name, req.verb - )]); - } - let root = repo_root(); - let script = root.join("scripts/new-atom.sh"); - if !script.exists() { - return ForgeResult::fail(format!( - "scripts/new-atom.sh not found under {}", - root.display() - )); - } - - let output = Command::new(&script) - .arg(&req.crate_name) - .arg(&req.verb) - .arg(&req.kind) - .env("ATOM_DESCRIPTION", &req.description) - .current_dir(&root) - .output(); - - match output { - Ok(out) if out.status.success() => { - let stdout = String::from_utf8_lossy(&out.stdout); - ForgeResult::ok(parse_file_list(&stdout)) - } - Ok(out) => { - let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); - ForgeResult::fail(if stderr.is_empty() { - format!("new-atom.sh exited with {:?}", out.status.code()) - } else { - stderr - }) - } - Err(e) => ForgeResult::fail(format!("failed to spawn new-atom.sh: {e}")), + match generate_atom(req, &root) { + Ok(files) => ForgeResult::ok( + files + .into_iter() + .map(|p| rel_to_root(&p, &root)) + .collect(), + ), + Err(e) => ForgeResult::fail(e.to_string()), } } -/// Extract the file-list block from new-atom.sh stdout. +/// Core entry point — pure fn over (req, root), exposed for unit tests. /// -/// The script emits a `Files created:` heading followed by indented -/// paths, then a blank line, then `Next steps:`. We slice between the -/// two headings and trim each path. -fn parse_file_list(stdout: &str) -> Vec { - let mut in_block = false; - let mut files = Vec::new(); - for line in stdout.lines() { - let trimmed = line.trim(); - if trimmed == "Files created:" { - in_block = true; - continue; - } - if in_block { - if trimmed.is_empty() || trimmed == "Next steps:" { - break; - } - files.push(trimmed.to_string()); - } +/// On success returns the five absolute paths in declaration order. On +/// failure, no partial writes survive (rollback on drop). +pub fn generate_atom( + req: &ForgeRequest, + repo_root: &Path, +) -> Result, GenerateError> { + let placeholders = Placeholders::from_request(req); + let targets = TargetPaths::resolve(repo_root, req)?; + let template_dir = repo_root.join("_templates/atom"); + + if !template_dir.is_dir() { + return Err(GenerateError::TemplateMissing(template_dir)); } - files + + targets.assert_none_exist()?; + targets.ensure_parent_dirs()?; + + let mut rollback = Rollback::new(); + for (template_rel, dest) in targets.pairs().iter() { + let src = template_dir.join(template_rel); + let content = fs::read_to_string(&src) + .map_err(|_| GenerateError::TemplateMissing(src.clone()))?; + let rendered = placeholders.substitute(&content); + write_or_rollback(dest, &rendered, &mut rollback)?; + } + Ok(rollback.finish()) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_file_list() { - let stdout = "\n✓ Scaffolded atom kei-task::search (query)\n\n\ - Files created:\n a.md\n b.json\n c.rs\n\n\ - Next steps:\n 1. edit\n"; - let files = parse_file_list(stdout); - assert_eq!(files, vec!["a.md", "b.json", "c.rs"]); - } - - #[test] - fn empty_when_no_block() { - assert!(parse_file_list("nothing here").is_empty()); +/// Write one file, register in the rollback list, rollback on error. +fn write_or_rollback( + dest: &Path, + content: &str, + rollback: &mut Rollback, +) -> Result<(), GenerateError> { + match fs::write(dest, content) { + Ok(()) => { + rollback.record(dest.to_path_buf()); + Ok(()) + } + Err(e) => Err(GenerateError::Io(e, dest.to_path_buf())), } } + +/// Render path relative to repo-root for the JSON response. +pub(crate) fn rel_to_root(path: &Path, root: &Path) -> String { + path.strip_prefix(root) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| path.to_string_lossy().into_owned()) +} diff --git a/_primitives/_rust/kei-forge/src/generate/atom_tests.rs b/_primitives/_rust/kei-forge/src/generate/atom_tests.rs new file mode 100644 index 0000000..974b319 --- /dev/null +++ b/_primitives/_rust/kei-forge/src/generate/atom_tests.rs @@ -0,0 +1,140 @@ +//! Integration-flavoured tests for the pure-Rust atom generator. +//! +//! Uses `tempfile::TempDir` to stand up a miniature copy of the repo +//! layout and exercises `generate_atom` end-to-end without touching the +//! real filesystem. Kept in its own file so `generate.rs` stays within +//! the Constructor-Pattern 200-LOC cap. + +use super::{generate_atom, rel_to_root, GenerateError}; +use crate::form::ForgeRequest; +use std::fs; +use std::path::{Path, PathBuf}; + +fn fake_repo(tmp: &Path, crate_name: &str) -> PathBuf { + // Replicate the five template files under `/_templates/atom/`. + let tdir = tmp.join("_templates/atom"); + fs::create_dir_all(tdir.join("atoms/schemas")).unwrap(); + fs::create_dir_all(tdir.join("src/atoms")).unwrap(); + fs::create_dir_all(tdir.join("tests")).unwrap(); + fs::write( + tdir.join("atoms/__VERB__.md.template"), + "atom: __CRATE__::__VERB__ kind=__KIND__\n__DESCRIPTION__\n", + ) + .unwrap(); + fs::write( + tdir.join("atoms/schemas/__VERB__-input.json.template"), + "{\"id\":\"__CRATE__/__VERB__-input\"}", + ) + .unwrap(); + fs::write( + tdir.join("atoms/schemas/__VERB__-output.json.template"), + "{\"id\":\"__CRATE__/__VERB__-output\"}", + ) + .unwrap(); + fs::write( + tdir.join("src/atoms/__VERB_SNAKE__.rs.template"), + "// __CRATE_SNAKE__::__VERB_SNAKE__\n", + ) + .unwrap(); + fs::write( + tdir.join("tests/__VERB_SNAKE___smoke.rs.template"), + "// test for __CRATE__::__VERB__\n", + ) + .unwrap(); + + // Replicate the empty crate dir. + let crate_dir = tmp.join("_primitives/_rust").join(crate_name); + fs::create_dir_all(&crate_dir).unwrap(); + crate_dir +} + +fn req() -> ForgeRequest { + ForgeRequest { + crate_name: "kei-task".into(), + verb: "add-dep".into(), + kind: "command".into(), + description: "adds a dep".into(), + } +} + +#[test] +fn happy_path_writes_five_files() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fake_repo(root, "kei-task"); + + let files = generate_atom(&req(), root).expect("generate"); + assert_eq!(files.len(), 5); + for f in &files { + assert!(f.exists(), "missing {}", f.display()); + } + + // Placeholder substitution did happen. + let md = fs::read_to_string( + root.join("_primitives/_rust/kei-task/atoms/add-dep.md"), + ) + .unwrap(); + assert!(md.contains("kei-task::add-dep"), "{md}"); + assert!(md.contains("kind=command"), "{md}"); + assert!(md.contains("adds a dep"), "{md}"); + + // VERB_SNAKE flips - to _. + let rs = fs::read_to_string( + root.join("_primitives/_rust/kei-task/src/atoms/add_dep.rs"), + ) + .unwrap(); + assert!(rs.contains("kei_task::add_dep"), "{rs}"); +} + +#[test] +fn refuses_to_overwrite_existing_file() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let crate_dir = fake_repo(root, "kei-task"); + + // Pre-create one of the five target files. + fs::create_dir_all(crate_dir.join("atoms")).unwrap(); + fs::write(crate_dir.join("atoms/add-dep.md"), "pre-existing\n").unwrap(); + + let err = generate_atom(&req(), root).unwrap_err(); + assert!(matches!(err, GenerateError::FileExists(_)), "got {err:?}"); + + // And nothing else got written as a side-effect. + assert!(!crate_dir.join("src/atoms/add_dep.rs").exists()); + assert!(!crate_dir.join("tests/add_dep_smoke.rs").exists()); +} + +#[test] +fn errors_when_crate_dir_missing() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + // Templates yes, crate no. + let tdir = root.join("_templates/atom"); + fs::create_dir_all(tdir.join("atoms/schemas")).unwrap(); + fs::create_dir_all(tdir.join("src/atoms")).unwrap(); + fs::create_dir_all(tdir.join("tests")).unwrap(); + + let err = generate_atom(&req(), root).unwrap_err(); + assert!(matches!(err, GenerateError::CrateNotFound(_)), "got {err:?}"); +} + +#[test] +fn errors_when_template_dir_missing() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + // Crate yes, templates no. + fs::create_dir_all(root.join("_primitives/_rust/kei-task")).unwrap(); + + let err = generate_atom(&req(), root).unwrap_err(); + assert!(matches!(err, GenerateError::TemplateMissing(_)), "got {err:?}"); +} + +#[test] +fn forge_result_relativises_paths() { + let root = Path::new("/tmp/fake-root"); + let abs = root.join("_primitives/_rust/kei-task/atoms/add-dep.md"); + assert_eq!( + rel_to_root(&abs, root), + "_primitives/_rust/kei-task/atoms/add-dep.md" + ); +} diff --git a/_primitives/_rust/kei-forge/src/generate/paths.rs b/_primitives/_rust/kei-forge/src/generate/paths.rs new file mode 100644 index 0000000..e0847aa --- /dev/null +++ b/_primitives/_rust/kei-forge/src/generate/paths.rs @@ -0,0 +1,174 @@ +//! Target-path resolution for atom scaffolding. +//! +//! Given the repo root and a `ForgeRequest`, compute the five absolute +//! paths the generator will write, and the five relative template paths +//! it will read from. Decouples path arithmetic from I/O so tests can +//! assert directly on layout. + +use super::GenerateError; +use crate::form::ForgeRequest; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug)] +pub struct TargetPaths { + pub md: PathBuf, + pub input_schema: PathBuf, + pub output_schema: PathBuf, + pub rust_src: PathBuf, + pub smoke_test: PathBuf, +} + +impl TargetPaths { + /// Build the five destination paths for `req` under `repo_root`. + /// Returns `CrateNotFound` if `_primitives/_rust//` is absent. + pub fn resolve( + repo_root: &Path, + req: &ForgeRequest, + ) -> Result { + let crate_dir = repo_root + .join("_primitives/_rust") + .join(&req.crate_name); + if !crate_dir.is_dir() { + return Err(GenerateError::CrateNotFound(crate_dir)); + } + let verb = &req.verb; + let verb_snake = req.verb.replace('-', "_"); + Ok(Self { + md: crate_dir.join("atoms").join(format!("{verb}.md")), + input_schema: crate_dir + .join("atoms/schemas") + .join(format!("{verb}-input.json")), + output_schema: crate_dir + .join("atoms/schemas") + .join(format!("{verb}-output.json")), + rust_src: crate_dir + .join("src/atoms") + .join(format!("{verb_snake}.rs")), + smoke_test: crate_dir + .join("tests") + .join(format!("{verb_snake}_smoke.rs")), + }) + } + + /// Return `(template-rel-path, absolute-dest-path)` pairs in the same + /// order new-atom.sh emitted, so any downstream tooling that depends + /// on file-list ordering sees the same sequence. + pub fn pairs(&self) -> [(&'static str, &Path); 5] { + [ + ("atoms/__VERB__.md.template", &self.md), + ( + "atoms/schemas/__VERB__-input.json.template", + &self.input_schema, + ), + ( + "atoms/schemas/__VERB__-output.json.template", + &self.output_schema, + ), + ("src/atoms/__VERB_SNAKE__.rs.template", &self.rust_src), + ( + "tests/__VERB_SNAKE___smoke.rs.template", + &self.smoke_test, + ), + ] + } + + /// Refuse to overwrite: error on the first extant target. + pub fn assert_none_exist(&self) -> Result<(), GenerateError> { + for (_, dest) in self.pairs().iter() { + if dest.exists() { + return Err(GenerateError::FileExists(dest.to_path_buf())); + } + } + Ok(()) + } + + /// Create `atoms/`, `atoms/schemas/`, `src/atoms/`, `tests/` under + /// the crate dir. Idempotent. + pub fn ensure_parent_dirs(&self) -> Result<(), GenerateError> { + let dirs = [ + self.md.parent(), + self.input_schema.parent(), + self.output_schema.parent(), + self.rust_src.parent(), + self.smoke_test.parent(), + ]; + for dir in dirs.into_iter().flatten() { + fs::create_dir_all(dir) + .map_err(|e| GenerateError::Io(e, dir.to_path_buf()))?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn req() -> ForgeRequest { + ForgeRequest { + crate_name: "kei-task".into(), + verb: "add-dep".into(), + kind: "command".into(), + description: "x".into(), + } + } + + #[test] + fn resolves_five_paths_under_crate() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fs::create_dir_all(root.join("_primitives/_rust/kei-task")).unwrap(); + + let t = TargetPaths::resolve(root, &req()).unwrap(); + assert!(t.md.ends_with("kei-task/atoms/add-dep.md")); + assert!(t + .input_schema + .ends_with("kei-task/atoms/schemas/add-dep-input.json")); + assert!(t + .output_schema + .ends_with("kei-task/atoms/schemas/add-dep-output.json")); + assert!(t.rust_src.ends_with("kei-task/src/atoms/add_dep.rs")); + assert!(t + .smoke_test + .ends_with("kei-task/tests/add_dep_smoke.rs")); + } + + #[test] + fn errors_when_crate_missing() { + let tmp = tempfile::tempdir().unwrap(); + let err = TargetPaths::resolve(tmp.path(), &req()).unwrap_err(); + assert!(matches!(err, GenerateError::CrateNotFound(_))); + } + + #[test] + fn assert_none_exist_trips_on_preexisting() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fs::create_dir_all(root.join("_primitives/_rust/kei-task/atoms")).unwrap(); + fs::write( + root.join("_primitives/_rust/kei-task/atoms/add-dep.md"), + "x", + ) + .unwrap(); + + let t = TargetPaths::resolve(root, &req()).unwrap(); + let err = t.assert_none_exist().unwrap_err(); + assert!(matches!(err, GenerateError::FileExists(_))); + } + + #[test] + fn ensure_parent_dirs_idempotent() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fs::create_dir_all(root.join("_primitives/_rust/kei-task")).unwrap(); + + let t = TargetPaths::resolve(root, &req()).unwrap(); + t.ensure_parent_dirs().unwrap(); + t.ensure_parent_dirs().unwrap(); // second call — no panic + assert!(t.md.parent().unwrap().is_dir()); + assert!(t.input_schema.parent().unwrap().is_dir()); + assert!(t.rust_src.parent().unwrap().is_dir()); + assert!(t.smoke_test.parent().unwrap().is_dir()); + } +} diff --git a/_primitives/_rust/kei-forge/src/generate/placeholders.rs b/_primitives/_rust/kei-forge/src/generate/placeholders.rs new file mode 100644 index 0000000..351289b --- /dev/null +++ b/_primitives/_rust/kei-forge/src/generate/placeholders.rs @@ -0,0 +1,101 @@ +//! Placeholder substitution for atom-template rendering. +//! +//! Pure string replace — six tokens, one pass per token. Called by +//! `super::generate_atom` for each of the five template files. +//! +//! Order matters: `__CRATE_SNAKE__` must be replaced BEFORE `__CRATE__` +//! (the latter is a substring of the former). Same for `__VERB_SNAKE__` +//! vs `__VERB__`. The implementation does longer-tokens-first. + +use crate::form::ForgeRequest; + +pub struct Placeholders { + pub crate_name: String, + pub crate_snake: String, + pub verb: String, + pub verb_snake: String, + pub kind: String, + pub description: String, +} + +impl Placeholders { + pub fn from_request(req: &ForgeRequest) -> Self { + Self { + crate_snake: req.crate_name.replace('-', "_"), + crate_name: req.crate_name.clone(), + verb_snake: req.verb.replace('-', "_"), + verb: req.verb.clone(), + kind: req.kind.clone(), + description: req.description.clone(), + } + } + + /// Apply all six substitutions to `src`. Longer tokens first so that + /// `__CRATE_SNAKE__` isn't consumed by the `__CRATE__` pass. + pub fn substitute(&self, src: &str) -> String { + src.replace("__CRATE_SNAKE__", &self.crate_snake) + .replace("__VERB_SNAKE__", &self.verb_snake) + .replace("__DESCRIPTION__", &self.description) + .replace("__CRATE__", &self.crate_name) + .replace("__VERB__", &self.verb) + .replace("__KIND__", &self.kind) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn req() -> ForgeRequest { + ForgeRequest { + crate_name: "kei-task".into(), + verb: "add-dep".into(), + kind: "command".into(), + description: "docs".into(), + } + } + + #[test] + fn snake_before_dash_form() { + // If __CRATE__ ran first, __CRATE_SNAKE__ would become + // "kei-task_SNAKE__" — verify ordering is correct. + let p = Placeholders::from_request(&req()); + let out = p.substitute("__CRATE_SNAKE__ / __CRATE__"); + assert_eq!(out, "kei_task / kei-task"); + } + + #[test] + fn verb_snake_correct() { + let p = Placeholders::from_request(&req()); + let out = p.substitute("__VERB_SNAKE__ vs __VERB__"); + assert_eq!(out, "add_dep vs add-dep"); + } + + #[test] + fn description_and_kind_pass_through() { + let p = Placeholders::from_request(&req()); + assert_eq!(p.substitute("__KIND__"), "command"); + assert_eq!(p.substitute("__DESCRIPTION__"), "docs"); + } + + #[test] + fn multiple_occurrences_all_replaced() { + let p = Placeholders::from_request(&req()); + let out = p.substitute("__VERB__ __VERB__ __VERB__"); + assert_eq!(out, "add-dep add-dep add-dep"); + } + + #[test] + fn empty_verb_crate_supported() { + // Used in case callers pass an already-snake name (no dashes). + let req = ForgeRequest { + crate_name: "noop".into(), + verb: "run".into(), + kind: "query".into(), + description: "".into(), + }; + let p = Placeholders::from_request(&req); + assert_eq!(p.substitute("__CRATE__ __CRATE_SNAKE__"), "noop noop"); + assert_eq!(p.substitute("__VERB__ __VERB_SNAKE__"), "run run"); + } +} diff --git a/_primitives/_rust/kei-forge/src/generate/rollback.rs b/_primitives/_rust/kei-forge/src/generate/rollback.rs new file mode 100644 index 0000000..9c767f5 --- /dev/null +++ b/_primitives/_rust/kei-forge/src/generate/rollback.rs @@ -0,0 +1,100 @@ +//! Rollback accumulator for atom scaffolding writes. +//! +//! Keeps the list of successfully-written paths. On `finish()` the list +//! is returned (success). On `Drop` without `finish()` — i.e. an early +//! return from the caller due to an error — every recorded path is +//! deleted best-effort. Mirrors `trap rollback ERR` in new-atom.sh. +//! +//! Deletion is best-effort: we ignore `std::fs::remove_file` errors +//! because the caller already has a more-specific error to return. + +use std::fs; +use std::path::PathBuf; + +pub struct Rollback { + written: Vec, + completed: bool, +} + +impl Rollback { + pub fn new() -> Self { + Self { written: Vec::new(), completed: false } + } + + /// Register a successful write so the rollback can undo it on drop. + pub fn record(&mut self, path: PathBuf) { + self.written.push(path); + } + + /// Consume the rollback — mark complete and return the recorded + /// paths. Must be called on the success path; otherwise `Drop` + /// deletes everything. + pub fn finish(mut self) -> Vec { + self.completed = true; + std::mem::take(&mut self.written) + } +} + +impl Drop for Rollback { + fn drop(&mut self) { + if self.completed { + return; + } + for path in &self.written { + let _ = fs::remove_file(path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finish_returns_paths_and_suppresses_rollback() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + let b = tmp.path().join("b.txt"); + fs::write(&a, "x").unwrap(); + fs::write(&b, "y").unwrap(); + + let mut r = Rollback::new(); + r.record(a.clone()); + r.record(b.clone()); + let files = r.finish(); + + assert_eq!(files, vec![a.clone(), b.clone()]); + assert!(a.exists(), "finish must NOT delete"); + assert!(b.exists()); + } + + #[test] + fn drop_without_finish_deletes_recorded_files() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + let b = tmp.path().join("b.txt"); + fs::write(&a, "x").unwrap(); + fs::write(&b, "y").unwrap(); + + { + let mut r = Rollback::new(); + r.record(a.clone()); + r.record(b.clone()); + // scope ends without finish — Drop fires + } + + assert!(!a.exists(), "rollback must delete a"); + assert!(!b.exists(), "rollback must delete b"); + } + + #[test] + fn drop_tolerates_missing_files() { + let tmp = tempfile::tempdir().unwrap(); + let missing = tmp.path().join("never-existed.txt"); + { + let mut r = Rollback::new(); + r.record(missing); + // Drop must not panic on a missing file. + } + } +} diff --git a/_primitives/_rust/kei-forge/src/lib.rs b/_primitives/_rust/kei-forge/src/lib.rs index 2ddf2db..7c03896 100644 --- a/_primitives/_rust/kei-forge/src/lib.rs +++ b/_primitives/_rust/kei-forge/src/lib.rs @@ -7,7 +7,7 @@ //! - [`headers`] — CSP / nosniff / frame-deny / referrer headers //! - [`html`] — static HTML form (JSON-over-fetch) //! - [`form`] — request deserialization + validation -//! - [`generate`] — invoke scripts/new-atom.sh, parse output +//! - [`generate`] — pure-Rust atom templating (no shell-out) //! //! Public entry point is [`server::app`], which returns the fully-wired //! `axum::Router` ready to be served by any bind target (production = diff --git a/_primitives/_rust/kei-forge/tests/smoke.rs b/_primitives/_rust/kei-forge/tests/smoke.rs index b322a73..ea91167 100644 --- a/_primitives/_rust/kei-forge/tests/smoke.rs +++ b/_primitives/_rust/kei-forge/tests/smoke.rs @@ -1,11 +1,18 @@ //! Integration smoke test for kei-forge. //! //! Exercises GET / and POST /forge via `tower::ServiceExt::oneshot` on -//! the Router — no real socket, no real shell-out. The `mock-generate` -//! feature makes `generate::forge` return a synthesized success payload -//! so the test never touches the filesystem. +//! the Router — no real socket. With pure-Rust templating, the generator +//! is hermetic when pointed at a non-existent crate name: it returns a +//! structured `CrateNotFound` without touching the filesystem, so these +//! tests can run in any working directory without creating or mutating +//! real atoms on disk. //! -//! Run with: `cargo test -p kei-forge --features mock-generate` +//! Unit tests for the pure-Rust pipeline (happy path, file-exists refuse, +//! crate-not-found, template-missing) live inside `src/generate.rs` and +//! its three Constructor-Pattern submodules (placeholders, paths, +//! rollback) — they use `tempfile::TempDir` for full hermetic runs. +//! +//! Run with: `cargo test -p kei-forge` use axum::{ body::{to_bytes, Body}, @@ -51,8 +58,10 @@ async fn get_root_serves_form() { #[tokio::test] async fn post_forge_returns_json_shape() { + // Use a crate name guaranteed not to exist under _primitives/_rust/ + // so the generator returns CrateNotFound (422) without mutating disk. let app = server::app(); - let body = r#"{"crate":"kei-task","verb":"add-dependency","kind":"command","description":"test desc"}"#; + let body = r#"{"crate":"kei-nonexistent-test-crate","verb":"add-dependency","kind":"command","description":"test desc"}"#; let resp = app.oneshot(post_json("/forge", body)).await.unwrap();