use chrono::Local;
use colored::Colorize;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use std::process::ExitCode;
use linthis::utils::types::LintIssue;
const DEFAULT_MAX_BACKUPS: usize = 5;
#[derive(Debug, Serialize, Deserialize)]
pub struct BackupManifest {
pub timestamp: String,
pub files: Vec<String>,
pub description: String,
}
pub fn get_backup_dir() -> PathBuf {
linthis::utils::get_backup_dir()
}
pub fn create_backup(files: &[PathBuf], description: &str, quiet: bool) -> Option<String> {
if files.is_empty() {
return None;
}
let backup_dir = get_backup_dir();
let timestamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
let backup_path = backup_dir.join(×tamp);
if let Err(e) = fs::create_dir_all(&backup_path) {
eprintln!(
"{}: Failed to create backup directory: {}",
"Warning".yellow(),
e
);
return None;
}
let project_root = linthis::utils::get_project_root();
let mut backed_up_files = Vec::new();
for file in files {
let rel_path = match file.strip_prefix(&project_root) {
Ok(p) => p.to_path_buf(),
Err(_) => file.clone(),
};
let backup_file_path = backup_path.join(&rel_path);
if let Some(parent) = backup_file_path.parent() {
if let Err(e) = fs::create_dir_all(parent) {
eprintln!(
"{}: Failed to create directory {}: {}",
"Warning".yellow(),
parent.display(),
e
);
continue;
}
}
if !file.is_file() {
continue;
}
if rel_path.components().any(|c| c.as_os_str() == ".linthis") {
continue;
}
if let Err(e) = fs::copy(file, &backup_file_path) {
eprintln!(
"{}: Failed to backup {}: {}",
"Warning".yellow(),
file.display(),
e
);
continue;
}
backed_up_files.push(rel_path.to_string_lossy().to_string());
}
if backed_up_files.is_empty() {
let _ = fs::remove_dir_all(&backup_path);
return None;
}
let manifest = BackupManifest {
timestamp: timestamp.clone(),
files: backed_up_files.clone(),
description: description.to_string(),
};
let manifest_path = backup_path.join("manifest.json");
if let Err(e) = fs::write(
&manifest_path,
serde_json::to_string_pretty(&manifest).unwrap_or_default(),
) {
eprintln!(
"{}: Failed to write backup manifest: {}",
"Warning".yellow(),
e
);
}
if !quiet {
println!("{} Backup created: {}", "✓".green(), backup_path.display());
println!(
" {} file{} backed up",
backed_up_files.len(),
if backed_up_files.len() == 1 { "" } else { "s" }
);
}
cleanup_old_backups();
Some(timestamp)
}
pub fn cleanup_old_backups_with_limit(max_backups: usize) {
if max_backups == 0 {
return; }
let keep = max_backups.max(1);
let backup_dir = get_backup_dir();
if !backup_dir.exists() {
return;
}
let mut backups: Vec<_> = match fs::read_dir(&backup_dir) {
Ok(entries) => entries
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.map(|e| e.path())
.collect(),
Err(_) => return,
};
if backups.len() <= keep {
return;
}
backups.sort();
let project_root = linthis::utils::get_project_root();
while backups.len() > keep && backups.len() >= 2 {
let check_idx = backups.len() - 2;
if !backup_has_diff(&backups[check_idx], &project_root) {
let _ = fs::remove_dir_all(&backups[check_idx]);
backups.remove(check_idx);
} else {
break;
}
}
while backups.len() > keep {
let _ = fs::remove_dir_all(&backups[0]);
backups.remove(0);
}
}
pub fn cleanup_old_backups() {
let project_root = linthis::utils::get_project_root();
let max = linthis::config::Config::load_project_config(&project_root)
.map(|c| c.retention.backups)
.unwrap_or(DEFAULT_MAX_BACKUPS);
cleanup_old_backups_with_limit(max);
}
pub fn handle_list_backups(restore_cmd: &str) -> ExitCode {
let backup_dir = get_backup_dir();
if !backup_dir.exists() {
println!("{} No backups found.", "→".cyan());
println!(" Backups are created automatically when running fix/format commands.");
return ExitCode::SUCCESS;
}
let mut backups: Vec<_> = match fs::read_dir(&backup_dir) {
Ok(entries) => entries
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.map(|e| e.path())
.collect(),
Err(e) => {
eprintln!("{}: Failed to read backup directory: {}", "Error".red(), e);
return ExitCode::from(1);
}
};
if backups.is_empty() {
println!("{} No backups found.", "→".cyan());
return ExitCode::SUCCESS;
}
backups.sort();
backups.reverse();
println!("{} Available backups:", "→".cyan());
println!();
for (idx, backup_path) in backups.iter().enumerate() {
let backup_name = backup_path
.file_name()
.unwrap_or_default()
.to_string_lossy();
let manifest_path = backup_path.join("manifest.json");
let (file_count, description) = if manifest_path.exists() {
match fs::read_to_string(&manifest_path) {
Ok(content) => match serde_json::from_str::<BackupManifest>(&content) {
Ok(m) => (m.files.len(), m.description),
Err(_) => (0, String::new()),
},
Err(_) => (0, String::new()),
}
} else {
(0, String::new())
};
let marker = if idx == 0 { "(latest)" } else { "" };
println!(
" {} {} {} - {} file{}",
format!("[{}]", idx + 1).cyan(),
backup_name,
marker.green(),
file_count,
if file_count == 1 { "" } else { "s" }
);
if !description.is_empty() {
println!(" {}", description.dimmed());
}
}
println!();
println!(
"To restore: {} or {} <backup-name>",
format!("{} --undo", restore_cmd).cyan(),
format!("{} --undo", restore_cmd).cyan()
);
ExitCode::SUCCESS
}
fn resolve_backup_path(
backup_dir: &std::path::Path,
source: &str,
list_cmd: &str,
) -> Result<PathBuf, ExitCode> {
if source == "last" {
let mut backups: Vec<_> = match fs::read_dir(backup_dir) {
Ok(entries) => entries
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.map(|e| e.path())
.collect(),
Err(e) => {
eprintln!("{}: Failed to read backup directory: {}", "Error".red(), e);
return Err(ExitCode::from(1));
}
};
if backups.is_empty() {
eprintln!("{}: No backups found.", "Error".red());
return Err(ExitCode::from(1));
}
backups.sort();
Ok(backups.pop().unwrap())
} else {
let path = backup_dir.join(source);
if !path.exists() {
eprintln!("{}: Backup not found: {}", "Error".red(), source);
eprintln!(" Run {} to see available backups.", list_cmd.cyan());
return Err(ExitCode::from(1));
}
Ok(path)
}
}
fn read_backup_manifest(backup_path: &std::path::Path) -> Result<BackupManifest, ExitCode> {
let manifest_path = backup_path.join("manifest.json");
if !manifest_path.exists() {
eprintln!("{}: Backup manifest not found.", "Error".red());
return Err(ExitCode::from(1));
}
let content = fs::read_to_string(&manifest_path).map_err(|e| {
eprintln!("{}: Failed to read manifest: {}", "Error".red(), e);
ExitCode::from(1)
})?;
serde_json::from_str(&content).map_err(|e| {
eprintln!("{}: Failed to parse manifest: {}", "Error".red(), e);
ExitCode::from(1)
})
}
fn restore_backup_files(
manifest: &BackupManifest,
backup_path: &std::path::Path,
project_root: &std::path::Path,
) -> (usize, usize) {
let mut restored_count = 0;
let mut failed_count = 0;
for rel_path in &manifest.files {
let backup_file = backup_path.join(rel_path);
let target_file = project_root.join(rel_path);
if !backup_file.exists() {
eprintln!(" {} Missing backup file: {}", "⚠".yellow(), rel_path);
failed_count += 1;
continue;
}
if let Some(parent) = target_file.parent() {
if let Err(e) = fs::create_dir_all(parent) {
eprintln!(
" {} Failed to create directory for {}: {}",
"✗".red(),
rel_path,
e
);
failed_count += 1;
continue;
}
}
match fs::copy(&backup_file, &target_file) {
Ok(_) => {
println!(" {} Restored: {}", "✓".green(), rel_path);
restored_count += 1;
}
Err(e) => {
eprintln!(" {} Failed to restore {}: {}", "✗".red(), rel_path, e);
failed_count += 1;
}
}
}
(restored_count, failed_count)
}
#[allow(dead_code)]
pub fn handle_undo(source: &str, list_cmd: &str) -> ExitCode {
let backup_dir = get_backup_dir();
if !backup_dir.exists() {
eprintln!("{}: No backups found.", "Error".red());
eprintln!(" Run a fix or format command first to create a backup.");
return ExitCode::from(1);
}
let backup_path = match resolve_backup_path(&backup_dir, source, list_cmd) {
Ok(p) => p,
Err(code) => return code,
};
let backup_name = backup_path
.file_name()
.unwrap_or_default()
.to_string_lossy();
println!("{} Restoring from backup: {}", "→".cyan(), backup_name);
let manifest = match read_backup_manifest(&backup_path) {
Ok(m) => m,
Err(code) => return code,
};
let project_root = linthis::utils::get_project_root();
let (restored_count, failed_count) =
restore_backup_files(&manifest, &backup_path, &project_root);
println!();
if failed_count == 0 {
println!(
"{} Restored {} file{} from backup {}",
"✓".green().bold(),
restored_count,
if restored_count == 1 { "" } else { "s" },
backup_name
);
ExitCode::SUCCESS
} else {
println!(
"{} Restored {} file{}, {} failed",
"⚠".yellow(),
restored_count,
if restored_count == 1 { "" } else { "s" },
failed_count
);
ExitCode::from(1)
}
}
pub fn handle_backup_create(files: &[PathBuf], description: &str) -> ExitCode {
if files.is_empty() {
eprintln!("{}: No files specified.", "Error".red());
return ExitCode::from(1);
}
match create_backup(files, description, false) {
Some(_) => ExitCode::SUCCESS,
None => {
eprintln!("{}: No files were backed up.", "Warning".yellow());
ExitCode::SUCCESS
}
}
}
pub fn handle_backup_show(id: &str) -> ExitCode {
let backup_dir = get_backup_dir();
if !backup_dir.exists() {
println!("No backups found.");
return ExitCode::SUCCESS;
}
let backup_path = match resolve_backup_path(&backup_dir, id, "linthis backup list") {
Ok(p) => p,
Err(code) => return code,
};
let manifest = match read_backup_manifest(&backup_path) {
Ok(m) => m,
Err(code) => return code,
};
println!("{}", "Backup Details".bold());
println!(" Timestamp: {}", manifest.timestamp.cyan());
println!(" Description: {}", manifest.description);
println!(" Files ({}):", manifest.files.len());
for f in &manifest.files {
println!(" {}", f);
}
ExitCode::SUCCESS
}
pub fn handle_undo_filtered(filter: &str) -> ExitCode {
let backup_dir = get_backup_dir();
if !backup_dir.exists() {
eprintln!("{}: No backups found.", "Error".red());
return ExitCode::from(1);
}
let backup_path = if matches!(filter, "format" | "fix" | "hook") {
match find_latest_backup_by_type(&backup_dir, filter) {
Some(p) => p,
None => {
eprintln!(
"{}: No '{}' backup found. Run {} to see available backups.",
"Error".red(),
filter,
"linthis backup list".cyan()
);
return ExitCode::from(1);
}
}
} else {
match resolve_backup_path(&backup_dir, filter, "linthis backup list") {
Ok(p) => p,
Err(code) => return code,
}
};
let manifest = match read_backup_manifest(&backup_path) {
Ok(m) => m,
Err(code) => return code,
};
println!(
"{} Undoing: {} ({})",
"←".cyan().bold(),
manifest.description,
manifest.timestamp
);
let project_root = linthis::utils::get_project_root();
save_redo_state(&manifest, &project_root);
let (restored, failed) = restore_backup_files(&manifest, &backup_path, &project_root);
if failed == 0 {
println!(
"{} Undone: {} file{} restored. Use {} to re-apply.",
"✓".green(),
restored,
if restored == 1 { "" } else { "s" },
"linthis backup redo".cyan()
);
ExitCode::SUCCESS
} else {
println!(
"{} Restored {} file{}, {} failed",
"⚠".yellow(),
restored,
if restored == 1 { "" } else { "s" },
failed
);
ExitCode::from(1)
}
}
pub fn handle_redo() -> ExitCode {
let redo_dir = get_redo_dir();
if !redo_dir.exists() {
eprintln!("{}: Nothing to redo.", "Error".red());
return ExitCode::from(1);
}
let manifest = match read_backup_manifest(&redo_dir) {
Ok(m) => m,
Err(_) => {
eprintln!("{}: No redo state found.", "Error".red());
return ExitCode::from(1);
}
};
println!(
"{} Redoing: {} file{}",
"→".cyan().bold(),
manifest.files.len(),
if manifest.files.len() == 1 { "" } else { "s" }
);
let project_root = linthis::utils::get_project_root();
let (restored, failed) = restore_backup_files(&manifest, &redo_dir, &project_root);
let _ = fs::remove_dir_all(&redo_dir);
if failed == 0 {
println!(
"{} Redone: {} file{} restored",
"✓".green(),
restored,
if restored == 1 { "" } else { "s" }
);
ExitCode::SUCCESS
} else {
println!(
"{} Restored {} file{}, {} failed",
"⚠".yellow(),
restored,
if restored == 1 { "" } else { "s" },
failed
);
ExitCode::from(1)
}
}
fn get_redo_dir() -> PathBuf {
let project_root = linthis::utils::get_project_root();
project_root.join(".linthis").join("redo")
}
fn save_redo_state(manifest: &BackupManifest, project_root: &std::path::Path) {
let redo_dir = get_redo_dir();
let _ = fs::remove_dir_all(&redo_dir);
if fs::create_dir_all(&redo_dir).is_err() {
return;
}
let mut saved_files = Vec::new();
for rel_path in &manifest.files {
let source = project_root.join(rel_path);
if !source.is_file() {
continue;
}
let dest = redo_dir.join(rel_path);
if let Some(parent) = dest.parent() {
let _ = fs::create_dir_all(parent);
}
if fs::copy(&source, &dest).is_ok() {
saved_files.push(rel_path.clone());
}
}
let redo_manifest = BackupManifest {
timestamp: chrono::Local::now().format("%Y%m%d-%H%M%S").to_string(),
files: saved_files,
description: format!("redo state for undo of '{}'", manifest.description),
};
let _ = fs::write(
redo_dir.join("manifest.json"),
serde_json::to_string_pretty(&redo_manifest).unwrap_or_default(),
);
}
fn find_latest_backup_by_type(backup_dir: &std::path::Path, filter: &str) -> Option<PathBuf> {
let mut backups: Vec<PathBuf> = fs::read_dir(backup_dir)
.ok()?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.map(|e| e.path())
.collect();
backups.sort();
backups.reverse();
for backup_path in backups {
let manifest_path = backup_path.join("manifest.json");
if let Ok(content) = fs::read_to_string(&manifest_path) {
if let Ok(manifest) = serde_json::from_str::<BackupManifest>(&content) {
let desc_lower = manifest.description.to_lowercase();
let matches = match filter {
"format" => desc_lower.contains("format"),
"fix" => desc_lower.contains("fix"),
"hook" => desc_lower.contains("hook"),
_ => false,
};
if matches {
return Some(backup_path);
}
}
}
}
None
}
fn backup_has_diff(backup_path: &std::path::Path, project_root: &std::path::Path) -> bool {
let manifest = match read_backup_manifest(backup_path) {
Ok(m) => m,
Err(_) => return false,
};
for rel_path in &manifest.files {
let backup_file = backup_path.join(rel_path);
let current_file = project_root.join(rel_path);
let backup_content = fs::read_to_string(&backup_file).unwrap_or_default();
let current_content = fs::read_to_string(¤t_file).unwrap_or_default();
if backup_content != current_content {
return true;
}
}
false
}
pub fn handle_backup_diff(id: &str) -> ExitCode {
let backup_dir = get_backup_dir();
if !backup_dir.exists() {
eprintln!("{}: No backups found.", "Error".red());
return ExitCode::from(1);
}
let project_root = linthis::utils::get_project_root();
let backup_path = if id == "last" {
match find_latest_backup_with_diff(&backup_dir, &project_root) {
Some(p) => p,
None => {
println!(" {}", "No backups with differences found.".green());
return ExitCode::SUCCESS;
}
}
} else {
match resolve_backup_path(&backup_dir, id, "linthis backup list") {
Ok(p) => p,
Err(code) => return code,
}
};
let manifest = match read_backup_manifest(&backup_path) {
Ok(m) => m,
Err(code) => return code,
};
println!(
"📊 Diff: {} ({})",
manifest.description,
manifest.timestamp.cyan()
);
println!();
let mut has_diff = false;
for rel_path in &manifest.files {
let backup_file = backup_path.join(rel_path);
let current_file = project_root.join(rel_path);
let backup_content = fs::read_to_string(&backup_file).unwrap_or_default();
let current_content = fs::read_to_string(¤t_file).unwrap_or_default();
if backup_content == current_content {
continue;
}
has_diff = true;
print_unified_diff(
&backup_content,
¤t_content,
&format!("a/{} (backup)", rel_path),
&format!("b/{} (current)", rel_path),
);
println!();
}
if !has_diff {
println!(
" {}",
"No differences — current files match backup.".green()
);
}
ExitCode::SUCCESS
}
fn find_latest_backup_with_diff(
backup_dir: &std::path::Path,
project_root: &std::path::Path,
) -> Option<PathBuf> {
let mut backups: Vec<PathBuf> = fs::read_dir(backup_dir)
.ok()?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.map(|e| e.path())
.collect();
if backups.is_empty() {
return None;
}
backups.sort();
for backup_path in backups.iter().rev() {
if backup_has_diff(backup_path, project_root) {
return Some(backup_path.clone());
}
}
None
}
fn print_unified_diff(old_content: &str, new_content: &str, old_label: &str, new_label: &str) {
use similar::{ChangeTag, TextDiff};
let diff = TextDiff::from_lines(old_content, new_content);
let mut has_output = false;
for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
if !has_output {
println!("{}", format!("--- {}", old_label).red());
println!("{}", format!("+++ {}", new_label).green());
has_output = true;
}
println!("{}", format!("{}", hunk.header()).cyan());
for change in hunk.iter_changes() {
match change.tag() {
ChangeTag::Delete => {
print!("{}", format!("-{}", change).red());
}
ChangeTag::Insert => {
print!("{}", format!("+{}", change).green());
}
ChangeTag::Equal => {
print!(" {}", change);
}
}
}
}
}
pub fn handle_backup_command(action: super::commands::BackupCommands) -> ExitCode {
use super::commands::BackupCommands;
match action {
BackupCommands::Create { files, description } => handle_backup_create(&files, &description),
BackupCommands::List => handle_list_backups("linthis backup list"),
BackupCommands::Show { id } => handle_backup_show(&id),
BackupCommands::Diff { id } => handle_backup_diff(&id),
BackupCommands::Undo { filter, list } => {
if list {
handle_list_backups("linthis backup list")
} else {
handle_undo_filtered(&filter)
}
}
BackupCommands::Redo => handle_redo(),
}
}
pub fn collect_files_from_issues(issues: &[LintIssue]) -> Vec<PathBuf> {
let mut files: HashSet<PathBuf> = HashSet::new();
for issue in issues {
files.insert(issue.file_path.clone());
}
files.into_iter().collect()
}