cordance-cli 0.1.1

Cordance CLI — installs the `cordance` binary. The umbrella package `cordance` re-exports this entry; either install command works.
Documentation
//! Implementation of `cordance check` — drift detection vs the sources.lock.
//!
//! The flow is:
//! 1. Read the saved `.cordance/sources.lock` (the *previous* state).
//! 2. Re-scan the target by running a dry-run `pack` build; that produces a
//!    fresh `SourceLock` representing the *current* state.
//! 3. Diff fresh against previous and report drift.
//!
//! Earlier revisions diff'd the previous lock against itself, which is
//! always trivially clean — CRITICAL #4 in the round-1 code review. The
//! fresh rescan below is what actually detects modification.

use std::collections::HashSet;

use anyhow::{Context, Result};
use camino::Utf8PathBuf;
use cordance_core::lock::SourceLock;
use cordance_core::pack::PackTargets;

use crate::pack_cmd::{self, OutputMode, PackConfig};

pub fn run(target: &Utf8PathBuf) -> Result<i32> {
    let lock_path = target.join(".cordance/sources.lock");
    if !lock_path.exists() {
        eprintln!("cordance check: no sources.lock found at {lock_path}");
        eprintln!("  Run 'cordance pack' first to generate a lock.");
        return Ok(1);
    }
    let prev_json = std::fs::read_to_string(&lock_path)
        .with_context(|| format!("reading {lock_path}"))?;
    let prev: SourceLock = serde_json::from_str(&prev_json)
        .with_context(|| format!("parsing {lock_path}"))?;

    // Re-scan the target by running pack in dry-run mode. This must NOT
    // touch the filesystem (no `.cordance/sources.lock` overwrite) and must
    // disable LLM enrichment to stay offline.
    let fresh = compute_fresh_lock(target)?;
    // I/O lives at the adapter boundary; `SourceLock::diff` is pure. We
    // compute the on-disk fence set here and hand it to `diff` so the
    // domain type never reads from the filesystem.
    let fenced = collect_fenced_outputs(&fresh, target);
    let report = fresh.diff(&prev, &fenced);

    if report.is_clean() {
        println!("cordance check: clean");
    } else {
        if !report.source_drifts.is_empty() {
            eprintln!(
                "Source drift detected ({} file(s)):",
                report.source_drifts.len()
            );
            for d in &report.source_drifts {
                // Round-4 bughunt #2: `SourceLock::diff` now reports newly
                // added and deleted files alongside content drift. Format
                // each with a glyph so the operator can scan the list:
                //   + new file (old_sha256 == "ADDED")
                //   - file gone (new_sha256 == "DELETED")
                //   ~ in-place drift (both hashes are real)
                if d.old_sha256 == "ADDED" {
                    let new_short: String = d.new_sha256.chars().take(8).collect();
                    eprintln!("  + {} ({new_short})", d.path);
                } else if d.new_sha256 == "DELETED" {
                    let old_short: String = d.old_sha256.chars().take(8).collect();
                    eprintln!("  - {} ({old_short})", d.path);
                } else {
                    let old_short: String = d.old_sha256.chars().take(8).collect();
                    let new_short: String = d.new_sha256.chars().take(8).collect();
                    eprintln!("  ~ {} ({old_short} -> {new_short})", d.path);
                }
            }
        }
        if !report.fenced_output_drifts.is_empty() {
            eprintln!(
                "Managed region edited out-of-band ({} file(s)):",
                report.fenced_output_drifts.len()
            );
            for d in &report.fenced_output_drifts {
                eprintln!("  {}", d.path);
            }
        }
        if !report.unfenced_output_drifts.is_empty() {
            println!(
                "User-owned changes ({} file(s)) — not an error:",
                report.unfenced_output_drifts.len()
            );
        }
    }
    Ok(report.exit_code())
}

/// Compute a `SourceLock` for the on-disk state of `target` without writing
/// anything to disk. Exposed at module scope so the MCP twin
/// (`mcp::tools::check`) can share the exact same logic.
pub fn compute_fresh_lock(target: &Utf8PathBuf) -> Result<SourceLock> {
    let scan_cfg = PackConfig {
        target: target.clone(),
        output_mode: OutputMode::DryRun,
        // Build all targets so the output entries in the fresh lock line up
        // with the previous lock's output set — otherwise a previously
        // tracked AGENTS.md would always look "deleted" on rescan.
        selected_targets: PackTargets {
            claude_code: true,
            cursor: true,
            codex: true,
            axiom_harness_target: true,
            cortex_receipt: true,
        },
        doctrine_root: None,
        // Force-disable LLM enrichment; check must be offline and
        // deterministic.
        llm_provider: Some("none".into()),
        ollama_model: None,
        quiet: true,
    };
    let fresh_pack = pack_cmd::run(&scan_cfg).context("cordance check: rescan failed")?;
    Ok(SourceLock::compute_from_pack(&fresh_pack))
}

/// Read every output path referenced by `lock` and return the subset that
/// currently contain a cordance fence marker on disk. This is the only place
/// in the check pipeline that touches the filesystem — `SourceLock::diff`
/// stays pure (ADR `modularity-and-ports-adapters.md`).
///
/// Missing or unreadable files are **omitted** from the returned set. The
/// caller (`SourceLock::diff`) treats absence as "fenced" so missing managed
/// regions are still reported as drift; an unreadable file therefore errs on
/// the side of surfacing drift rather than silently masking it.
pub fn collect_fenced_outputs(
    lock: &SourceLock,
    repo_root: &Utf8PathBuf,
) -> HashSet<String> {
    let mut set = HashSet::new();
    for entry in &lock.outputs {
        let abs = repo_root.join(&entry.path);
        let Ok(content) = std::fs::read_to_string(&abs) else {
            continue;
        };
        if cordance_core::fence::find_regions(&content)
            .map(|regions| !regions.is_empty())
            .unwrap_or(false)
        {
            set.insert(entry.path.as_str().to_string());
        }
    }
    set
}