cordance-cli 0.1.1

Cordance CLI — installs the `cordance` binary. The umbrella package `cordance` re-exports this entry; either install command works.
Documentation
//! Check tier: `cordance_check_drift`.
//!
//! Reads `.cordance/sources.lock` and reports drift relative to the pack
//! whose `sha256` digests it remembers. Mirrors `cordance check` exactly so
//! the wire shape matches what a human sees on the CLI.
//!
//! Like the CLI twin, this runs a dry-run pack rescan internally and diffs
//! the fresh result against the saved lock. Earlier revisions diff'd the
//! saved lock against itself, which is always trivially clean — CRITICAL #4
//! in the round-1 code review.

use camino::Utf8PathBuf;
use cordance_core::lock::{DriftReport, OutputDriftEntry, SourceDriftEntry, SourceLock};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::check_cmd;
use crate::mcp::error::{McpToolError, McpToolResult};

#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
pub struct CheckDriftParams {
    #[serde(default)]
    pub target: Option<String>,
}

#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct CheckDriftOutput {
    pub schema: String,
    pub clean: bool,
    pub exit_code: i32,
    pub source_drifts: Vec<SourceDriftSummary>,
    pub fenced_output_drifts: Vec<OutputDriftSummary>,
    pub unfenced_output_drifts: Vec<OutputDriftSummary>,
}

#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct SourceDriftSummary {
    pub id: String,
    pub path: String,
    pub old_sha256: String,
    pub new_sha256: String,
}

#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct OutputDriftSummary {
    pub path: String,
    pub old_sha256: String,
    pub new_sha256: String,
}

pub fn drift(target: &Utf8PathBuf) -> McpToolResult<CheckDriftOutput> {
    let lock_path = target.join(".cordance/sources.lock");
    if !lock_path.exists() {
        // Round-4 redteam #2 / bughunt #5: never echo the canonical
        // `lock_path` to the peer. Log it to stderr only and return the
        // fixed-shape `artifact_missing` reply.
        tracing::warn!(path = %lock_path, "check_drift: sources.lock missing");
        return Err(McpToolError::artifact_missing("sources.lock"));
    }
    let raw = std::fs::read_to_string(&lock_path).map_err(McpToolError::from_io)?;
    let prev: SourceLock = serde_json::from_str(&raw)
        .map_err(|e| McpToolError::internal_redacted("parse sources.lock", e))?;

    // Re-scan the target and compute a fresh lock to diff against. Shares
    // its implementation with `check_cmd::run` so the CLI and MCP outputs
    // can never diverge. `SourceLock::diff` is pure; the on-disk fence
    // check is done here at the adapter boundary.
    let fresh =
        check_cmd::compute_fresh_lock(target).map_err(McpToolError::from_anyhow)?;
    let fenced = check_cmd::collect_fenced_outputs(&fresh, target);
    let report = fresh.diff(&prev, &fenced);
    Ok(to_output(&report))
}

fn to_output(report: &DriftReport) -> CheckDriftOutput {
    CheckDriftOutput {
        schema: "cordance-check-drift.v1".to_string(),
        clean: report.is_clean(),
        exit_code: report.exit_code(),
        source_drifts: report
            .source_drifts
            .iter()
            .map(summarise_source_drift)
            .collect(),
        fenced_output_drifts: report
            .fenced_output_drifts
            .iter()
            .map(summarise_output_drift)
            .collect(),
        unfenced_output_drifts: report
            .unfenced_output_drifts
            .iter()
            .map(summarise_output_drift)
            .collect(),
    }
}

fn summarise_source_drift(d: &SourceDriftEntry) -> SourceDriftSummary {
    SourceDriftSummary {
        id: d.id.clone(),
        path: d.path.clone(),
        old_sha256: d.old_sha256.clone(),
        new_sha256: d.new_sha256.clone(),
    }
}

fn summarise_output_drift(d: &OutputDriftEntry) -> OutputDriftSummary {
    OutputDriftSummary {
        path: d.path.clone(),
        old_sha256: d.old_sha256.clone(),
        new_sha256: d.new_sha256.clone(),
    }
}