//! Narrative explanation generation for project claims. //! //! Three distinct outputs: //! - `generate_onboarding()` — lightweight summary for `aphoria explain` //! - `generate_full_docs()` — comprehensive reference for `aphoria docs generate` //! - `generate_explanation()` — legacy function (kept for backward compat, delegates to `generate_onboarding`) use std::collections::BTreeMap; use crate::coverage::CoverageReport; use crate::types::authored_claim::{AuthoredClaim, ClaimStatus}; use crate::verify::{AuditVerdict, VerifyReport}; /// Generate an onboarding overview for `aphoria explain`. /// /// Lightweight narrative: category counts, verification health, coverage snapshot. /// Takes pre-computed data so the caller handles scanning. pub fn generate_onboarding( claims: &[AuthoredClaim], verify_report: &VerifyReport, coverage_report: &CoverageReport, project_name: &str, format: &str, ) -> String { match format { "json" => generate_onboarding_json(claims, verify_report, coverage_report, project_name), _ => generate_onboarding_markdown(claims, verify_report, coverage_report, project_name), } } /// Generate comprehensive reference docs for `aphoria docs generate`. /// /// Full claim details + verification results + coverage table. /// Takes pre-computed data so the caller handles scanning. pub fn generate_full_docs( claims: &[AuthoredClaim], verify_report: &VerifyReport, coverage_report: &CoverageReport, project_name: &str, format: &str, ) -> String { match format { "json" => generate_full_docs_json(claims, verify_report, coverage_report, project_name), _ => generate_full_docs_markdown(claims, verify_report, coverage_report, project_name), } } // --- Onboarding (aphoria explain) --- fn generate_onboarding_markdown( claims: &[AuthoredClaim], verify_report: &VerifyReport, coverage_report: &CoverageReport, project_name: &str, ) -> String { let mut out = String::new(); out.push_str(&format!("# {project_name} — Claim Overview\n\n")); // Category summary let categories = group_by_category(claims); let active_count = claims.iter().filter(|c| c.status == ClaimStatus::Active).count(); out.push_str(&format!( "**{project_name}** has **{active_count}** active claims across **{}** categories.\n\n", categories.len() )); if !categories.is_empty() { out.push_str("## Categories\n\n"); out.push_str("| Category | Active | Total |\n"); out.push_str("|----------|--------|-------|\n"); for (cat, cat_claims) in &categories { let active = cat_claims.iter().filter(|c| c.status == ClaimStatus::Active).count(); out.push_str(&format!("| {} | {} | {} |\n", capitalize(cat), active, cat_claims.len())); } out.push('\n'); } // Verification health let summary = &verify_report.summary; out.push_str("## Verification Health\n\n"); out.push_str(&format!( "- **Pass:** {}\n- **Conflict:** {}\n- **Missing:** {}\n- **Unclaimed observations:** {}\n\n", summary.pass, summary.conflict, summary.missing, summary.unclaimed, )); if summary.conflict > 0 { out.push_str("*Conflicts indicate code behavior differs from authored claims.*\n\n"); } // Coverage snapshot let cov = &coverage_report.summary; out.push_str("## Coverage Snapshot\n\n"); out.push_str(&format!( "- **Claimed:** {:.1}% of {} observations\n", cov.claimed_percentage, cov.total_observations, )); out.push_str(&format!( "- **Modules with claims:** {} / {}\n", cov.modules_with_claims, cov.modules_with_claims + cov.modules_without_claims, )); // Top uncovered modules let uncovered: Vec<_> = coverage_report .modules .iter() .filter(|m| m.claim_count == 0 && m.observation_count > 0) .take(5) .collect(); if !uncovered.is_empty() { out.push_str("\n**Top uncovered modules:**\n"); for m in uncovered { out.push_str(&format!( "- `{}` ({} observations)\n", m.module_path, m.observation_count, )); } } out.push_str("\n---\n"); out.push_str("Run `aphoria claims explain` for full claim details.\n"); out.push_str("Run `aphoria docs generate` for comprehensive reference documentation.\n"); out } fn generate_onboarding_json( claims: &[AuthoredClaim], verify_report: &VerifyReport, coverage_report: &CoverageReport, project_name: &str, ) -> String { let categories = group_by_category(claims); let active_count = claims.iter().filter(|c| c.status == ClaimStatus::Active).count(); let cat_summary: Vec = categories .iter() .map(|(cat, cat_claims)| { let active = cat_claims.iter().filter(|c| c.status == ClaimStatus::Active).count(); serde_json::json!({ "category": cat, "active": active, "total": cat_claims.len(), }) }) .collect(); let json = serde_json::json!({ "project": project_name, "type": "onboarding", "active_claims": active_count, "categories": cat_summary, "verification": { "pass": verify_report.summary.pass, "conflict": verify_report.summary.conflict, "missing": verify_report.summary.missing, "unclaimed": verify_report.summary.unclaimed, }, "coverage": { "claimed_percentage": coverage_report.summary.claimed_percentage, "total_observations": coverage_report.summary.total_observations, "modules_with_claims": coverage_report.summary.modules_with_claims, "modules_without_claims": coverage_report.summary.modules_without_claims, }, }); serde_json::to_string_pretty(&json).unwrap_or_else(|_| "{}".to_string()) } // --- Full docs (aphoria docs generate) --- fn generate_full_docs_markdown( claims: &[AuthoredClaim], verify_report: &VerifyReport, coverage_report: &CoverageReport, project_name: &str, ) -> String { let mut out = String::new(); out.push_str(&format!("# {project_name} — Reference Documentation\n\n")); // Section 1: Full claim details (reuse claims_explain) out.push_str(&crate::claims_explain::render_claims_markdown(claims, project_name)); out.push('\n'); // Section 2: Verification results out.push_str("---\n\n"); out.push_str("# Verification Results\n\n"); let summary = &verify_report.summary; out.push_str(&format!( "**Total:** {} claims verified — {} pass, {} conflict, {} missing, {} unclaimed observations\n\n", summary.total_claims, summary.pass, summary.conflict, summary.missing, summary.unclaimed, )); // Group verify results by verdict let mut conflicts = Vec::new(); let mut missing = Vec::new(); for result in &verify_report.results { match result.verdict { AuditVerdict::Conflict => conflicts.push(result), AuditVerdict::Missing => missing.push(result), _ => {} } } if !conflicts.is_empty() { out.push_str("## Conflicts\n\n"); for r in &conflicts { if let Some(ref claim) = r.claim { out.push_str(&format!("- **{}**: {}\n", claim.id, r.explanation)); } } out.push('\n'); } if !missing.is_empty() { out.push_str("## Missing Observations\n\n"); for r in &missing { if let Some(ref claim) = r.claim { out.push_str(&format!("- **{}**: {}\n", claim.id, r.explanation)); } } out.push('\n'); } // Section 3: Coverage table out.push_str("---\n\n"); out.push_str(&crate::coverage::format_coverage_markdown(coverage_report)); out } fn generate_full_docs_json( claims: &[AuthoredClaim], verify_report: &VerifyReport, coverage_report: &CoverageReport, project_name: &str, ) -> String { let claims_json: Vec = claims .iter() .map(|c| { serde_json::json!({ "id": c.id, "concept_path": c.concept_path, "predicate": c.predicate, "value": format!("{}", c.value), "provenance": c.provenance, "invariant": c.invariant, "consequence": c.consequence, "authority_tier": c.authority_tier, "category": c.category, "status": format!("{:?}", c.status), }) }) .collect(); let verify_json: Vec = verify_report .results .iter() .filter(|r| r.claim.is_some()) .map(|r| { let claim = r.claim.as_ref().unwrap_or_else(|| { // Safety: filtered above unreachable!() }); serde_json::json!({ "claim_id": claim.id, "verdict": format!("{}", r.verdict), "explanation": r.explanation, "matching_observations": r.matching_observations.len(), }) }) .collect(); let json = serde_json::json!({ "project": project_name, "type": "full_docs", "claims": claims_json, "verification": { "summary": { "total_claims": verify_report.summary.total_claims, "pass": verify_report.summary.pass, "conflict": verify_report.summary.conflict, "missing": verify_report.summary.missing, "unclaimed": verify_report.summary.unclaimed, }, "results": verify_json, }, "coverage": coverage_report, }); serde_json::to_string_pretty(&json).unwrap_or_else(|_| "{}".to_string()) } // --- Helpers --- fn group_by_category(claims: &[AuthoredClaim]) -> BTreeMap> { let mut categories: BTreeMap> = BTreeMap::new(); for claim in claims { categories.entry(claim.category.clone()).or_default().push(claim); } categories } fn capitalize(s: &str) -> String { let mut chars = s.chars(); match chars.next() { None => String::new(), Some(c) => c.to_uppercase().collect::() + chars.as_str(), } } #[cfg(test)] mod tests { use super::*; use crate::types::authored_claim::{AuthoredValue, ComparisonMode}; use crate::verify::{VerifyResult, VerifySummary}; use crate::coverage::{CoverageSummary, ModuleCoverage}; fn sample_claim(id: &str, category: &str) -> AuthoredClaim { AuthoredClaim { id: id.to_string(), concept_path: "test/concept".to_string(), predicate: "test_pred".to_string(), value: AuthoredValue::Text("test_value".to_string()), comparison: ComparisonMode::default(), provenance: "Test provenance".to_string(), invariant: "Test invariant".to_string(), consequence: "Bad things happen".to_string(), authority_tier: "expert".to_string(), evidence: vec![], category: category.to_string(), status: ClaimStatus::Active, supersedes: None, created_by: "tester".to_string(), created_at: "2026-02-08".to_string(), updated_at: None, } } fn empty_verify_report() -> VerifyReport { VerifyReport { results: vec![], summary: VerifySummary::default(), } } fn empty_coverage_report() -> CoverageReport { CoverageReport { project: "test".to_string(), modules: vec![], summary: CoverageSummary { total_observations: 0, total_claims: 0, claimed_percentage: 0.0, unclaimed_count: 0, modules_with_claims: 0, modules_without_claims: 0, }, } } fn sample_verify_report() -> VerifyReport { VerifyReport { results: vec![ VerifyResult { claim: Some(sample_claim("c1", "safety")), verdict: AuditVerdict::Pass, matching_observations: vec![], explanation: "Matches".to_string(), }, VerifyResult { claim: Some(sample_claim("c2", "architecture")), verdict: AuditVerdict::Conflict, matching_observations: vec![], explanation: "Value mismatch".to_string(), }, ], summary: VerifySummary { total_claims: 2, pass: 1, conflict: 1, missing: 0, unclaimed: 3, }, } } fn sample_coverage_report() -> CoverageReport { CoverageReport { project: "test".to_string(), modules: vec![ ModuleCoverage { module_path: "tls".to_string(), files: vec!["src/tls/config.rs".to_string()], observation_count: 5, claim_count: 2, claimed_observations: 3, unclaimed_observations: 2, missing_claims: 0, density: 0.4, }, ModuleCoverage { module_path: "auth".to_string(), files: vec!["src/auth/jwt.rs".to_string()], observation_count: 3, claim_count: 0, claimed_observations: 0, unclaimed_observations: 3, missing_claims: 0, density: 0.0, }, ], summary: CoverageSummary { total_observations: 8, total_claims: 2, claimed_percentage: 37.5, unclaimed_count: 5, modules_with_claims: 1, modules_without_claims: 1, }, } } #[test] fn test_onboarding_has_category_table() { let claims = vec![ sample_claim("s1", "safety"), sample_claim("a1", "architecture"), sample_claim("s2", "safety"), ]; let out = generate_onboarding(&claims, &empty_verify_report(), &empty_coverage_report(), "myproject", "markdown"); assert!(out.contains("# myproject")); assert!(out.contains("3** active claims")); assert!(out.contains("| Safety")); assert!(out.contains("| Architecture")); } #[test] fn test_onboarding_shows_verification_health() { let claims = vec![sample_claim("c1", "safety")]; let vr = sample_verify_report(); let out = generate_onboarding(&claims, &vr, &empty_coverage_report(), "proj", "markdown"); assert!(out.contains("**Pass:** 1")); assert!(out.contains("**Conflict:** 1")); assert!(out.contains("Conflicts indicate")); } #[test] fn test_onboarding_shows_coverage_snapshot() { let claims = vec![sample_claim("c1", "safety")]; let cr = sample_coverage_report(); let out = generate_onboarding(&claims, &empty_verify_report(), &cr, "proj", "markdown"); assert!(out.contains("37.5%")); assert!(out.contains("`auth`")); } #[test] fn test_onboarding_pointers() { let out = generate_onboarding(&[], &empty_verify_report(), &empty_coverage_report(), "proj", "markdown"); assert!(out.contains("aphoria claims explain")); assert!(out.contains("aphoria docs generate")); } #[test] fn test_full_docs_includes_claim_details() { let claims = vec![sample_claim("c1", "safety")]; let out = generate_full_docs(&claims, &empty_verify_report(), &empty_coverage_report(), "proj", "markdown"); // Should contain per-claim fields from claims_explain assert!(out.contains("**Concept:**")); assert!(out.contains("**Invariant:**")); } #[test] fn test_full_docs_includes_verify_results() { let claims = vec![sample_claim("c1", "safety")]; let vr = sample_verify_report(); let out = generate_full_docs(&claims, &vr, &empty_coverage_report(), "proj", "markdown"); assert!(out.contains("# Verification Results")); assert!(out.contains("## Conflicts")); assert!(out.contains("Value mismatch")); } #[test] fn test_full_docs_includes_coverage_table() { let claims = vec![sample_claim("c1", "safety")]; let cr = sample_coverage_report(); let out = generate_full_docs(&claims, &empty_verify_report(), &cr, "proj", "markdown"); assert!(out.contains("# Aphoria Coverage:")); assert!(out.contains("Coverage Gaps")); } #[test] fn test_onboarding_and_full_docs_differ() { let claims = vec![sample_claim("c1", "safety")]; let vr = sample_verify_report(); let cr = sample_coverage_report(); let onboarding = generate_onboarding(&claims, &vr, &cr, "proj", "markdown"); let full_docs = generate_full_docs(&claims, &vr, &cr, "proj", "markdown"); assert_ne!(onboarding, full_docs); // Onboarding should NOT have per-claim concept details assert!(!onboarding.contains("**Concept:**")); // Full docs should NOT have the "Run `aphoria claims explain`" pointer assert!(!full_docs.contains("Run `aphoria claims explain`")); } #[test] fn test_onboarding_json() { let claims = vec![sample_claim("c1", "safety")]; let out = generate_onboarding(&claims, &empty_verify_report(), &empty_coverage_report(), "proj", "json"); let parsed: serde_json::Value = serde_json::from_str(&out).unwrap_or_default(); assert_eq!(parsed["type"], "onboarding"); assert_eq!(parsed["active_claims"], 1); } #[test] fn test_full_docs_json() { let claims = vec![sample_claim("c1", "safety")]; let out = generate_full_docs(&claims, &empty_verify_report(), &empty_coverage_report(), "proj", "json"); let parsed: serde_json::Value = serde_json::from_str(&out).unwrap_or_default(); assert_eq!(parsed["type"], "full_docs"); assert!(parsed["claims"].is_array()); assert!(parsed["verification"].is_object()); assert!(parsed["coverage"].is_object()); } }