malvin 0.2.4

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

pub(crate) const ACP_SPAWN_CHAMBER_GITIGNORE: &str = "*\n";

pub(super) fn acp_spawn_chamber_dir(work_dir: &Path) -> PathBuf {
    crate::workspace_paths::malvin_acp_spawn_chamber_dir(work_dir)
}

pub(super) fn ensure_acp_spawn_chamber_gitignore(chamber: &Path) -> Result<(), String> {
    let path = chamber.join(".gitignore");
    if path.is_file() {
        return Ok(());
    }
    std::fs::write(&path, ACP_SPAWN_CHAMBER_GITIGNORE).map_err(|e| e.to_string())
}

fn is_acp_spawn_lock_file(path: &Path) -> bool {
    path.extension().and_then(|e| e.to_str()) == Some("lock")
}

fn holder_pid_is_stale(contents: &str) -> bool {
    let Some(holder_pid) = contents.trim().parse::<u32>().ok() else {
        return true;
    };
    #[cfg(unix)]
    {
        !crate::acp::pid_alive(holder_pid)
    }
    #[cfg(not(unix))]
    {
        let _ = holder_pid;
        false
    }
}

fn remove_if_stale_acp_lock(path: &Path) -> Result<bool, String> {
    let Ok(contents) = std::fs::read_to_string(path) else {
        return Ok(false);
    };
    if !holder_pid_is_stale(&contents) {
        return Ok(false);
    }
    std::fs::remove_file(path).map_err(|e| e.to_string())?;
    Ok(true)
}

pub fn sweep_stale_acp_spawn_locks(work_dir: &Path) -> Result<usize, String> {
    let chamber = acp_spawn_chamber_dir(work_dir);
    let Ok(entries) = std::fs::read_dir(&chamber) else {
        return Ok(0);
    };
    let mut removed = 0usize;
    for entry in entries {
        let entry = entry.map_err(|e| e.to_string())?;
        let path = entry.path();
        if !is_acp_spawn_lock_file(&path) {
            continue;
        }
        if remove_if_stale_acp_lock(&path)? {
            removed += 1;
        }
    }
    Ok(removed)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::acp_spawn_lock::{acquire_acp_spawn_lock_for_slot, release_acp_spawn_lock};

    fn write_lock(chamber: &Path, name: &str, contents: &str) {
        std::fs::write(chamber.join(name), contents).expect("write lock");
    }

    #[cfg(unix)]
    #[test]
    fn sweep_stale_acp_spawn_locks_removes_dead_and_invalid() {
        crate::test_utils::with_isolated_home(|work| {
            let chamber = acp_spawn_chamber_dir(work);
            std::fs::create_dir_all(&chamber).expect("mkdir chamber");
            write_lock(&chamber, "dead.lock", "424242");
            write_lock(&chamber, "invalid.lock", "not-a-pid");
            write_lock(&chamber, "live.lock", &std::process::id().to_string());
            let removed = sweep_stale_acp_spawn_locks(work).expect("sweep");
            assert_eq!(removed, 2, "dead and invalid locks removed");
            assert!(!chamber.join("dead.lock").exists());
            assert!(!chamber.join("invalid.lock").exists());
            assert!(chamber.join("live.lock").exists(), "live lock kept");
            let _ = std::fs::remove_file(chamber.join("live.lock"));
        });
    }

    #[test]
    fn sweep_stale_acp_spawn_locks_noop_on_missing_dir() {
        crate::test_utils::with_isolated_home(|work| {
            assert_eq!(sweep_stale_acp_spawn_locks(work).expect("sweep"), 0);
        });
    }

    #[cfg(unix)]
    #[test]
    fn sweep_stale_acp_spawn_locks_keeps_concurrent_live_slots() {
        crate::test_utils::with_isolated_home(|work| {
            let chamber = acp_spawn_chamber_dir(work);
            std::fs::create_dir_all(&chamber).expect("mkdir chamber");
            acquire_acp_spawn_lock_for_slot(work, "alpha").expect("alpha");
            acquire_acp_spawn_lock_for_slot(work, "beta").expect("beta");
            write_lock(&chamber, "stale.lock", "424242");
            let removed = sweep_stale_acp_spawn_locks(work).expect("sweep");
            assert_eq!(removed, 1);
            assert!(chamber.join("alpha.lock").exists());
            assert!(chamber.join("beta.lock").exists());
            release_acp_spawn_lock(work, "alpha");
            release_acp_spawn_lock(work, "beta");
        });
    }
}