use std::fs;
use std::path::{Path, PathBuf};
use colored::Colorize;
use anyhow::{Context, Result, bail};
use crate::core::backup::manager::BackupManager;
use crate::core::cache;
use crate::core::detection::workspace::detect_workspaces;
use crate::core::diff::preview::PreviewConfig;
use crate::core::diff::renderer::DiffRenderer;
use crate::core::execution::{ExecutionConfig, num_cpus};
use crate::core::pipeline::executor::PipelineExecutor;
use crate::core::recipe::{FileClassification, TransformMode, TransformOptions, RecipeMaturity};
use crate::core::registry::RecipeRegistry;
use crate::core::report::generator::ReportGenerator;
use crate::core::report::models::ExecutionMetadata;
use crate::core::session::{MigrationSession, SessionStore};
use crate::utils::terminal;
#[allow(clippy::too_many_arguments)]
pub fn execute(
recipe_names: &[String],
path: &Path,
mut dry_run: bool,
mut write: bool,
mut review: bool,
autofix: bool,
mut verbose: bool,
mut summary_only: bool,
max_preview_lines: Option<usize>,
mut allow_risky: bool,
strict: bool,
report_json: bool,
report_md: bool,
report_dir: &Path,
format: bool,
prettier: bool,
no_format: bool,
jobs: Option<usize>,
sequential: bool,
project_root: &Path,
package: Option<&str>,
profile: Option<&str>,
output_style: Option<&str>,
tag: Option<&str>,
) -> Result<()> {
let exec_config = if sequential {
ExecutionConfig::sequential()
} else {
ExecutionConfig::with_workers(jobs.unwrap_or_else(num_cpus))
};
if verbose {
terminal::log(
terminal::LogLevel::Verbose,
&format!(
"Execution config: {} workers, sequential={}",
exec_config.max_workers, exec_config.sequential
),
verbose,
);
}
if let Some(style) = output_style {
match style {
"minimal" => {
summary_only = true;
verbose = false;
}
"detailed" => {
summary_only = false;
verbose = true;
}
"default" => {}
_ => {
println!("{} Unknown output style '{}', using default", terminal::warning_prefix(), style);
}
}
}
let mut max_files_limit = None;
let mut max_duration_limit = None;
if let Ok(content) = std::fs::read_to_string(project_root.join("morph-cli.toml")) {
if let Ok(config) = toml::from_str::<crate::core::config::schema::MorphCliSchema>(&content) {
let mut applied_dry_run_default = config.dry_run_default;
let mut applied_review = false;
let mut applied_allow_risky = config.allow_risky_transforms;
max_files_limit = Some(config.max_files);
max_duration_limit = Some(config.max_duration_seconds);
if let Some(profile_name) = profile {
if let Some(prof) = config.profiles.get(profile_name) {
if let Some(v) = prof.dry_run_default {
applied_dry_run_default = v;
}
if let Some(v) = prof.review {
applied_review = v;
}
if let Some(v) = prof.allow_risky_transforms {
applied_allow_risky = v;
}
if let Some(v) = prof.max_files {
max_files_limit = Some(v);
}
if let Some(v) = prof.max_duration_seconds {
max_duration_limit = Some(v);
}
if verbose {
println!("{} Loaded config profile: {}", terminal::info_prefix(), profile_name);
}
} else {
println!("{} Profile `{}` not found in morph-cli.toml", terminal::warning_prefix(), profile_name);
}
}
if !write && !dry_run {
if applied_dry_run_default {
dry_run = true;
} else {
write = true;
}
}
review = review || applied_review;
allow_risky = allow_risky || applied_allow_risky;
}
} else if !write && !dry_run {
dry_run = true;
}
let mut registry = RecipeRegistry::new();
registry.load_plugins(project_root);
for name in recipe_names {
if registry.find(name).is_none() {
let all_names: Vec<String> = registry.all()
.iter()
.map(|r| r.metadata().name.to_string())
.collect();
let mut best_suggestion = None;
let mut min_dist = 999;
for candidate in &all_names {
let dist = levenshtein_distance(name, candidate);
if dist < min_dist && dist <= 4 {
min_dist = dist;
best_suggestion = Some(candidate.clone());
}
}
let err_msg = if let Some(sug) = best_suggestion {
format!(
"Unknown recipe `{}`.\n\n\
{} Did you mean `{}`?\n\n\
{} Suggested Command:\n\
`morph run {} . --dry-run` to test it safely!\n\n\
🚀 Beginner-Safe Recipes to explore:\n\
- `commonjs-to-esm` : Convert legacy require calls to ESM imports.\n\
- `js-to-ts` : Safely migrate JavaScript to TypeScript.\n\n\
👉 Next-Step Hints:\n\
- Run `morph list` to inspect all supported recipes.\n\
- Run `morph search <query>` to find a specific transform.",
name.red().bold(),
"💡".yellow(),
sug.cyan().bold(),
"👉".green(),
sug
)
} else {
format!(
"Unknown recipe `{}`.\n\n\
🚀 Beginner-Safe Recipes to explore:\n\
- `commonjs-to-esm` : Convert legacy require calls to ESM imports.\n\
- `js-to-ts` : Safely migrate JavaScript to TypeScript.\n\n\
👉 Next-Step Hints:\n\
- Run `morph list` to inspect all supported recipes.\n\
- Run `morph search <query>` to find a specific transform.",
name.red().bold()
)
};
bail!(err_msg);
}
}
let execution_path = resolve_package_path(path, package)?;
if !execution_path.exists() {
bail!("Target path does not exist: {}", execution_path.display());
}
if write && dry_run {
bail!("Use either `--write` or `--dry-run`, not both.");
}
if report_json || report_md {
fs::create_dir_all(report_dir).with_context(|| {
format!(
"Failed to create report directory: {}",
report_dir.display()
)
})?;
}
let mode = if write {
TransformMode::Write
} else {
TransformMode::DryRun
};
let options = crate::core::session::SessionOptions {
write,
review,
autofix,
allow_risky,
strict,
format,
prettier,
no_format,
jobs,
sequential,
};
let session_mode = match mode {
TransformMode::DryRun => "dry-run",
TransformMode::Write => "write",
};
if recipe_names.len() == 1 {
let run_session =
MigrationSession::new(recipe_names.to_vec(), session_mode, execution_path.clone(), options);
execute_single_recipe(
®istry,
&recipe_names[0],
&execution_path,
mode,
review,
autofix,
verbose,
summary_only,
max_preview_lines,
write,
allow_risky,
strict,
report_json,
report_md,
report_dir,
project_root,
run_session,
tag,
)
} else {
let run_session =
MigrationSession::new(recipe_names.to_vec(), session_mode, execution_path.clone(), options);
execute_pipeline(
®istry,
recipe_names,
&execution_path,
mode,
review,
autofix,
verbose,
write,
allow_risky,
strict,
report_json,
report_md,
report_dir,
project_root,
run_session,
max_files_limit,
max_duration_limit,
tag,
)
}
}
fn resolve_package_path(path: &Path, package: Option<&str>) -> Result<PathBuf> {
let Some(package_name) = package else {
return Ok(path.to_path_buf());
};
let workspace = detect_workspaces(path);
let Some(package) = workspace.find_package(package_name) else {
bail!("Workspace package not found: {}", package_name);
};
println!(
"{}: {} ({})",
terminal::label("workspace package"),
package.name,
package.path.display()
);
Ok(package.path.clone())
}
#[allow(clippy::too_many_arguments)]
fn execute_single_recipe(
registry: &RecipeRegistry,
recipe_name: &str,
path: &Path,
mode: TransformMode,
review: bool,
autofix: bool,
verbose: bool,
summary_only: bool,
max_preview_lines: Option<usize>,
write: bool,
allow_risky: bool,
strict: bool,
report_json: bool,
report_md: bool,
report_dir: &Path,
project_root: &Path,
run_session: MigrationSession,
tag: Option<&str>,
) -> Result<()> {
let session_mode = match mode {
TransformMode::DryRun => "dry-run",
TransformMode::Write => "write",
};
let session_id = run_session.id.clone();
let config_opt = if let Ok(content) = std::fs::read_to_string(project_root.join("morph-cli.toml")) {
toml::from_str::<crate::core::config::schema::MorphCliSchema>(&content).ok()
} else {
None
};
if let Some(ref config) = config_opt {
run_hook(
config.hooks.before_run.as_deref(),
"before-run",
&session_id,
&[recipe_name.to_string()],
session_mode,
)?;
}
let recipe = registry.find(recipe_name).with_context(|| {
format!(
"Unknown recipe `{}`. Run `morph list` to see available recipes.",
recipe_name
)
})?;
let execution = ExecutionMetadata {
command: format!("morph run {} {}", recipe_name, path.display()),
version: env!("CARGO_PKG_VERSION").to_string(),
mode: match mode {
TransformMode::DryRun => "dry-run".to_string(),
TransformMode::Write => "write".to_string(),
},
project_root: project_root.to_path_buf(),
target_path: path.to_path_buf(),
allow_risky,
strict,
};
let mut generator = ReportGenerator::new(execution);
let metadata = recipe.metadata();
let spinner = terminal::spinner(&format!(
"Scanning {} with `{}`",
path.display(),
metadata.name
));
let detect_start = std::time::Instant::now();
let mut report = recipe.detect(path, &spinner)?;
let detect_ms = detect_start.elapsed().as_millis() as u64;
spinner.finish_and_clear();
for analysis in &mut report.analyses {
let is_risky = analysis.classification == FileClassification::Risky;
analysis.tags = crate::core::recipe::compute_tags_for_file(&analysis.path, None, &analysis.detected_patterns, is_risky, false);
}
if let Some(t) = tag {
let original_len = report.analyses.len();
report.analyses.retain(|analysis| analysis.tags.iter().any(|tag_str| tag_str == t));
if verbose {
println!("{} Filtered files by tag '{}': kept {} of {} analyzed files.", terminal::info_prefix(), t, report.analyses.len(), original_len);
}
}
if write && metadata.maturity == RecipeMaturity::Experimental {
let prompt = format!("Recipe `{}` is experimental. Do you want to continue?", metadata.name);
match inquire::Confirm::new(&prompt).with_default(false).prompt() {
Ok(true) => {},
_ => {
terminal::print_info("Migration aborted by user.");
return Ok(());
}
}
}
terminal::print_info(&format!("Recipe: {} [{}]", terminal::label(metadata.name), metadata.maturity));
println!("{}", metadata.description);
println!(
"{}: {}",
terminal::label("extensions"),
metadata.supported_extensions.join(", ")
);
use colored::Colorize;
println!();
println!(" ┌──────────────────────────────────────────────────────────┐");
println!(" │ 📊 {} {:<37} │", "Scan Report:".bold().cyan(), metadata.name.bold());
println!(" ├──────────────────────────────────────────────────────────┤");
println!(" │ 🔍 Total files: {:<6} │ 📂 Parseable: {:<17} │", report.total_files, report.parseable_files);
let failed_val = report.failed_files.len();
let failed_color = if failed_val == 0 {
failed_val.to_string().normal()
} else {
failed_val.to_string().bold().red()
};
println!(" │ ❌ Failed: {:<6} │ ⚙️ Analyzed: {:<17} │", failed_color, report.analyses.len());
let risky_val = report.risky_transforms();
let risky_color = if risky_val == 0 {
risky_val.to_string().normal()
} else {
risky_val.to_string().bold().yellow()
};
println!(" │ 🟢 Safe: {:<6} │ ⚠️ Risky: {:<17} │", report.safe_transforms(), risky_color);
println!(" │ ⚡ Skipped: {:<41} │", report.skipped_files.len());
println!(" │ 🛠️ Mode: {:<41} │", match mode {
TransformMode::DryRun => "dry-run",
TransformMode::Write => "write",
});
if allow_risky {
println!(" │ ⚠️ Allow Risky: enabled │");
}
if strict {
println!(" │ 🚫 Strict Mode: enabled │");
}
println!(" └──────────────────────────────────────────────────────────┘");
if !report.failed_files.is_empty() {
println!();
println!(" {}", "❌ Parse Failures (Grouped by Error):".bold().red());
let mut grouped_failures: std::collections::HashMap<String, Vec<&Path>> = std::collections::HashMap::new();
for f in &report.failed_files {
grouped_failures.entry(f.error.clone()).or_default().push(&f.path);
}
for (err, paths) in grouped_failures {
println!(" └─ {} ({} files):", err.bold().red(), paths.len());
for p in paths.iter().take(5) {
println!(" • {}", p.display());
}
if paths.len() > 5 {
println!(" • ... and {} more files", paths.len() - 5);
}
}
}
for analysis in &report.analyses {
let classification = match analysis.classification {
FileClassification::Safe => terminal::success_prefix(),
FileClassification::Risky => terminal::warning_prefix(),
};
if verbose {
println!(
" {} {} [confidence: {}] [tags: {}] {}",
classification,
analysis.path.display(),
analysis.confidence_score,
analysis.tags.join(", "),
analysis.detected_patterns.join(", ")
);
}
}
for skipped in &report.skipped_files {
if verbose {
println!(" {} {}", terminal::muted_prefix(), terminal::explain_skip(&skipped.display().to_string()));
}
}
let max_lines = max_preview_lines.unwrap_or(100);
let preview_config = PreviewConfig {
max_lines,
show_line_numbers: true,
summary_only,
verbose,
};
let files_to_backup: Vec<PathBuf> = report.analyses.iter().map(|a| a.path.clone()).collect();
let mut backup_session = None;
if write && !files_to_backup.is_empty() {
let backup_manager = BackupManager::new(project_root)?;
println!();
println!("Creating backup session...");
backup_session = Some(backup_manager.create_session(metadata.name, &files_to_backup)?);
println!(
"{} Backup created: session {}",
terminal::success_prefix(),
backup_session
.as_ref()
.map(|s| s.id.as_str())
.unwrap_or("?")
);
}
let transform_start = std::time::Instant::now();
let mut transform_report = recipe.transform(
&report,
TransformOptions {
mode,
review,
autofix,
format: run_session.options.format,
prettier: run_session.options.prettier,
no_format: run_session.options.no_format,
},
)?;
let transform_ms = transform_start.elapsed().as_millis() as u64;
transform_report.populate_confidences(&report);
let modified_files = transform_report.changed_files.clone();
if write && !modified_files.is_empty() {
if let Some(ref config) = config_opt {
run_hook(
config.hooks.after_write.as_deref(),
"after-write",
&session_id,
&[recipe_name.to_string()],
session_mode,
)?;
}
}
let mut diff_report = crate::core::diff::preview::TransformationReport::new();
for changed in &transform_report.changed_files {
diff_report
.changed_files
.push(crate::core::diff::preview::ChangedFile {
path: changed.clone(),
lines_added: 1,
lines_removed: 1,
preview: None,
});
}
for skipped in &transform_report.skipped_files {
diff_report
.skipped_files
.push(crate::core::diff::preview::SkippedFile {
path: skipped.path.clone(),
reason: crate::core::diff::preview::SkipReason::NoChanges,
});
}
diff_report.finish();
let renderer = DiffRenderer::new(preview_config);
if mode == TransformMode::DryRun {
renderer.render_report(&diff_report);
} else {
if let Some(ref mut session) = backup_session {
let backup_manager = BackupManager::new(project_root)?;
if let Err(e) = backup_manager.complete_session(session) {
eprintln!(
"{} Failed to complete backup session: {}",
terminal::warning_prefix(),
e
);
}
}
println!();
println!(" ┌──────────────────────────────────────────────────────────┐");
println!(" │ 🚀 {} {:<32} │", "Transformation Complete:".bold().green(), metadata.name.bold());
println!(" ├──────────────────────────────────────────────────────────┤");
println!(" │ 📝 Changed Files: {:<37} │", transform_report.changed_file_count());
let mut grouped_confidences: std::collections::HashMap<String, Vec<&PathBuf>> = std::collections::HashMap::new();
for path in &transform_report.changed_files {
if let Some(conf) = transform_report.file_confidences.get(path) {
grouped_confidences.entry(conf.to_string()).or_default().push(path);
}
}
for (conf_str, files) in grouped_confidences {
let label = match conf_str.as_str() {
"Safe" | "safe" => "🟢 Safe".green(),
"Moderate" | "moderate" => "🟡 Moderate".yellow(),
_ => "🔴 Risky".red(),
};
println!(" │ ├─ {} ({} files): │", label, files.len());
for f in files.iter().take(5) {
let f_str = f.display().to_string();
let display_len = if f_str.len() > 38 {
format!("...{}", &f_str[f_str.len()-35..])
} else {
f_str
};
println!(" │ │ • {:<38} │", display_len);
}
if files.len() > 5 {
println!(" │ │ • ... and {} more files", files.len() - 5);
}
}
let unsup_len = transform_report.unsupported_patterns.len();
println!(" │ ⚠️ Unsupported patterns: {:<29} │", unsup_len);
if !transform_report.unsupported_patterns.is_empty() {
for u in transform_report.unsupported_patterns.iter().take(5) {
let u_str = u.path.display().to_string();
let display_len = if u_str.len() > 30 {
format!("...{}", &u_str[u_str.len()-27..])
} else {
u_str
};
println!(" │ ├─ {:<30} [{:<8}] │", display_len, u.patterns.join(", "));
}
if transform_report.unsupported_patterns.len() > 5 {
println!(" │ ├─ ... and {} more", transform_report.unsupported_patterns.len() - 5);
}
}
println!(" └──────────────────────────────────────────────────────────┘");
}
if autofix && mode == TransformMode::Write {
for path in &transform_report.changed_files {
let _ = crate::core::ast::cleanup::run_autofix(path);
}
}
let timing = crate::core::report::models::RecipeTiming {
detect_ms,
transform_ms,
total_ms: detect_ms + transform_ms,
};
generator.add_recipe_results(recipe, &report, &transform_report, timing);
if let Some(ref session) = backup_session {
generator.set_rollback_session(
session.id.clone(),
session.timestamp as i64,
session.files.len(),
);
}
let backup_session_id = backup_session.as_ref().map(|session| session.id.clone());
if mode == TransformMode::DryRun {
let snapshot_id = format!("dry-run-{}", chrono::Utc::now().timestamp_millis());
let mut warnings = generator.report().safety.warnings.clone();
for f in &report.failed_files {
warnings.push(format!("Failed to parse {}: {}", f.path.display(), f.error));
}
for u in &transform_report.unsupported_patterns {
warnings.push(format!("Unsupported pattern in {}: {}", u.path.display(), u.patterns.join(", ")));
}
let snapshot = crate::core::dry_run::DryRunSnapshot {
id: snapshot_id.clone(),
timestamp: chrono::Utc::now().timestamp() as u64,
target_path: path.to_path_buf(),
recipes: vec![recipe_name.to_string()],
changed_files_count: transform_report.changed_file_count(),
risky_files_count: report.risky_transforms(),
warnings,
};
let store = crate::core::dry_run::DryRunSnapshotStore::new(project_root);
if let Err(e) = store.save(&snapshot) {
eprintln!("Warning: Failed to save dry-run snapshot: {}", e);
} else {
println!(
"{} Dry-run snapshot saved: {}",
terminal::success_prefix(),
snapshot_id
);
}
}
write_reports(&generator, report_json, report_md, report_dir)?;
save_run_session(
project_root,
run_session,
modified_files,
backup_session_id,
)?;
cache::print_stats();
if let Some(ref config) = config_opt {
run_hook(
config.hooks.after_run.as_deref(),
"after-run",
&session_id,
&[recipe_name.to_string()],
session_mode,
)?;
}
println!(
"{} run completed for `{}`",
terminal::success_prefix(),
metadata.name
);
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn execute_pipeline(
registry: &RecipeRegistry,
recipe_names: &[String],
path: &Path,
mode: TransformMode,
review: bool,
autofix: bool,
verbose: bool,
write: bool,
allow_risky: bool,
strict: bool,
report_json: bool,
report_md: bool,
report_dir: &Path,
project_root: &Path,
run_session: MigrationSession,
max_files_limit: Option<usize>,
max_duration_limit: Option<u64>,
tag: Option<&str>,
) -> Result<()> {
let session_mode = match mode {
TransformMode::DryRun => "dry-run",
TransformMode::Write => "write",
};
let session_id = run_session.id.clone();
let config_opt = if let Ok(content) = std::fs::read_to_string(project_root.join("morph-cli.toml")) {
toml::from_str::<crate::core::config::schema::MorphCliSchema>(&content).ok()
} else {
None
};
if let Some(ref config) = config_opt {
run_hook(
config.hooks.before_run.as_deref(),
"before-run",
&session_id,
recipe_names,
session_mode,
)?;
}
let execution = ExecutionMetadata {
command: format!("morph run {} {}", recipe_names.join(" "), path.display()),
version: env!("CARGO_PKG_VERSION").to_string(),
mode: match mode {
TransformMode::DryRun => "dry-run".to_string(),
TransformMode::Write => "write".to_string(),
},
project_root: project_root.to_path_buf(),
target_path: path.to_path_buf(),
allow_risky,
strict,
};
let mut generator = ReportGenerator::new(execution);
println!();
println!("{}", terminal::label("Pipeline Mode"));
println!("Recipes: {}", recipe_names.join(" → "));
let mut executor = PipelineExecutor::new(project_root.to_path_buf())
.with_session(session_id.clone(), run_session.options.clone());
if let Some(t) = tag {
executor = executor.with_tag(Some(t.to_string()));
}
if let (Some(max_files), Some(max_duration)) = (max_files_limit, max_duration_limit) {
executor = executor.with_limits(max_files, max_duration);
} else if let Ok(content) = std::fs::read_to_string(project_root.join("morph-cli.toml")) {
if let Ok(config) = toml::from_str::<crate::core::config::schema::MorphCliSchema>(&content) {
executor = executor.with_limits(config.max_files, config.max_duration_seconds);
}
}
for name in recipe_names {
let recipe = registry
.find(name)
.with_context(|| format!("Unknown recipe `{}`", name))?;
if write && recipe.metadata().maturity == RecipeMaturity::Experimental {
let prompt = format!("Recipe `{}` is experimental. Do you want to continue?", name);
match inquire::Confirm::new(&prompt).with_default(false).prompt() {
Ok(true) => {},
_ => {
terminal::print_info("Migration aborted by user.");
return Ok(());
}
}
}
executor = executor.add_recipe(name);
}
let summary = executor.execute(path, mode, review, autofix, registry, verbose)?;
let modified_files = summary.modified_files.clone();
if write && !modified_files.is_empty() {
if let Some(ref config) = config_opt {
run_hook(
config.hooks.after_write.as_deref(),
"after-write",
&session_id,
recipe_names,
session_mode,
)?;
}
}
for stage in &summary.stages {
if let (Some(detect), Some(transform)) = (&stage.detect_result, &stage.transform_result) {
if let Some(recipe) = registry.find(stage.recipe_name) {
let timing = crate::core::report::models::RecipeTiming {
detect_ms: stage.timing.detect_ms,
transform_ms: stage.timing.transform_ms,
total_ms: stage.timing.total_ms,
};
generator.add_recipe_results(recipe, detect, transform, timing);
}
}
}
generator.add_pipeline_results(&summary);
if !summary.stages_failed.is_empty() {
eprintln!();
eprintln!(
"{} {} stage(s) failed: {}",
terminal::warning_prefix(),
summary.stages_failed.len(),
summary.stages_failed.join(", ")
);
}
summary.print_summary();
let mut backup_session_id = None;
if summary.total_changed_files > 0 && write {
println!();
println!("Creating backup session...");
let files: Vec<PathBuf> = vec![];
let backup_manager = BackupManager::new(project_root)?;
let mut session = backup_manager.create_session("pipeline", &files)?;
if let Err(e) = backup_manager.complete_session(&mut session) {
eprintln!(
"{} Failed to complete backup session: {}",
terminal::warning_prefix(),
e
);
}
println!(
"{} Backup created: session {}",
terminal::success_prefix(),
session.id
);
generator.set_rollback_session(session.id.clone(), session.timestamp as i64, files.len());
backup_session_id = Some(session.id);
}
if mode == TransformMode::DryRun {
let snapshot_id = format!("dry-run-{}", chrono::Utc::now().timestamp_millis());
let risky_count = summary.stages.iter().map(|s| {
s.transform_result.as_ref().map(|tr| {
tr.file_confidences.values()
.filter(|&c| matches!(c, crate::core::recipe::TransformConfidence::Risky))
.count()
}).unwrap_or(0)
}).sum();
let mut warnings = generator.report().safety.warnings.clone();
for stage in &summary.stages {
if let Some(ref detect) = stage.detect_result {
for f in &detect.failed_files {
warnings.push(format!("Failed to parse {}: {}", f.path.display(), f.error));
}
}
if let Some(ref transform) = stage.transform_result {
for u in &transform.unsupported_patterns {
warnings.push(format!("Unsupported pattern in {}: {}", u.path.display(), u.patterns.join(", ")));
}
}
}
let snapshot = crate::core::dry_run::DryRunSnapshot {
id: snapshot_id.clone(),
timestamp: chrono::Utc::now().timestamp() as u64,
target_path: path.to_path_buf(),
recipes: recipe_names.to_vec(),
changed_files_count: summary.total_changed_files,
risky_files_count: risky_count,
warnings,
};
let store = crate::core::dry_run::DryRunSnapshotStore::new(project_root);
if let Err(e) = store.save(&snapshot) {
eprintln!("Warning: Failed to save dry-run snapshot: {}", e);
} else {
println!(
"{} Dry-run snapshot saved: {}",
terminal::success_prefix(),
snapshot_id
);
}
}
write_reports(&generator, report_json, report_md, report_dir)?;
save_run_session(
project_root,
run_session,
modified_files,
backup_session_id,
)?;
cache::print_stats();
if let Some(ref config) = config_opt {
run_hook(
config.hooks.after_run.as_deref(),
"after-run",
&session_id,
recipe_names,
session_mode,
)?;
}
println!();
println!("{} pipeline completed", terminal::success_prefix());
Ok(())
}
fn save_run_session(
project_root: &Path,
run_session: MigrationSession,
modified_files: Vec<PathBuf>,
backup_session_id: Option<String>,
) -> Result<()> {
let session = run_session.complete(modified_files, backup_session_id);
SessionStore::new(project_root).save(&session)?;
println!(
"{} Session saved: {}",
terminal::success_prefix(),
session.id
);
Ok(())
}
fn write_reports(
generator: &ReportGenerator,
report_json: bool,
report_md: bool,
report_dir: &Path,
) -> Result<()> {
let report_id = generator.report_id();
if report_json {
let json_path = report_dir.join(format!("{}.json", report_id));
generator
.write_json(&json_path)
.with_context(|| format!("Failed to write JSON report to {}", json_path.display()))?;
println!(
"{} JSON report: {}",
terminal::success_prefix(),
json_path.display()
);
}
if report_md {
let md_path = report_dir.join(format!("{}.md", report_id));
generator
.write_markdown(&md_path)
.with_context(|| format!("Failed to write Markdown report to {}", md_path.display()))?;
println!(
"{} Markdown report: {}",
terminal::success_prefix(),
md_path.display()
);
}
Ok(())
}
fn run_hook(
hook_cmd: Option<&str>,
hook_name: &str,
session_id: &str,
recipe_names: &[String],
mode: &str,
) -> Result<()> {
let Some(cmd_str) = hook_cmd else {
return Ok(());
};
if cmd_str.trim().is_empty() {
return Ok(());
}
println!(
"{} Running hook `{}`: {}",
terminal::info_prefix(),
hook_name,
cmd_str
);
#[cfg(target_os = "windows")]
let (shell, arg) = ("cmd", "/C");
#[cfg(not(target_os = "windows"))]
let (shell, arg) = ("sh", "-c");
let recipes_comma = recipe_names.join(",");
let mut cmd = std::process::Command::new(shell);
cmd.arg(arg)
.arg(cmd_str)
.env("MORPH_CLI_SESSION_ID", session_id)
.env("MORPH_CLI_RECIPE_NAMES", &recipes_comma)
.env("MORPH_CLI_EXECUTION_MODE", mode)
.env("SESSION_ID", session_id)
.env("RECIPE_NAMES", &recipes_comma)
.env("EXECUTION_MODE", mode);
let status = cmd
.status()
.with_context(|| format!("Failed to execute hook command: {}", cmd_str))?;
if !status.success() {
bail!(
"Hook `{}` failed with exit status: {:?}",
hook_name,
status.code()
);
}
Ok(())
}
pub(crate) fn find_similar_file(project_root: &Path, missing_path: &Path) -> Option<std::path::PathBuf> {
let missing_filename = missing_path.file_name()?;
let missing_rel = missing_path.strip_prefix(project_root).unwrap_or(missing_path);
let missing_rel_str = missing_rel.to_string_lossy();
let mut best_match = None;
let mut min_distance = usize::MAX;
for entry in walkdir::WalkDir::new(project_root)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
let path = entry.path();
if path.file_name() == Some(missing_filename) {
let candidate_rel = path.strip_prefix(project_root).unwrap_or(path);
let candidate_rel_str = candidate_rel.to_string_lossy();
let dist = levenshtein_distance(&missing_rel_str, &candidate_rel_str);
if dist < min_distance {
min_distance = dist;
best_match = Some(path.to_path_buf());
}
}
}
best_match
}
pub fn resume(checkpoint_id: &str, project_root: &Path) -> Result<()> {
let store = crate::core::session::CheckpointStore::new(project_root);
let mut checkpoint = match store.load(checkpoint_id)? {
Some(cp) => cp,
None => bail!("Checkpoint `{}` not found.", checkpoint_id),
};
println!();
println!("Resuming migration checkpoint: {}", checkpoint_id);
println!("Session: {}", checkpoint.session_id);
println!("Completed Stages: {}", checkpoint.completed_recipes.join(" → "));
if checkpoint.remaining_recipes.is_empty() {
println!("No remaining recipes to run. Migration is already complete!");
return Ok(());
}
println!("Remaining Stages: {}", checkpoint.remaining_recipes.join(" → "));
println!("Target Path: {}", checkpoint.target_path.display());
println!();
if let Ok(Some(recorded_fp)) = crate::core::fingerprint::ProjectFingerprint::load(project_root) {
if let Ok(current_fp) = crate::core::fingerprint::ProjectFingerprint::generate(project_root) {
if let Err(warning) = recorded_fp.compatibility_check(¤t_fp) {
println!(
"{} {} Resuming checkpoint on a significantly different repository state:",
terminal::warning_prefix(),
"UNSAFE RESUME WARNING:".bold().yellow()
);
println!(" {}", warning.yellow());
println!(" {} Resuming on an altered repository layout can cause conflicting AST transforms or duplicate imports.", "Reason:".dimmed());
println!();
}
}
}
let mut missing_files = Vec::new();
let mut renamed_files = Vec::new();
let target_exists = checkpoint.target_path.exists();
if !target_exists {
if let Some(similar) = find_similar_file(project_root, &checkpoint.target_path) {
println!(
"{} {} Original target path '{}' is missing, but a matching path was found at '{}'.",
terminal::warning_prefix(),
"[RENAMED TARGET]".yellow().bold(),
checkpoint.target_path.display(),
similar.display()
);
println!(" └─ {} Automatically adapting target path to the resolved location.", "Action:".dimmed());
checkpoint.target_path = similar;
} else {
println!(
"{} {} Original target path '{}' is completely missing and could not be resolved.",
terminal::warning_prefix(),
"[MISSING TARGET]".red().bold(),
checkpoint.target_path.display()
);
println!(" └─ {} Checkpoint recovery might fail or run on an empty path scope.", "Reason:".dimmed());
}
}
for file in &checkpoint.modified_files {
if !file.exists() {
if let Some(similar) = find_similar_file(project_root, file) {
renamed_files.push((file.clone(), similar));
} else {
missing_files.push(file.clone());
}
}
}
if !missing_files.is_empty() || !renamed_files.is_empty() {
println!(
"{} {} File structure divergence detected since the checkpoint was saved:",
terminal::warning_prefix(),
"WARNING:".bold().yellow()
);
for file in &missing_files {
println!(" {} Original modified file '{}' is missing and could not be resolved.", "[MISSING]".red().bold(), file.display());
}
for (old_file, new_file) in &renamed_files {
let rel_new = new_file.strip_prefix(project_root).unwrap_or(new_file);
println!(" {} Original modified file '{}' is missing, but a matching file was found at '{}'.", "[RENAMED]".yellow().bold(), old_file.display(), rel_new.display());
}
println!(" {} Unresolved missing files will be skipped during this recovery run.", "Reason:".dimmed());
println!();
}
if !checkpoint.modified_files.is_empty() {
println!(
"{} {} Partially Completed Session Checkpoint:",
terminal::warning_prefix(),
"RECOVERY WARNING:".bold().yellow()
);
println!(" Checkpoint contains {} modified files from completed stages.", checkpoint.modified_files.len());
println!(" {} Rerunning or resuming might overlap transforms if the repository state has changed or files were edited manually.", "Reason:".dimmed());
println!();
}
let mut registry = RecipeRegistry::new();
registry.load_plugins(project_root);
for name in &checkpoint.remaining_recipes {
registry.find(name).with_context(|| {
format!(
"Unknown recipe `{}`. Run `morph list` to see available recipes.",
name
)
})?;
}
let mode = if checkpoint.options.write {
TransformMode::Write
} else {
TransformMode::DryRun
};
let session_mode = match mode {
TransformMode::DryRun => "dry-run",
TransformMode::Write => "write",
};
let run_session = MigrationSession::new(
checkpoint.remaining_recipes.clone(),
session_mode,
checkpoint.target_path.clone(),
checkpoint.options.clone(),
);
execute_pipeline(
®istry,
&checkpoint.remaining_recipes,
&checkpoint.target_path,
mode,
checkpoint.options.review,
checkpoint.options.autofix,
true, checkpoint.options.write,
checkpoint.options.allow_risky,
checkpoint.options.strict,
false, false, &project_root.join(".morph-cli/reports"),
project_root,
run_session,
None, None, None, )
}
fn levenshtein_distance(a: &str, b: &str) -> usize {
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = b.chars().collect();
let a_len = a_chars.len();
let b_len = b_chars.len();
let mut dp = vec![vec![0; b_len + 1]; a_len + 1];
for i in 0..=a_len { dp[i][0] = i; }
for j in 0..=b_len { dp[0][j] = j; }
for i in 1..=a_len {
for j in 1..=b_len {
if a_chars[i - 1] == b_chars[j - 1] {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = 1 + dp[i - 1][j].min(dp[i][j - 1]).min(dp[i - 1][j - 1]);
}
}
}
dp[a_len][b_len]
}