//! Markdown output format for documentation and PR comments. //! //! Produces a full markdown document with summary table, //! detailed conflict sections, and action items. use super::{extract_leaf_concept, object_value_display, verdict_label, ReportFormatter}; use crate::types::{ScanResult, Verdict}; /// Markdown report formatter. pub struct MarkdownReport; impl ReportFormatter for MarkdownReport { fn format(&self, result: &ScanResult) -> String { let mut out = String::new(); // Title out.push_str(&format!("# Aphoria Scan: {}\n\n", result.project)); // Summary let drift_info = if result.has_drifts() { format!(" | **{}** drifts", result.drift_count()) } else { String::new() }; out.push_str(&format!( "**{}** files scanned | **{}** claims extracted | **{}** conflicts{}\n", result.files_scanned, result.claims_extracted, result.conflicts.len(), drift_info )); if result.strict { out.push_str("\n**Mode:** strict (BLOCK >= 0.50, FLAG >= 0.30)\n"); } out.push('\n'); if result.conflicts.is_empty() && result.drifts.is_empty() { out.push_str("No conflicts or drifts found.\n"); // Still show claims if requested, even with no conflicts if let Some(claims) = &result.claims { out.push_str("\n## Extracted Claims\n\n"); if claims.is_empty() { out.push_str("No claims extracted.\n\n"); } else { out.push_str("| Concept | Value | File | Line | Confidence |\n"); out.push_str("|---------|-------|------|------|------------|\n"); for claim in claims { out.push_str(&format!( "| `{}` | `{}` | `{}` | {} | {:.0}% |\n", extract_leaf_concept(&claim.concept_path), object_value_display(&claim.value), claim.file, claim.line, claim.confidence * 100.0, )); } out.push('\n'); } } return out; } // Verdict badges let blocks = result.count_by_verdict(Verdict::Block); let flags = result.count_by_verdict(Verdict::Flag); let drifts = result.drift_count(); if blocks > 0 { out.push_str(&format!("**{blocks} BLOCK** ")); } if flags > 0 { out.push_str(&format!("**{flags} FLAG** ")); } if drifts > 0 { out.push_str(&format!("**{drifts} DRIFT** ")); } out.push('\n'); out.push('\n'); // Summary table out.push_str("| Verdict | Concept | Citation | File | Score |\n"); out.push_str("|---------|---------|----------|------|-------|\n"); for conflict in &result.conflicts { let concept = extract_leaf_concept(&conflict.claim.concept_path); // Get RFC/OWASP citation let citation = conflict .conflicts .first() .and_then(|s| s.rfc_citation.as_ref()) .map(|c| c.as_str()) .unwrap_or("-"); out.push_str(&format!( "| {} | `{}` | {} | `{}:{}` | {:.2} |\n", verdict_label(conflict.verdict), concept, citation, conflict.claim.file, conflict.claim.line, conflict.conflict_score, )); } out.push('\n'); // Detailed sections for BLOCK and FLAG let actionable: Vec<_> = result .conflicts .iter() .filter(|c| c.verdict == Verdict::Block || c.verdict == Verdict::Flag) .collect(); if !actionable.is_empty() { out.push_str("## Details\n\n"); for conflict in actionable { out.push_str(&format!( "### {} `{}`\n\n", verdict_label(conflict.verdict), conflict.claim.concept_path )); out.push_str(&format!( "- **Your code:** {} (`{}:{}`)\n", conflict.claim.description, conflict.claim.file, conflict.claim.line )); for source in &conflict.conflicts { let citation = source .rfc_citation .as_ref() .map(|c| format!(" [{}]", c)) .unwrap_or_default(); out.push_str(&format!( "- **{:?}** (Tier {}){}: `{}`\n", source.source_class, source.source_class.tier(), citation, object_value_display(&source.value), )); // Show policy source if this came from a Trust Pack if let Some(policy) = &source.policy_source { let signer = policy.signer_name.as_deref().unwrap_or(&policy.issuer_hex); out.push_str(&format!( " - *Source: {} v{} ({})*\n", policy.pack_name, policy.pack_version, signer )); if let Some(contact) = &policy.contact { out.push_str(&format!(" - *Contact: {}*\n", contact)); } } } out.push_str(&format!("- **Score:** {:.2}\n", conflict.conflict_score)); // Show tier breakdown in debug mode if let Some(breakdown) = &conflict.tier_breakdown { let parts: Vec = breakdown .iter() .map(|tb| format!("{:?} (Tier {})", tb.source_class, tb.tier)) .collect(); out.push_str(&format!("- **Authority:** {}\n", parts.join(" > "))); } if let Some(ack) = &conflict.acknowledged { if ack.expired { // Expired acknowledgment out.push_str(&format!( "- **Previous acknowledgment expired** {}\n", ack.expires_at.as_deref().unwrap_or("(unknown date)") )); out.push_str(&format!( "- **Prior ack** by {} on {}: \"{}\"\n", ack.by, ack.timestamp, ack.reason )); if conflict.verdict == Verdict::Block { out.push_str( "- **Action:** Fix or run `aphoria ack --reason \"...\"`\n", ); } else { out.push_str("- **Action:** Review recommended\n"); } } else { // Active acknowledgment out.push_str(&format!( "- **Acknowledged** by {} on {}: \"{}\"\n", ack.by, ack.timestamp, ack.reason )); if let Some(exp) = &ack.expires_at { out.push_str(&format!("- **Expires:** {}\n", exp)); } } } else if conflict.verdict == Verdict::Block { out.push_str( "- **Action:** Fix or run `aphoria ack --reason \"...\"`\n", ); } else { out.push_str("- **Action:** Review recommended\n"); } out.push('\n'); } } // Drift section if !result.drifts.is_empty() { out.push_str("## Drift Detected\n\n"); out.push_str("| Concept | Current | Prior | File |\n"); out.push_str("|---------|---------|-------|------|\n"); for drift in &result.drifts { out.push_str(&format!( "| `{}` | `{}` | `{}` | `{}:{}` |\n", extract_leaf_concept(&drift.claim.concept_path), object_value_display(&drift.claim.value), object_value_display(&drift.prior.value), drift.claim.file, drift.claim.line, )); } out.push('\n'); out.push_str("**Action:** Review these changes to ensure they were intentional.\n\n"); } // Deprecated patterns section if !result.deprecated_usages.is_empty() { out.push_str("## Deprecated Patterns\n\n"); out.push_str("> ⚠️ **Migration Required:** The following patterns are deprecated and should be updated.\n\n"); out.push_str("| Pattern | File | Severity | Sunset |\n"); out.push_str("|---------|------|----------|--------|\n"); for usage in &result.deprecated_usages { let sunset = usage .days_until_sunset .map(|d| if d < 0 { format!("OVERDUE ({}d)", -d) } else { format!("{}d", d) }) .unwrap_or_else(|| "-".to_string()); out.push_str(&format!( "| `{}` | `{}:{}` | {} | {} |\n", usage.pattern_name, usage.file_path, usage.line, usage.severity(), sunset, )); } out.push('\n'); // Details for each deprecated pattern out.push_str("### Migration Details\n\n"); for usage in &result.deprecated_usages { out.push_str(&format!("#### `{}`\n\n", usage.pattern_name)); out.push_str(&format!("- **Reason:** {}\n", usage.reason)); out.push_str(&format!("- **Location:** `{}:{}`\n", usage.file_path, usage.line)); if let Some(ref replacement) = usage.superseded_by { out.push_str(&format!("- **Replacement:** `{}`\n", replacement)); } if let Some(ref guide) = usage.migration_guide { out.push_str(&format!("- **Migration Guide:** {}\n", guide)); } out.push('\n'); } } // Extracted Claims section if let Some(claims) = &result.claims { out.push_str("## Extracted Claims\n\n"); if claims.is_empty() { out.push_str("No claims extracted.\n\n"); } else { out.push_str("| Concept | Value | File | Line | Confidence |\n"); out.push_str("|---------|-------|------|------|------------|\n"); for claim in claims { out.push_str(&format!( "| `{}` | `{}` | `{}` | {} | {:.0}% |\n", extract_leaf_concept(&claim.concept_path), object_value_display(&claim.value), claim.file, claim.line, claim.confidence * 100.0, )); } out.push('\n'); } } out } } #[cfg(test)] mod tests { use super::*; use crate::types::{ConflictResult, ConflictingSource, Observation}; use stemedb_core::types::{ObjectValue, SourceClass}; #[test] fn test_markdown_with_conflicts() { let formatter = MarkdownReport; let result = ScanResult { project: "testproject".to_string(), scan_id: "scan-md".to_string(), files_scanned: 20, claims_extracted: 4, conflicts: vec![ConflictResult { claim: Observation { concept_path: "code://rust/test/cors/allow_origin".to_string(), predicate: "config_value".to_string(), value: ObjectValue::Text("*".to_string()), file: "src/server.rs".to_string(), line: 55, matched_text: "allow_origin(\"*\")".to_string(), confidence: 1.0, description: "CORS wildcard allow-origin".to_string(), }, conflicts: vec![ConflictingSource { path: "owasp://cors/allow_origin".to_string(), source_class: SourceClass::Clinical, value: ObjectValue::Text("explicit_list".to_string()), confidence: 1.0, rfc_citation: Some("OWASP A05:2021".to_string()), policy_source: None, }], conflict_score: 0.77, verdict: Verdict::Block, acknowledged: None, trace: None, tier_breakdown: None, }], drifts: vec![], format: "markdown".to_string(), debug: false, strict: false, observations_recorded: 0, timing: None, claims: None, deprecated_usages: vec![], }; let output = formatter.format(&result); assert!(output.contains("# Aphoria Scan: testproject")); assert!(output.contains("| BLOCK |")); assert!(output.contains("## Details")); assert!(output.contains("CORS wildcard")); assert!(output.contains("`aphoria ack")); } #[test] fn test_markdown_empty() { let formatter = MarkdownReport; let result = ScanResult::stub(&std::path::PathBuf::from("empty"), "markdown"); let output = formatter.format(&result); assert!(output.contains("No conflicts or drifts found")); } }