Claims now flow through StemeDB's append-only knowledge graph instead of mutable TOML files. This resolves all 6 critical claim-bypass code paths: - Bridge: lossless AuthoredClaim ↔ Assertion round-trip (comparison, status, lifecycle mapping) - LocalEpisteme: ingest_authored_claim() and fetch_authored_claims() with AUTHORED_CLAIM predicate index - EpistemeClaimStore: ClaimStore trait backed by StemeDB (append-only delete via deprecation) - CLI handlers: all claim commands read/write through StemeDB - Scanner: loads claims from StemeDB with auto-migration fallback to TOML - Export: new `aphoria claims export` serializes StemeDB claims to TOML/JSON Also cleans up dead code (EpistemeConfig.url), renames ingest_claims→ingest_observations, fixes ClaimFilter.authority_tier type, adds Draft variant to ClaimStatus, and fixes pre-existing clippy warnings (too_many_arguments, filter_next→rfind). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
210 lines
7.3 KiB
Rust
210 lines
7.3 KiB
Rust
//! 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<String, Vec<&AuthoredClaim>> =
|
|
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<String, serde_json::Error> {
|
|
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<String, serde_json::Error> {
|
|
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::<String>() + 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\""));
|
|
}
|
|
}
|