use clap::{Arg, ArgAction, Command};
use qbak::{backup_file, dump_config, load_config, QbakError};
use std::path::Path;
use std::process;
fn main() {
let result = run();
match result {
Ok(exit_code) => process::exit(exit_code),
Err(error) => {
eprintln!("Error: {error}");
let suggestions = error.suggestions();
if !suggestions.is_empty() {
eprintln!("\nSuggestions:");
for suggestion in suggestions {
eprintln!(" - {suggestion}");
}
}
if matches!(error, QbakError::Interrupted) {
qbak::signal::cleanup_active_operations();
if let Ok(current_dir) = std::env::current_dir() {
let _ = qbak::backup::cleanup_temp_files(¤t_dir);
}
}
process::exit(error.exit_code());
}
}
}
fn run() -> Result<i32, QbakError> {
let matches = Command::new("qbak")
.version(env!("CARGO_PKG_VERSION"))
.author("Andreas Glaser <andreas.glaser@pm.me>")
.about("A single-command backup helper for Linux and POSIX systems")
.long_about(
"qbak creates timestamped backup copies of files and directories.\n\
Example: qbak example.txt → example-20250603T145231-qbak.txt",
)
.arg(
Arg::new("targets")
.help("Files or directories to back up")
.required(false)
.num_args(1..)
.value_name("TARGET"),
)
.arg(
Arg::new("dry-run")
.short('n')
.long("dry-run")
.help("Show what would be backed up without doing it")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("verbose")
.short('v')
.long("verbose")
.help("Show detailed progress information")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("quiet")
.short('q')
.long("quiet")
.help("Suppress all output except errors")
.action(ArgAction::SetTrue)
.conflicts_with_all(["verbose", "progress"]),
)
.arg(
Arg::new("progress")
.long("progress")
.help("Force progress indication even for small operations")
.action(ArgAction::SetTrue)
.conflicts_with("no-progress"),
)
.arg(
Arg::new("no-progress")
.long("no-progress")
.help("Disable progress indication completely")
.action(ArgAction::SetTrue)
.conflicts_with("progress"),
)
.arg(
Arg::new("dump-config")
.long("dump-config")
.help("Display current configuration settings and exit")
.action(ArgAction::SetTrue),
)
.get_matches();
let dump_config_flag = matches.get_flag("dump-config");
let dry_run = matches.get_flag("dry-run");
let verbose = matches.get_flag("verbose");
let quiet = matches.get_flag("quiet");
let force_progress = matches.get_flag("progress");
let no_progress = matches.get_flag("no-progress");
let mut config = load_config()
.map_err(|e| {
if verbose {
eprintln!("Warning: Could not load config, using defaults: {e}");
}
e
})
.unwrap_or_else(|_| qbak::default_config());
if quiet || no_progress {
config.progress.enabled = false;
} else if force_progress {
config.progress.force_enabled = true;
}
if dump_config_flag {
dump_config(&config)?;
return Ok(0);
}
let targets: Vec<&str> = if let Some(target_values) = matches.get_many::<String>("targets") {
target_values.map(|s| s.as_str()).collect()
} else {
return Err(QbakError::validation(
"No targets specified. Use --help for usage information.",
));
};
setup_signal_handlers();
let mut success_count = 0;
let mut error_count = 0;
for target_str in targets {
let target_path = Path::new(target_str);
match process_target(
target_path,
&config,
dry_run,
verbose,
quiet,
force_progress,
) {
Ok(_) => success_count += 1,
Err(e) => {
error_count += 1;
if e.is_recoverable() {
if !quiet {
eprintln!("Error processing {target_str}: {e}");
let suggestions = e.suggestions();
if !suggestions.is_empty() && verbose {
eprintln!("Suggestions:");
for suggestion in suggestions {
eprintln!(" - {suggestion}");
}
}
}
} else {
return Err(e);
}
}
}
}
if !quiet && (success_count > 1 || error_count > 0) {
println!("Backup summary: {success_count} succeeded, {error_count} failed");
}
if error_count > 0 {
Ok(1) } else {
Ok(0) }
}
fn process_target(
target: &Path,
config: &qbak::Config,
dry_run: bool,
verbose: bool,
quiet: bool,
force_progress: bool,
) -> Result<(), QbakError> {
if dry_run {
let backup_path = qbak::generate_backup_name(target, config)?;
let final_path = qbak::resolve_collision(&backup_path)?;
if target.is_dir() {
let should_show_progress =
config.progress.should_show_progress(0, 0, force_progress) && !quiet;
let (file_count, total_size) = if should_show_progress {
qbak::count_files_and_size_with_progress(target, config)?
} else {
qbak::count_files_and_size(target, config)?
};
let size_str = qbak::utils::format_size(total_size);
println!(
"Would create backup: {} ({} files, {size_str})",
final_path.display(),
file_count
);
} else {
let size = qbak::calculate_size(target)?;
let size_str = qbak::utils::format_size(size);
println!("Would create backup: {} ({size_str})", final_path.display());
}
return Ok(());
}
let result = if target.is_dir() {
qbak::backup_directory_with_progress(target, config, force_progress || verbose, quiet)?
} else {
backup_file(target, config)?
};
if verbose {
println!("Processed: {}", target.display());
println!(" → {}", result.backup_path.display());
let files = result.files_processed;
let size_str = qbak::utils::format_size(result.total_size);
let duration = result.duration.as_secs_f64();
println!(" Files: {files}");
println!(" Size: {size_str}");
println!(" Duration: {duration:.2}s");
} else if !quiet {
let summary = result.summary();
println!("{summary}");
}
Ok(())
}
fn setup_signal_handlers() {
#[cfg(unix)]
{
use std::sync::atomic::Ordering;
let context = qbak::signal::BackupContext::new();
let interrupt_flag = context.interrupt_flag();
ctrlc::set_handler(move || {
interrupt_flag.store(true, Ordering::SeqCst);
eprintln!("\nInterrupted by user.");
})
.expect("Error setting Ctrl-C handler");
qbak::signal::set_global_context(context);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use tempfile::tempdir;
#[test]
fn test_process_target_file() {
let dir = tempdir().unwrap();
let source_path = dir.path().join("test.txt");
File::create(&source_path).unwrap();
let config = qbak::default_config();
let result = process_target(&source_path, &config, false, false, true, false);
assert!(result.is_ok());
}
#[test]
fn test_process_target_dry_run() {
let dir = tempdir().unwrap();
let source_path = dir.path().join("test.txt");
File::create(&source_path).unwrap();
let config = qbak::default_config();
let result = process_target(&source_path, &config, true, false, false, false);
assert!(result.is_ok());
let backup_path = qbak::generate_backup_name(&source_path, &config).unwrap();
assert!(!backup_path.exists());
}
#[test]
fn test_process_target_nonexistent() {
let dir = tempdir().unwrap();
let source_path = dir.path().join("nonexistent.txt");
let config = qbak::default_config();
let result = process_target(&source_path, &config, false, false, true, false);
assert!(result.is_err());
match result.unwrap_err() {
QbakError::SourceNotFound { .. } => (),
_ => panic!("Expected SourceNotFound error"),
}
}
#[test]
fn test_process_target_directory() {
let dir = tempdir().unwrap();
let source_dir = dir.path().join("test_dir");
std::fs::create_dir_all(&source_dir).unwrap();
std::fs::write(source_dir.join("file.txt"), "content").unwrap();
let config = qbak::default_config();
let result = process_target(&source_dir, &config, false, false, true, false);
assert!(result.is_ok());
}
#[test]
fn test_process_target_verbose_mode() {
let dir = tempdir().unwrap();
let source_path = dir.path().join("test.txt");
let mut file = File::create(&source_path).unwrap();
writeln!(file, "Test content").unwrap();
let config = qbak::default_config();
let result = process_target(&source_path, &config, false, true, false, false);
assert!(result.is_ok());
}
#[test]
fn test_process_target_dry_run_directory() {
let dir = tempdir().unwrap();
let source_dir = dir.path().join("test_dir");
std::fs::create_dir_all(&source_dir).unwrap();
std::fs::write(source_dir.join("file.txt"), "content").unwrap();
let config = qbak::default_config();
let result = process_target(&source_dir, &config, true, false, false, false);
assert!(result.is_ok());
let backup_path = qbak::generate_backup_name(&source_dir, &config).unwrap();
assert!(!backup_path.exists());
}
#[test]
fn test_process_target_quiet_mode() {
let dir = tempdir().unwrap();
let source_path = dir.path().join("test.txt");
File::create(&source_path).unwrap();
let config = qbak::default_config();
let result = process_target(&source_path, &config, false, false, true, false);
assert!(result.is_ok());
}
#[test]
fn test_process_target_with_different_config() {
let dir = tempdir().unwrap();
let source_path = dir.path().join("test.txt");
std::fs::write(&source_path, "test content").unwrap();
let mut config = qbak::default_config();
config.backup_suffix = "custom".to_string();
config.preserve_permissions = false;
let result = process_target(&source_path, &config, false, false, true, false);
assert!(result.is_ok());
}
#[test]
fn test_process_target_large_file() {
let dir = tempdir().unwrap();
let source_path = dir.path().join("large.txt");
let content = "x".repeat(50000);
std::fs::write(&source_path, content).unwrap();
let config = qbak::default_config();
let result = process_target(&source_path, &config, false, true, false, false);
assert!(result.is_ok());
}
#[test]
fn test_process_target_empty_file() {
let dir = tempdir().unwrap();
let source_path = dir.path().join("empty.txt");
File::create(&source_path).unwrap();
let config = qbak::default_config();
let result = process_target(&source_path, &config, false, false, false, false);
assert!(result.is_ok());
}
#[test]
fn test_process_target_special_characters_in_path() {
let dir = tempdir().unwrap();
let source_path = dir.path().join("file with spaces.txt");
std::fs::write(&source_path, "content").unwrap();
let config = qbak::default_config();
let result = process_target(&source_path, &config, false, false, true, false);
assert!(result.is_ok());
}
#[test]
fn test_process_target_unicode_filename() {
let dir = tempdir().unwrap();
let source_path = dir.path().join("тест.txt"); std::fs::write(&source_path, "unicode content").unwrap();
let config = qbak::default_config();
let result = process_target(&source_path, &config, false, false, true, false);
assert!(result.is_ok());
}
#[test]
fn test_process_target_no_extension() {
let dir = tempdir().unwrap();
let source_path = dir.path().join("README");
std::fs::write(&source_path, "readme content").unwrap();
let config = qbak::default_config();
let result = process_target(&source_path, &config, false, false, true, false);
assert!(result.is_ok());
}
#[test]
fn test_process_target_multiple_extensions() {
let dir = tempdir().unwrap();
let source_path = dir.path().join("archive.tar.gz");
std::fs::write(&source_path, "archive content").unwrap();
let config = qbak::default_config();
let result = process_target(&source_path, &config, false, false, true, false);
assert!(result.is_ok());
}
#[test]
fn test_process_target_hidden_file() {
let dir = tempdir().unwrap();
let source_path = dir.path().join(".hidden");
std::fs::write(&source_path, "hidden content").unwrap();
let config = qbak::default_config();
let result = process_target(&source_path, &config, false, false, true, false);
assert!(result.is_ok());
}
#[test]
fn test_process_target_dry_run_verbose() {
let dir = tempdir().unwrap();
let source_path = dir.path().join("test.txt");
std::fs::write(&source_path, "content").unwrap();
let config = qbak::default_config();
let result = process_target(&source_path, &config, true, true, false, false);
assert!(result.is_ok());
}
#[test]
fn test_signal_handler_cleanup_integration() {
use qbak::signal::get_active_operations;
let dir = tempdir().unwrap();
let source_path = dir.path().join("test.txt");
std::fs::write(&source_path, "test content").unwrap();
let config = qbak::default_config();
let backup_path = qbak::generate_backup_name(&source_path, &config).unwrap();
let final_backup_path = qbak::resolve_collision(&backup_path).unwrap();
{
let _guard = qbak::signal::create_backup_guard(final_backup_path.clone());
std::fs::copy(&source_path, &final_backup_path).unwrap();
let active_ops = get_active_operations();
assert!(active_ops.contains(&final_backup_path));
assert!(final_backup_path.exists());
qbak::signal::cleanup_active_operations();
assert!(!final_backup_path.exists());
let remaining_ops = get_active_operations();
assert!(remaining_ops.is_empty());
}
assert!(!final_backup_path.exists());
}
#[test]
fn test_multiple_targets_with_interruption_simulation() {
let dir = tempdir().unwrap();
let source1 = dir.path().join("file1.txt");
let source2 = dir.path().join("file2.txt");
std::fs::write(&source1, "content1").unwrap();
std::fs::write(&source2, "content2").unwrap();
let config = qbak::default_config();
let backup1 =
qbak::resolve_collision(&qbak::generate_backup_name(&source1, &config).unwrap())
.unwrap();
let backup2 =
qbak::resolve_collision(&qbak::generate_backup_name(&source2, &config).unwrap())
.unwrap();
{
let _guard1 = qbak::signal::create_backup_guard(backup1.clone());
let _guard2 = qbak::signal::create_backup_guard(backup2.clone());
std::fs::copy(&source1, &backup1).unwrap();
std::fs::copy(&source2, &backup2).unwrap();
assert!(backup1.exists());
assert!(backup2.exists());
qbak::signal::cleanup_active_operations();
assert!(!backup1.exists());
assert!(!backup2.exists());
}
}
}