Implement structured approval workflows for pattern promotion with full audit trails for SOC 2 compliance. Core Components: - governance/types.rs: ApprovalRequest, ApprovalStatus, ApprovalDecision - governance/workflow.rs: ApprovalWorkflow, ApprovalStage with escalation - governance/store.rs: JSONL persistence for requests and decisions - governance/state_machine.rs: Approval state transitions with auto-advance - governance/audit.rs: AuditTrail with JSON/CSV/Markdown export CLI Commands: - aphoria governance pending/approve/reject/escalate/status/create - aphoria audit trail/export/summary Integration: - Pipeline gate blocks promotion until governance approval - Auto-creates approval requests when governance enabled - Evidence-based auto-approval for high-confidence patterns Also includes: - Phase 11-13: Evidence, Lifecycle, Scope modules - 62+ governance-specific tests (946 total passing) - Clippy clean with -D warnings - Refactored cli.rs into submodules (governance, lifecycle, scope, etc.) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
394 lines
12 KiB
Rust
394 lines
12 KiB
Rust
//! 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<String>,
|
|
expires: Option<String>,
|
|
) -> 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 <concept_path> -V <value> -r <reason>");
|
|
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<DateTime<Utc>, 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());
|
|
}
|
|
}
|