//! Scope command handlers for knowledge hierarchy management. use std::process::ExitCode; use chrono::{DateTime, Duration, NaiveDate, Utc}; use aphoria::scope::{override_store_dir, OverrideStore, OverrideValue, ScopeId, ScopeOverride}; use aphoria::AphoriaConfig; use crate::cli::ScopeCommands; pub async fn handle_scope_command(command: ScopeCommands, config: &AphoriaConfig) -> ExitCode { match command { ScopeCommands::Status => handle_scope_status(config), ScopeCommands::Override { concept_path, value, reason, evidence, expires } => { handle_scope_override(config, concept_path, value, reason, evidence, expires) } ScopeCommands::List { include_inherited, show_expired } => { handle_scope_list(config, include_inherited, show_expired) } ScopeCommands::Remove { concept_path, force } => { handle_scope_remove(config, concept_path, force) } } } fn handle_scope_status(config: &AphoriaConfig) -> ExitCode { println!("Scope Configuration"); println!("==================="); println!(); // Show configured scope hierarchy println!("Configured Hierarchy:"); if let Some(ref org) = config.scope.organization { println!(" Organization: {}", org); } else { println!(" Organization: (not set)"); } if let Some(ref team) = config.scope.team { println!(" Team: {}", team); } else { println!(" Team: (not set)"); } if let Some(ref project) = config.scope.project { println!(" Project: {}", project); } else { // Try to infer from project config let project_name = config.project.name.as_deref().unwrap_or("(auto-detected)"); println!(" Project: {}", project_name); } println!(); // Show inheritance chain let ctx = config.scope.to_context(); let chain = ctx.inheritance_chain(); if chain.is_empty() { println!("Inheritance Chain: (empty - no scopes configured)"); } else { println!("Inheritance Chain (most specific first):"); for (i, scope) in chain.iter().enumerate() { let arrow = if i == 0 { " *" } else { " " }; println!("{} {} ({})", arrow, scope.name, scope.level); } } // Show override store status println!(); let store_dir = override_store_dir(); match OverrideStore::new(&store_dir) { Ok(store) => { let active = store.active_count(); let expired = store.expired_count(); println!("Override Store:"); println!(" Location: {}", store.path().display()); println!(" Active: {}", active); println!(" Expired: {}", expired); } Err(e) => { println!("Override Store: Error - {}", e); } } println!(); println!("Configure scopes in aphoria.toml:"); println!(); println!(" [scope]"); println!(" project = \"my-project\""); println!(" team = \"my-team\""); println!(" organization = \"my-org\""); ExitCode::SUCCESS } fn handle_scope_override( config: &AphoriaConfig, concept_path: String, value: String, reason: String, evidence: Option, expires: Option, ) -> ExitCode { // Get current scope let ctx = config.scope.to_context(); let current_scope = match ctx.current_scope() { Some(s) => s, None => { eprintln!("No scope configured. Cannot create override."); eprintln!(); eprintln!("Configure a scope in aphoria.toml first:"); eprintln!(" [scope]"); eprintln!(" project = \"my-project\""); return ExitCode::from(1); } }; // Validate scope name if let Err(e) = ScopeId::validate_name(¤t_scope.name) { eprintln!("Invalid scope name: {}", e); return ExitCode::from(1); } // Parse expiry if provided let expires_at = match expires.as_ref() { Some(exp_str) => match parse_expiry(exp_str) { Ok(dt) => Some(dt), Err(e) => { eprintln!("Invalid expiry format: {}", e); eprintln!(); eprintln!("Valid formats:"); eprintln!(" Duration: 90d, 30d, 7d (days from now)"); eprintln!(" Date: 2026-12-31 (ISO 8601 date)"); return ExitCode::from(1); } }, None => None, }; // Parse the value (infer type from string) let parsed_value = OverrideValue::parse(&value); // Extract predicate from concept_path (last segment after /) let predicate = concept_path.rsplit('/').next().unwrap_or("value").to_string(); // Create override let mut override_record = ScopeOverride::new(current_scope.clone(), &concept_path, predicate, parsed_value, &reason); if let Some(ref ev) = evidence { override_record = override_record.with_evidence(ev); } if let Some(exp) = expires_at { override_record = override_record.with_expires_at(exp); } // Persist to store let store_dir = override_store_dir(); let mut store = match OverrideStore::new(&store_dir) { Ok(s) => s, Err(e) => { eprintln!("Failed to open override store: {}", e); return ExitCode::from(3); } }; if let Err(e) = store.add(override_record) { eprintln!("Failed to save override: {}", e); return ExitCode::from(3); } println!("Override created successfully."); println!(); println!(" Scope: {}", current_scope); println!(" Concept: {}", concept_path); println!(" Value: {}", value); println!(" Reason: {}", reason); if let Some(ref ev) = evidence { println!(" Evidence: {}", ev); } if let Some(ref exp) = expires { println!(" Expires: {}", exp); } println!(); println!("Stored in: {}", store.path().display()); ExitCode::SUCCESS } fn handle_scope_list( config: &AphoriaConfig, include_inherited: bool, show_expired: bool, ) -> ExitCode { let ctx = config.scope.to_context(); let current_scope = ctx.current_scope(); let chain = ctx.inheritance_chain(); println!("Scope Overrides"); println!("==============="); println!(); if let Some(ref scope) = current_scope { println!("Current scope: {}", scope); } else { println!("Current scope: (none configured)"); } if include_inherited { println!("Showing: local + inherited overrides"); } else { println!("Showing: local overrides only"); } if show_expired { println!("Including: expired overrides"); } println!(); // Open store let store_dir = override_store_dir(); let store = match OverrideStore::new(&store_dir) { Ok(s) => s, Err(e) => { eprintln!("Failed to open override store: {}", e); return ExitCode::from(3); } }; // Get overrides let overrides = if include_inherited { store.list_with_inheritance(&chain, show_expired) } else { store.list(current_scope.as_ref(), show_expired) }; if overrides.is_empty() { println!("No overrides found."); println!(); println!("Create an override with:"); println!(" aphoria scope override -V -r "); return ExitCode::SUCCESS; } // Print table header println!("{:<30} {:<12} {:<20} Reason", "Concept", "Scope", "Value"); println!("{}", "-".repeat(80)); for o in &overrides { let status = if o.is_expired() { " (expired)" } else { "" }; let scope_short = format!("{}:{}", o.scope.level, truncate(&o.scope.name, 8)); println!( "{:<30} {:<12} {:<20} {}{}", truncate(&o.concept_path, 30), scope_short, truncate(&o.value.to_string(), 20), truncate(&o.reason, 20), status ); if let Some(ref ev) = o.evidence { println!(" Evidence: {}", ev); } if let Some(days) = o.days_until_expiration() { if days > 0 { println!(" Expires in: {} days", days); } } } println!(); println!("Total: {} override(s)", overrides.len()); ExitCode::SUCCESS } fn handle_scope_remove(config: &AphoriaConfig, concept_path: String, force: bool) -> ExitCode { let ctx = config.scope.to_context(); let current_scope = match ctx.current_scope() { Some(s) => s, None => { eprintln!("No scope configured."); return ExitCode::from(1); } }; if !force { println!("Would remove override for '{}' at scope '{}'", concept_path, current_scope); println!(); println!("Use --force to confirm removal."); return ExitCode::SUCCESS; } // Open store let store_dir = override_store_dir(); let mut store = match OverrideStore::new(&store_dir) { Ok(s) => s, Err(e) => { eprintln!("Failed to open override store: {}", e); return ExitCode::from(3); } }; match store.remove(¤t_scope, &concept_path) { Ok(true) => { println!("Removed override for '{}' at scope '{}'", concept_path, current_scope); } Ok(false) => { println!("No override found for '{}' at scope '{}'", concept_path, current_scope); } Err(e) => { eprintln!("Failed to remove override: {}", e); return ExitCode::from(3); } } ExitCode::SUCCESS } /// Parse an expiry string into a DateTime. /// /// Supports: /// - Duration format: "90d", "30d", "7d" (days from now) /// - ISO date format: "2026-12-31" fn parse_expiry(s: &str) -> Result, String> { let s = s.trim(); // Try duration format (e.g., "90d") if let Some(days_str) = s.strip_suffix('d') { let days: i64 = days_str.parse().map_err(|_| format!("Invalid day count: '{}'", days_str))?; if days <= 0 { return Err("Days must be positive".to_string()); } return Ok(Utc::now() + Duration::days(days)); } // Try ISO date format (e.g., "2026-12-31") if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { let datetime = date.and_hms_opt(23, 59, 59).ok_or_else(|| "Invalid date".to_string())?; return Ok(DateTime::from_naive_utc_and_offset(datetime, Utc)); } Err(format!("Could not parse '{}'. Use '90d' for duration or '2026-12-31' for date.", s)) } /// Truncate a string for display. fn truncate(s: &str, max_len: usize) -> String { if s.len() <= max_len { s.to_string() } else { format!("{}...", &s[..max_len.saturating_sub(3)]) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_expiry_duration() { let result = parse_expiry("90d").expect("parse 90d"); let days_from_now = (result - Utc::now()).num_days(); assert!((89..=90).contains(&days_from_now)); let result = parse_expiry("7d").expect("parse 7d"); let days_from_now = (result - Utc::now()).num_days(); assert!((6..=7).contains(&days_from_now)); } #[test] fn test_parse_expiry_date() { let result = parse_expiry("2030-12-31").expect("parse date"); assert_eq!(result.date_naive().to_string(), "2030-12-31"); } #[test] fn test_parse_expiry_invalid() { assert!(parse_expiry("").is_err()); assert!(parse_expiry("invalid").is_err()); assert!(parse_expiry("0d").is_err()); assert!(parse_expiry("-5d").is_err()); } }