cordance-cli 0.1.1

Cordance CLI — installs the `cordance` binary. The umbrella package `cordance` re-exports this entry; either install command works.
Documentation
//! `cordance doctor` — pre-flight environment checks.

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

use crate::config::Config;

struct Check {
    label: &'static str,
    status: Status,
    detail: String,
}

#[derive(PartialEq, Eq)]
enum Status {
    Pass,
    Warn,
    Fail,
}

// The `Ok(())` return is required so `dispatch` can use `?` uniformly across
// all command arms, even though this particular function cannot currently fail.
#[allow(clippy::unnecessary_wraps)]
pub fn run(cfg: &Config, target: &Utf8PathBuf) -> Result<()> {
    println!("cordance doctor — pre-flight checks\n");

    let checks = [
        check_cordance_toml(target),
        check_doctrine(cfg, target),
        check_axiom(cfg, target),
        check_axiom_latest(cfg, target),
        check_sources_lock(target),
        check_ollama(cfg),
    ];

    let mut any_fail = false;
    for c in &checks {
        let sym = match c.status {
            Status::Pass => "",
            Status::Warn => "",
            Status::Fail => "",
        };
        println!("  {sym}  {:<28} {}", c.label, c.detail);
        if c.status == Status::Fail {
            any_fail = true;
        }
    }

    println!();
    if any_fail {
        println!(
            "cordance doctor: some checks failed — run `cordance init` to create cordance.toml"
        );
        std::process::exit(1);
    } else {
        println!("cordance doctor: all checks passed");
    }

    Ok(())
}

fn check_cordance_toml(target: &Utf8PathBuf) -> Check {
    let path = target.join("cordance.toml");
    if path.exists() {
        Check {
            label: "cordance.toml",
            status: Status::Pass,
            detail: format!("found at {path}"),
        }
    } else {
        Check {
            label: "cordance.toml",
            status: Status::Warn,
            detail: format!("{path} not found (using defaults)"),
        }
    }
}

fn check_doctrine(cfg: &Config, target: &Utf8PathBuf) -> Check {
    let root = cfg.doctrine_root(target);
    if root.exists() {
        Check {
            label: "doctrine source",
            status: Status::Pass,
            detail: format!("{root} (exists)"),
        }
    } else {
        Check {
            label: "doctrine source",
            status: Status::Fail,
            detail: format!("{root} (not found)"),
        }
    }
}

fn check_axiom(cfg: &Config, target: &Utf8PathBuf) -> Check {
    let root = cfg.axiom_root(target);
    if root.exists() {
        Check {
            label: "axiom source",
            status: Status::Pass,
            detail: format!("{root} (exists)"),
        }
    } else {
        Check {
            label: "axiom source",
            status: Status::Fail,
            detail: format!("{root} (not found)"),
        }
    }
}

fn check_axiom_latest(cfg: &Config, target: &Utf8PathBuf) -> Check {
    let root = cfg.axiom_root(target);
    if !root.exists() {
        return Check {
            label: "axiom algorithm LATEST",
            status: Status::Warn,
            detail: "axiom source not found — skipping".into(),
        };
    }

    // Probe whether the LATEST file actually exists before relying on the
    // fallback value that axiom_version() always returns.
    let candidates = [
        root.join("PAI/Algorithm/LATEST"),
        root.join("axiom/Algorithm/LATEST"),
    ];
    let found = candidates.iter().any(|p| p.exists());

    if found {
        let version = cfg.axiom_version(target);
        Check {
            label: "axiom algorithm LATEST",
            status: Status::Pass,
            detail: version,
        }
    } else {
        Check {
            label: "axiom algorithm LATEST",
            status: Status::Warn,
            detail: "not found".into(),
        }
    }
}

fn check_sources_lock(target: &Utf8PathBuf) -> Check {
    let lock = target.join(".cordance/sources.lock");
    if lock.exists() {
        Check {
            label: "sources.lock",
            status: Status::Pass,
            detail: "present".into(),
        }
    } else {
        Check {
            label: "sources.lock",
            status: Status::Warn,
            detail: "run 'cordance pack' first".into(),
        }
    }
}

fn check_ollama(cfg: &Config) -> Check {
    match cfg.llm.provider.as_str() {
        "none" => Check {
            label: "Ollama (LLM)",
            status: Status::Pass,
            detail: "disabled (provider = none)".into(),
        },
        "ollama" => {
            // ADR 0015: route the reachability check through the configured
            // adapter so [llm.ollama].base_url and .model are reported back to
            // the operator alongside Pass/Fail.
            let adapter = cordance_llm::OllamaAdapter::from_config(&cfg.llm.ollama);
            let base_url = adapter.base_url.clone();
            let model = adapter.model.clone();
            if adapter.is_available() {
                Check {
                    label: "Ollama (LLM)",
                    status: Status::Pass,
                    detail: format!("reachable at {base_url} (model: {model})"),
                }
            } else {
                Check {
                    label: "Ollama (LLM)",
                    status: Status::Fail,
                    detail: format!("not reachable at {base_url} — start Ollama first"),
                }
            }
        }
        other => Check {
            label: "Ollama (LLM)",
            status: Status::Warn,
            detail: format!("unknown provider '{other}' — skipping"),
        },
    }
}