use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt::Write as _;
use serde::Serialize;
use crate::check::Check;
use crate::principles::registry::{Level, REQUIREMENTS};
use crate::types::{CheckGroup, CheckResult, CheckStatus};
pub const SCHEMA_VERSION: &str = "1.1";
#[derive(Serialize)]
pub struct Scorecard {
pub schema_version: &'static str,
pub results: Vec<CheckResultView>,
pub summary: Summary,
pub coverage_summary: CoverageSummary,
pub audience: Option<String>,
pub audit_profile: Option<String>,
}
#[derive(Serialize)]
pub struct LevelCounts {
pub total: usize,
pub verified: usize,
}
#[derive(Serialize)]
pub struct CoverageSummary {
pub must: LevelCounts,
pub should: LevelCounts,
pub may: LevelCounts,
}
#[derive(Serialize)]
pub struct Summary {
pub total: usize,
pub pass: usize,
pub warn: usize,
pub fail: usize,
pub skip: usize,
pub error: usize,
}
#[derive(Serialize)]
pub struct CheckResultView {
pub id: String,
pub label: String,
pub group: String,
pub layer: String,
pub status: String,
pub evidence: Option<String>,
}
impl CheckResultView {
pub fn from_result(r: &CheckResult) -> Self {
let (status, evidence) = match &r.status {
CheckStatus::Pass => ("pass".to_string(), None),
CheckStatus::Warn(e) => ("warn".to_string(), Some(e.clone())),
CheckStatus::Fail(e) => ("fail".to_string(), Some(e.clone())),
CheckStatus::Skip(e) => ("skip".to_string(), Some(e.clone())),
CheckStatus::Error(e) => ("error".to_string(), Some(e.clone())),
};
let group = serde_json::to_value(r.group)
.ok()
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_else(|| format!("{:?}", r.group));
let layer = serde_json::to_value(r.layer)
.ok()
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_else(|| format!("{:?}", r.layer));
CheckResultView {
id: r.id.clone(),
label: r.label.clone(),
group,
layer,
status,
evidence,
}
}
}
fn build_summary(results: &[CheckResult]) -> Summary {
Summary {
total: results.len(),
pass: results
.iter()
.filter(|r| matches!(r.status, CheckStatus::Pass))
.count(),
warn: results
.iter()
.filter(|r| matches!(r.status, CheckStatus::Warn(_)))
.count(),
fail: results
.iter()
.filter(|r| matches!(r.status, CheckStatus::Fail(_)))
.count(),
skip: results
.iter()
.filter(|r| matches!(r.status, CheckStatus::Skip(_)))
.count(),
error: results
.iter()
.filter(|r| matches!(r.status, CheckStatus::Error(_)))
.count(),
}
}
fn group_display(group: &CheckGroup) -> &'static str {
match group {
CheckGroup::P1 => "P1 — Non-Interactive by Default",
CheckGroup::P2 => "P2 — Structured Output",
CheckGroup::P3 => "P3 — Progressive Help",
CheckGroup::P4 => "P4 — Actionable Errors",
CheckGroup::P5 => "P5 — Safe Retries",
CheckGroup::P6 => "P6 — Composable Structure",
CheckGroup::P7 => "P7 — Bounded Responses",
CheckGroup::CodeQuality => "Code Quality",
CheckGroup::ProjectStructure => "Project Structure",
}
}
fn group_order(group: &CheckGroup) -> u8 {
match group {
CheckGroup::P1 => 1,
CheckGroup::P2 => 2,
CheckGroup::P3 => 3,
CheckGroup::P4 => 4,
CheckGroup::P5 => 5,
CheckGroup::P6 => 6,
CheckGroup::P7 => 7,
CheckGroup::CodeQuality => 8,
CheckGroup::ProjectStructure => 9,
}
}
pub fn format_text(results: &[CheckResult], quiet: bool) -> String {
let mut out = String::new();
let mut grouped: BTreeMap<u8, (CheckGroup, Vec<&CheckResult>)> = BTreeMap::new();
for r in results {
let order = group_order(&r.group);
grouped
.entry(order)
.or_insert_with(|| (r.group, Vec::new()))
.1
.push(r);
}
for (group, checks) in grouped.values() {
if !quiet {
let _ = writeln!(out, "\n{}", group_display(group));
}
for r in checks {
let prefix = match &r.status {
CheckStatus::Pass => {
if quiet {
continue;
}
"PASS"
}
CheckStatus::Warn(_) => "WARN",
CheckStatus::Fail(_) => "FAIL",
CheckStatus::Skip(_) => {
if quiet {
continue;
}
"SKIP"
}
CheckStatus::Error(_) => "ERR ",
};
let _ = writeln!(out, " [{prefix}] {} ({})", r.label, r.id);
match &r.status {
CheckStatus::Warn(e) | CheckStatus::Fail(e) | CheckStatus::Error(e) => {
for line in e.lines() {
let _ = writeln!(out, " {line}");
}
}
CheckStatus::Skip(reason) if !quiet => {
let _ = writeln!(out, " {reason}");
}
_ => {}
}
}
}
let s = build_summary(results);
let _ = writeln!(
out,
"\n{} checks: {} pass, {} warn, {} fail, {} skip, {} error",
s.total, s.pass, s.warn, s.fail, s.skip, s.error
);
out
}
pub fn build_scorecard(
results: &[CheckResult],
ran_checks: &[Box<dyn Check>],
audience: Option<String>,
audit_profile: Option<String>,
) -> Scorecard {
Scorecard {
schema_version: SCHEMA_VERSION,
results: results.iter().map(CheckResultView::from_result).collect(),
summary: build_summary(results),
coverage_summary: build_coverage_summary(results, ran_checks),
audience,
audit_profile,
}
}
pub fn format_json(results: &[CheckResult], ran_checks: &[Box<dyn Check>]) -> String {
let scorecard = build_scorecard(results, ran_checks, None, None);
serde_json::to_string_pretty(&scorecard).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
}
fn build_coverage_summary(
results: &[CheckResult],
ran_checks: &[Box<dyn Check>],
) -> CoverageSummary {
let covers_by_id: HashMap<&str, &'static [&'static str]> =
ran_checks.iter().map(|c| (c.id(), c.covers())).collect();
let mut verified: HashSet<&'static str> = HashSet::new();
for r in results {
if let Some(ids) = covers_by_id.get(r.id.as_str()) {
verified.extend(ids.iter().copied());
}
}
let mut must = LevelCounts {
total: 0,
verified: 0,
};
let mut should = LevelCounts {
total: 0,
verified: 0,
};
let mut may = LevelCounts {
total: 0,
verified: 0,
};
for req in REQUIREMENTS {
let bucket = match req.level {
Level::Must => &mut must,
Level::Should => &mut should,
Level::May => &mut may,
};
bucket.total += 1;
if verified.contains(req.id) {
bucket.verified += 1;
}
}
CoverageSummary { must, should, may }
}
pub fn exit_code(results: &[CheckResult]) -> i32 {
let has_fail_or_error = results
.iter()
.any(|r| matches!(r.status, CheckStatus::Fail(_) | CheckStatus::Error(_)));
let has_warn = results
.iter()
.any(|r| matches!(r.status, CheckStatus::Warn(_)));
if has_fail_or_error {
2
} else if has_warn {
1
} else {
0
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{CheckGroup, CheckLayer, CheckResult, CheckStatus};
fn make_result(id: &str, status: CheckStatus, group: CheckGroup) -> CheckResult {
CheckResult {
id: id.to_string(),
label: format!("Test {id}"),
group,
layer: CheckLayer::Behavioral,
status,
}
}
#[test]
fn test_format_json_valid() {
let results = vec![
make_result("c1", CheckStatus::Pass, CheckGroup::P1),
make_result("c2", CheckStatus::Fail("bad".into()), CheckGroup::P2),
];
let json = format_json(&results, &[]);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert_eq!(parsed["schema_version"], "1.1");
assert_eq!(parsed["summary"]["total"], 2);
assert_eq!(parsed["summary"]["pass"], 1);
assert_eq!(parsed["summary"]["fail"], 1);
assert_eq!(parsed["results"][0]["status"], "pass");
assert!(parsed["results"][0]["evidence"].is_null());
assert_eq!(parsed["results"][1]["status"], "fail");
assert_eq!(parsed["results"][1]["evidence"], "bad");
assert!(parsed["coverage_summary"]["must"]["total"].is_number());
assert!(parsed["coverage_summary"]["should"]["total"].is_number());
assert!(parsed["coverage_summary"]["may"]["total"].is_number());
assert!(parsed["audience"].is_null());
assert!(parsed["audit_profile"].is_null());
}
#[test]
fn coverage_summary_counts_verified_requirements() {
use crate::check::Check;
use crate::project::Project;
use crate::types::CheckLayer;
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, _p: &Project) -> bool {
true
}
fn run(&self, _p: &Project) -> anyhow::Result<CheckResult> {
unreachable!()
}
fn covers(&self) -> &'static [&'static str] {
self.covers
}
}
let results = vec![make_result("verifier-a", CheckStatus::Pass, CheckGroup::P1)];
let checks: Vec<Box<dyn Check>> = vec![Box::new(FakeCheck {
id: "verifier-a",
covers: &["p1-must-no-interactive"],
})];
let summary = build_coverage_summary(&results, &checks);
assert_eq!(summary.must.verified, 1);
assert_eq!(summary.should.verified, 0);
assert_eq!(summary.may.verified, 0);
assert!(summary.must.total >= 1);
}
#[test]
fn test_exit_code_all_pass() {
let results = vec![
make_result("c1", CheckStatus::Pass, CheckGroup::P1),
make_result("c2", CheckStatus::Skip("n/a".into()), CheckGroup::P2),
];
assert_eq!(exit_code(&results), 0);
}
#[test]
fn test_exit_code_warn() {
let results = vec![
make_result("c1", CheckStatus::Pass, CheckGroup::P1),
make_result("c2", CheckStatus::Warn("meh".into()), CheckGroup::P2),
];
assert_eq!(exit_code(&results), 1);
}
#[test]
fn test_exit_code_fail() {
let results = vec![
make_result("c1", CheckStatus::Fail("bad".into()), CheckGroup::P1),
make_result("c2", CheckStatus::Warn("meh".into()), CheckGroup::P2),
];
assert_eq!(exit_code(&results), 2);
}
#[test]
fn test_exit_code_error() {
let results = vec![make_result(
"c1",
CheckStatus::Error("boom".into()),
CheckGroup::P1,
)];
assert_eq!(exit_code(&results), 2);
}
#[test]
fn test_check_result_view_conversion() {
let r = make_result(
"test-id",
CheckStatus::Warn("warning msg".into()),
CheckGroup::P3,
);
let view = CheckResultView::from_result(&r);
assert_eq!(view.id, "test-id");
assert_eq!(view.status, "warn");
assert_eq!(view.evidence.as_deref(), Some("warning msg"));
assert_eq!(view.layer, "behavioral");
}
#[test]
fn test_check_result_view_pass_has_no_evidence() {
let r = make_result("pass-id", CheckStatus::Pass, CheckGroup::P1);
let view = CheckResultView::from_result(&r);
assert_eq!(view.status, "pass");
assert!(view.evidence.is_none());
}
}