//! Command handlers for authored claims management. use std::process::ExitCode; use aphoria::claims_explain; use aphoria::claims_file::ClaimsFile; use aphoria::pending_markers::{MarkerStatus, PendingMarkersFile}; use aphoria::AphoriaConfig; use aphoria::{parse_authority_tier, AuthoredClaim, AuthoredValue, ClaimStatus}; use chrono::Utc; use crate::cli::ClaimsCommands; /// Find the project root by walking up from cwd looking for `.aphoria/claims.toml`. /// /// Falls back to cwd if no claims file is found in any parent. fn project_root() -> Result { let cwd = std::env::current_dir().map_err(|e| { eprintln!("Error: cannot determine current directory: {e}"); ExitCode::from(3) })?; // Check cwd first if cwd.join(".aphoria/claims.toml").exists() { return Ok(cwd); } // Walk up parents let mut dir = cwd.as_path(); while let Some(parent) = dir.parent() { if parent.join(".aphoria/claims.toml").exists() { return Ok(parent.to_path_buf()); } dir = parent; } // Fall back to cwd (will return empty claims) Ok(cwd) } /// Handle claims subcommands. pub async fn handle_claims_command(command: ClaimsCommands, config: &AphoriaConfig) -> ExitCode { match command { ClaimsCommands::Create { id, concept_path, predicate, value, comparison, provenance, invariant, consequence, tier, evidence, category, by, } => { handle_claims_create( id, concept_path, predicate, value, comparison, provenance, invariant, consequence, tier, evidence, category, by, config, ) .await } ClaimsCommands::List { category, status, format } => { handle_claims_list(category, status, format, config).await } ClaimsCommands::Explain { claim, output, format } => { handle_claims_explain(claim, output, format, config).await } ClaimsCommands::Update { id, provenance, invariant, consequence, tier, evidence, category, value, } => { handle_claims_update( id, provenance, invariant, consequence, tier, evidence, category, value, config, ) .await } ClaimsCommands::Supersede { id, new_id, value, provenance, invariant, consequence, tier, evidence, by, } => { handle_claims_supersede( id, new_id, value, provenance, invariant, consequence, tier, evidence, by, config, ) .await } ClaimsCommands::Deprecate { id, reason } => { handle_claims_deprecate(id, reason, config).await } ClaimsCommands::ListMarkers { status, format } => { handle_list_markers(status, format, config).await } ClaimsCommands::FormalizeMarker { marker_id, id, tier, evidence, by } => { handle_formalize_marker(marker_id, id, tier, evidence, by, config).await } ClaimsCommands::RejectMarker { marker_id, reason } => { handle_reject_marker(marker_id, reason, config).await } ClaimsCommands::Import { file, authority_tier, source_guide, dry_run, merge, template, validate_only, format, } => { let options = ImportOptions { file, authority_tier, source_guide, dry_run, merge, template, validate_only, format, }; handle_claims_import(options, config).await } } } #[allow(clippy::too_many_arguments)] async fn handle_claims_create( id: String, concept_path: String, predicate: String, value: String, comparison: String, provenance: String, invariant: String, consequence: String, tier: String, evidence: Vec, category: String, by: String, _config: &AphoriaConfig, ) -> ExitCode { use aphoria::ComparisonMode; // Validate authority tier if let Err(e) = parse_authority_tier(&tier) { eprintln!("Error: {e}"); return ExitCode::from(3); } // Parse comparison mode let comparison_mode = match comparison.to_lowercase().as_str() { "equals" => ComparisonMode::Equals, "not_equals" => ComparisonMode::NotEquals, "present" => ComparisonMode::Present, "absent" => ComparisonMode::Absent, "contains" => ComparisonMode::Contains, "not_contains" => ComparisonMode::NotContains, _ => { eprintln!( "Error: Invalid comparison mode '{comparison}'. Expected: equals, not_equals, present, absent, contains, not_contains" ); return ExitCode::from(3); } }; let root = match project_root() { Ok(r) => r, Err(code) => return code, }; let path = ClaimsFile::default_path(&root); let mut claims_file = match ClaimsFile::load(&path) { Ok(f) => f, Err(e) => { eprintln!("Error loading claims file: {e}"); return ExitCode::from(3); } }; // Check for duplicate ID if claims_file.find_by_id(&id).is_some() { eprintln!("Error: Claim with ID '{id}' already exists"); return ExitCode::from(3); } let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); let claim = AuthoredClaim { id: id.clone(), concept_path, predicate, value: AuthoredValue::parse(&value), comparison: comparison_mode, provenance, invariant, consequence, authority_tier: tier.to_lowercase(), evidence, category, status: ClaimStatus::Active, supersedes: None, created_by: by, created_at: now, updated_at: None, }; claims_file.add(claim); if let Err(e) = claims_file.save(&path) { eprintln!("Error saving claims file: {e}"); return ExitCode::from(3); } println!("Created claim '{id}' in {}", path.display()); ExitCode::SUCCESS } async fn handle_claims_list( category: Option, status: Option, format: String, _config: &AphoriaConfig, ) -> ExitCode { let root = match project_root() { Ok(r) => r, Err(code) => return code, }; let path = ClaimsFile::default_path(&root); let claims_file = match ClaimsFile::load(&path) { Ok(f) => f, Err(e) => { eprintln!("Error loading claims file: {e}"); return ExitCode::from(3); } }; let mut claims: Vec<&AuthoredClaim> = claims_file.claims.iter().collect(); // Filter by category if let Some(ref cat) = category { claims.retain(|c| c.category == *cat); } // Filter by status if let Some(ref st) = status { let target = match st.to_lowercase().as_str() { "active" => ClaimStatus::Active, "deprecated" => ClaimStatus::Deprecated, "superseded" => ClaimStatus::Superseded, other => { eprintln!("Unknown status: {other}. Expected: active, deprecated, superseded"); return ExitCode::from(3); } }; claims.retain(|c| c.status == target); } if format == "json" { let envelope = serde_json::json!({ "type": "claims_list", "total": claims.len(), "claims": claims }); match serde_json::to_string_pretty(&envelope) { Ok(json) => println!("{json}"), Err(e) => { eprintln!("Error serializing claims: {e}"); return ExitCode::from(3); } } } else { // Table format if claims.is_empty() { println!("No claims found."); return ExitCode::SUCCESS; } let mut table = comfy_table::Table::new(); table.set_header(vec!["ID", "Category", "Tier", "Status", "Invariant"]); for claim in &claims { let invariant_short = if claim.invariant.len() > 50 { format!("{}...", &claim.invariant[..47]) } else { claim.invariant.clone() }; table.add_row(vec![ &claim.id, &claim.category, &claim.authority_tier, &claim.status.to_string(), &invariant_short, ]); } println!("{table}"); println!("\n{} claim(s) total", claims.len()); } ExitCode::SUCCESS } async fn handle_claims_explain( claim_id: Option, output: Option, format: String, _config: &AphoriaConfig, ) -> ExitCode { let root = match project_root() { Ok(r) => r, Err(code) => return code, }; let path = ClaimsFile::default_path(&root); let claims_file = match ClaimsFile::load(&path) { Ok(f) => f, Err(e) => { eprintln!("Error loading claims file: {e}"); return ExitCode::from(3); } }; let project_name = root .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "project".to_string()); let content = if let Some(ref id) = claim_id { // Single claim let claim = match claims_file.find_by_id(id) { Some(c) => c, None => { eprintln!("Claim not found: {id}"); return ExitCode::from(3); } }; if format == "json" { match claims_explain::render_claim_json(claim, &project_name) { Ok(json) => json, Err(e) => { eprintln!("Error rendering claim: {e}"); return ExitCode::from(3); } } } else { let mut out = String::new(); claims_explain::render_single_claim(&mut out, claim); out } } else { // All claims if format == "json" { match claims_explain::render_claims_json(&claims_file.claims, &project_name) { Ok(json) => json, Err(e) => { eprintln!("Error rendering claims: {e}"); return ExitCode::from(3); } } } else { claims_explain::render_claims_markdown(&claims_file.claims, &project_name) } }; if let Some(ref out_path) = output { if let Some(parent) = out_path.parent() { if !parent.exists() { if let Err(e) = std::fs::create_dir_all(parent) { eprintln!("Error creating output directory: {e}"); return ExitCode::from(3); } } } if let Err(e) = std::fs::write(out_path, &content) { eprintln!("Error writing output: {e}"); return ExitCode::from(3); } println!("Written to {}", out_path.display()); } else { println!("{content}"); } ExitCode::SUCCESS } #[allow(clippy::too_many_arguments)] async fn handle_claims_update( id: String, provenance: Option, invariant: Option, consequence: Option, tier: Option, evidence: Vec, category: Option, value: Option, _config: &AphoriaConfig, ) -> ExitCode { // Validate tier if provided if let Some(ref t) = tier { if let Err(e) = parse_authority_tier(t) { eprintln!("Error: {e}"); return ExitCode::from(3); } } let root = match project_root() { Ok(r) => r, Err(code) => return code, }; let path = ClaimsFile::default_path(&root); let mut claims_file = match ClaimsFile::load(&path) { Ok(f) => f, Err(e) => { eprintln!("Error loading claims file: {e}"); return ExitCode::from(3); } }; let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); let result = claims_file.update(&id, |c| { if let Some(p) = provenance { c.provenance = p; } if let Some(i) = invariant { c.invariant = i; } if let Some(con) = consequence { c.consequence = con; } if let Some(t) = tier { c.authority_tier = t.to_lowercase(); } if !evidence.is_empty() { for e in evidence { if !c.evidence.contains(&e) { c.evidence.push(e); } } } if let Some(cat) = category { c.category = cat; } if let Some(v) = value { c.value = AuthoredValue::parse(&v); } c.updated_at = Some(now); }); if let Err(e) = result { eprintln!("Error: {e}"); return ExitCode::from(3); } if let Err(e) = claims_file.save(&path) { eprintln!("Error saving claims file: {e}"); return ExitCode::from(3); } println!("Updated claim '{id}'"); ExitCode::SUCCESS } #[allow(clippy::too_many_arguments)] async fn handle_claims_supersede( old_id: String, new_id: Option, value: Option, provenance: Option, invariant: Option, consequence: Option, tier: Option, evidence: Vec, by: Option, _config: &AphoriaConfig, ) -> ExitCode { let root = match project_root() { Ok(r) => r, Err(code) => return code, }; let path = ClaimsFile::default_path(&root); let mut claims_file = match ClaimsFile::load(&path) { Ok(f) => f, Err(e) => { eprintln!("Error loading claims file: {e}"); return ExitCode::from(3); } }; // Get the old claim to copy fields from let old_claim = match claims_file.find_by_id(&old_id) { Some(c) => c.clone(), None => { eprintln!("Claim not found: {old_id}"); return ExitCode::from(3); } }; let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); let actual_new_id = new_id.unwrap_or_else(|| format!("{old_id}-v2")); // Check for duplicate if claims_file.find_by_id(&actual_new_id).is_some() { eprintln!("Error: Claim with ID '{actual_new_id}' already exists"); return ExitCode::from(3); } // Validate new tier if provided let new_tier = tier.map(|t| t.to_lowercase()).unwrap_or(old_claim.authority_tier.clone()); if let Err(e) = parse_authority_tier(&new_tier) { eprintln!("Error: {e}"); return ExitCode::from(3); } let new_claim = AuthoredClaim { id: actual_new_id.clone(), concept_path: old_claim.concept_path.clone(), predicate: old_claim.predicate.clone(), value: value.map(|v| AuthoredValue::parse(&v)).unwrap_or(old_claim.value.clone()), comparison: old_claim.comparison.clone(), provenance: provenance.unwrap_or(old_claim.provenance.clone()), invariant: invariant.unwrap_or(old_claim.invariant.clone()), consequence: consequence.unwrap_or(old_claim.consequence.clone()), authority_tier: new_tier, evidence: if evidence.is_empty() { old_claim.evidence.clone() } else { evidence }, category: old_claim.category.clone(), status: ClaimStatus::Active, supersedes: Some(old_id.clone()), created_by: by.unwrap_or(old_claim.created_by.clone()), created_at: now, updated_at: None, }; if let Err(e) = claims_file.supersede(&old_id, new_claim) { eprintln!("Error: {e}"); return ExitCode::from(3); } if let Err(e) = claims_file.save(&path) { eprintln!("Error saving claims file: {e}"); return ExitCode::from(3); } println!("Created claim '{actual_new_id}' superseding '{old_id}'"); ExitCode::SUCCESS } async fn handle_claims_deprecate(id: String, reason: String, _config: &AphoriaConfig) -> ExitCode { let root = match project_root() { Ok(r) => r, Err(code) => return code, }; let path = ClaimsFile::default_path(&root); let mut claims_file = match ClaimsFile::load(&path) { Ok(f) => f, Err(e) => { eprintln!("Error loading claims file: {e}"); return ExitCode::from(3); } }; let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); // Update the claim with deprecation info let result = claims_file.update(&id, |c| { c.status = ClaimStatus::Deprecated; c.updated_at = Some(format!("{now} (deprecated: {reason})")); }); if let Err(e) = result { eprintln!("Error: {e}"); return ExitCode::from(3); } if let Err(e) = claims_file.save(&path) { eprintln!("Error saving claims file: {e}"); return ExitCode::from(3); } println!("Deprecated claim '{id}': {reason}"); ExitCode::SUCCESS } // ============================================================================ // Marker Management Handlers // ============================================================================ async fn handle_list_markers( status_filter: Option, format: String, _config: &AphoriaConfig, ) -> ExitCode { let root = match project_root() { Ok(r) => r, Err(code) => return code, }; let path = PendingMarkersFile::default_path(&root); let markers_file = match PendingMarkersFile::load(&path) { Ok(f) => f, Err(e) => { eprintln!("Error loading pending markers: {e}"); return ExitCode::from(3); } }; // Filter markers by status if requested let markers = if let Some(status_str) = status_filter { let status = match status_str.to_lowercase().as_str() { "pending" => MarkerStatus::Pending, "formalized" => MarkerStatus::Formalized, "rejected" => MarkerStatus::Rejected, _ => { eprintln!( "Error: Invalid status '{}'. Use: pending, formalized, or rejected", status_str ); return ExitCode::from(3); } }; markers_file.filter_by_status(&status) } else { markers_file.markers.iter().collect() }; if markers.is_empty() { println!("No pending markers found"); return ExitCode::SUCCESS; } match format.as_str() { "table" => { // Table header println!( "{:<20} {:<40} {:>6} {:<12} {:<}", "ID", "File", "Line", "Category", "Invariant" ); println!("{}", "=".repeat(120)); for marker in markers { let category = marker.category.as_deref().unwrap_or("none"); let invariant = if marker.invariant.len() > 50 { format!("{}...", &marker.invariant[..47]) } else { marker.invariant.clone() }; println!( "{:<20} {:<40} {:>6} {:<12} {}", marker.id, marker.file, marker.line, category, invariant ); } } "json" => { let output = serde_json::json!({ "type": "pending_markers", "total": markers.len(), "markers": markers, }); match serde_json::to_string_pretty(&output) { Ok(json) => println!("{json}"), Err(e) => { eprintln!("Error serializing JSON: {e}"); return ExitCode::from(3); } } } _ => { eprintln!("Error: Invalid format '{}'. Use: table or json", format); return ExitCode::from(3); } } ExitCode::SUCCESS } /// Validates a claim ID follows naming conventions. fn validate_claim_id(id: &str) -> Result<(), String> { if id.is_empty() { return Err("Claim ID cannot be empty".to_string()); } if id.len() > 64 { return Err("Claim ID too long (max 64 characters)".to_string()); } // Must be kebab-case: alphanumeric + hyphens, no leading/trailing hyphens let mut prev_hyphen = false; let mut has_content = false; for (i, c) in id.chars().enumerate() { match c { 'a'..='z' | '0'..='9' => { has_content = true; prev_hyphen = false; } '-' => { if i == 0 || i == id.len() - 1 || prev_hyphen { return Err(format!( "Claim ID must be kebab-case (no leading/trailing/consecutive hyphens): '{}'", id )); } prev_hyphen = true; } _ => { return Err(format!( "Claim ID must be kebab-case (lowercase alphanumeric + hyphens): '{}'", id )); } } } if !has_content { return Err("Claim ID must contain alphanumeric characters".to_string()); } Ok(()) } async fn handle_formalize_marker( marker_id: String, claim_id: String, tier: String, evidence: Vec, by: String, _config: &AphoriaConfig, ) -> ExitCode { let root = match project_root() { Ok(r) => r, Err(code) => return code, }; // Validate claim ID early if let Err(e) = validate_claim_id(&claim_id) { eprintln!("Error: Invalid claim ID: {}", e); eprintln!("Example: myapp-pool-max-001"); return ExitCode::from(3); } // Check for ID collision let claims_path = ClaimsFile::default_path(&root); if let Ok(existing_claims) = ClaimsFile::load(&claims_path) { if existing_claims.find_by_id(&claim_id).is_some() { eprintln!("Error: Claim ID '{}' already exists", claim_id); eprintln!( "Use a different ID or update the existing claim with 'aphoria claims update'." ); return ExitCode::from(3); } } // Load pending markers let markers_path = PendingMarkersFile::default_path(&root); let mut markers_file = match PendingMarkersFile::load(&markers_path) { Ok(f) => f, Err(e) => { eprintln!("Error loading pending markers: {e}"); return ExitCode::from(3); } }; // Find the marker let marker = match markers_file.find_by_id(&marker_id) { Some(m) => m.clone(), None => { eprintln!("Error: Marker '{}' not found", marker_id); return ExitCode::from(3); } }; // Validate marker still exists in source file let file_path = root.join(&marker.file); let file_content = match std::fs::read_to_string(&file_path) { Ok(content) => content, Err(e) => { eprintln!("Error: Cannot read file '{}': {}", marker.file, e); eprintln!("The file may have been moved or deleted since marker was detected."); eprintln!("Run 'aphoria scan' to refresh markers."); return ExitCode::from(3); } }; // Verify the marker is still at the expected line let lines: Vec<&str> = file_content.lines().collect(); if marker.line == 0 || marker.line > lines.len() { eprintln!("Error: Marker line {} is out of bounds in '{}'", marker.line, marker.file); eprintln!("File may have been modified. Run 'aphoria scan' to refresh markers."); return ExitCode::from(3); } // Check if line still contains the marker pattern (basic check) let line_content = lines[marker.line - 1]; // Convert to 0-indexed if !line_content.contains("@aphoria:claim") && !line_content.contains("@aphoria:claimed") { eprintln!("Warning: Marker no longer exists at line {} in '{}'", marker.line, marker.file); eprintln!("Line content: {}", line_content); eprintln!("The file may have been modified. Proceeding anyway, but verify manually."); // Don't fail here - allow manual override, but warn } // Generate concept_path from marker file location // Extract file stem and build path: project/file_stem/line let file_stem = std::path::Path::new(&marker.file) .file_stem() .and_then(|s| s.to_str()) .unwrap_or("unknown"); let concept_path = format!( "{}/{}/{}", root.file_name().and_then(|n| n.to_str()).unwrap_or("project"), file_stem, marker.line ); // Infer predicate and value from context // For now, use generic "inline_marker_value" predicate // TODO: In the skill, this should be smarter (analyze surrounding code) let predicate = "inline_marker_value".to_string(); let value = AuthoredValue::Text(marker.invariant.clone()); // Prompt for consequence if missing let consequence = marker.consequence.clone().unwrap_or_else(|| { eprintln!("Warning: Marker has no consequence. Consider adding one."); "Unspecified consequence".to_string() }); // Create the claim let provenance = format!("Inline marker from {}:{}", marker.file, marker.line); let category = marker.category.clone().unwrap_or_else(|| "general".to_string()); let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); let claim = AuthoredClaim { id: claim_id.clone(), concept_path, predicate, value, comparison: Default::default(), provenance, invariant: marker.invariant.clone(), consequence, authority_tier: tier, evidence, category, status: ClaimStatus::Active, supersedes: None, created_by: by, created_at: now, updated_at: None, }; // Add to claims file let claims_path = ClaimsFile::default_path(&root); let mut claims_file = match ClaimsFile::load(&claims_path) { Ok(f) => f, Err(e) => { eprintln!("Error loading claims file: {e}"); return ExitCode::from(3); } }; claims_file.add(claim); if let Err(e) = claims_file.save(&claims_path) { eprintln!("Error saving claims file: {e}"); return ExitCode::from(3); } // Update marker status if let Err(e) = markers_file.update_status( &marker_id, MarkerStatus::Formalized, Some(claim_id.clone()), None, ) { eprintln!("Error updating marker status: {e}"); return ExitCode::from(3); } if let Err(e) = markers_file.save(&markers_path) { eprintln!("Error saving markers file: {e}"); return ExitCode::from(3); } println!("āœ“ Marker {} formalized to claim {}", marker_id, claim_id); println!(" File: {}:{}", marker.file, marker.line); println!(); println!("Consider updating the comment:"); let display_category = marker.category.as_deref().unwrap_or("category"); println!(" // @aphoria:claim[{}] {}", display_category, marker.invariant); println!(" →"); println!(" // @aphoria:claimed {}", claim_id); ExitCode::SUCCESS } async fn handle_reject_marker( marker_id: String, reason: String, _config: &AphoriaConfig, ) -> ExitCode { let root = match project_root() { Ok(r) => r, Err(code) => return code, }; let markers_path = PendingMarkersFile::default_path(&root); let mut markers_file = match PendingMarkersFile::load(&markers_path) { Ok(f) => f, Err(e) => { eprintln!("Error loading pending markers: {e}"); return ExitCode::from(3); } }; if let Err(e) = markers_file.update_status(&marker_id, MarkerStatus::Rejected, None, Some(reason.clone())) { eprintln!("Error: {e}"); return ExitCode::from(3); } if let Err(e) = markers_file.save(&markers_path) { eprintln!("Error saving markers file: {e}"); return ExitCode::from(3); } println!("āœ— Marker {} rejected: \"{}\"", marker_id, reason); ExitCode::SUCCESS } /// Options for bulk claim import. struct ImportOptions { file: Option, authority_tier: Option, source_guide: Option, dry_run: bool, merge: String, template: bool, validate_only: bool, format: String, } /// Validation error for a specific claim in the import file. #[derive(Debug)] struct ValidationError { claim_index: usize, claim_id: Option, field: String, error: String, } /// Warning for a claim that's valid but potentially problematic. #[derive(Debug)] struct ValidationWarning { #[allow(dead_code)] claim_index: usize, claim_id: String, message: String, } /// Result of validating all claims in an import file. #[derive(Debug)] struct ValidationResult { errors: Vec, warnings: Vec, } impl ValidationResult { fn is_valid(&self) -> bool { self.errors.is_empty() } } /// Action taken for a specific claim during import. #[derive(Debug, serde::Serialize)] #[serde(rename_all = "lowercase")] enum ImportAction { Added, Skipped, Overwritten, } /// Detail for a single claim in the import report. #[derive(Debug, serde::Serialize)] struct ImportDetail { action: ImportAction, claim_id: String, reason: Option, } /// Summary counts for the import operation. #[derive(Debug, serde::Serialize)] struct ImportSummary { total_in_file: usize, added: usize, skipped: usize, overwritten: usize, failed: usize, } /// Complete import report with summary, details, and warnings. #[derive(Debug, serde::Serialize)] struct ImportReport { summary: ImportSummary, details: Vec, warnings: Vec, } impl ImportReport { /// Format as human-readable table. fn format_table(&self, dry_run: bool) -> String { let mut output = String::new(); output.push_str("Aphoria Claims Import\n"); output.push_str(&"=".repeat(40)); output.push('\n'); if dry_run { output.push_str("šŸ” Dry-run mode (no changes written)\n\n"); } else { output.push_str("āœ“ Import Complete\n\n"); } output.push_str("Summary:\n"); output.push_str(&format!(" Total claims in file: {}\n", self.summary.total_in_file)); output.push_str(&format!(" Added: {}\n", self.summary.added)); output.push_str(&format!(" Skipped: {}\n", self.summary.skipped)); output.push_str(&format!(" Overwritten: {}\n", self.summary.overwritten)); if !self.details.is_empty() { output.push_str("\nDetails:\n"); for detail in &self.details { let symbol = match detail.action { ImportAction::Added => "āœ“", ImportAction::Skipped => "āŠ—", ImportAction::Overwritten => "↻", }; let action_str = match detail.action { ImportAction::Added => "ADD ", ImportAction::Skipped => "SKIP ", ImportAction::Overwritten => "UPDATE", }; let reason_str = detail.reason.as_ref() .map(|r| format!(" ({})", r)) .unwrap_or_default(); output.push_str(&format!(" {} {} {}{}\n", symbol, action_str, detail.claim_id, reason_str)); } } if !self.warnings.is_empty() { output.push_str("\nWarnings:\n"); for warning in &self.warnings { output.push_str(&format!(" ⚠ {}\n", warning)); } } output } /// Format as JSON for tooling integration. fn format_json(&self) -> Result { serde_json::to_string_pretty(self) } } /// Generates an example TOML template for bulk claim import. fn generate_import_template() -> String { r#"# Aphoria Claims Import Template # # This file contains example claims that demonstrate the TOML format for bulk import. # To import claims: # aphoria claims import my-claims.toml # # To preview without writing: # aphoria claims import my-claims.toml --dry-run # # To validate format: # aphoria claims import my-claims.toml --validate-only # # Merge strategies: # --merge skip_existing (default) Skip claims with duplicate IDs # --merge overwrite Replace existing claims with same ID # --merge fail_on_duplicate Exit with error if any ID exists # Example 1: Architecture Decision [[claim]] id = "myapp-core-no-tokio-001" concept_path = "myapp/core/imports/tokio" predicate = "imported" value = false comparison = "absent" provenance = "Architecture decision by tech lead 2024-12-15" invariant = "Core modules MUST remain sync-only" consequence = "Importing tokio makes core async-only, breaking sync library users" authority_tier = "expert" evidence = ["ADR-003", "design review notes"] category = "architecture" status = "active" created_by = "tech-lead" created_at = "2024-12-15T10:00:00Z" # Example 2: Security Requirement [[claim]] id = "myapp-http-tls-cert-validation-001" concept_path = "myapp/httpclient/tls/certificate_validation" predicate = "enabled" value = true comparison = "equals" provenance = "OWASP A02:2021 - Cryptographic Failures" invariant = "HTTP clients MUST validate TLS certificates" consequence = "Disabled validation exposes MITM attacks" authority_tier = "regulatory" evidence = ["OWASP Top 10", "CWE-295"] category = "security" status = "active" created_by = "security-team" created_at = "2024-12-15T10:00:00Z" # Example 3: Safety Invariant [[claim]] id = "myapp-pool-max-size-001" concept_path = "myapp/pool/config/max_size" predicate = "max_value" value = 50 comparison = "equals" provenance = "Load testing results 2024-12-10" invariant = "Connection pool size MUST NOT exceed 50" consequence = "Larger pools cause OOM under sustained load" authority_tier = "expert" evidence = ["tests/pool_tests.rs load test"] category = "safety" status = "active" created_by = "sre-team" created_at = "2024-12-15T10:00:00Z" # Field Reference: # # Required fields: # id - Unique kebab-case identifier (e.g., "myapp-feature-001") # concept_path - Hierarchical path to concept (e.g., "myapp/module/feature") # predicate - Property being claimed (e.g., "enabled", "max_version") # value - Expected value (bool, number, or "text") # comparison - How to compare: equals, not_equals, present, absent, contains, not_contains # provenance - Where this rule came from # invariant - What MUST remain true # consequence - What breaks if violated # authority_tier - regulatory, clinical, observational, expert, community, anecdotal # category - safety, architecture, security, imports, constants, derives, etc. # created_by - Author name # created_at - ISO 8601 timestamp # # Optional fields: # evidence - Array of supporting references (default: []) # status - active (default), deprecated, superseded # supersedes - ID of claim this replaces # updated_at - ISO 8601 timestamp of last update "#.to_string() } /// Validates all claims before import. /// /// Checks: /// - ID format (kebab-case, length) /// - Authority tier validity /// - Required fields presence /// - Duplicate IDs within import file /// - Duplicate concept_path+predicate combinations (warning) fn validate_imported_claims( claims: &[aphoria::AuthoredClaim], existing_claims: &ClaimsFile, ) -> ValidationResult { use std::collections::{HashMap, HashSet}; let mut errors = Vec::new(); let mut warnings = Vec::new(); let mut seen_ids = HashSet::new(); let mut seen_concepts = HashMap::new(); for (index, claim) in claims.iter().enumerate() { // Validate ID format if let Err(e) = validate_claim_id(&claim.id) { errors.push(ValidationError { claim_index: index, claim_id: Some(claim.id.clone()), field: "id".to_string(), error: e, }); } // Check for duplicate IDs within import file if seen_ids.contains(&claim.id) { errors.push(ValidationError { claim_index: index, claim_id: Some(claim.id.clone()), field: "id".to_string(), error: format!("Duplicate ID '{}' within import file", claim.id), }); } seen_ids.insert(claim.id.clone()); // Validate authority tier if let Err(e) = parse_authority_tier(&claim.authority_tier) { errors.push(ValidationError { claim_index: index, claim_id: Some(claim.id.clone()), field: "authority_tier".to_string(), error: e.to_string(), }); } // Check required fields if claim.provenance.trim().is_empty() { errors.push(ValidationError { claim_index: index, claim_id: Some(claim.id.clone()), field: "provenance".to_string(), error: "provenance cannot be empty".to_string(), }); } if claim.invariant.trim().is_empty() { errors.push(ValidationError { claim_index: index, claim_id: Some(claim.id.clone()), field: "invariant".to_string(), error: "invariant cannot be empty".to_string(), }); } if claim.consequence.trim().is_empty() { errors.push(ValidationError { claim_index: index, claim_id: Some(claim.id.clone()), field: "consequence".to_string(), error: "consequence cannot be empty".to_string(), }); } if claim.category.trim().is_empty() { errors.push(ValidationError { claim_index: index, claim_id: Some(claim.id.clone()), field: "category".to_string(), error: "category cannot be empty".to_string(), }); } if claim.created_by.trim().is_empty() { errors.push(ValidationError { claim_index: index, claim_id: Some(claim.id.clone()), field: "created_by".to_string(), error: "created_by cannot be empty".to_string(), }); } // Warn on duplicate concept_path + predicate (not an error, but suspicious) let concept_key = format!("{}::{}", claim.concept_path, claim.predicate); if let Some(&prev_index) = seen_concepts.get(&concept_key) { warnings.push(ValidationWarning { claim_index: index, claim_id: claim.id.clone(), message: format!( "Duplicate concept_path+predicate '{}' (also in claim at index {})", concept_key, prev_index ), }); } else { seen_concepts.insert(concept_key, index); } // Warn if ID already exists in existing claims file if existing_claims.find_by_id(&claim.id).is_some() { warnings.push(ValidationWarning { claim_index: index, claim_id: claim.id.clone(), message: format!("Claim ID '{}' already exists in claims file", claim.id), }); } } ValidationResult { errors, warnings } } async fn handle_claims_import(options: ImportOptions, _config: &AphoriaConfig) -> ExitCode { use aphoria::claims_file::ClaimsFile; use aphoria::AuthoredClaim; // Destructure options let ImportOptions { file, authority_tier, source_guide, dry_run, merge, template, validate_only, format, } = options; // Handle --template flag if template { print!("{}", generate_import_template()); return ExitCode::SUCCESS; } // Require file path if not generating template let file = match file { Some(f) => f, None => { eprintln!("Error: FILE path required (or use --template to generate example)"); return ExitCode::from(3); } }; // Get project root let root = match project_root() { Ok(r) => r, Err(code) => return code, }; // Load import file let import_content = match std::fs::read_to_string(&file) { Ok(c) => c, Err(e) => { eprintln!("Error reading import file: {e}"); return ExitCode::from(3); } }; // Parse claims from TOML #[derive(serde::Deserialize)] struct ImportFile { claim: Vec, } let mut import: ImportFile = match toml::from_str(&import_content) { Ok(i) => i, Err(e) => { eprintln!("Error parsing import file: {e}"); return ExitCode::from(3); } }; // Override authority tier if specified if let Some(ref tier) = authority_tier { // Validate the override tier first if let Err(e) = parse_authority_tier(tier) { eprintln!("Error: Invalid authority tier '{}': {}", tier, e); return ExitCode::from(3); } for claim in &mut import.claim { claim.authority_tier = tier.clone(); } } // Load existing claims let claims_path = ClaimsFile::default_path(&root); let mut claims_file = match ClaimsFile::load(&claims_path) { Ok(f) => f, Err(e) => { eprintln!("Error loading claims file: {e}"); return ExitCode::from(3); } }; // Validate all claims before any writes let validation = validate_imported_claims(&import.claim, &claims_file); // Report validation errors if !validation.is_valid() { eprintln!("\nāŒ Validation Failed\n"); eprintln!("Found {} error(s) in import file:\n", validation.errors.len()); for err in &validation.errors { let claim_desc = err .claim_id .as_ref() .map(|id| format!("'{}'", id)) .unwrap_or_else(|| format!("at index {}", err.claim_index)); eprintln!(" • Claim {} - {}: {}", claim_desc, err.field, err.error); } eprintln!("\nFix these errors and try again."); return ExitCode::from(3); } // Report validation warnings if !validation.warnings.is_empty() { eprintln!("\nāš ļø {} warning(s):\n", validation.warnings.len()); for warn in &validation.warnings { eprintln!(" • Claim '{}': {}", warn.claim_id, warn.message); } eprintln!(); } // If validate-only mode, report success and exit if validate_only { println!("\nāœ“ Validation passed"); println!(" Total claims: {}", import.claim.len()); println!(" Warnings: {}", validation.warnings.len()); println!("\nFile is ready for import."); return ExitCode::SUCCESS; } // Process claims and build report let total_in_file = import.claim.len(); let mut added_count = 0; let mut skipped_count = 0; let mut overwritten_count = 0; let mut details = Vec::new(); let mut report_warnings = Vec::new(); // Collect validation warnings for report for warn in &validation.warnings { report_warnings.push(format!("{}: {}", warn.claim_id, warn.message)); } for claim in import.claim { let existing = claims_file.claims.iter().position(|c| c.id == claim.id); match (existing, merge.as_str()) { (Some(_idx), "skip_existing") => { skipped_count += 1; details.push(ImportDetail { action: ImportAction::Skipped, claim_id: claim.id.clone(), reason: Some("already exists".to_string()), }); } (Some(idx), "overwrite") => { if !dry_run { claims_file.claims[idx] = claim.clone(); } overwritten_count += 1; details.push(ImportDetail { action: ImportAction::Overwritten, claim_id: claim.id.clone(), reason: None, }); } (Some(_), "fail_on_duplicate") => { eprintln!("Error: Duplicate claim ID: {}", claim.id); return ExitCode::from(3); } (None, _) => { if !dry_run { claims_file.claims.push(claim.clone()); } added_count += 1; details.push(ImportDetail { action: ImportAction::Added, claim_id: claim.id.clone(), reason: None, }); } _ => { eprintln!("Invalid merge strategy: {merge}"); return ExitCode::from(3); } } } // Save (unless dry-run) if !dry_run { if let Err(e) = claims_file.save(&claims_path) { eprintln!("Error saving claims file: {e}"); return ExitCode::from(3); } // Track guideline metadata if source_guide is provided if let Some(guide_name) = source_guide { use aphoria::ingested_guides::{GuidelineMetadata, IngestedGuidesFile}; let guides_path = IngestedGuidesFile::default_path(&root); let mut guides_file = IngestedGuidesFile::load(&guides_path).unwrap_or_default(); // Compute document hash if source file exists let (source_path, document_hash) = if let Ok(content) = std::fs::read(&file) { use blake3::Hasher; let mut hasher = Hasher::new(); hasher.update(&content); let hash = hasher.finalize(); (Some(file.clone()), Some(format!("blake3:{}", hash.to_hex()))) } else { (None, None) }; // Create guideline metadata let guideline = GuidelineMetadata { id: guide_name.clone(), name: guide_name.clone(), source_path, document_hash, ingested_at: Utc::now().to_rfc3339(), claims_count: added_count + overwritten_count, authority_tier: authority_tier.clone().unwrap_or_else(|| "team_policy".to_string()), category: "imported".to_string(), claim_ids: claims_file .claims .iter() .rev() .take(added_count + overwritten_count) .map(|c| c.id.clone()) .collect(), }; guides_file.upsert(guideline); if let Err(e) = guides_file.save(&guides_path) { eprintln!("Warning: Failed to save guideline tracking: {e}"); } else { println!(" Guideline tracked: {guide_name}"); } } } // Build report let report = ImportReport { summary: ImportSummary { total_in_file, added: added_count, skipped: skipped_count, overwritten: overwritten_count, failed: 0, }, details, warnings: report_warnings, }; // Output report in requested format match format.as_str() { "json" => { match report.format_json() { Ok(json) => println!("{}", json), Err(e) => { eprintln!("Error formatting JSON output: {}", e); return ExitCode::from(3); } } } "table" => { println!("{}", report.format_table(dry_run)); } _ => { eprintln!("Error: Invalid format '{}'. Use: table or json", format); return ExitCode::from(3); } } ExitCode::SUCCESS }