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>
695 lines
21 KiB
Rust
695 lines
21 KiB
Rust
//! Lifecycle command handlers for knowledge deprecation and migration tracking.
|
|
|
|
use std::process::ExitCode;
|
|
|
|
use chrono::{NaiveDate, Utc};
|
|
use uuid::Uuid;
|
|
|
|
use aphoria::{
|
|
learning_store_dir, AphoriaConfig, DeprecatedUsage, KnowledgeStatus, LifecycleStore,
|
|
LocalPatternStore, MigrationProgress, MigrationStore, PatternStore, StatusTransition,
|
|
};
|
|
|
|
use crate::cli::{LifecycleCommands, MigrationCommands};
|
|
|
|
/// Handle lifecycle subcommands.
|
|
pub async fn handle_lifecycle_command(
|
|
command: LifecycleCommands,
|
|
config: &AphoriaConfig,
|
|
) -> ExitCode {
|
|
match command {
|
|
LifecycleCommands::Deprecate {
|
|
pattern_id,
|
|
reason,
|
|
superseded_by,
|
|
sunset_date,
|
|
migration_guide,
|
|
} => {
|
|
handle_deprecate(
|
|
&pattern_id,
|
|
&reason,
|
|
superseded_by.as_deref(),
|
|
sunset_date.as_deref(),
|
|
migration_guide,
|
|
config,
|
|
)
|
|
.await
|
|
}
|
|
LifecycleCommands::Archive { pattern_id, reason } => {
|
|
handle_archive(&pattern_id, &reason, config).await
|
|
}
|
|
LifecycleCommands::Reactivate { pattern_id, reason } => {
|
|
handle_reactivate(&pattern_id, &reason, config).await
|
|
}
|
|
LifecycleCommands::History { pattern_id, format } => {
|
|
handle_history(&pattern_id, &format, config).await
|
|
}
|
|
LifecycleCommands::List { status, overdue, format } => {
|
|
handle_list(status.as_deref(), overdue, &format, config).await
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle migrations subcommands.
|
|
pub async fn handle_migrations_command(
|
|
command: MigrationCommands,
|
|
config: &AphoriaConfig,
|
|
) -> ExitCode {
|
|
match command {
|
|
MigrationCommands::Status { pattern, scope, format } => {
|
|
handle_migration_status(pattern.as_deref(), scope.as_deref(), &format, config).await
|
|
}
|
|
MigrationCommands::Export { output, format, include_resolved } => {
|
|
handle_migration_export(&output, &format, include_resolved, config).await
|
|
}
|
|
MigrationCommands::Blockers { pattern_id, scope } => {
|
|
handle_migration_blockers(&pattern_id, scope.as_deref(), config).await
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Deprecate a pattern.
|
|
async fn handle_deprecate(
|
|
pattern_id: &str,
|
|
reason: &str,
|
|
superseded_by: Option<&str>,
|
|
sunset_date: Option<&str>,
|
|
migration_guide: Option<String>,
|
|
_config: &AphoriaConfig,
|
|
) -> ExitCode {
|
|
// Parse pattern ID
|
|
let id = match Uuid::parse_str(pattern_id) {
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
eprintln!("Invalid pattern ID '{}': {}", pattern_id, e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
// Parse superseded_by if provided
|
|
let superseded_by_id = if let Some(s) = superseded_by {
|
|
match Uuid::parse_str(s) {
|
|
Ok(id) => Some(id),
|
|
Err(e) => {
|
|
eprintln!("Invalid superseded-by ID '{}': {}", s, e);
|
|
return ExitCode::from(1);
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Parse sunset date if provided
|
|
let sunset_datetime = if let Some(date_str) = sunset_date {
|
|
match NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
|
|
Ok(date) => {
|
|
let datetime = date.and_hms_opt(23, 59, 59);
|
|
datetime.map(|dt| chrono::TimeZone::from_utc_datetime(&Utc, &dt))
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Invalid sunset date '{}': {}. Use YYYY-MM-DD format.", date_str, e);
|
|
return ExitCode::from(1);
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Load pattern store to verify pattern exists
|
|
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(&id) {
|
|
Some(p) => p,
|
|
None => {
|
|
eprintln!("Pattern '{}' not found", pattern_id);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
// Create lifecycle store
|
|
let lifecycle_store = match LifecycleStore::open_default() {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Failed to open lifecycle store: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
// Get current status
|
|
let current_status = lifecycle_store.get_current_status(&id).unwrap_or(KnowledgeStatus::Active);
|
|
|
|
// Create new deprecated status
|
|
let new_status = KnowledgeStatus::Deprecated {
|
|
reason: reason.to_string(),
|
|
superseded_by: superseded_by_id,
|
|
sunset_date: sunset_datetime,
|
|
migration_guide,
|
|
};
|
|
|
|
// Record transition
|
|
let transition = StatusTransition::new(
|
|
id,
|
|
current_status,
|
|
new_status.clone(),
|
|
whoami::username(),
|
|
Some(format!("Deprecated: {}", reason)),
|
|
);
|
|
|
|
if let Err(e) = lifecycle_store.record_transition(transition) {
|
|
eprintln!("Failed to record transition: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
|
|
// Display result
|
|
println!("Pattern deprecated successfully");
|
|
println!();
|
|
println!(" Pattern ID: {}", id);
|
|
println!(" Pattern Name: {}", pattern.claim_template.predicate);
|
|
println!(" Reason: {}", reason);
|
|
|
|
if let Some(s) = superseded_by_id {
|
|
println!(" Superseded By: {}", s);
|
|
}
|
|
|
|
if let Some(date) = sunset_datetime {
|
|
println!(" Sunset Date: {}", date.format("%Y-%m-%d"));
|
|
}
|
|
|
|
println!();
|
|
println!("Scans will now FLAG this pattern with migration guidance.");
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
/// Archive a pattern.
|
|
async fn handle_archive(pattern_id: &str, reason: &str, _config: &AphoriaConfig) -> ExitCode {
|
|
let id = match Uuid::parse_str(pattern_id) {
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
eprintln!("Invalid pattern ID '{}': {}", pattern_id, e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
// Load pattern store to verify pattern exists
|
|
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);
|
|
}
|
|
};
|
|
|
|
if pattern_store.get_pattern_by_id(&id).is_none() {
|
|
eprintln!("Pattern '{}' not found", pattern_id);
|
|
return ExitCode::from(1);
|
|
}
|
|
|
|
// Create lifecycle store
|
|
let lifecycle_store = match LifecycleStore::open_default() {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Failed to open lifecycle store: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let current_status = lifecycle_store.get_current_status(&id).unwrap_or(KnowledgeStatus::Active);
|
|
|
|
let new_status =
|
|
KnowledgeStatus::Archived { archived_at: Utc::now(), reason: reason.to_string() };
|
|
|
|
let transition = StatusTransition::new(
|
|
id,
|
|
current_status,
|
|
new_status,
|
|
whoami::username(),
|
|
Some(format!("Archived: {}", reason)),
|
|
);
|
|
|
|
if let Err(e) = lifecycle_store.record_transition(transition) {
|
|
eprintln!("Failed to record transition: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
|
|
println!("Pattern archived successfully");
|
|
println!();
|
|
println!(" Pattern ID: {}", id);
|
|
println!(" Reason: {}", reason);
|
|
println!();
|
|
println!("Pattern will no longer match during scans.");
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
/// Reactivate a deprecated pattern.
|
|
async fn handle_reactivate(pattern_id: &str, reason: &str, _config: &AphoriaConfig) -> ExitCode {
|
|
let id = match Uuid::parse_str(pattern_id) {
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
eprintln!("Invalid pattern ID '{}': {}", pattern_id, 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);
|
|
}
|
|
};
|
|
|
|
if pattern_store.get_pattern_by_id(&id).is_none() {
|
|
eprintln!("Pattern '{}' not found", pattern_id);
|
|
return ExitCode::from(1);
|
|
}
|
|
|
|
// Create lifecycle store
|
|
let lifecycle_store = match LifecycleStore::open_default() {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Failed to open lifecycle store: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let current_status = match lifecycle_store.get_current_status(&id) {
|
|
Some(s) => s,
|
|
None => {
|
|
eprintln!("Pattern has no lifecycle history (already active)");
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
if !current_status.is_deprecated() {
|
|
eprintln!("Pattern is not deprecated (status: {})", current_status.status_name());
|
|
return ExitCode::from(1);
|
|
}
|
|
|
|
let transition = StatusTransition::new(
|
|
id,
|
|
current_status,
|
|
KnowledgeStatus::Active,
|
|
whoami::username(),
|
|
Some(format!("Reactivated: {}", reason)),
|
|
);
|
|
|
|
if let Err(e) = lifecycle_store.record_transition(transition) {
|
|
eprintln!("Failed to record transition: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
|
|
println!("Pattern reactivated successfully");
|
|
println!();
|
|
println!(" Pattern ID: {}", id);
|
|
println!(" Reason: {}", reason);
|
|
println!();
|
|
println!("Pattern is now active and will match without deprecation warnings.");
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
/// Show lifecycle history for a pattern.
|
|
async fn handle_history(pattern_id: &str, format: &str, _config: &AphoriaConfig) -> ExitCode {
|
|
let id = match Uuid::parse_str(pattern_id) {
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
eprintln!("Invalid pattern ID '{}': {}", pattern_id, e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let lifecycle_store = match LifecycleStore::open_default() {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Failed to open lifecycle store: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let history = lifecycle_store.get_history(&id);
|
|
|
|
if history.is_empty() {
|
|
println!("No lifecycle history for pattern {}", pattern_id);
|
|
println!();
|
|
println!("Pattern is in Active status (default).");
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
|
|
match format {
|
|
"json" => {
|
|
let json = serde_json::to_string_pretty(&history).unwrap_or_else(|_| "[]".to_string());
|
|
println!("{}", json);
|
|
}
|
|
_ => {
|
|
println!("Lifecycle History for {}", pattern_id);
|
|
println!("{}", "=".repeat(60));
|
|
println!();
|
|
|
|
for transition in &history {
|
|
let arrow = "→";
|
|
println!(
|
|
"{} {} {} → {}",
|
|
transition.timestamp.format("%Y-%m-%d %H:%M"),
|
|
arrow,
|
|
transition.from_status.status_name(),
|
|
transition.to_status.status_name()
|
|
);
|
|
println!(" By: {}", transition.initiated_by);
|
|
if let Some(ref comment) = transition.comment {
|
|
println!(" Comment: {}", comment);
|
|
}
|
|
println!();
|
|
}
|
|
}
|
|
}
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
/// List patterns by lifecycle status.
|
|
async fn handle_list(
|
|
status_filter: Option<&str>,
|
|
overdue: bool,
|
|
format: &str,
|
|
_config: &AphoriaConfig,
|
|
) -> ExitCode {
|
|
let lifecycle_store = match LifecycleStore::open_default() {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Failed to open lifecycle store: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
// Collect patterns with their status
|
|
let mut results: Vec<(Uuid, String, KnowledgeStatus)> = Vec::new();
|
|
|
|
// Get all patterns and their statuses
|
|
for pattern in pattern_store.get_all_patterns() {
|
|
let status =
|
|
lifecycle_store.get_current_status(&pattern.id).unwrap_or(KnowledgeStatus::Active);
|
|
|
|
// Apply filters
|
|
if let Some(filter) = status_filter {
|
|
if status.status_name() != filter {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if overdue && !status.is_past_sunset() {
|
|
continue;
|
|
}
|
|
|
|
results.push((pattern.id, pattern.claim_template.predicate.clone(), status));
|
|
}
|
|
|
|
if results.is_empty() {
|
|
println!("No patterns found matching criteria.");
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
|
|
match format {
|
|
"json" => {
|
|
let json_data: Vec<serde_json::Value> = results
|
|
.iter()
|
|
.map(|(id, name, status)| {
|
|
serde_json::json!({
|
|
"id": id.to_string(),
|
|
"name": name,
|
|
"status": status.status_name(),
|
|
"days_until_sunset": status.days_until_sunset(),
|
|
})
|
|
})
|
|
.collect();
|
|
let json =
|
|
serde_json::to_string_pretty(&json_data).unwrap_or_else(|_| "[]".to_string());
|
|
println!("{}", json);
|
|
}
|
|
_ => {
|
|
println!("Patterns by Lifecycle Status");
|
|
println!("{}", "=".repeat(60));
|
|
println!();
|
|
|
|
for (id, name, status) in &results {
|
|
let sunset_info = status
|
|
.days_until_sunset()
|
|
.map(|d| {
|
|
if d < 0 {
|
|
format!(" (OVERDUE by {} days)", -d)
|
|
} else {
|
|
format!(" ({} days until sunset)", d)
|
|
}
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
println!("{:<40} {:<12} {}", name, status.status_name(), sunset_info);
|
|
println!(" {}", id);
|
|
}
|
|
|
|
println!();
|
|
println!("Total: {} patterns", results.len());
|
|
}
|
|
}
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
/// Show migration status for deprecated patterns.
|
|
async fn handle_migration_status(
|
|
pattern_filter: Option<&str>,
|
|
_scope_filter: Option<&str>,
|
|
format: &str,
|
|
_config: &AphoriaConfig,
|
|
) -> ExitCode {
|
|
let migration_store = match MigrationStore::open_default() {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Failed to open migration store: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let lifecycle_store = match LifecycleStore::open_default() {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Failed to open lifecycle store: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
// Get deprecated patterns
|
|
let deprecated = lifecycle_store.get_deprecated_patterns();
|
|
|
|
if deprecated.is_empty() {
|
|
println!("No deprecated patterns found.");
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
|
|
let mut progress_list: Vec<MigrationProgress> = Vec::new();
|
|
|
|
for (pattern_id, _status) in &deprecated {
|
|
// Apply pattern filter if specified
|
|
if let Some(filter) = pattern_filter {
|
|
if let Ok(filter_id) = Uuid::parse_str(filter) {
|
|
if pattern_id != &filter_id {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get pattern name
|
|
let pattern_name = pattern_store
|
|
.get_pattern_by_id(pattern_id)
|
|
.map(|p| p.claim_template.predicate.clone())
|
|
.unwrap_or_else(|| "Unknown".to_string());
|
|
|
|
let progress = migration_store.get_progress(pattern_id, &pattern_name);
|
|
progress_list.push(progress);
|
|
}
|
|
|
|
if progress_list.is_empty() {
|
|
println!("No migration data found.");
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
|
|
match format {
|
|
"json" => {
|
|
let json =
|
|
serde_json::to_string_pretty(&progress_list).unwrap_or_else(|_| "[]".to_string());
|
|
println!("{}", json);
|
|
}
|
|
_ => {
|
|
println!("Migration Status");
|
|
println!("{}", "=".repeat(70));
|
|
println!();
|
|
println!("{:<30} {:>8} {:>8} {:>10}", "Pattern", "Total", "Resolved", "Progress");
|
|
println!("{}", "-".repeat(70));
|
|
|
|
for progress in &progress_list {
|
|
println!(
|
|
"{:<30} {:>8} {:>8} {:>9.1}%",
|
|
truncate(&progress.pattern_name, 30),
|
|
progress.total_usages,
|
|
progress.resolved_usages,
|
|
progress.completion_percent()
|
|
);
|
|
}
|
|
|
|
println!();
|
|
let total_usages: usize = progress_list.iter().map(|p| p.total_usages).sum();
|
|
let total_resolved: usize = progress_list.iter().map(|p| p.resolved_usages).sum();
|
|
let overall_percent = if total_usages > 0 {
|
|
(total_resolved as f32 / total_usages as f32) * 100.0
|
|
} else {
|
|
100.0
|
|
};
|
|
|
|
println!(
|
|
"Overall: {} of {} usages resolved ({:.1}%)",
|
|
total_resolved, total_usages, overall_percent
|
|
);
|
|
}
|
|
}
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
/// Export migration data.
|
|
async fn handle_migration_export(
|
|
output: &std::path::Path,
|
|
format: &str,
|
|
include_resolved: bool,
|
|
_config: &AphoriaConfig,
|
|
) -> ExitCode {
|
|
let migration_store = match MigrationStore::open_default() {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Failed to open migration store: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let content = match format {
|
|
"csv" => migration_store.export_csv(include_resolved),
|
|
"json" => {
|
|
// For JSON, we need to manually build the data
|
|
let lifecycle_store = match LifecycleStore::open_default() {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Failed to open lifecycle store: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let deprecated = lifecycle_store.get_deprecated_patterns();
|
|
let mut all_usages: Vec<DeprecatedUsage> = Vec::new();
|
|
|
|
for (pattern_id, _) in deprecated {
|
|
let usages = if include_resolved {
|
|
migration_store.get_usages(&pattern_id)
|
|
} else {
|
|
migration_store.get_pending_usages(&pattern_id)
|
|
};
|
|
all_usages.extend(usages);
|
|
}
|
|
|
|
serde_json::to_string_pretty(&all_usages).unwrap_or_else(|_| "[]".to_string())
|
|
}
|
|
_ => {
|
|
eprintln!("Unknown format '{}'. Use 'csv' or 'json'.", format);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
if let Err(e) = std::fs::write(output, content) {
|
|
eprintln!("Failed to write to {}: {}", output.display(), e);
|
|
return ExitCode::from(1);
|
|
}
|
|
|
|
println!("Exported migration data to {}", output.display());
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
/// Show migration blockers for a pattern.
|
|
async fn handle_migration_blockers(
|
|
pattern_id: &str,
|
|
_scope_filter: Option<&str>,
|
|
_config: &AphoriaConfig,
|
|
) -> ExitCode {
|
|
let id = match Uuid::parse_str(pattern_id) {
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
eprintln!("Invalid pattern ID '{}': {}", pattern_id, e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let migration_store = match MigrationStore::open_default() {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Failed to open migration store: {}", e);
|
|
return ExitCode::from(1);
|
|
}
|
|
};
|
|
|
|
let pending = migration_store.get_pending_usages(&id);
|
|
|
|
if pending.is_empty() {
|
|
println!("No pending usages found for pattern {}", pattern_id);
|
|
println!();
|
|
println!("Migration is complete.");
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
|
|
println!("Migration Blockers for {}", pattern_id);
|
|
println!("{}", "=".repeat(70));
|
|
println!();
|
|
|
|
for usage in &pending {
|
|
println!("{}:{}", usage.file_path, usage.line);
|
|
println!(" Project: {}", &usage.project_hash[..8.min(usage.project_hash.len())]);
|
|
println!(" First seen: {}", usage.first_detected.format("%Y-%m-%d"));
|
|
println!(" Last seen: {}", usage.last_detected.format("%Y-%m-%d"));
|
|
println!();
|
|
}
|
|
|
|
println!("Total blockers: {}", pending.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 - 3])
|
|
}
|
|
}
|