cordance-cli 0.1.2

Cordance CLI — installs the `cordance` binary. The umbrella package `cordance` re-exports this entry; either install command works.
Documentation
//! Advise tier: `cordance_advise_findings`, `cordance_advise_by_rule`,
//! `cordance_advise_run`.
//!
//! `findings` and `by_rule` are read-only views over the same deterministic
//! report. `run` recomputes the report fresh (supervised tier).

use camino::Utf8PathBuf;
use cordance_core::advise::{AdviseFinding, AdviseReport, Severity};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::config::Config;
use crate::mcp::error::{McpToolError, McpToolResult};

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

#[derive(Clone, Debug, Deserialize, JsonSchema)]
pub struct AdviseByRuleParams {
    #[serde(default)]
    pub target: Option<String>,
    /// Substring matched against `AdviseFinding::id` (case-sensitive).
    pub rule_id: String,
}

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

/// JSON-RPC shape for the advise tools.
///
/// Round-4 bughunt #1: the on-disk `AdviseReport` lost its `generated_at`
/// timestamp to keep `pack.json` byte-deterministic. The wire output keeps
/// a `generated_at` field set to the moment the MCP responder produced the
/// payload — informational only ("when did this tool run"), not an attestation
/// of when the underlying pack was built.
#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct AdviseReportOutput {
    pub schema: String,
    pub generated_at: String,
    pub findings: Vec<AdviseFindingSummary>,
}

#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct AdviseFindingSummary {
    pub id: String,
    pub severity: String,
    pub summary: String,
    #[schemars(with = "String")]
    pub doctrine_anchor: Utf8PathBuf,
    #[schemars(with = "Vec<String>")]
    pub project_paths: Vec<Utf8PathBuf>,
    pub remediation: String,
}

/// Read all findings for the most recent pack of `target`.
pub fn findings(target: &Utf8PathBuf, cfg: &Config) -> McpToolResult<AdviseReportOutput> {
    let pack = super::pack::build_pack(target, cfg)?;
    let report = cordance_advise::run_all(&pack)
        .map_err(|e| McpToolError::Internal(format!("advise failed: {e}")))?;
    Ok(to_summary(&report))
}

/// Filter the most recent pack's advise report by `rule_id` substring.
pub fn by_rule(
    target: &Utf8PathBuf,
    cfg: &Config,
    rule_id: &str,
) -> McpToolResult<AdviseReportOutput> {
    if rule_id.is_empty() {
        return Err(McpToolError::InvalidArgument(
            "rule_id must not be empty".into(),
        ));
    }
    let report = run_report(target, cfg)?;
    let filtered: Vec<AdviseFinding> = report
        .findings
        .iter()
        .filter(|f| f.id.contains(rule_id))
        .cloned()
        .collect();
    Ok(to_summary(&AdviseReport {
        schema: report.schema,
        findings: filtered,
    }))
}

/// Force a fresh advise pass (supervised tier — recomputes from scratch).
pub fn run(target: &Utf8PathBuf, cfg: &Config) -> McpToolResult<AdviseReportOutput> {
    let report = run_report(target, cfg)?;
    Ok(to_summary(&report))
}

fn run_report(target: &Utf8PathBuf, cfg: &Config) -> McpToolResult<AdviseReport> {
    let pack = super::pack::build_pack(target, cfg)?;
    cordance_advise::run_all(&pack)
        .map_err(|e| McpToolError::Internal(format!("advise failed: {e}")))
}

fn to_summary(report: &AdviseReport) -> AdviseReportOutput {
    AdviseReportOutput {
        schema: report.schema.clone(),
        // Wire-only timestamp ("when did this tool run") — see `AdviseReportOutput` docs.
        generated_at: chrono::Utc::now().to_rfc3339(),
        findings: report.findings.iter().map(summarise_finding).collect(),
    }
}

fn summarise_finding(f: &AdviseFinding) -> AdviseFindingSummary {
    AdviseFindingSummary {
        id: f.id.clone(),
        severity: severity_label(f.severity).to_string(),
        summary: f.summary.clone(),
        doctrine_anchor: f.doctrine_anchor.clone(),
        project_paths: f.project_paths.clone(),
        remediation: f.remediation.clone(),
    }
}

const fn severity_label(s: Severity) -> &'static str {
    match s {
        Severity::Error => "error",
        Severity::Warning => "warning",
        Severity::Info => "info",
    }
}