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>
697 lines
22 KiB
Rust
697 lines
22 KiB
Rust
//! Governance command handlers for approval workflows and audit trails.
|
|
|
|
use std::path::Path;
|
|
use std::process::ExitCode;
|
|
|
|
use chrono::{NaiveDate, TimeZone, Utc};
|
|
use uuid::Uuid;
|
|
|
|
use aphoria::{
|
|
learning_store_dir, AphoriaConfig, ApprovalRequest, ApprovalStatus, AuditTrail, ExportFormat,
|
|
GovernanceStateMachine, GovernanceStore, LocalPatternStore, PatternStore,
|
|
};
|
|
|
|
use crate::cli::{AuditCommands, GovernanceCommands};
|
|
|
|
/// Handle governance subcommands.
|
|
pub async fn handle_governance_command(
|
|
command: GovernanceCommands,
|
|
config: &AphoriaConfig,
|
|
) -> ExitCode {
|
|
if !config.governance.enabled && !matches!(command, GovernanceCommands::Status { .. }) {
|
|
eprintln!("Governance is not enabled. Add [governance] enabled = true to aphoria.toml");
|
|
return ExitCode::from(1);
|
|
}
|
|
|
|
match command {
|
|
GovernanceCommands::Pending { workflow, format } => {
|
|
handle_pending(workflow.as_deref(), &format, config).await
|
|
}
|
|
GovernanceCommands::Approve { id, comment } => handle_approve(&id, comment, config).await,
|
|
GovernanceCommands::Reject { id, reason } => handle_reject(&id, &reason, config).await,
|
|
GovernanceCommands::Escalate { id } => handle_escalate(&id, config).await,
|
|
GovernanceCommands::Status { pattern, all, format } => {
|
|
handle_status(pattern.as_deref(), all, &format, config).await
|
|
}
|
|
GovernanceCommands::CheckTimeouts => handle_check_timeouts(config).await,
|
|
GovernanceCommands::Create { pattern_id, workflow } => {
|
|
handle_create(&pattern_id, workflow.as_deref(), config).await
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle audit subcommands.
|
|
pub async fn handle_audit_command(command: AuditCommands, config: &AphoriaConfig) -> ExitCode {
|
|
match command {
|
|
AuditCommands::Trail { pattern, format } => handle_trail(&pattern, &format, config).await,
|
|
AuditCommands::Export { output, format, date_range } => {
|
|
handle_export(&output, &format, date_range.as_deref(), config).await
|
|
}
|
|
AuditCommands::Summary { format } => handle_summary(&format, config).await,
|
|
}
|
|
}
|
|
|
|
/// List pending approval requests.
|
|
async fn handle_pending(
|
|
workflow_filter: Option<&str>,
|
|
format: &str,
|
|
config: &AphoriaConfig,
|
|
) -> ExitCode {
|
|
let sm = match GovernanceStateMachine::open_default(config.governance.clone()) {
|
|
Ok(sm) => sm,
|
|
Err(e) => {
|
|
eprintln!("Failed to open governance store: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
// Check timeouts if configured
|
|
if config.governance.check_timeouts_on_access {
|
|
let _ = sm.check_timeouts();
|
|
}
|
|
|
|
let pending = match sm.list_pending() {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
eprintln!("Failed to list pending requests: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
// Filter by workflow if specified
|
|
let filtered: Vec<_> = if let Some(wf) = workflow_filter {
|
|
pending.into_iter().filter(|r| r.workflow_name == wf).collect()
|
|
} else {
|
|
pending
|
|
};
|
|
|
|
if filtered.is_empty() {
|
|
println!("No pending approval requests.");
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
|
|
match format {
|
|
"json" => {
|
|
let json = serde_json::to_string_pretty(&filtered).unwrap_or_else(|_| "[]".to_string());
|
|
println!("{}", json);
|
|
}
|
|
_ => {
|
|
println!("Pending Approval Requests");
|
|
println!("{}", "=".repeat(80));
|
|
println!();
|
|
println!("{:<36} {:<20} {:<15} {:<10}", "Request ID", "Pattern", "Stage", "Days");
|
|
println!("{}", "-".repeat(80));
|
|
|
|
for request in &filtered {
|
|
let stage = request.status.current_stage().unwrap_or("-");
|
|
let days = (Utc::now() - request.created_at).num_days();
|
|
let pattern_name = truncate(&request.pattern_name, 20);
|
|
|
|
println!("{:<36} {:<20} {:<15} {:<10}", request.id, pattern_name, stage, days);
|
|
}
|
|
|
|
println!();
|
|
println!("Total: {} pending requests", filtered.len());
|
|
}
|
|
}
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
/// Approve a pending request.
|
|
async fn handle_approve(id: &str, comment: Option<String>, config: &AphoriaConfig) -> ExitCode {
|
|
let request_id = match Uuid::parse_str(id) {
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
eprintln!("Invalid request ID '{}': {}", id, e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let sm = match GovernanceStateMachine::open_default(config.governance.clone()) {
|
|
Ok(sm) => sm,
|
|
Err(e) => {
|
|
eprintln!("Failed to open governance store: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let approver = whoami::username();
|
|
|
|
match sm.approve(request_id, &approver, comment) {
|
|
Ok(request) => {
|
|
println!("Approved successfully");
|
|
println!();
|
|
println!(" Request ID: {}", request.id);
|
|
println!(" Pattern: {}", request.pattern_name);
|
|
println!(" Status: {}", request.status);
|
|
|
|
if request.status.is_approved() {
|
|
println!();
|
|
println!("Workflow complete. Pattern can now be promoted.");
|
|
}
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to approve: {}", e);
|
|
ExitCode::from(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reject a pending request.
|
|
async fn handle_reject(id: &str, reason: &str, config: &AphoriaConfig) -> ExitCode {
|
|
let request_id = match Uuid::parse_str(id) {
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
eprintln!("Invalid request ID '{}': {}", id, e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let sm = match GovernanceStateMachine::open_default(config.governance.clone()) {
|
|
Ok(sm) => sm,
|
|
Err(e) => {
|
|
eprintln!("Failed to open governance store: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let approver = whoami::username();
|
|
|
|
match sm.reject(request_id, &approver, reason.to_string()) {
|
|
Ok(request) => {
|
|
println!("Request rejected");
|
|
println!();
|
|
println!(" Request ID: {}", request.id);
|
|
println!(" Pattern: {}", request.pattern_name);
|
|
println!(" Reason: {}", reason);
|
|
println!();
|
|
println!("Pattern promotion blocked. Create a new request to try again.");
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to reject: {}", e);
|
|
ExitCode::from(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Escalate a pending request.
|
|
async fn handle_escalate(id: &str, config: &AphoriaConfig) -> ExitCode {
|
|
let request_id = match Uuid::parse_str(id) {
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
eprintln!("Invalid request ID '{}': {}", id, e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let sm = match GovernanceStateMachine::open_default(config.governance.clone()) {
|
|
Ok(sm) => sm,
|
|
Err(e) => {
|
|
eprintln!("Failed to open governance store: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let escalator = whoami::username();
|
|
|
|
match sm.escalate(request_id, &escalator) {
|
|
Ok(request) => {
|
|
println!("Request escalated");
|
|
println!();
|
|
println!(" Request ID: {}", request.id);
|
|
println!(" Pattern: {}", request.pattern_name);
|
|
println!(" Status: {}", request.status);
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to escalate: {}", e);
|
|
ExitCode::from(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Show request status.
|
|
async fn handle_status(
|
|
pattern_filter: Option<&str>,
|
|
show_all: bool,
|
|
format: &str,
|
|
_config: &AphoriaConfig,
|
|
) -> ExitCode {
|
|
let store = match GovernanceStore::open_default() {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Failed to open governance store: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let requests = if let Some(pattern_str) = pattern_filter {
|
|
match Uuid::parse_str(pattern_str) {
|
|
Ok(pattern_id) => match store.get_request_by_pattern(&pattern_id) {
|
|
Ok(Some(r)) => vec![r],
|
|
Ok(None) => {
|
|
println!("No approval request found for pattern {}", pattern_str);
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to get request: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
},
|
|
Err(e) => {
|
|
eprintln!("Invalid pattern ID '{}': {}", pattern_str, e);
|
|
return ExitCode::from(1);
|
|
}
|
|
}
|
|
} else if show_all {
|
|
match store.list_all() {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
eprintln!("Failed to list requests: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
}
|
|
} else {
|
|
match store.list_pending() {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
eprintln!("Failed to list pending: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
}
|
|
};
|
|
|
|
if requests.is_empty() {
|
|
println!("No approval requests found.");
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
|
|
match format {
|
|
"json" => {
|
|
let json = serde_json::to_string_pretty(&requests).unwrap_or_else(|_| "[]".to_string());
|
|
println!("{}", json);
|
|
}
|
|
_ => {
|
|
for request in &requests {
|
|
print_request_details(request);
|
|
println!();
|
|
}
|
|
}
|
|
}
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
/// Print detailed request information.
|
|
fn print_request_details(request: &ApprovalRequest) {
|
|
println!("Request: {}", request.id);
|
|
println!("{}", "=".repeat(60));
|
|
println!(" Pattern: {} ({})", request.pattern_name, request.pattern_id);
|
|
println!(" Workflow: {}", request.workflow_name);
|
|
println!(" Status: {}", request.status);
|
|
println!(
|
|
" Created: {} by {}",
|
|
request.created_at.format("%Y-%m-%d %H:%M"),
|
|
request.created_by
|
|
);
|
|
println!(" Updated: {}", request.updated_at.format("%Y-%m-%d %H:%M"));
|
|
|
|
if let Some(deadline) = request.stage_deadline {
|
|
let remaining = deadline - Utc::now();
|
|
if remaining.num_seconds() > 0 {
|
|
println!(
|
|
" Deadline: {} ({} hours remaining)",
|
|
deadline.format("%Y-%m-%d %H:%M"),
|
|
remaining.num_hours()
|
|
);
|
|
} else {
|
|
println!(" Deadline: {} (OVERDUE)", deadline.format("%Y-%m-%d %H:%M"));
|
|
}
|
|
}
|
|
|
|
if let Some(ref evidence) = request.evidence_summary {
|
|
println!(" Evidence: {}", evidence);
|
|
}
|
|
|
|
if !request.decisions.is_empty() {
|
|
println!();
|
|
println!(" Decisions:");
|
|
for decision in &request.decisions {
|
|
let comment = decision.comment.as_deref().unwrap_or("-");
|
|
println!(
|
|
" {} {} by {} at {} ({})",
|
|
decision.timestamp.format("%Y-%m-%d %H:%M"),
|
|
decision.decision,
|
|
decision.approver,
|
|
decision.stage,
|
|
comment
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check and process timed-out requests.
|
|
async fn handle_check_timeouts(config: &AphoriaConfig) -> ExitCode {
|
|
let sm = match GovernanceStateMachine::open_default(config.governance.clone()) {
|
|
Ok(sm) => sm,
|
|
Err(e) => {
|
|
eprintln!("Failed to open governance store: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
match sm.check_timeouts() {
|
|
Ok(processed) => {
|
|
if processed.is_empty() {
|
|
println!("No timed-out requests found.");
|
|
} else {
|
|
println!("Processed {} timed-out requests:", processed.len());
|
|
println!();
|
|
|
|
for request in &processed {
|
|
println!(" {} - {} - {}", request.id, request.pattern_name, request.status);
|
|
}
|
|
}
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to check timeouts: {}", e);
|
|
ExitCode::from(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create an approval request for a pattern.
|
|
async fn handle_create(
|
|
pattern_id_str: &str,
|
|
workflow_name: Option<&str>,
|
|
config: &AphoriaConfig,
|
|
) -> ExitCode {
|
|
let pattern_id = match Uuid::parse_str(pattern_id_str) {
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
eprintln!("Invalid pattern ID '{}': {}", pattern_id_str, e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
// Load pattern store
|
|
let pattern_store = match LocalPatternStore::new(&learning_store_dir()) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Failed to open pattern store: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let pattern = match pattern_store.get_pattern_by_id(&pattern_id) {
|
|
Some(p) => p,
|
|
None => {
|
|
eprintln!("Pattern '{}' not found", pattern_id_str);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
// Get workflow
|
|
let workflow = if let Some(name) = workflow_name {
|
|
match config.governance.get_workflow(name) {
|
|
Some(w) => w.clone(),
|
|
None => {
|
|
eprintln!("Workflow '{}' not found", name);
|
|
return ExitCode::from(1);
|
|
}
|
|
}
|
|
} else {
|
|
match config.governance.get_default_workflow() {
|
|
Some(w) => w.clone(),
|
|
None => {
|
|
eprintln!("No default workflow configured");
|
|
return ExitCode::from(1);
|
|
}
|
|
}
|
|
};
|
|
|
|
let sm = match GovernanceStateMachine::open_default(config.governance.clone()) {
|
|
Ok(sm) => sm,
|
|
Err(e) => {
|
|
eprintln!("Failed to open governance store: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let creator = whoami::username();
|
|
|
|
match sm.create_request(&pattern, &workflow, &creator) {
|
|
Ok(request) => {
|
|
println!("Approval request created");
|
|
println!();
|
|
println!(" Request ID: {}", request.id);
|
|
println!(" Pattern: {}", request.pattern_name);
|
|
println!(" Workflow: {}", request.workflow_name);
|
|
println!(" Status: {}", request.status);
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to create request: {}", e);
|
|
ExitCode::from(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Show audit trail for a pattern.
|
|
async fn handle_trail(pattern_id_str: &str, format: &str, config: &AphoriaConfig) -> ExitCode {
|
|
let _ = config; // Unused but kept for API consistency
|
|
let pattern_id = match Uuid::parse_str(pattern_id_str) {
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
eprintln!("Invalid pattern ID '{}': {}", pattern_id_str, e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let trail = match AuditTrail::open_default() {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
eprintln!("Failed to open audit trail: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let events = match trail.get_pattern_timeline(&pattern_id) {
|
|
Ok(e) => e,
|
|
Err(e) => {
|
|
eprintln!("Failed to get timeline: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
if events.is_empty() {
|
|
println!("No audit events for pattern {}", pattern_id_str);
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
|
|
match format {
|
|
"json" => {
|
|
let json = serde_json::to_string_pretty(&events).unwrap_or_else(|_| "[]".to_string());
|
|
println!("{}", json);
|
|
}
|
|
_ => {
|
|
println!("Audit Trail for {}", pattern_id_str);
|
|
println!("{}", "=".repeat(70));
|
|
println!();
|
|
println!("{:<20} {:<20} {:<15} {:<15}", "Timestamp", "Event", "Actor", "Request");
|
|
println!("{}", "-".repeat(70));
|
|
|
|
for event in &events {
|
|
println!(
|
|
"{:<20} {:<20} {:<15} {:<15}",
|
|
event.timestamp.format("%Y-%m-%d %H:%M"),
|
|
event.event_type.to_string(),
|
|
truncate(&event.actor, 15),
|
|
&event.request_id.to_string()[..8],
|
|
);
|
|
}
|
|
|
|
println!();
|
|
println!("Total: {} events", events.len());
|
|
}
|
|
}
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
/// Export audit data.
|
|
async fn handle_export(
|
|
output: &Path,
|
|
format_str: &str,
|
|
date_range: Option<&str>,
|
|
_config: &AphoriaConfig,
|
|
) -> ExitCode {
|
|
let format = match format_str.parse::<ExportFormat>() {
|
|
Ok(f) => f,
|
|
Err(e) => {
|
|
eprintln!("Invalid format: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let trail = match AuditTrail::open_default() {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
eprintln!("Failed to open audit trail: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let result = if let Some(range) = date_range {
|
|
// Parse date range
|
|
let parts: Vec<&str> = range.split("..").collect();
|
|
if parts.len() != 2 {
|
|
eprintln!("Invalid date range format. Use: YYYY-MM-DD..YYYY-MM-DD");
|
|
return ExitCode::from(1);
|
|
}
|
|
|
|
let start = match NaiveDate::parse_from_str(parts[0], "%Y-%m-%d") {
|
|
Ok(d) => Utc.from_utc_datetime(&d.and_hms_opt(0, 0, 0).unwrap_or_default()),
|
|
Err(e) => {
|
|
eprintln!("Invalid start date: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let end = match NaiveDate::parse_from_str(parts[1], "%Y-%m-%d") {
|
|
Ok(d) => Utc.from_utc_datetime(&d.and_hms_opt(23, 59, 59).unwrap_or_default()),
|
|
Err(e) => {
|
|
eprintln!("Invalid end date: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
trail.export_date_range(format, output, start, end)
|
|
} else {
|
|
trail.export(format, output)
|
|
};
|
|
|
|
match result {
|
|
Ok(()) => {
|
|
println!("Exported audit data to {}", output.display());
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to export: {}", e);
|
|
ExitCode::from(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Show audit summary.
|
|
async fn handle_summary(format: &str, _config: &AphoriaConfig) -> ExitCode {
|
|
let store = match GovernanceStore::open_default() {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Failed to open governance store: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let trail = match AuditTrail::open_default() {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
eprintln!("Failed to open audit trail: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let requests = match store.list_all() {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
eprintln!("Failed to list requests: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let events = match trail.get_all_events() {
|
|
Ok(e) => e,
|
|
Err(e) => {
|
|
eprintln!("Failed to get events: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
// Calculate statistics
|
|
let total_requests = requests.len();
|
|
let approved = requests.iter().filter(|r| r.status.is_approved()).count();
|
|
let rejected = requests.iter().filter(|r| r.status.is_rejected()).count();
|
|
let pending = requests.iter().filter(|r| r.status.is_pending()).count();
|
|
let expired = requests.iter().filter(|r| matches!(r.status, ApprovalStatus::Expired)).count();
|
|
|
|
let approval_rate =
|
|
if total_requests > 0 { (approved as f32 / total_requests as f32) * 100.0 } else { 0.0 };
|
|
|
|
// Calculate average approval time for completed requests
|
|
let avg_approval_days: Option<f32> = {
|
|
let completed: Vec<_> = requests.iter().filter(|r| r.status.is_approved()).collect();
|
|
if completed.is_empty() {
|
|
None
|
|
} else {
|
|
let total_days: i64 =
|
|
completed.iter().map(|r| (r.updated_at - r.created_at).num_days()).sum();
|
|
Some(total_days as f32 / completed.len() as f32)
|
|
}
|
|
};
|
|
|
|
match format {
|
|
"json" => {
|
|
let summary = serde_json::json!({
|
|
"total_requests": total_requests,
|
|
"approved": approved,
|
|
"rejected": rejected,
|
|
"pending": pending,
|
|
"expired": expired,
|
|
"approval_rate_percent": approval_rate,
|
|
"avg_approval_days": avg_approval_days,
|
|
"total_events": events.len(),
|
|
});
|
|
let json = serde_json::to_string_pretty(&summary).unwrap_or_else(|_| "{}".to_string());
|
|
println!("{}", json);
|
|
}
|
|
_ => {
|
|
println!("Governance Audit Summary");
|
|
println!("{}", "=".repeat(50));
|
|
println!();
|
|
println!("Requests:");
|
|
println!(" Total: {}", total_requests);
|
|
println!(" Approved: {} ({:.1}%)", approved, approval_rate);
|
|
println!(" Rejected: {}", rejected);
|
|
println!(" Pending: {}", pending);
|
|
println!(" Expired: {}", expired);
|
|
println!();
|
|
|
|
if let Some(days) = avg_approval_days {
|
|
println!("Average approval time: {:.1} days", days);
|
|
}
|
|
|
|
println!("Total audit events: {}", events.len());
|
|
}
|
|
}
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
/// Truncate a string to a maximum length.
|
|
fn truncate(s: &str, max: usize) -> String {
|
|
if s.len() <= max {
|
|
s.to_string()
|
|
} else {
|
|
format!("{}...", &s[..max.saturating_sub(3)])
|
|
}
|
|
}
|