//! Claims file persistence (TOML). //! //! Stores human-authored claims in `.aphoria/claims.toml`, following //! the same pattern as `ack_file.rs` for acknowledgments. //! //! ## File Format //! //! ```toml //! # Aphoria Claims - version controlled //! # //! # Human-authored claims with provenance, invariants, and consequences. //! # Manage with: aphoria claims create|list|explain|update|supersede|deprecate //! //! [[claim]] //! id = "wallet-seqcst-001" //! concept_path = "maxwell/wallet/atomics/ordering" //! predicate = "required_ordering" //! value = "SeqCst" //! provenance = "Safety analysis by lead developer" //! invariant = "All wallet atomics MUST use SeqCst" //! consequence = "Double-spend race condition" //! authority_tier = "expert" //! evidence = ["wallet ADR-003", "Intel SDM Vol 4"] //! category = "safety" //! status = "active" //! created_by = "jml" //! created_at = "2026-02-08T12:00:00Z" //! ``` use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::types::authored_claim::{AuthoredClaim, ClaimStatus}; use crate::AphoriaError; /// Default path for the claims file relative to project root. pub const CLAIMS_FILE_PATH: &str = ".aphoria/claims.toml"; /// Container for all authored claims in the file. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ClaimsFile { /// List of authored claims. #[serde(default, rename = "claim")] pub claims: Vec, } impl ClaimsFile { /// Create an empty claims file. pub fn new() -> Self { Self { claims: Vec::new() } } /// Add a claim entry, deduplicating by ID. /// /// Warns if an active claim already exists for the same concept_path/predicate. pub fn add(&mut self, claim: AuthoredClaim) { // Check for duplicate ID if self.claims.iter().any(|c| c.id == claim.id) { return; // Skip duplicate ID } // Check for duplicate active claims (same concept_path + predicate) if claim.status == ClaimStatus::Active { let duplicates: Vec<_> = self.claims.iter() .filter(|c| c.status == ClaimStatus::Active) .filter(|c| c.concept_path == claim.concept_path) .filter(|c| c.predicate == claim.predicate) .filter(|c| c.id != claim.id) .collect(); if !duplicates.is_empty() { #[allow(clippy::print_stderr)] { eprintln!("⚠️ Warning: Active claim(s) already exist for {}::{}", claim.concept_path, claim.predicate); for dup in &duplicates { eprintln!(" - {} ({})", dup.id, dup.invariant); } eprintln!("Consider using 'aphoria claims supersede {}' instead", duplicates[0].id); } } } self.claims.push(claim); } /// Load from a TOML file. pub fn load(path: &Path) -> Result { if !path.exists() { return Ok(Self::new()); } let content = std::fs::read_to_string(path) .map_err(|e| AphoriaError::Io(std::io::Error::new(e.kind(), format!("{e}"))))?; toml::from_str(&content) .map_err(|e| AphoriaError::Claims(format!("Failed to parse claims file: {e}"))) } /// Save to a TOML file. pub fn save(&self, path: &Path) -> Result<(), AphoriaError> { if let Some(parent) = path.parent() { if !parent.exists() { std::fs::create_dir_all(parent)?; } } let header = r#"# Aphoria Claims - version controlled # # Human-authored claims with provenance, invariants, and consequences. # Each claim represents a deliberate architectural decision or safety invariant. # # Manage with: aphoria claims create|list|explain|update|supersede|deprecate "#; let content = toml::to_string_pretty(self).map_err(|e| AphoriaError::Claims(e.to_string()))?; std::fs::write(path, format!("{header}{content}")) .map_err(|e| AphoriaError::Io(std::io::Error::new(e.kind(), format!("{e}"))))?; Ok(()) } /// Get the default path for the claims file. pub fn default_path(project_root: &Path) -> PathBuf { project_root.join(CLAIMS_FILE_PATH) } /// Check if a claims file exists at the default location. pub fn exists(project_root: &Path) -> bool { Self::default_path(project_root).exists() } /// Get the number of claims. pub fn len(&self) -> usize { self.claims.len() } /// Check if empty. pub fn is_empty(&self) -> bool { self.claims.is_empty() } /// Find a claim by ID. pub fn find_by_id(&self, id: &str) -> Option<&AuthoredClaim> { self.claims.iter().find(|c| c.id == id) } /// Find a claim by ID (mutable). pub fn find_by_id_mut(&mut self, id: &str) -> Option<&mut AuthoredClaim> { self.claims.iter_mut().find(|c| c.id == id) } /// Find claims by category. pub fn find_by_category(&self, category: &str) -> Vec<&AuthoredClaim> { self.claims.iter().filter(|c| c.category == category).collect() } /// Find claims by status. pub fn find_by_status(&self, status: &ClaimStatus) -> Vec<&AuthoredClaim> { self.claims.iter().filter(|c| &c.status == status).collect() } /// Update a claim's fields. Returns error if claim not found. pub fn update(&mut self, id: &str, updater: F) -> Result<(), AphoriaError> where F: FnOnce(&mut AuthoredClaim), { let claim = self .find_by_id_mut(id) .ok_or_else(|| AphoriaError::Claims(format!("Claim not found: {id}")))?; updater(claim); Ok(()) } /// Mark a claim as superseded and add the superseding claim. pub fn supersede( &mut self, old_id: &str, new_claim: AuthoredClaim, ) -> Result<(), AphoriaError> { // Mark old claim as superseded let now = new_claim.created_at.clone(); self.update(old_id, |c| { c.status = ClaimStatus::Superseded; c.updated_at = Some(now); })?; // Add new claim self.add(new_claim); Ok(()) } /// Mark a claim as deprecated. pub fn deprecate(&mut self, id: &str, timestamp: &str) -> Result<(), AphoriaError> { self.update(id, |c| { c.status = ClaimStatus::Deprecated; c.updated_at = Some(timestamp.to_string()); }) } } #[cfg(test)] mod tests { use super::*; use crate::types::authored_claim::AuthoredValue; use tempfile::TempDir; fn sample_claim(id: &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".to_string(), consequence: "Test consequence".to_string(), authority_tier: "expert".to_string(), evidence: vec!["evidence-1".to_string()], category: "safety".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_claims_file_roundtrip() { let temp_dir = TempDir::new().expect("create temp dir"); let path = temp_dir.path().join(".aphoria/claims.toml"); let mut file = ClaimsFile::new(); file.add(sample_claim("claim-001")); file.add(sample_claim("claim-002")); file.save(&path).expect("save claims file"); let loaded = ClaimsFile::load(&path).expect("load claims file"); assert_eq!(loaded.len(), 2); assert_eq!(loaded.claims[0].id, "claim-001"); assert_eq!(loaded.claims[1].id, "claim-002"); } #[test] fn test_no_duplicates() { let mut file = ClaimsFile::new(); file.add(sample_claim("claim-001")); file.add(sample_claim("claim-001")); assert_eq!(file.len(), 1); } #[test] fn test_find_by_id() { let mut file = ClaimsFile::new(); file.add(sample_claim("claim-001")); file.add(sample_claim("claim-002")); assert!(file.find_by_id("claim-001").is_some()); assert!(file.find_by_id("nonexistent").is_none()); } #[test] fn test_find_by_category() { let mut file = ClaimsFile::new(); let mut arch_claim = sample_claim("arch-001"); arch_claim.category = "architecture".to_string(); file.add(sample_claim("safety-001")); file.add(arch_claim); assert_eq!(file.find_by_category("safety").len(), 1); assert_eq!(file.find_by_category("architecture").len(), 1); assert_eq!(file.find_by_category("imports").len(), 0); } #[test] fn test_find_by_status() { let mut file = ClaimsFile::new(); let mut dep_claim = sample_claim("dep-001"); dep_claim.status = ClaimStatus::Deprecated; file.add(sample_claim("active-001")); file.add(dep_claim); assert_eq!(file.find_by_status(&ClaimStatus::Active).len(), 1); assert_eq!(file.find_by_status(&ClaimStatus::Deprecated).len(), 1); } #[test] fn test_update() { let mut file = ClaimsFile::new(); file.add(sample_claim("claim-001")); file.update("claim-001", |c| { c.provenance = "Updated provenance".to_string(); c.updated_at = Some("2026-02-08T13:00:00Z".to_string()); }) .expect("update claim"); assert_eq!( file.find_by_id("claim-001").map(|c| c.provenance.as_str()), Some("Updated provenance") ); } #[test] fn test_update_not_found() { let mut file = ClaimsFile::new(); assert!(file.update("nonexistent", |_| {}).is_err()); } #[test] fn test_supersede() { let mut file = ClaimsFile::new(); file.add(sample_claim("claim-001")); let mut new_claim = sample_claim("claim-002"); new_claim.supersedes = Some("claim-001".to_string()); new_claim.provenance = "Updated analysis".to_string(); file.supersede("claim-001", new_claim).expect("supersede"); assert_eq!(file.find_by_id("claim-001").map(|c| &c.status), Some(&ClaimStatus::Superseded)); assert_eq!( file.find_by_id("claim-002").map(|c| c.supersedes.as_deref()), Some(Some("claim-001")) ); } #[test] fn test_deprecate() { let mut file = ClaimsFile::new(); file.add(sample_claim("claim-001")); file.deprecate("claim-001", "2026-02-08T14:00:00Z").expect("deprecate"); let claim = file.find_by_id("claim-001").expect("find"); assert_eq!(claim.status, ClaimStatus::Deprecated); assert_eq!(claim.updated_at.as_deref(), Some("2026-02-08T14:00:00Z")); } #[test] fn test_load_nonexistent() { let temp_dir = TempDir::new().expect("create temp dir"); let path = temp_dir.path().join("nonexistent.toml"); let file = ClaimsFile::load(&path).expect("load should succeed"); assert!(file.is_empty()); } #[test] fn test_duplicate_active_warning() { let mut file = ClaimsFile::new(); // Add first claim file.add(sample_claim("claim-001")); assert_eq!(file.len(), 1); // Add duplicate with same concept_path/predicate // This should print a warning but still add the claim let mut dup_claim = sample_claim("claim-002"); dup_claim.concept_path = "test/concept".to_string(); dup_claim.predicate = "test_pred".to_string(); file.add(dup_claim); assert_eq!(file.len(), 2); } #[test] fn test_no_warning_for_deprecated_duplicates() { let mut file = ClaimsFile::new(); // Add first claim and deprecate it file.add(sample_claim("claim-001")); file.deprecate("claim-001", "2026-02-08T14:00:00Z").expect("deprecate"); // Add another claim with same concept_path/predicate // Should NOT warn because the first is deprecated let mut new_claim = sample_claim("claim-002"); new_claim.concept_path = "test/concept".to_string(); new_claim.predicate = "test_pred".to_string(); file.add(new_claim); assert_eq!(file.len(), 2); assert_eq!(file.find_by_status(&ClaimStatus::Active).len(), 1); } }