arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! `arc stubs publish` (PROGRAM.md AP2.1-11) — publish real stub/scaffolding
//! files into the application.
//!
//! Unlike `arc make`, `arc stubs` publishes a *fixed set* of scaffolding
//! files (not a single named item). This wave ships one real, useful stub:
//! `tests/helpers/mod.rs` — a shared integration-test helper module with a
//! working `project_root()` helper. The set is intentionally minimal and
//! real: no invented config, no placeholder TODOs, no fake security. Future
//! waves may add more stubs (auth starter scaffolding after AP2.1-S lands,
//! UI component vendoring) through the same transactional path.
//!
//! The publish is a transactional [`super::plan::Plan`]:
//! conflict-detect → dry-run → stage → rollback-on-failure. A pre-existing
//! user file is never silently overwritten; `--force` overwrites explicitly
//! and `--dry-run` reports without touching the filesystem.

use std::path::Path;

use super::plan::{OverwritePolicy, Plan, PlanError};
use crate::cli::StubsCommand;
use crate::error::CommandError;
use crate::project;

/// One stub file the publisher can write. The content is real, compilable
/// Rust — no placeholders, no TODOs, no fake security.
struct Stub {
    /// The destination path relative to the project root.
    path: std::path::PathBuf,
    /// The full file content.
    content: String,
}

/// Build the transactional [`Plan`] for `arc stubs publish`, rooted at
/// `root`. The plan is not executed here; the caller runs dry-run or commit.
/// Exposed for tests.
pub(crate) fn build_plan(root: &Path) -> Plan {
    let stubs = stubs();
    let mut plan = Plan::new();
    for stub in stubs {
        let path = resolve(root, &stub.path);
        plan = plan.create(path, stub.content);
    }
    plan
}

/// The fixed set of stubs this wave publishes.
fn stubs() -> Vec<Stub> {
    vec![Stub {
        path: std::path::PathBuf::from("tests/helpers/mod.rs"),
        content: helpers_mod_rs(),
    }]
}

/// The `tests/helpers/mod.rs` content: a real, working shared helper for the
/// application's integration tests. Each `tests/<name>.rs` includes it with
/// `mod helpers;` (Rust 2018+ resolves `tests/helpers/mod.rs`).
fn helpers_mod_rs() -> String {
    "\
//! Shared helpers for the application's integration tests.
//!
//! Each integration test in `tests/` is its own crate. Include this module
//! from a test file with `mod helpers;` and add shared setup, fixtures, or
//! assertions here so individual test files stay focused on behaviour.
//!
//! Generated by `arc stubs publish`. Edit freely; `arc stubs publish` will
//! refuse to overwrite an existing `tests/helpers/mod.rs` unless you pass
//! `--force`.

use std::path::PathBuf;

/// The project root directory (the package's `CARGO_MANIFEST_DIR`).
///
/// Resolved at compile time from the test crate's environment, so it is the
/// directory containing the `Cargo.toml` of the package under test — the
/// project root. Useful for locating fixtures, migration SQL, or assets
/// relative to the repository rather than the test's working directory.
#[must_use]
pub fn project_root() -> PathBuf {
    PathBuf::from(env!(\"CARGO_MANIFEST_DIR\"))
}
"
    .to_owned()
}

/// Resolve a stub's destination against the project root, checking the path
/// stays within the root (defence-in-depth; the stubs use fixed relative
/// paths).
fn resolve(root: &Path, relative: &Path) -> std::path::PathBuf {
    root.join(relative)
}

/// Entry point for `arc stubs publish` / `arc stubs --dry-run`.
pub(crate) fn execute_stubs(cmd: StubsCommand) -> Result<(), CommandError> {
    let project = project::discover()?;
    let root = project.root().to_path_buf();
    match cmd {
        StubsCommand::Publish { dry_run, force } => {
            publish(&root, dry_run, force).map_err(CommandError::Metadata)
        }
    }
}

fn publish(root: &Path, dry_run: bool, force: bool) -> Result<(), String> {
    let overwrite = if force {
        OverwritePolicy::Overwrite
    } else {
        OverwritePolicy::Refuse
    };
    let mut plan = build_plan(root);
    if plan.is_empty() {
        println!("no stubs to publish");
        return Ok(());
    }
    if dry_run {
        let report = plan.dry_run_report(overwrite).map_err(|e| e.to_string())?;
        println!("{report}");
        return Ok(());
    }
    match plan.execute(overwrite, None) {
        Ok(written) => {
            for path in &written {
                println!("created {}", path.display());
            }
            println!("stubs published: {}", written.len());
            Ok(())
        }
        Err(PlanError::Conflict(path)) => Err(format!(
            "refusing to overwrite existing file: {}; pass --force to overwrite",
            path.display()
        )),
        Err(error) => Err(error.to_string()),
    }
}

#[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-stubs-{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 helpers_mod_rs_is_real_and_compilable() {
        let content = helpers_mod_rs();
        // Real, working helper — no placeholders, no TODOs, no fake security.
        assert!(content.contains("pub fn project_root()"));
        assert!(content.contains("use std::path::PathBuf;"));
        assert!(content.contains("CARGO_MANIFEST_DIR"));
        assert!(!content.contains("TODO"));
        assert!(!content.contains("todo!"));
        assert!(!content.contains("unimplemented!"));
        assert!(!content.contains("unwrap()"));
    }

    #[test]
    fn build_plan_targets_tests_helpers_mod_rs() {
        let root = temp_root("plan");
        let plan = build_plan(&root);
        let ops = plan.ops();
        assert_eq!(ops.len(), 1);
        match &ops[0] {
            super::super::plan::PlannedOp::Create { path, .. } => {
                assert_eq!(*path, root.join("tests").join("helpers").join("mod.rs"));
            }
            _ => panic!("expected a Create op"),
        }
        cleanup(&root);
    }

    #[test]
    fn publish_creates_helpers_under_project_root() {
        let root = temp_root("publish");
        publish(&root, false, false).expect("publish should succeed");
        let file = root.join("tests").join("helpers").join("mod.rs");
        assert!(file.is_file(), "tests/helpers/mod.rs should be created");
        let content = fs::read_to_string(&file).unwrap();
        assert!(content.contains("pub fn project_root()"));
        cleanup(&root);
    }

    #[test]
    fn dry_run_publishes_nothing() {
        let root = temp_root("dry-run");
        publish(&root, true, false).expect("dry-run should succeed");
        assert!(
            !root.join("tests").exists(),
            "dry run must not create directories"
        );
        cleanup(&root);
    }

    #[test]
    fn publish_refuses_overwrite_without_force() {
        let root = temp_root("refuse");
        let file = root.join("tests").join("helpers").join("mod.rs");
        fs::create_dir_all(file.parent().unwrap()).unwrap();
        fs::write(&file, "user helpers").unwrap();
        let err = publish(&root, false, false).expect_err("should refuse to overwrite");
        assert!(err.contains("refusing to overwrite"));
        assert!(err.contains("--force"));
        assert_eq!(fs::read_to_string(&file).unwrap(), "user helpers");
        cleanup(&root);
    }

    #[test]
    fn publish_with_force_overwrites_existing() {
        let root = temp_root("force");
        let file = root.join("tests").join("helpers").join("mod.rs");
        fs::create_dir_all(file.parent().unwrap()).unwrap();
        fs::write(&file, "user helpers").unwrap();
        publish(&root, false, true).expect("force should overwrite");
        let content = fs::read_to_string(&file).unwrap();
        assert!(content.contains("pub fn project_root()"));
        assert!(!content.contains("user helpers"));
        cleanup(&root);
    }
}