use std::path::Path;
use anyhow::Result;
use chrono::{DateTime, Utc};
use colored::Colorize;
use crate::core::backup::manager::BackupManager;
use crate::core::session::{MigrationSession, SessionStore};
use crate::utils::terminal;
pub fn execute(project_root: &Path, args: &SessionsArgs) -> Result<()> {
match args {
SessionsArgs::List => list_sessions(project_root),
SessionsArgs::Show { id } => show_session(project_root, id),
SessionsArgs::Rollback {
session_id,
preview,
force,
} => rollback_session(project_root, session_id, *preview, *force),
SessionsArgs::Replay {
session_id,
write,
} => replay_session(project_root, session_id, *write),
}
}
fn list_sessions(project_root: &Path) -> Result<()> {
let store = SessionStore::new(project_root);
let sessions = store.list()?;
if sessions.is_empty() {
println!("{}", terminal::label("No migration sessions found"));
return Ok(());
}
println!();
println!("{}", terminal::label("Migration Sessions"));
println!("{}", "=".repeat(60));
for session in &sessions {
println!();
println!("{} {}", "Session:".bold(), session.id);
println!(" {} {}", "recipes:".cyan(), session.recipe_names.join(", "));
println!(" {} {}", "mode:".cyan(), session.mode);
println!(" {} {}", "modified files:".cyan(), session.modified_files.len());
let datetime = format_timestamp(session.started_at);
println!(" {} {}", "time:".cyan(), datetime.to_rfc3339());
}
Ok(())
}
fn show_session(project_root: &Path, id: &str) -> Result<()> {
let store = SessionStore::new(project_root);
let Some(session) = store.load(id)? else {
anyhow::bail!("Session not found: {}", id);
};
print_session_detail(&session);
if let Ok(Some(fp)) = crate::core::fingerprint::ProjectFingerprint::load(project_root) {
let dt = DateTime::from_timestamp(fp.timestamp as i64, 0).unwrap();
println!();
println!(" {}", terminal::label("Project Fingerprint at Last Scan:"));
println!(" Frameworks: {}", fp.detected_frameworks.join(", "));
println!(" Workspaces: {}", if fp.workspaces.is_empty() { "none".to_string() } else { fp.workspaces.join(", ") });
println!(" Total Files: {}", fp.file_statistics.total_files);
println!(" Last Scanned: {}", dt.format("%Y-%m-%d %H:%M:%S UTC"));
}
Ok(())
}
fn print_session_detail(session: &MigrationSession) {
println!();
println!("{} {}", "Session:".bold(), session.id);
println!("{}", "=".repeat(60));
println!("{} {}", "recipes:".cyan(), session.recipe_names.join(", "));
println!("{} {}", "mode:".cyan(), session.mode);
println!("{} {}", "target:".cyan(), session.target_path.display());
println!(
"{} {}",
"backup:".cyan(),
session.backup_session_id.as_deref().unwrap_or("none")
);
println!(
"{} {}",
"started:".cyan(),
format_timestamp(session.started_at).to_rfc3339()
);
println!(
"{} {}",
"completed:".cyan(),
format_timestamp(session.completed_at).to_rfc3339()
);
println!("{} {}", "modified files:".cyan(), session.modified_files.len());
for path in &session.modified_files {
println!(" - {}", path.display());
}
}
fn format_timestamp(timestamp: u64) -> DateTime<Utc> {
DateTime::from_timestamp(timestamp as i64, 0)
.unwrap_or_else(|| DateTime::from_timestamp(0, 0).unwrap())
}
fn rollback_session(
project_root: &Path,
session_id: &str,
preview: bool,
_force: bool,
) -> Result<()> {
let store = SessionStore::new(project_root);
let Some(session) = store.load(session_id)? else {
anyhow::bail!("Session not found: {}", session_id);
};
if preview {
println!();
println!(
"{} Rollback preview for session: {}",
"info".bold().cyan(),
session.id
);
print_session_detail(&session);
return Ok(());
}
let backup_session_id = resolve_backup_session_id(project_root, &session)?;
println!("Rolling back session: {}", session.id);
let Some(backup_session_id) = backup_session_id else {
print_rollback_summary(&[], &[], &session.modified_files);
return Ok(());
};
let result = BackupManager::new(project_root)?
.rollback_files(&backup_session_id, &session.modified_files)?;
print_rollback_summary(&result.restored, &result.skipped, &result.missing_backups);
Ok(())
}
fn resolve_backup_session_id(
project_root: &Path,
session: &MigrationSession,
) -> Result<Option<String>> {
if session.mode != "write" {
return Ok(None);
}
if let Some(id) = &session.backup_session_id {
return Ok(Some(id.clone()));
}
let expected_recipe = if session.recipe_names.len() == 1 {
session.recipe_names[0].as_str()
} else {
"pipeline"
};
Ok(BackupManager::new(project_root)?
.list_sessions()?
.into_iter()
.filter(|backup| backup.recipe == expected_recipe)
.filter(|backup| {
backup.timestamp >= session.started_at && backup.timestamp <= session.completed_at
})
.max_by_key(|backup| backup.timestamp)
.map(|backup| backup.id))
}
fn replay_session(project_root: &Path, session_id: &str, write_override: bool) -> Result<()> {
let store = SessionStore::new(project_root);
let Some(session) = store.load(session_id)? else {
anyhow::bail!("Session not found: {}", session_id);
};
println!();
println!("{} Replaying session: {}", terminal::label("info"), session.id);
println!("Original Recipes: {}", session.recipe_names.join(", "));
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!();
println!(
"{} {} Replaying on a significantly different repository state:",
terminal::warning_prefix(),
"UNSAFE REPLAY WARNING:".bold().yellow()
);
println!(" {}", warning.yellow());
println!(" {} Replaying on an altered repository layout can cause conflicting AST transforms or duplicate imports.", "Reason:".dimmed());
println!();
}
}
}
let checkpoint_store = crate::core::session::CheckpointStore::new(project_root);
let checkpoints = checkpoint_store.list().unwrap_or_default();
let has_checkpoint = checkpoints.iter().any(|c| c.session_id == session.id);
let is_partial = session.completed_at == 0 || session.completed_at == session.started_at || has_checkpoint;
if is_partial {
println!(
"{} {} Partially Completed Session Detected:",
terminal::warning_prefix(),
"UNSAFE RECOVERY WARNING:".bold().yellow()
);
println!(" Session was interrupted, cancelled, or failed to complete fully.");
println!(" {} Replaying or recovering from an incomplete pipeline could apply transforms onto unstable intermediate files.", "Reason:".dimmed());
if has_checkpoint {
println!(" {} A checkpoint exists with remaining recipes. Replaying may conflict with current files.", "Hint:".dimmed());
}
println!();
}
let mut missing_files = Vec::new();
let mut renamed_files = Vec::new();
let mut target_path = session.target_path.clone();
let target_exists = target_path.exists();
if !target_exists {
if let Some(similar) = crate::commands::run::find_similar_file(project_root, &target_path) {
println!(
"{} {} Original target path '{}' is missing, but a matching path was found at '{}'.",
terminal::warning_prefix(),
"[RENAMED TARGET]".yellow().bold(),
target_path.display(),
similar.display()
);
println!(" └─ {} Automatically adapting target path to the resolved location.", "Action:".dimmed());
target_path = similar;
} else {
println!(
"{} {} Original target path '{}' is completely missing and could not be resolved.",
terminal::warning_prefix(),
"[MISSING TARGET]".red().bold(),
target_path.display()
);
println!(" └─ {} Replay execution might fail or run on an empty path scope.", "Reason:".dimmed());
}
}
for file in &session.modified_files {
if !file.exists() {
if let Some(similar) = crate::commands::run::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 session run:",
terminal::warning_prefix(),
"WARNING:".bold().yellow()
);
for file in &missing_files {
println!(" {} Original 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 file '{}' is missing, but a matching file was found at '{}'.", "[RENAMED]".yellow().bold(), old_file.display(), rel_new.display());
println!(" └─ {} Verify and target the new file path in your manual execution.", "Hint:".dimmed());
}
println!(" {} Unresolved missing files will be skipped during this replay run.", "Reason:".dimmed());
println!();
}
let options = &session.options;
let write = write_override || options.write;
let dry_run = !write;
crate::commands::run::execute(
&session.recipe_names,
&target_path,
dry_run,
write,
options.review,
options.autofix,
false, false, None, options.allow_risky,
options.strict,
false, false, Path::new(".morph-cli/reports"),
options.format,
options.prettier,
options.no_format,
options.jobs,
options.sequential,
project_root,
None, None, None, None, )
}
fn print_rollback_summary(
restored: &[std::path::PathBuf],
skipped: &[(std::path::PathBuf, String)],
missing_backups: &[std::path::PathBuf],
) {
println!();
println!("{}", terminal::label("Rollback Summary"));
println!(" restored files: {}", restored.len());
println!(" skipped files: {}", skipped.len());
println!(" missing backups: {}", missing_backups.len());
for path in restored {
println!(" {} restored {}", terminal::success_prefix(), path.display());
}
for (path, reason) in skipped {
println!(
" {} skipped {} ({})",
terminal::muted_prefix(),
path.display(),
reason
);
}
for path in missing_backups {
println!(
" {} missing backup {}",
terminal::warning_prefix(),
path.display()
);
}
}
#[derive(Debug)]
pub enum SessionsArgs {
List,
Show { id: String },
Rollback {
session_id: String,
preview: bool,
force: bool,
},
Replay {
session_id: String,
write: bool,
},
}