arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! `arc make test <name>` — generate an integration test file skeleton.
//!
//! Creates `tests/<name>_test.rs` with a minimal real test (no placeholder
//! TODO, no `todo!`). The test file lives outside `src/` (Rust integration
//! test convention). The name is a type name (e.g. `Links` -> `tests/links_test.rs`).
//!
//! The write is a transactional [`super::plan::Plan`]: conflict-detect →
//! dry-run → stage → rollback-on-failure (PROGRAM.md AP2.1-11). Unlike the
//! A13 per-file writer, the plan supports `--dry-run` and `--force` from the
//! CLI. A pre-existing test file is never silently overwritten.
//!
//! This generator is independent of the Data subsystem: it produces a plain
//! test file, no model or schema awareness.

use std::path::Path;

use super::naming::{snake_case, tests_file_path, validate_type_name};
use super::plan::Plan;

/// Build the transactional [`Plan`] for `arc make test <name>`. Returns the
/// plan and the file stem (for status messages). The plan is not executed
/// here; the caller runs dry-run or commit.
pub(crate) fn plan(root: &Path, raw_name: &str) -> Result<(Plan, String), String> {
    let name = validate_type_name(raw_name)?;
    let stem = format!("{}_test", snake_case(&name));
    let path = tests_file_path(root, &format!("{stem}.rs"))?;
    let content = render(&name);
    let plan = Plan::new().create(path, content);
    Ok((plan, stem))
}

fn render(name: &str) -> String {
    let snake = snake_case(name);
    format!(
        "//! Integration test for `{name}`.\n\
         \n\
         /// Smoke test generated by `arc make test {name}`. Replace the body\n\
         /// with a real test. Run with `cargo test --test {snake}_test`.\n\
         \n\
         // To share setup across integration tests, include the helpers\n\
         // module published by `arc stubs publish`:\n\
         //\n\
         // mod helpers;\n\
         \n\
         #[test]\n\
         fn {snake}_smoke() {{\n\
         \x20   assert!(true);\n\
         }}\n"
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::PathBuf;

    fn temp_root(label: &str) -> PathBuf {
        let root = std::env::temp_dir().join(format!(
            "arcature-cli-make-test-{label}-{}-{}",
            std::process::id(),
            unique_suffix()
        ));
        fs::create_dir_all(&root).expect("temp root should be created");
        root
    }

    fn unique_suffix() -> u128 {
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map_or(0, |d| d.as_nanos())
    }

    fn cleanup(root: &Path) {
        let _ = fs::remove_dir_all(root);
    }

    #[test]
    fn plan_targets_tests_name_test_rs() {
        let root = temp_root("plan");
        let (plan, stem) = plan(&root, "Links").expect("plan should build");
        assert_eq!(stem, "links_test");
        let ops = plan.ops();
        assert_eq!(ops.len(), 1);
        match &ops[0] {
            super::super::plan::PlannedOp::Create { path, content } => {
                assert!(path.ends_with("tests/links_test.rs"));
                assert!(content.contains("links_smoke"));
                assert!(!content.contains("TODO"));
                assert!(!content.contains("todo!"));
            }
            _ => panic!("expected a Create op"),
        }
        cleanup(&root);
    }

    #[test]
    fn plan_rejects_invalid_name() {
        let root = temp_root("invalid");
        assert!(plan(&root, "").is_err());
        assert!(plan(&root, "123Bad").is_err());
        assert!(plan(&root, "a/b").is_err());
        assert!(plan(&root, "Links").is_ok());
        cleanup(&root);
    }

    #[test]
    fn commit_creates_test_file() {
        let root = temp_root("commit");
        let (mut plan, _) = plan(&root, "Links").expect("plan should build");
        plan.execute(super::super::plan::OverwritePolicy::Refuse, None)
            .expect("commit should succeed");
        let file = root.join("tests").join("links_test.rs");
        assert!(file.is_file());
        let content = fs::read_to_string(&file).unwrap();
        assert!(content.contains("fn links_smoke()"));
        cleanup(&root);
    }

    #[test]
    fn dry_run_creates_nothing() {
        let root = temp_root("dry-run");
        let (plan, _) = plan(&root, "Links").expect("plan should build");
        let report = plan
            .dry_run_report(super::super::plan::OverwritePolicy::Refuse)
            .expect("dry-run should report");
        assert!(report.contains("no files will be written"));
        assert!(!root.join("tests").exists());
        cleanup(&root);
    }

    #[test]
    fn conflict_on_existing_test_file() {
        let root = temp_root("conflict");
        let file = root.join("tests").join("links_test.rs");
        fs::create_dir_all(file.parent().unwrap()).unwrap();
        fs::write(&file, "user test").unwrap();
        let (mut plan, _) = plan(&root, "Links").expect("plan should build");
        let err = plan
            .execute(super::super::plan::OverwritePolicy::Refuse, None)
            .expect_err("should refuse to overwrite");
        assert!(matches!(err, super::super::plan::PlanError::Conflict(p) if p == file));
        assert_eq!(fs::read_to_string(&file).unwrap(), "user test");
        cleanup(&root);
    }
}