## Phase 8: Enterprise Extractor Improvements ✅ - 14 security extractors (TLS, JWT, SQL injection, XSS, etc.) - 10 framework-specific extractors (Spring, Django, Rails, etc.) - Config file security detection (YAML, TOML) ## Phase 9: Autonomous Extractor Generation ✅ - Shadow mode executor with TP/FP tracking - Graduation pipeline with confidence thresholds - Auto-rollback on regression detection - Cross-project pattern syncing ## UAT Suite Complete (14 scripts, 90 tests) - test-core-detection.sh (6 tests) - test-declarative-extractors.sh (5 tests) - test-domain-frameworks.sh (5 tests) - test-domain-unreal.sh (3 tests) - test-llm-extraction.sh (6 tests) - test-eval-harness.sh (5 tests) - test-cross-language.sh (3 tests) - test-precommit-performance.sh (4 tests) - test-output-formats.sh (8 tests) - test-drift-detection.sh (6 tests) - test-exit-codes.sh (12 tests) + 3 more scripts ## Other Changes - Updated roadmap to mark Phase 8-9 complete - Added .gitignore entries for build artifacts - Updated pre-commit: 800 line limit, exclude tests/data/cmd Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
654 lines
21 KiB
Rust
654 lines
21 KiB
Rust
//! Extractor command handlers (stats, candidates, review, promote, auto-promote, versioning)
|
|
|
|
use std::process::ExitCode;
|
|
|
|
use aphoria::{
|
|
learning::learning_store_dir,
|
|
promotion::{compute_metrics_delta, ChangelogEntry, VersionStore},
|
|
AphoriaConfig, LocalPatternStore, ShadowStore,
|
|
};
|
|
|
|
use crate::cli::ExtractorCommands;
|
|
|
|
use super::utils::truncate_for_display;
|
|
|
|
pub async fn handle_extractor_command(
|
|
command: ExtractorCommands,
|
|
config: &AphoriaConfig,
|
|
) -> ExitCode {
|
|
// Open pattern store
|
|
let store_dir = learning_store_dir();
|
|
let store = match LocalPatternStore::new(&store_dir) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Failed to open pattern store: {e}");
|
|
return ExitCode::from(3);
|
|
}
|
|
};
|
|
|
|
match command {
|
|
ExtractorCommands::Stats => handle_extractor_stats(&store, config),
|
|
|
|
ExtractorCommands::Candidates { verbose } => {
|
|
handle_extractor_candidates(&store, config, verbose)
|
|
}
|
|
|
|
ExtractorCommands::Review { limit, auto } => {
|
|
handle_extractor_review(&store, config, limit, auto).await
|
|
}
|
|
|
|
ExtractorCommands::Promote { pattern_id, force } => {
|
|
handle_extractor_promote(&store, config, &pattern_id, force).await
|
|
}
|
|
|
|
ExtractorCommands::AutoPromote { dry_run, min_confidence, min_projects } => {
|
|
handle_auto_promote(&store, config, dry_run, min_confidence, min_projects).await
|
|
}
|
|
|
|
ExtractorCommands::ShadowStatus { verbose } => {
|
|
super::shadow::handle_shadow_status(config, verbose)
|
|
}
|
|
|
|
ExtractorCommands::Feedback { test, limit } => {
|
|
super::shadow::handle_shadow_feedback(config, &test, limit)
|
|
}
|
|
|
|
ExtractorCommands::Graduate { test, force } => {
|
|
super::shadow::handle_shadow_graduate(config, &test, force)
|
|
}
|
|
|
|
ExtractorCommands::Rollback { test, reason } => {
|
|
super::shadow::handle_shadow_rollback(config, &test, &reason)
|
|
}
|
|
|
|
ExtractorCommands::AutoCheck => super::shadow::handle_shadow_auto_check(config),
|
|
|
|
ExtractorCommands::Versions { name } => handle_versions(&name, config),
|
|
|
|
ExtractorCommands::Compare { name, version_a, version_b } => {
|
|
handle_compare(&name, version_a, version_b, config)
|
|
}
|
|
|
|
ExtractorCommands::RollbackVersion { name, version, reason } => {
|
|
handle_rollback_version(&name, version, &reason, config)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_extractor_stats(store: &LocalPatternStore, config: &AphoriaConfig) -> ExitCode {
|
|
use aphoria::PromotionPipeline;
|
|
|
|
let pipeline = match PromotionPipeline::new(store, None, &config.learning.promotion, None) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
eprintln!("Failed to create pipeline: {e}");
|
|
return ExitCode::from(3);
|
|
}
|
|
};
|
|
|
|
let stats = pipeline.stats();
|
|
|
|
println!("Pattern Learning Statistics");
|
|
println!("===========================");
|
|
println!();
|
|
println!("Total patterns: {}", stats.total_patterns);
|
|
println!("Eligible for promotion: {}", stats.eligible_patterns);
|
|
println!("Already promoted: {}", stats.promoted_patterns);
|
|
println!("Pending review: {}", stats.pending_review);
|
|
println!();
|
|
|
|
if stats.eligible_patterns > 0 {
|
|
println!("Eligible pattern averages:");
|
|
println!(" Confidence: {:.2}", stats.avg_confidence);
|
|
println!(" Projects: {:.1}", stats.avg_projects);
|
|
}
|
|
|
|
println!();
|
|
println!("Promotion thresholds (from config):");
|
|
println!(" Min projects: {}", config.learning.promotion.min_projects);
|
|
println!(" Min confidence: {:.2}", config.learning.promotion.min_confidence);
|
|
println!(" Auto-promote: {}", config.learning.promotion.auto_promote);
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
fn handle_extractor_candidates(
|
|
store: &LocalPatternStore,
|
|
config: &AphoriaConfig,
|
|
verbose: bool,
|
|
) -> ExitCode {
|
|
use aphoria::PromotionPipeline;
|
|
|
|
let pipeline = match PromotionPipeline::new(store, None, &config.learning.promotion, None) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
eprintln!("Failed to create pipeline: {e}");
|
|
return ExitCode::from(3);
|
|
}
|
|
};
|
|
|
|
let candidates = pipeline.get_candidates();
|
|
|
|
if candidates.is_empty() {
|
|
println!("No patterns eligible for promotion.");
|
|
println!();
|
|
println!("Patterns become eligible when:");
|
|
println!(" - Seen in {}+ projects", config.learning.promotion.min_projects);
|
|
println!(" - Average confidence >= {:.2}", config.learning.promotion.min_confidence);
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
|
|
println!("Patterns eligible for promotion ({} total):\n", candidates.len());
|
|
println!("{:<36} {:>8} {:>6} Subject", "Pattern ID", "Projects", "Conf");
|
|
println!("{}", "-".repeat(70));
|
|
|
|
for pattern in &candidates {
|
|
let subject = if pattern.claim_template.subject_template.len() > 25 {
|
|
format!("{}...", &pattern.claim_template.subject_template[..22])
|
|
} else {
|
|
pattern.claim_template.subject_template.clone()
|
|
};
|
|
|
|
println!(
|
|
"{:<36} {:>8} {:>6.2} {}",
|
|
pattern.id,
|
|
pattern.project_count(),
|
|
pattern.avg_confidence,
|
|
subject
|
|
);
|
|
|
|
if verbose {
|
|
println!(" Language: {}", pattern.language);
|
|
println!(" Example: {}", truncate_for_display(&pattern.example_code, 60));
|
|
println!(" Normalized: {}", pattern.normalized_pattern);
|
|
println!();
|
|
}
|
|
}
|
|
|
|
println!();
|
|
println!("To promote a pattern, run:");
|
|
println!(" aphoria extractors promote <PATTERN_ID>");
|
|
println!();
|
|
println!("For interactive review:");
|
|
println!(" aphoria extractors review");
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
async fn handle_extractor_review(
|
|
store: &LocalPatternStore,
|
|
config: &AphoriaConfig,
|
|
limit: Option<usize>,
|
|
auto: bool,
|
|
) -> ExitCode {
|
|
use aphoria::{llm::GeminiClient, InteractiveReviewer, PromotionPipeline};
|
|
|
|
// Create LLM client
|
|
let client = match GeminiClient::new(&config.llm) {
|
|
Ok(Some(c)) => c,
|
|
Ok(None) => {
|
|
eprintln!("LLM not configured. Cannot generate regex patterns.");
|
|
eprintln!();
|
|
eprintln!("To configure LLM, add this to your aphoria.toml:");
|
|
eprintln!();
|
|
eprintln!(" [llm]");
|
|
eprintln!(" enabled = true");
|
|
eprintln!(" api_key_env = \"GEMINI_API_KEY\"");
|
|
return ExitCode::from(3);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to create LLM client: {e}");
|
|
return ExitCode::from(3);
|
|
}
|
|
};
|
|
|
|
let output_dir = config.learning.promotion.output_dir.clone();
|
|
let pipeline = match PromotionPipeline::new(
|
|
store,
|
|
Some(&client),
|
|
&config.learning.promotion,
|
|
Some(output_dir),
|
|
) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
eprintln!("Failed to create pipeline: {e}");
|
|
return ExitCode::from(3);
|
|
}
|
|
};
|
|
|
|
let mut reviewer = InteractiveReviewer::new(&pipeline).with_auto_approve(auto);
|
|
if let Some(n) = limit {
|
|
reviewer = reviewer.with_limit(n);
|
|
}
|
|
|
|
match reviewer.run() {
|
|
Ok(result) => {
|
|
println!();
|
|
println!("Review session complete:");
|
|
println!(" Approved: {}", result.approved);
|
|
println!(" Rejected: {}", result.rejected);
|
|
println!(" Regenerated: {}", result.regenerated);
|
|
println!(" Skipped: {}", result.skipped);
|
|
|
|
if !result.promoted_files.is_empty() {
|
|
println!();
|
|
println!("Promoted extractors written to:");
|
|
for path in &result.promoted_files {
|
|
println!(" {}", path.display());
|
|
}
|
|
}
|
|
|
|
if !result.errors.is_empty() {
|
|
println!();
|
|
println!("Errors:");
|
|
for err in &result.errors {
|
|
println!(" - {}", err);
|
|
}
|
|
}
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Review error: {e}");
|
|
ExitCode::from(3)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn handle_extractor_promote(
|
|
store: &LocalPatternStore,
|
|
config: &AphoriaConfig,
|
|
pattern_id: &str,
|
|
_force: bool,
|
|
) -> ExitCode {
|
|
use aphoria::{llm::GeminiClient, PromotionPipeline};
|
|
use uuid::Uuid;
|
|
|
|
// Parse pattern ID
|
|
let id = match Uuid::parse_str(pattern_id) {
|
|
Ok(id) => id,
|
|
Err(_) => {
|
|
eprintln!("Invalid pattern ID format. Expected UUID.");
|
|
return ExitCode::from(3);
|
|
}
|
|
};
|
|
|
|
// Create LLM client
|
|
let client = match GeminiClient::new(&config.llm) {
|
|
Ok(Some(c)) => c,
|
|
Ok(None) => {
|
|
eprintln!("LLM not configured. Cannot generate regex patterns.");
|
|
return ExitCode::from(3);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to create LLM client: {e}");
|
|
return ExitCode::from(3);
|
|
}
|
|
};
|
|
|
|
let output_dir = config.learning.promotion.output_dir.clone();
|
|
let pipeline = match PromotionPipeline::new(
|
|
store,
|
|
Some(&client),
|
|
&config.learning.promotion,
|
|
Some(output_dir),
|
|
) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
eprintln!("Failed to create pipeline: {e}");
|
|
return ExitCode::from(3);
|
|
}
|
|
};
|
|
|
|
match pipeline.promote_by_id(&id) {
|
|
Ok(path) => {
|
|
println!("Pattern promoted successfully!");
|
|
println!(" Extractor written to: {}", path.display());
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Promotion failed: {e}");
|
|
ExitCode::from(3)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn handle_auto_promote(
|
|
store: &LocalPatternStore,
|
|
config: &AphoriaConfig,
|
|
dry_run: bool,
|
|
min_confidence: Option<f32>,
|
|
min_projects: Option<usize>,
|
|
) -> ExitCode {
|
|
use aphoria::{llm::GeminiClient, PromotionPipeline};
|
|
|
|
// Build autonomous config with overrides
|
|
let mut auto_config = config.autonomous.clone();
|
|
if let Some(conf) = min_confidence {
|
|
auto_config.min_confidence = conf;
|
|
}
|
|
if let Some(proj) = min_projects {
|
|
auto_config.min_projects = proj;
|
|
}
|
|
|
|
// For dry run, temporarily enable autonomous mode
|
|
if dry_run {
|
|
auto_config.enabled = true;
|
|
}
|
|
|
|
// Check if autonomous promotion is enabled
|
|
if !auto_config.enabled && !dry_run {
|
|
println!("Autonomous promotion is disabled.");
|
|
println!();
|
|
println!("To enable, add this to your aphoria.toml:");
|
|
println!();
|
|
println!(" [autonomous]");
|
|
println!(" enabled = true");
|
|
println!(" min_confidence = 0.95");
|
|
println!(" min_projects = 10");
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
|
|
// Create LLM client
|
|
let client = match GeminiClient::new(&config.llm) {
|
|
Ok(Some(c)) => c,
|
|
Ok(None) => {
|
|
eprintln!("LLM not configured. Cannot generate regex patterns.");
|
|
eprintln!();
|
|
eprintln!("To configure LLM, add this to your aphoria.toml:");
|
|
eprintln!();
|
|
eprintln!(" [llm]");
|
|
eprintln!(" enabled = true");
|
|
eprintln!(" api_key_env = \"GEMINI_API_KEY\"");
|
|
return ExitCode::from(3);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to create LLM client: {e}");
|
|
return ExitCode::from(3);
|
|
}
|
|
};
|
|
|
|
let output_dir = config.learning.promotion.output_dir.clone();
|
|
let pipeline = match PromotionPipeline::new(
|
|
store,
|
|
Some(&client),
|
|
&config.learning.promotion,
|
|
Some(output_dir),
|
|
) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
eprintln!("Failed to create pipeline: {e}");
|
|
return ExitCode::from(3);
|
|
}
|
|
};
|
|
|
|
if dry_run {
|
|
// Preview mode: check what would be promoted without making changes
|
|
println!("Autonomous Promotion Preview (dry run)");
|
|
println!("======================================");
|
|
println!();
|
|
println!("Thresholds:");
|
|
println!(" Min confidence: {:.2}", auto_config.min_confidence);
|
|
println!(" Min projects: {}", auto_config.min_projects);
|
|
println!(" Zero failures: {}", auto_config.require_zero_failures);
|
|
println!(" Zero warnings: {}", auto_config.require_zero_warnings);
|
|
println!();
|
|
|
|
let candidates = pipeline.get_candidates();
|
|
if candidates.is_empty() {
|
|
println!("No patterns eligible for promotion.");
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
|
|
let mut would_promote = 0;
|
|
let mut needs_review = 0;
|
|
|
|
for pattern in &candidates {
|
|
// Create a mock candidate to check eligibility
|
|
match pipeline.generate_candidate(pattern) {
|
|
Ok(candidate) => {
|
|
if candidate.should_auto_promote(&auto_config) {
|
|
would_promote += 1;
|
|
println!(
|
|
"[WOULD AUTO-PROMOTE] {} (conf: {:.2}, projects: {})",
|
|
pattern.id,
|
|
pattern.avg_confidence,
|
|
pattern.project_count()
|
|
);
|
|
} else {
|
|
needs_review += 1;
|
|
let blockers = candidate.auto_promotion_blockers(&auto_config);
|
|
println!("[NEEDS REVIEW] {} - {}", pattern.id, blockers.join(", "));
|
|
}
|
|
}
|
|
Err(e) => {
|
|
println!("[ERROR] {} - {}", pattern.id, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
println!();
|
|
println!("Summary:");
|
|
println!(" Would auto-promote: {}", would_promote);
|
|
println!(" Needs review: {}", needs_review);
|
|
println!();
|
|
println!("To run for real, remove --dry-run flag.");
|
|
} else {
|
|
// Real mode: actually promote
|
|
println!("Running Autonomous Promotion");
|
|
println!("============================");
|
|
println!();
|
|
println!("Thresholds:");
|
|
println!(" Min confidence: {:.2}", auto_config.min_confidence);
|
|
println!(" Min projects: {}", auto_config.min_projects);
|
|
println!();
|
|
|
|
match pipeline.smart_auto_promote_all(&auto_config) {
|
|
Ok(result) => {
|
|
println!("Results:");
|
|
println!(" Auto-promoted: {}", result.auto_promoted);
|
|
println!(" Requires review: {}", result.requires_review);
|
|
println!(" Errors: {}", result.errors.len());
|
|
|
|
if !result.promoted_files.is_empty() {
|
|
println!();
|
|
println!("Promoted extractors written to:");
|
|
for path in &result.promoted_files {
|
|
println!(" {}", path.display());
|
|
}
|
|
}
|
|
|
|
if !result.errors.is_empty() {
|
|
println!();
|
|
println!("Errors:");
|
|
for err in &result.errors {
|
|
println!(" - {}", err);
|
|
}
|
|
}
|
|
|
|
// Print audit log location
|
|
let audit_dir = auto_config.get_audit_dir();
|
|
println!();
|
|
println!("Audit log: {}/autonomous-decisions.jsonl", audit_dir.display());
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Auto-promotion failed: {e}");
|
|
return ExitCode::from(3);
|
|
}
|
|
}
|
|
}
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
// ============================================================================
|
|
// Version Command Handlers
|
|
// ============================================================================
|
|
|
|
/// Handle the `extractors versions` command.
|
|
fn handle_versions(name: &str, config: &AphoriaConfig) -> ExitCode {
|
|
let extractors_dir = config.learning.promotion.output_dir.clone();
|
|
let version_store = match VersionStore::new(&extractors_dir) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Failed to open version store: {e}");
|
|
return ExitCode::from(3);
|
|
}
|
|
};
|
|
|
|
let changelog = match version_store.read_changelog(name) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
eprintln!("Failed to read changelog for {}: {e}", name);
|
|
return ExitCode::from(3);
|
|
}
|
|
};
|
|
|
|
if changelog.entries.is_empty() {
|
|
println!("No version history found for '{}'.", name);
|
|
println!();
|
|
println!("Version history is created when extractors are promoted");
|
|
println!("using the versioned promotion system.");
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
|
|
println!("Version History: {}", name);
|
|
println!("Current version: {}", changelog.current_version);
|
|
println!();
|
|
println!("{:<8} {:<12} Changes", "Version", "Date");
|
|
println!("{}", "-".repeat(60));
|
|
|
|
// Show entries newest first
|
|
for entry in changelog.entries.iter().rev() {
|
|
let changes = if entry.changes.len() > 40 {
|
|
format!("{}...", &entry.changes[..37])
|
|
} else {
|
|
entry.changes.clone()
|
|
};
|
|
|
|
println!("{:<8} {:<12} {}", entry.version, entry.date, changes);
|
|
|
|
if let Some(ref metrics) = entry.metrics {
|
|
println!(
|
|
" {:<12} Matches: {}, FP: {}",
|
|
"", metrics.matches, metrics.false_positives
|
|
);
|
|
}
|
|
}
|
|
|
|
println!();
|
|
println!("To compare versions:");
|
|
println!(" aphoria extractors compare {} -a 1 -b 2", name);
|
|
println!();
|
|
println!("To rollback to a previous version:");
|
|
println!(" aphoria extractors rollback-version {} --version 1 --reason \"...\"", name);
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
/// Handle the `extractors compare` command.
|
|
fn handle_compare(name: &str, version_a: u32, version_b: u32, config: &AphoriaConfig) -> ExitCode {
|
|
// Open shadow store for metrics
|
|
let shadow_dir = config.shadow.get_shadow_dir();
|
|
let shadow_store = match ShadowStore::new(&shadow_dir) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Failed to open shadow store: {e}");
|
|
return ExitCode::from(3);
|
|
}
|
|
};
|
|
|
|
println!("Comparison: {} v{} vs v{}", name, version_a, version_b);
|
|
println!();
|
|
|
|
match compute_metrics_delta(&shadow_store, name, version_a, version_b) {
|
|
Ok(Some(delta)) => {
|
|
println!("{:<20} {}", "Matches", delta.matches);
|
|
println!("{:<20} {}", "False Positives", delta.false_positives);
|
|
}
|
|
Ok(None) => {
|
|
println!("Insufficient metrics data available for comparison.");
|
|
println!();
|
|
println!("Metrics are collected during shadow mode testing.");
|
|
println!("Ensure the extractor has been through shadow mode with");
|
|
println!("sufficient feedback before comparing versions.");
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to compute metrics: {e}");
|
|
return ExitCode::from(3);
|
|
}
|
|
}
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
/// Handle the `extractors rollback-version` command.
|
|
fn handle_rollback_version(
|
|
name: &str,
|
|
version: u32,
|
|
reason: &str,
|
|
config: &AphoriaConfig,
|
|
) -> ExitCode {
|
|
let extractors_dir = config.learning.promotion.output_dir.clone();
|
|
let version_store = match VersionStore::new(&extractors_dir) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Failed to open version store: {e}");
|
|
return ExitCode::from(3);
|
|
}
|
|
};
|
|
|
|
// Check that the version exists
|
|
let versions = match version_store.list_versions(name) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
eprintln!("Failed to list versions: {e}");
|
|
return ExitCode::from(3);
|
|
}
|
|
};
|
|
|
|
if !versions.contains(&version) {
|
|
eprintln!("Version {} not found for '{}'.", version, name);
|
|
if versions.is_empty() {
|
|
eprintln!("No archived versions available.");
|
|
} else {
|
|
eprintln!("Available versions: {:?}", versions);
|
|
}
|
|
return ExitCode::from(3);
|
|
}
|
|
|
|
// Perform the rollback
|
|
let path = match version_store.restore_version(name, version, &extractors_dir) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
eprintln!("Failed to restore version: {e}");
|
|
return ExitCode::from(3);
|
|
}
|
|
};
|
|
|
|
// Record rollback in changelog
|
|
let new_version = match version_store.next_version(name) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
eprintln!("Warning: Failed to determine new version number: {e}");
|
|
0
|
|
}
|
|
};
|
|
|
|
let rollback_entry =
|
|
ChangelogEntry::new(new_version, format!("Rollback to v{}: {}", version, reason));
|
|
|
|
if let Err(e) = version_store.append_changelog(name, rollback_entry) {
|
|
eprintln!("Warning: Failed to update changelog: {e}");
|
|
}
|
|
|
|
println!("Rolled back {} to v{}", name, version);
|
|
println!("Restored as: {}", path.display());
|
|
println!();
|
|
println!("Reason: {}", reason);
|
|
println!();
|
|
println!("A new changelog entry has been created documenting this rollback.");
|
|
|
|
ExitCode::SUCCESS
|
|
}
|