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 status` -- compact read-only operator summary.

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

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

pub fn run(cfg: &Config, target: &Utf8PathBuf) -> Result<()> {
    let pack = build_status_pack(cfg, target)?;
    let source_lock = inspect_source_lock(target, &pack)?;
    let advise = AdviseCounts::from_pack(&pack);
    let readiness = readiness(&source_lock, &advise);

    println!("cordance status");
    println!("target: {target}");
    println!("config: {}", config_summary(target));
    println!("source lock: {}", source_lock.summary());
    println!(
        "advise: {} findings ({} error, {} warning, {} info)",
        advise.total, advise.errors, advise.warnings, advise.info
    );
    println!("doctrine pin: {}", doctrine_pin_summary(&pack));
    println!("axiom pin: {}", axiom_pin_summary(&pack));
    println!("pack id: {}", pack_id_summary(&pack));
    println!("readiness: {readiness}");

    Ok(())
}

fn build_status_pack(cfg: &Config, target: &Utf8PathBuf) -> Result<CordancePack> {
    let pack_config = PackConfig {
        target: target.clone(),
        output_mode: OutputMode::DryRun,
        selected_targets: PackTargets::all(),
        doctrine_root: read_only_doctrine_root_override(cfg, target)?,
        llm_provider: Some("none".into()),
        ollama_model: None,
        quiet: true,
        from_cortex_push: false,
        cortex_receipt_requested_explicitly: false,
    };
    pack_cmd::run(&pack_config).context("building read-only status pack")
}

fn read_only_doctrine_root_override(
    cfg: &Config,
    target: &Utf8PathBuf,
) -> Result<Option<Utf8PathBuf>> {
    if cfg.doctrine_root(target).exists() {
        return Ok(None);
    }

    let temp_dir = Utf8PathBuf::from_path_buf(std::env::temp_dir()).map_err(|path| {
        anyhow!(
            "system temp directory is not valid UTF-8: {}",
            path.display()
        )
    })?;
    Ok(Some(temp_dir))
}

fn config_summary(target: &Utf8PathBuf) -> String {
    let config_path = target.join("cordance.toml");
    if config_path.exists() {
        "cordance.toml found".into()
    } else {
        "using default config (cordance.toml missing)".into()
    }
}

fn inspect_source_lock(target: &Utf8PathBuf, pack: &CordancePack) -> Result<SourceLockStatus> {
    let lock_path = target.join(".cordance/sources.lock");
    if !lock_path.exists() {
        return Ok(SourceLockStatus::Missing);
    }

    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}"))?;
    let fenced = crate::check_cmd::collect_fenced_outputs(&pack.source_lock, target);
    let report = pack.source_lock.diff(&prev, &fenced);

    if report.is_clean() {
        Ok(SourceLockStatus::Clean)
    } else {
        Ok(SourceLockStatus::Drift(report))
    }
}

enum SourceLockStatus {
    Missing,
    Clean,
    Drift(DriftReport),
}

impl SourceLockStatus {
    fn summary(&self) -> String {
        match self {
            Self::Missing => "missing (run `cordance pack` first)".into(),
            Self::Clean => "clean".into(),
            Self::Drift(report) => format!(
                "drifted ({} source, {} managed output, {} user-owned output)",
                report.source_drifts.len(),
                report.fenced_output_drifts.len(),
                report.unfenced_output_drifts.len()
            ),
        }
    }
}

struct AdviseCounts {
    total: usize,
    errors: usize,
    warnings: usize,
    info: usize,
}

impl AdviseCounts {
    fn from_pack(pack: &CordancePack) -> Self {
        let mut counts = Self {
            total: pack.advise.findings.len(),
            errors: 0,
            warnings: 0,
            info: 0,
        };

        for finding in &pack.advise.findings {
            match finding.severity {
                Severity::Error => counts.errors += 1,
                Severity::Warning => counts.warnings += 1,
                Severity::Info => counts.info += 1,
            }
        }

        counts
    }
}

fn doctrine_pin_summary(pack: &CordancePack) -> String {
    let Some(pin) = pack.doctrine_pins.first() else {
        return "none".into();
    };

    let short_commit = pin.commit.chars().take(12).collect::<String>();
    format!("{}@{}", pin.repo, short_commit)
}

fn axiom_pin_summary(pack: &CordancePack) -> &str {
    pack.project.axiom_pin.as_deref().unwrap_or("unknown")
}

fn pack_id_summary(pack: &CordancePack) -> String {
    if pack.source_lock.pack_id.is_empty() {
        return "none".into();
    }
    pack.source_lock.pack_id.chars().take(12).collect()
}

const fn readiness(source_lock: &SourceLockStatus, advise: &AdviseCounts) -> &'static str {
    match source_lock {
        SourceLockStatus::Drift(report)
            if !report.source_drifts.is_empty() || !report.fenced_output_drifts.is_empty() =>
        {
            "blocked"
        }
        _ if advise.errors > 0 => "blocked",
        SourceLockStatus::Missing | SourceLockStatus::Drift(_) => "attention",
        _ if advise.warnings > 0 => "attention",
        SourceLockStatus::Clean => "ready",
    }
}