//! Markdown rendering for authored claims. //! //! Generates `claims-explained.md` style output, grouping claims by category //! and rendering full provenance details. use crate::types::authored_claim::{ format_authority_tier, parse_authority_tier, AuthoredClaim, ClaimStatus, }; /// Render all claims as a markdown document grouped by category. pub fn render_claims_markdown(claims: &[AuthoredClaim], project_name: &str) -> String { let mut out = String::new(); out.push_str(&format!("# Claims for {project_name}\n\n")); // Group by category let mut categories: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for claim in claims { categories.entry(claim.category.clone()).or_default().push(claim); } if categories.is_empty() { out.push_str("No claims authored yet.\n"); return out; } for (category, cat_claims) in &categories { let active_count = cat_claims.iter().filter(|c| c.status == ClaimStatus::Active).count(); let title = capitalize(category); out.push_str(&format!( "## {title} Claims ({active_count} active, {} total)\n\n", cat_claims.len() )); for claim in cat_claims { render_single_claim(&mut out, claim); out.push('\n'); } } out } /// Render a single claim as a markdown section. pub fn render_single_claim(out: &mut String, claim: &AuthoredClaim) { let status_badge = match claim.status { ClaimStatus::Draft => " [DRAFT]", ClaimStatus::Active => "", ClaimStatus::Deprecated => " [DEPRECATED]", ClaimStatus::Superseded => " [SUPERSEDED]", }; out.push_str(&format!("### {}: {}{status_badge}\n", claim.id, claim.invariant)); out.push_str(&format!("- **Concept:** `{}`\n", claim.concept_path)); out.push_str(&format!("- **Predicate:** `{}` = `{}`\n", claim.predicate, claim.value)); out.push_str(&format!("- **Invariant:** {}\n", claim.invariant)); out.push_str(&format!("- **Consequence:** {}\n", claim.consequence)); out.push_str(&format!("- **Provenance:** {}\n", claim.provenance)); let tier_display = parse_authority_tier(&claim.authority_tier) .map(format_authority_tier) .unwrap_or_else(|_| { tracing::warn!( claim_id = %claim.id, raw_tier = %claim.authority_tier, "Failed to parse authority tier, using raw value" ); claim.authority_tier.clone() }); out.push_str(&format!("- **Authority:** {tier_display}\n")); if !claim.evidence.is_empty() { out.push_str(&format!("- **Evidence:** {}\n", claim.evidence.join(", "))); } out.push_str(&format!("- **Status:** {}\n", claim.status)); out.push_str(&format!("- **Author:** {} ({})\n", claim.created_by, claim.created_at)); if let Some(ref supersedes) = claim.supersedes { out.push_str(&format!("- **Supersedes:** {supersedes}\n")); } if let Some(ref updated) = claim.updated_at { out.push_str(&format!("- **Updated:** {updated}\n")); } } /// Render a single claim as JSON wrapped in a structured envelope. pub fn render_claim_json( claim: &AuthoredClaim, project_name: &str, ) -> Result { let envelope = serde_json::json!({ "type": "claim_detail", "project": project_name, "claim": claim }); serde_json::to_string_pretty(&envelope) } /// Render all claims as JSON wrapped in a structured envelope. pub fn render_claims_json( claims: &[AuthoredClaim], project_name: &str, ) -> Result { let envelope = serde_json::json!({ "type": "claims_explain", "project": project_name, "total": claims.len(), "claims": claims }); serde_json::to_string_pretty(&envelope) } 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; 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: Default::default(), provenance: "Test provenance".to_string(), invariant: "Test invariant MUST hold".to_string(), consequence: "Bad things happen".to_string(), authority_tier: "expert".to_string(), evidence: vec!["ADR-001".to_string()], category: category.to_string(), status: ClaimStatus::Active, supersedes: None, created_by: "tester".to_string(), created_at: "2026-02-08T12:00:00Z".to_string(), updated_at: None, } } #[test] fn test_render_empty() { let md = render_claims_markdown(&[], "test-project"); assert!(md.contains("No claims authored yet")); } #[test] fn test_render_groups_by_category() { let claims = vec![ sample_claim("safety-001", "safety"), sample_claim("arch-001", "architecture"), sample_claim("safety-002", "safety"), ]; let md = render_claims_markdown(&claims, "test-project"); assert!(md.contains("## Architecture Claims (1 active, 1 total)")); assert!(md.contains("## Safety Claims (2 active, 2 total)")); } #[test] fn test_render_single_claim_fields() { let claim = sample_claim("test-001", "safety"); let mut out = String::new(); render_single_claim(&mut out, &claim); assert!(out.contains("### test-001:")); assert!(out.contains("**Concept:** `test/concept`")); assert!(out.contains("**Predicate:** `test_pred` = `test_value`")); assert!(out.contains("**Invariant:**")); assert!(out.contains("**Consequence:**")); assert!(out.contains("**Provenance:**")); assert!(out.contains("Expert (Tier 3)")); assert!(out.contains("**Evidence:** ADR-001")); } #[test] fn test_deprecated_badge() { let mut claim = sample_claim("dep-001", "safety"); claim.status = ClaimStatus::Deprecated; let mut out = String::new(); render_single_claim(&mut out, &claim); assert!(out.contains("[DEPRECATED]")); } #[test] fn test_render_json() { let claim = sample_claim("test-001", "safety"); let json = render_claim_json(&claim, "test-project").expect("json"); assert!(json.contains("\"type\": \"claim_detail\"")); assert!(json.contains("\"project\": \"test-project\"")); assert!(json.contains("\"id\": \"test-001\"")); } #[test] fn test_render_claims_json_envelope() { let claims = vec![sample_claim("c-001", "arch"), sample_claim("c-002", "safety")]; let json = render_claims_json(&claims, "my-project").expect("json"); assert!(json.contains("\"type\": \"claims_explain\"")); assert!(json.contains("\"total\": 2")); assert!(json.contains("\"project\": \"my-project\"")); } }