use crate::config::Config;
use crate::logger::*;
use crate::paths::*;
use std::io;
fn read_list(path: &str, home: &str) -> io::Result<(Vec<String>, Vec<String>)> {
use std::io::BufRead;
let mut tracked = Vec::new();
let mut excludes = Vec::new();
if !std::path::Path::new(path).exists() {
return Ok((tracked, excludes));
}
let file = std::fs::File::open(path)?;
let reader = io::BufReader::new(file);
for line in reader.lines() {
let mut line = line?.trim().to_string();
if line.is_empty() || line.starts_with('#') {
continue;
}
let is_exclude = if line.starts_with('!') {
line = line[1..].to_string();
true
} else {
false
};
let expanded = if line.starts_with('/') {
line
} else if line.starts_with('~') {
line.replacen('~', home, 1)
} else {
concat_paths(home, &line)
};
if is_exclude {
excludes.push(expanded);
} else {
tracked.push(expanded);
}
}
Ok((tracked, excludes))
}
fn visit_dirs(dir: &std::path::Path, files: &mut Vec<String>) -> io::Result<()> {
if dir.is_dir() {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
visit_dirs(&path, files)?;
} else if path.is_file() {
files.push(path.to_string_lossy().into_owned());
}
}
}
Ok(())
}
pub fn run_backup(output_path: Option<String>, config: &Config) -> io::Result<()> {
log_section("Starting dotfiles archive backup...");
let backup_file = match output_path {
Some(path) => {
if path.starts_with('~') {
path.replacen('~', &config.home, 1)
} else {
path
}
}
None => {
let now = std::process::Command::new("date")
.arg("+%Y%m%d-%H%M%S")
.output()?;
let date_str = String::from_utf8_lossy(&now.stdout).trim().to_string();
concat_paths(
&config.home,
&format!("dotfiles-backup-{}.tar.gz", date_str),
)
}
};
log_step(&format!("Target archive: {}", backup_file));
let (tracked_list, exclude_list) = read_list(&config.tracked_file, &config.home)?;
let (enc_list, enc_exclude_list) = read_list(&config.tracked_enc_file, &config.home)?;
let mut all_excludes = Vec::new();
all_excludes.extend(exclude_list);
all_excludes.extend(enc_exclude_list);
let mut discovered_files = Vec::new();
let mut discovered_map = Vec::new();
let all_patterns = enc_list.iter().chain(tracked_list.iter());
for pattern in all_patterns {
let expanded = expand_pattern_paths(pattern);
for target in expanded {
let path = std::path::Path::new(&target);
if path.is_dir() {
let mut dir_files = Vec::new();
let _ = visit_dirs(path, &mut dir_files);
for found_path in dir_files {
if !discovered_map.contains(&found_path) {
discovered_map.push(found_path.clone());
discovered_files.push(found_path);
}
}
} else if path.is_file() {
let target_str = target.to_string();
if !discovered_map.contains(&target_str) {
discovered_map.push(target_str.clone());
discovered_files.push(target_str);
}
}
}
}
let mut final_files = Vec::new();
for full_path in discovered_files {
if path_is_excluded(&full_path, &all_excludes) {
continue;
}
final_files.push(full_path);
}
if final_files.is_empty() {
log_warn("No files found to back up. Stalling backup.");
return Ok(());
}
log_step(&format!(
"Archiving {} file(s) relative to HOME...",
final_files.len()
));
let home_prefix = if config.home.ends_with('/') {
config.home.clone()
} else {
config.home.clone() + "/"
};
let mut relative_files = Vec::new();
for f in &final_files {
if f.starts_with(&home_prefix) {
relative_files.push(f[home_prefix.len()..].to_string());
} else {
log_warn(&format!("Skipping file outside home directory: {}", f));
}
}
if relative_files.is_empty() {
log_error("No files located within home directory could be archived.");
return Ok(());
}
let temp_dir = std::env::temp_dir();
let temp_archive_path = temp_dir.join(format!("dotfiles-backup-{}.tar.gz", std::process::id()));
let temp_archive_str = temp_archive_path.to_string_lossy().into_owned();
let mut tar_cmd = std::process::Command::new("tar");
tar_cmd
.arg("-czf")
.arg(&temp_archive_str)
.arg("-C")
.arg(&config.home);
for rf in &relative_files {
tar_cmd.arg(rf);
}
let tar_status = tar_cmd.status()?;
if !tar_status.success() {
log_error("System command 'tar' failed to package dotfiles archive.");
let _ = std::fs::remove_file(&temp_archive_str);
return Err(io::Error::new(io::ErrorKind::Other, "tar command failed"));
}
if !config.age_pass.is_empty() {
let enc_backup_file = if backup_file.ends_with(".enc") {
backup_file.clone()
} else {
backup_file.clone() + ".enc"
};
log_step(&format!(
"Symmetrically encrypting backup using age to: {}",
enc_backup_file
));
if std::process::Command::new("which")
.arg("expect")
.output()?
.status
.success()
{
let expect_cmd = format!(
"spawn age -p -o {} {}; expect \"Enter passphrase\"; send \"{}\\r\"; expect \"Confirm passphrase\"; send \"{}\\r\"; expect eof",
enc_backup_file, temp_archive_str, config.age_pass, config.age_pass
);
let status = std::process::Command::new("expect")
.args(&["-c", &expect_cmd])
.status()?;
if status.success() {
log_done(&format!(
"Backup successfully archived and encrypted at: {}",
enc_backup_file
));
} else {
log_error("Failed to encrypt backup symmetrically with age (expect failed).");
let _ = std::fs::remove_file(&temp_archive_str);
return Err(io::Error::new(
io::ErrorKind::Other,
"age encryption failed",
));
}
} else {
log_warn(
"expect tool is unavailable. Symmetrically encrypting via interactive prompt...",
);
let status = std::process::Command::new("age")
.args(&["-p", "-o", &enc_backup_file, &temp_archive_str])
.status()?;
if status.success() {
log_done(&format!(
"Backup successfully archived and encrypted at: {}",
enc_backup_file
));
} else {
log_error("Failed to encrypt backup symmetrically with age.");
let _ = std::fs::remove_file(&temp_archive_str);
return Err(io::Error::new(
io::ErrorKind::Other,
"age encryption failed",
));
}
}
} else {
std::fs::copy(&temp_archive_str, &backup_file)?;
log_done(&format!(
"Unencrypted backup successfully archived at: {}",
backup_file
));
}
let _ = std::fs::remove_file(&temp_archive_str);
Ok(())
}