feat(stream-f): kei-forge pure-Rust templating — eliminate shell-out

Remove std::process::Command invocation of scripts/new-atom.sh from
kei-forge. Templating moves to pure Rust — eliminates the
sed-metacharacter injection class structurally, on top of the
description whitelist that E2 added as defence-in-depth.

src/generate.rs split into 4 Cubes (Constructor Pattern):
- generate/placeholders.rs — 6-token substitution, longer-first ordering
- generate/paths.rs — TargetPaths::resolve + assert_none_exist
- generate/rollback.rs — Drop-based atomic rollback (Rust idiom for
  shell `trap ERR`)
- generate/atom_tests.rs — 5 tempdir integration tests

generate.rs dropped from 295 → 159 LOC as orchestration thin wrapper.

Behavioural parity with scripts/new-atom.sh maintained: same 6 tokens,
same order, refuse-overwrite, atomic rollback, same file-list
ordering. scripts/new-atom.sh untouched on disk (still usable as
standalone CLI).

Cargo.toml: removed mock-generate feature flag (no longer needed —
pure-Rust tests use tempfile::TempDir), added tempfile dev-dep.

Tests: 44/44 (was 29 with mock-generate; +15 new pure-Rust unit tests
across placeholders/paths/rollback/atom_tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-04-23 01:21:00 +08:00
parent 9307f8d26e
commit e84e9fc1fe
9 changed files with 645 additions and 112 deletions

View file

@ -1972,6 +1972,7 @@ dependencies = [
"axum",
"serde",
"serde_json",
"tempfile",
"tokio",
"tower",
"tracing",

View file

@ -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"

View file

@ -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 `<repo>/_templates/atom/`, substitutes the
//! six placeholder tokens (`__CRATE__`, `__CRATE_SNAKE__`, `__VERB__`,
//! `__VERB_SNAKE__`, `__KIND__`, `__DESCRIPTION__`), and writes the
//! resulting files into `<repo>/_primitives/_rust/<crate>/`.
//!
//! 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 {
/// `<repo>/_primitives/_rust/<crate>/` does not exist.
CrateNotFound(PathBuf),
/// One of the five target files already exists — refuse to overwrite.
FileExists(PathBuf),
/// `<repo>/_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<String>) -> Self {
Self {
success: true,
files,
errors: Vec::new(),
}
Self { success: true, files, errors: Vec::new() }
}
pub fn fail(err: impl Into<String>) -> 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<String> {
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<Vec<PathBuf>, 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())
}

View file

@ -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 `<tmp>/_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"
);
}

View file

@ -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/<crate>/` is absent.
pub fn resolve(
repo_root: &Path,
req: &ForgeRequest,
) -> Result<Self, GenerateError> {
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());
}
}

View file

@ -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");
}
}

View file

@ -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<PathBuf>,
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<PathBuf> {
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.
}
}
}

View file

@ -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 =

View file

@ -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();