cordance-cli 0.1.2

Cordance CLI — installs the `cordance` binary. The umbrella package `cordance` re-exports this entry; either install command works.
Documentation
//! `cordance init` — write a default `cordance.toml` in the target directory.
//!
//! The template here must round-trip through [`crate::config::Config::load_strict`].
//! Round-3 codereview HIGH: prior templates carried `[project]`, `[targets]`,
//! `[blocked]`, `[fence]`, and `[cortex]` sections that the `Config` struct
//! does not accept. Because the dispatcher now calls `load_strict`, the next
//! `cordance pack` after `cordance init` would fail with an "unknown field"
//! parse error on the freshly-written file.
//!
//! The schema-drift guard test `default_config_is_strict_loadable` keeps the
//! template aligned with the struct.

use std::fs::OpenOptions;
use std::io::{ErrorKind, Write};

use anyhow::Result;
use camino::Utf8PathBuf;

const DEFAULT_CONFIG: &str = r#"# cordance.toml — generated by `cordance init`.
# Documentation: docs/COMMAND_SURFACE.md

[doctrine]
# Path to engineering-doctrine repo. Relative to this file or absolute.
source = "../engineering-doctrine"
# Where to clone doctrine from when the sibling path doesn't exist.
fallback_repo = "https://github.com/0ryant/engineering-doctrine"
# "auto" = use HEAD commit; any other value pins a specific SHA.
pin_commit = "auto"

[axiom]
# Path to the pai-axiom repo. Relative to this file or absolute.
source = "../pai-axiom"
# "auto" = read PAI/Algorithm/LATEST from the configured source.
algorithm_latest = "auto"

[llm]
# Provider: "none" | "ollama" | "lm-studio"
provider = "none"

[llm.ollama]
base_url = "http://localhost:11434"
model = "qwen2.5-coder:14b"
temperature = 0.1
num_ctx = 8192

[mcp]
# Additional roots that `--target` is permitted to canonicalise into when
# running `cordance serve`. Each path is resolved against the server's
# launch directory. Default: only the server's launch directory.
allowed_roots = []
"#;

/// Write a fresh `cordance.toml` template at `{target}/cordance.toml`.
///
/// ## TOCTOU protection (Round-4 bughunt HIGH R4-bughunt-4)
///
/// The earlier `if path.exists() { skip } else { write }` pattern had a
/// time-of-check-vs-time-of-use race: two concurrent `cordance init`
/// invocations against the same directory (e.g. in a CI matrix that fans out
/// across jobs) could both observe non-existence and both write, with the
/// last writer winning and any operator customisation in between lost.
///
/// `OpenOptions::create_new(true)` requests the atomic `O_EXCL` semantics on
/// POSIX and `CREATE_NEW` on Windows — the kernel performs the existence
/// check and the file creation in a single operation. The race is
/// structurally impossible.
pub fn run(target: &Utf8PathBuf) -> Result<()> {
    let config_path = target.join("cordance.toml");
    match OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(config_path.as_std_path())
    {
        Ok(mut file) => {
            file.write_all(DEFAULT_CONFIG.as_bytes())?;
            println!("Created {config_path}");
            Ok(())
        }
        Err(e) if e.kind() == ErrorKind::AlreadyExists => {
            println!("cordance.toml already exists at {config_path} — skipping.");
            Ok(())
        }
        Err(e) => Err(e.into()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The template MUST round-trip through `Config::load_strict`. A drift in
    /// `Config`'s schema that adds a required field, or a drift in the
    /// template that adds an unknown table, will fail this test before users
    /// hit it.
    #[test]
    fn default_config_is_strict_loadable() {
        let dir = tempfile::tempdir().expect("tempdir");
        let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("tempdir is utf8");
        std::fs::write(target.join("cordance.toml"), DEFAULT_CONFIG)
            .expect("write default cordance.toml");
        let cfg = crate::config::Config::load_strict(&target)
            .expect("default cordance.toml must load_strict");
        // Spot-check a few fields to make sure defaults survive a write/read
        // round-trip rather than being silently dropped.
        assert_eq!(cfg.llm.provider, "none");
        assert_eq!(cfg.llm.ollama.model, "qwen2.5-coder:14b");
        assert_eq!(cfg.llm.ollama.base_url, "http://localhost:11434");
        assert_eq!(cfg.doctrine.source, "../engineering-doctrine");
        assert_eq!(cfg.axiom.source, "../pai-axiom");
        assert_eq!(cfg.axiom.algorithm_latest, "auto");
        assert!(cfg.mcp.allowed_roots.is_empty());
    }

    /// `cordance init` followed by `Config::load_strict` is the v0 happy path.
    /// This test exercises the on-disk surface end-to-end.
    #[test]
    fn run_writes_template_that_load_strict_accepts() {
        let dir = tempfile::tempdir().expect("tempdir");
        let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("tempdir is utf8");
        run(&target).expect("init succeeds");
        assert!(target.join("cordance.toml").exists());
        let cfg = crate::config::Config::load_strict(&target)
            .expect("generated cordance.toml must load_strict");
        assert_eq!(cfg.llm.ollama.model, "qwen2.5-coder:14b");
    }

    /// `cordance init` must not overwrite an existing cordance.toml — the
    /// inverse hazard would silently clobber operator customisation.
    #[test]
    fn run_skips_existing_config() {
        let dir = tempfile::tempdir().expect("tempdir");
        let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("tempdir is utf8");
        let original = "[llm]\nprovider = \"ollama\"\n";
        std::fs::write(target.join("cordance.toml"), original).expect("write");
        run(&target).expect("init succeeds when file exists");
        let on_disk = std::fs::read_to_string(target.join("cordance.toml")).expect("read back");
        assert_eq!(on_disk, original, "init must not overwrite existing config");
    }

    /// Round-4 bughunt HIGH R4-bughunt-4: two concurrent `cordance init`
    /// invocations against the same target directory must NOT race into a
    /// double-write. Exactly one thread is allowed to create the file; the
    /// other must observe the "already exists" branch.
    ///
    /// Verification shape: we wrap `run` in a `Barrier::wait()` so both
    /// threads enter the critical section at the same instant. After both
    /// return, the on-disk file content must be the canonical
    /// `DEFAULT_CONFIG` — i.e. one writer wrote, the other skipped. If the
    /// TOCTOU window were still open, one writer's bytes could be truncated
    /// or interleaved with the other's; the assertion catches that.
    #[test]
    fn concurrent_init_races_do_not_clobber() {
        use std::sync::{Arc, Barrier};
        use std::thread;

        let dir = tempfile::tempdir().expect("tempdir");
        let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("tempdir is utf8");

        let barrier = Arc::new(Barrier::new(2));
        let target_a = target.clone();
        let target_b = target.clone();
        let barrier_a = Arc::clone(&barrier);
        let barrier_b = Arc::clone(&barrier);

        let handle_a = thread::spawn(move || {
            barrier_a.wait();
            run(&target_a)
        });
        let handle_b = thread::spawn(move || {
            barrier_b.wait();
            run(&target_b)
        });

        let result_a = handle_a.join().expect("thread a panicked");
        let result_b = handle_b.join().expect("thread b panicked");

        // Both call sites must succeed — one writes, the other prints the
        // "skipping" line. Neither returns an error.
        result_a.expect("thread a should not error");
        result_b.expect("thread b should not error");

        // The on-disk file must be exactly the canonical default. If the
        // race were still open the bytes could be truncated or doubled.
        let on_disk = std::fs::read_to_string(target.join("cordance.toml")).expect("read back");
        assert_eq!(
            on_disk, DEFAULT_CONFIG,
            "concurrent init must leave exactly the canonical default config on disk"
        );
    }
}