agentnative 0.1.1

The agent-native CLI linter — check whether your CLI follows agent-readiness principles
//! Coverage matrix generator. Cross-references the requirement registry
//! against the checks discovered at runtime (behavioral + source + project).
//!
//! Output artifacts:
//! - `docs/coverage-matrix.md` — human-readable table grouped by principle.
//! - `coverage/matrix.json` — machine-readable, consumed by the site's
//!   `/coverage` page.
//!
//! The CLI surfaces this as `anc generate coverage-matrix` with `--check`
//! to fail CI when committed artifacts drift from the registry + checks.

use std::collections::BTreeMap;
use std::fmt::Write as _;

use serde::Serialize;

use crate::check::Check;
use crate::principles::registry::{Applicability, Level, REQUIREMENTS};
use crate::types::CheckLayer;

/// A check that covers a given requirement.
#[derive(Debug, Clone, Serialize)]
pub struct Verifier {
    pub check_id: String,
    pub layer: CheckLayer,
}

/// One row of the coverage matrix.
#[derive(Debug, Serialize)]
pub struct MatrixRow {
    pub id: &'static str,
    pub principle: u8,
    pub level: Level,
    pub summary: &'static str,
    pub applicability: Applicability,
    pub verifiers: Vec<Verifier>,
}

/// The rendered matrix, suitable for JSON serialization.
#[derive(Debug, Serialize)]
pub struct Matrix {
    pub schema_version: &'static str,
    pub generated_by: &'static str,
    pub rows: Vec<MatrixRow>,
    pub summary: MatrixSummary,
}

#[derive(Debug, Serialize)]
pub struct MatrixSummary {
    pub total: usize,
    pub covered: usize,
    pub uncovered: usize,
    pub must: LevelSummary,
    pub should: LevelSummary,
    pub may: LevelSummary,
}

#[derive(Debug, Serialize)]
pub struct LevelSummary {
    pub total: usize,
    pub covered: usize,
}

const SCHEMA_VERSION: &str = "1.0";
const GENERATED_BY: &str = "anc generate coverage-matrix";

/// Build the matrix from the requirement registry + a slice of checks.
/// Ownership stays with the caller; this reads `check.covers()` references.
pub fn build(checks: &[Box<dyn Check>]) -> Matrix {
    // Inverse map: requirement ID -> Vec<Verifier>.
    let mut coverage: BTreeMap<&'static str, Vec<Verifier>> = BTreeMap::new();
    for check in checks {
        for req_id in check.covers() {
            coverage.entry(req_id).or_default().push(Verifier {
                check_id: check.id().to_string(),
                layer: check.layer(),
            });
        }
    }

    let rows: Vec<MatrixRow> = REQUIREMENTS
        .iter()
        .map(|r| MatrixRow {
            id: r.id,
            principle: r.principle,
            level: r.level,
            summary: r.summary,
            applicability: r.applicability,
            verifiers: coverage.get(r.id).cloned().unwrap_or_default(),
        })
        .collect();

    let summary = summarize(&rows);

    Matrix {
        schema_version: SCHEMA_VERSION,
        generated_by: GENERATED_BY,
        rows,
        summary,
    }
}

fn summarize(rows: &[MatrixRow]) -> MatrixSummary {
    let mut must = LevelSummary {
        total: 0,
        covered: 0,
    };
    let mut should = LevelSummary {
        total: 0,
        covered: 0,
    };
    let mut may = LevelSummary {
        total: 0,
        covered: 0,
    };
    let mut covered = 0;

    for row in rows {
        let bucket = match row.level {
            Level::Must => &mut must,
            Level::Should => &mut should,
            Level::May => &mut may,
        };
        bucket.total += 1;
        if !row.verifiers.is_empty() {
            bucket.covered += 1;
            covered += 1;
        }
    }

    MatrixSummary {
        total: rows.len(),
        covered,
        uncovered: rows.len() - covered,
        must,
        should,
        may,
    }
}

/// Render the matrix as Markdown. Stable format — a small change in
/// structure will break golden-file tests on purpose.
pub fn render_markdown(matrix: &Matrix) -> String {
    let mut out = String::new();
    let _ = writeln!(out, "# Coverage Matrix");
    let _ = writeln!(out);
    let _ = writeln!(
        out,
        "<!-- Generated by `{}` — do not edit by hand. Commit regenerated output alongside code changes. -->",
        GENERATED_BY
    );
    let _ = writeln!(out);
    let _ = writeln!(
        out,
        "This table maps every MUST, SHOULD, and MAY in the agent-native CLI spec to the `anc` checks that verify it."
    );
    let _ = writeln!(
        out,
        "When a requirement has no verifier, the cell reads **UNCOVERED** and the reader knows the scorecard cannot speak to it."
    );
    let _ = writeln!(out);

    let s = &matrix.summary;
    let _ = writeln!(out, "## Summary");
    let _ = writeln!(out);
    let _ = writeln!(
        out,
        "- **Total**: {} requirements ({} covered / {} uncovered)",
        s.total, s.covered, s.uncovered
    );
    let _ = writeln!(
        out,
        "- **MUST**: {} of {} covered",
        s.must.covered, s.must.total
    );
    let _ = writeln!(
        out,
        "- **SHOULD**: {} of {} covered",
        s.should.covered, s.should.total
    );
    let _ = writeln!(
        out,
        "- **MAY**: {} of {} covered",
        s.may.covered, s.may.total
    );
    let _ = writeln!(out);

    // Group rows by principle for readability.
    let mut by_principle: BTreeMap<u8, Vec<&MatrixRow>> = BTreeMap::new();
    for row in &matrix.rows {
        by_principle.entry(row.principle).or_default().push(row);
    }

    for (principle, rows) in &by_principle {
        let _ = writeln!(out, "## P{}: {}", principle, principle_name(*principle));
        let _ = writeln!(out);
        let _ = writeln!(
            out,
            "| ID | Level | Applicability | Verifier(s) | Summary |"
        );
        let _ = writeln!(out, "| --- | --- | --- | --- | --- |");
        for row in rows {
            let level = match row.level {
                Level::Must => "MUST",
                Level::Should => "SHOULD",
                Level::May => "MAY",
            };
            let applicability = match row.applicability {
                Applicability::Universal => "Universal".to_string(),
                Applicability::Conditional(cond) => format!("If: {cond}"),
            };
            let verifiers = if row.verifiers.is_empty() {
                "**UNCOVERED**".to_string()
            } else {
                row.verifiers
                    .iter()
                    .map(|v| format!("`{}` ({})", v.check_id, layer_label(v.layer)))
                    .collect::<Vec<_>>()
                    .join("<br>")
            };
            let _ = writeln!(
                out,
                "| `{}` | {} | {} | {} | {} |",
                row.id,
                level,
                applicability,
                verifiers,
                escape_pipes(row.summary)
            );
        }
        let _ = writeln!(out);
    }

    out
}

fn layer_label(layer: CheckLayer) -> &'static str {
    match layer {
        CheckLayer::Behavioral => "behavioral",
        CheckLayer::Source => "source",
        CheckLayer::Project => "project",
    }
}

fn principle_name(principle: u8) -> &'static str {
    match principle {
        1 => "Non-Interactive by Default",
        2 => "Structured, Parseable Output",
        3 => "Progressive Help Discovery",
        4 => "Fail Fast, Actionable Errors",
        5 => "Safe Retries, Mutation Boundaries",
        6 => "Composable, Predictable Command Structure",
        7 => "Bounded, High-Signal Responses",
        _ => "Unknown",
    }
}

/// Replace pipe characters so markdown table rows stay well-formed.
fn escape_pipes(s: &str) -> String {
    s.replace('|', "\\|")
}

/// Render the matrix as pretty JSON.
pub fn render_json(matrix: &Matrix) -> String {
    serde_json::to_string_pretty(matrix).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
}

/// Unreferenced requirement IDs discovered in `Check::covers()`. Used by
/// the registry validator to catch dangling references at test time.
pub fn dangling_cover_ids(checks: &[Box<dyn Check>]) -> Vec<(String, String)> {
    let mut dangling = Vec::new();
    for check in checks {
        for req_id in check.covers() {
            if crate::principles::registry::find(req_id).is_none() {
                dangling.push((check.id().to_string(), (*req_id).to_string()));
            }
        }
    }
    dangling
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::check::Check;
    use crate::project::Project;
    use crate::types::{CheckGroup, CheckLayer, CheckResult, CheckStatus};

    struct FakeCheck {
        id: &'static str,
        covers: &'static [&'static str],
    }

    impl Check for FakeCheck {
        fn id(&self) -> &str {
            self.id
        }
        fn group(&self) -> CheckGroup {
            CheckGroup::P1
        }
        fn layer(&self) -> CheckLayer {
            CheckLayer::Behavioral
        }
        fn applicable(&self, _project: &Project) -> bool {
            true
        }
        fn run(&self, _project: &Project) -> anyhow::Result<CheckResult> {
            Ok(CheckResult {
                id: self.id.to_string(),
                label: self.id.to_string(),
                group: CheckGroup::P1,
                layer: CheckLayer::Behavioral,
                status: CheckStatus::Pass,
            })
        }
        fn covers(&self) -> &'static [&'static str] {
            self.covers
        }
    }

    #[test]
    fn build_marks_uncovered_rows_when_no_checks() {
        let checks: Vec<Box<dyn Check>> = vec![];
        let matrix = build(&checks);
        assert_eq!(matrix.rows.len(), REQUIREMENTS.len());
        assert!(matrix.rows.iter().all(|r| r.verifiers.is_empty()));
        assert_eq!(matrix.summary.covered, 0);
        assert_eq!(matrix.summary.uncovered, REQUIREMENTS.len());
    }

    #[test]
    fn build_links_check_to_requirement() {
        let checks: Vec<Box<dyn Check>> = vec![Box::new(FakeCheck {
            id: "fake-check",
            covers: &["p1-must-no-interactive"],
        })];
        let matrix = build(&checks);
        let row = matrix
            .rows
            .iter()
            .find(|r| r.id == "p1-must-no-interactive")
            .expect("requirement row");
        assert_eq!(row.verifiers.len(), 1);
        assert_eq!(row.verifiers[0].check_id, "fake-check");
    }

    #[test]
    fn render_markdown_contains_summary_and_uncovered_marker() {
        let checks: Vec<Box<dyn Check>> = vec![];
        let matrix = build(&checks);
        let md = render_markdown(&matrix);
        assert!(md.contains("# Coverage Matrix"));
        assert!(md.contains("## Summary"));
        assert!(md.contains("**UNCOVERED**"));
        assert!(md.contains("P1: Non-Interactive by Default"));
    }

    #[test]
    fn render_json_is_valid_json() {
        let checks: Vec<Box<dyn Check>> = vec![];
        let matrix = build(&checks);
        let json = render_json(&matrix);
        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
        assert_eq!(parsed["schema_version"], SCHEMA_VERSION);
        assert!(parsed["rows"].is_array());
    }

    #[test]
    fn dangling_cover_ids_detects_typo() {
        let checks: Vec<Box<dyn Check>> = vec![Box::new(FakeCheck {
            id: "typo-check",
            covers: &["p1-must-no-interactivx"], // typo on purpose
        })];
        let dangling = dangling_cover_ids(&checks);
        assert_eq!(dangling.len(), 1);
        assert_eq!(dangling[0].0, "typo-check");
    }

    #[test]
    fn dangling_cover_ids_empty_for_valid_refs() {
        let checks: Vec<Box<dyn Check>> = vec![Box::new(FakeCheck {
            id: "valid-check",
            covers: &["p1-must-no-interactive", "p1-should-tty-detection"],
        })];
        assert!(dangling_cover_ids(&checks).is_empty());
    }
}