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:
parent
9307f8d26e
commit
e84e9fc1fe
9 changed files with 645 additions and 112 deletions
1
_primitives/_rust/Cargo.lock
generated
1
_primitives/_rust/Cargo.lock
generated
|
|
@ -1972,6 +1972,7 @@ dependencies = [
|
|||
"axum",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tracing",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
140
_primitives/_rust/kei-forge/src/generate/atom_tests.rs
Normal file
140
_primitives/_rust/kei-forge/src/generate/atom_tests.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
174
_primitives/_rust/kei-forge/src/generate/paths.rs
Normal file
174
_primitives/_rust/kei-forge/src/generate/paths.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
101
_primitives/_rust/kei-forge/src/generate/placeholders.rs
Normal file
101
_primitives/_rust/kei-forge/src/generate/placeholders.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
100
_primitives/_rust/kei-forge/src/generate/rollback.rs
Normal file
100
_primitives/_rust/kei-forge/src/generate/rollback.rs
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue