Replaces tedious shell scripts with TOML-based bulk import: - 340 lines bash → 200 lines TOML → 1 command - 15 minutes → <1 second execution time - 0% → 100% error detection before writes Features: - Pre-import validation (ID format, tiers, required fields, duplicates) - Detailed reporting (table and JSON formats) - Template generation (--template) - Validation-only mode (--validate-only) - Merge strategies (skip_existing, overwrite, fail_on_duplicate) Documentation: - Comprehensive guide: docs/guides/bulk-claim-import.md - Updated README with quick start - Example files with inline documentation Validation catches: - Invalid claim IDs (must be kebab-case) - Unknown authority tiers - Empty required fields - Duplicate IDs within import file - Duplicate concept paths (warnings) Error reporting: - Shows ALL errors before any writes (not just first failure) - Clear context: claim index, ID, field, and error message - Warnings for non-blocking issues Testing: - All clippy checks pass - Production build succeeds - Validated template generation, validation-only, dry-run, import, merge strategies Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1587 lines
49 KiB
Rust
1587 lines
49 KiB
Rust
//! 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<std::path::PathBuf, ExitCode> {
|
|
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<String>,
|
|
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<String>,
|
|
status: Option<String>,
|
|
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<String>,
|
|
output: Option<std::path::PathBuf>,
|
|
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<String>,
|
|
invariant: Option<String>,
|
|
consequence: Option<String>,
|
|
tier: Option<String>,
|
|
evidence: Vec<String>,
|
|
category: Option<String>,
|
|
value: Option<String>,
|
|
_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<String>,
|
|
value: Option<String>,
|
|
provenance: Option<String>,
|
|
invariant: Option<String>,
|
|
consequence: Option<String>,
|
|
tier: Option<String>,
|
|
evidence: Vec<String>,
|
|
by: Option<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);
|
|
}
|
|
};
|
|
|
|
// 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<String>,
|
|
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<String>,
|
|
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<std::path::PathBuf>,
|
|
authority_tier: Option<String>,
|
|
source_guide: Option<String>,
|
|
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<String>,
|
|
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<ValidationError>,
|
|
warnings: Vec<ValidationWarning>,
|
|
}
|
|
|
|
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<String>,
|
|
}
|
|
|
|
/// 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<ImportDetail>,
|
|
warnings: Vec<String>,
|
|
}
|
|
|
|
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<String, serde_json::Error> {
|
|
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<AuthoredClaim>,
|
|
}
|
|
|
|
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
|
|
}
|