malvin 0.2.4

Non-interactive research and coding agent
use std::path::{Path, PathBuf};

use super::alloc::{DotfileBackupLabels, allocate_backup_dir, remove_if_exists};

const GITIGNORE_NAME: &str = ".gitignore";

const LABELS: DotfileBackupLabels = DotfileBackupLabels {
    mkdir: "gitignore backup mkdir",
    collision: "gitignore backup mkdir",
    restore: "gitignore restore",
};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitignoreFileBackup {
    pub rel: PathBuf,
    pub bytes: Vec<u8>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitignoreBackup {
    Missing,
    Present {
        backup_root: PathBuf,
        files: Vec<GitignoreFileBackup>,
    },
}

fn walk_gitignore_files(dir: &Path, work_dir: &Path, found: &mut Vec<PathBuf>) {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let Ok(file_type) = entry.file_type() else {
            continue;
        };
        let path = entry.path();
        if file_type.is_dir() {
            if path.file_name().and_then(|n| n.to_str()) == Some(".git") {
                continue;
            }
            walk_gitignore_files(&path, work_dir, found);
        } else if file_type.is_file()
            && path.file_name().and_then(|n| n.to_str()) == Some(GITIGNORE_NAME)
            && let Ok(rel) = path.strip_prefix(work_dir)
        {
            found.push(rel.to_path_buf());
        }
    }
}

fn collect_root_gitignore_only(work_dir: &Path) -> Vec<PathBuf> {
    if work_dir.join(GITIGNORE_NAME).is_file() {
        vec![PathBuf::from(GITIGNORE_NAME)]
    } else {
        vec![]
    }
}

#[must_use]
pub fn collect_workspace_gitignore_relpaths(work_dir: &Path) -> Vec<PathBuf> {
    if crate::git_worktree_toplevel(work_dir).is_some() {
        let mut found = Vec::new();
        walk_gitignore_files(work_dir, work_dir, &mut found);
        found.sort();
        found
    } else {
        collect_root_gitignore_only(work_dir)
    }
}

pub fn backup_workspace_gitignore_if_present(work_dir: &Path) -> Result<GitignoreBackup, String> {
    backup_workspace_gitignore_if_present_with_id(work_dir, super::alloc::random_backup_id)
}

pub fn backup_workspace_gitignore_if_present_with_id(
    work_dir: &Path,
    mut generate_id: impl FnMut(usize) -> String,
) -> Result<GitignoreBackup, String> {
    backup_gitignore_tree(work_dir, &mut generate_id)
}

pub(super) fn backup_gitignore_tree(
    work_dir: &Path,
    generate_id: &mut impl FnMut(usize) -> String,
) -> Result<GitignoreBackup, String> {
    let rels = collect_workspace_gitignore_relpaths(work_dir);
    if rels.is_empty() {
        return Ok(GitignoreBackup::Missing);
    }

    let root = crate::workspace_paths::snapshot_category_dir("gitignore");
    let dest_dir = allocate_backup_dir(&root, generate_id, &LABELS)?;

    let mut files = Vec::with_capacity(rels.len());
    for rel in rels {
        let src = work_dir.join(&rel);
        let bytes = std::fs::read(&src).map_err(|e| format!(".gitignore backup copy: {e}"))?;
        let dest_file = dest_dir.join(&rel);
        if let Some(parent) = dest_file.parent() {
            std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", LABELS.mkdir))?;
        }
        if let Err(e) = std::fs::write(&dest_file, &bytes) {
            let _ = std::fs::remove_dir_all(&dest_dir);
            return Err(format!(".gitignore backup copy: {e}"));
        }
        files.push(GitignoreFileBackup { rel, bytes });
    }

    Ok(GitignoreBackup::Present {
        backup_root: dest_dir,
        files,
    })
}

pub fn restore_workspace_gitignore_backup(
    work_dir: &Path,
    backup: &GitignoreBackup,
) -> Result<(), String> {
    match backup {
        GitignoreBackup::Missing => {
            for rel in collect_workspace_gitignore_relpaths(work_dir) {
                remove_if_exists(&work_dir.join(rel), LABELS.restore)?;
            }
            Ok(())
        }
        GitignoreBackup::Present { files, .. } => {
            let snapshot_rels: std::collections::BTreeSet<_> =
                files.iter().map(|file| &file.rel).collect();
            for file in files {
                let dst = work_dir.join(&file.rel);
                if let Some(parent) = dst.parent() {
                    std::fs::create_dir_all(parent)
                        .map_err(|e| format!("{}: {e}", LABELS.restore))?;
                }
                std::fs::write(&dst, &file.bytes).map_err(|e| format!("gitignore restore: {e}"))?;
            }
            for rel in collect_workspace_gitignore_relpaths(work_dir) {
                if !snapshot_rels.contains(&rel) {
                    remove_if_exists(&work_dir.join(rel), LABELS.restore)?;
                }
            }
            Ok(())
        }
    }
}

#[cfg(test)]
mod kiss_cov_auto {
    use super::*;

    #[test]
    fn kiss_cov_gitignore_backup_types() {
        let _: Option<GitignoreFileBackup> = None;
        let _: Option<GitignoreBackup> = None;
        let _ = collect_workspace_gitignore_relpaths;
        let _ = collect_root_gitignore_only;
    }
}

#[cfg(test)]
#[path = "gitignore_tree_tests.rs"]
pub(crate) mod gitignore_tree_tests;