fp-dotfiles-manager 0.2.5

Minimal, zero-dependency Chezmoi-based dotfiles manager
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...");

    // 1. Resolve output path
    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));

    // 2. Discover files
    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()
    ));

    // Make paths relative to home so they are cleanly unpackable
    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(());
    }

    // 3. Tar standard files
    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"));
    }

    // 4. Optionally encrypt standard file using age passphrase
    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 {
        // Standard unencrypted backup copy
        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(())
}