apm-core 0.1.15

Core library for APM — a git-native project manager for parallel AI coding agents.
Documentation
use anyhow::Result;
use std::path::Path;

#[derive(serde::Deserialize, serde::Serialize)]
pub struct PidFile {
    pub ticket_id: String,
    pub started_at: String,
}

pub fn read_pid_file(path: &Path) -> Result<(u32, PidFile)> {
    #[derive(serde::Deserialize)]
    struct Raw {
        pid: u32,
        ticket_id: String,
        started_at: String,
    }
    let content = std::fs::read_to_string(path)?;
    let raw: Raw = serde_json::from_str(&content)?;
    Ok((raw.pid, PidFile { ticket_id: raw.ticket_id, started_at: raw.started_at }))
}

fn state_is_zombie(state: &str) -> bool {
    state.trim_start().starts_with('Z')
}

fn process_state(pid: u32) -> Option<String> {
    let out = std::process::Command::new("ps")
        .args(["-p", &pid.to_string(), "-o", "state="])
        .output()
        .ok()?;
    if out.status.success() {
        Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
    } else {
        None
    }
}

pub fn is_alive(pid: u32) -> bool {
    let kill_ok = std::process::Command::new("kill")
        .args(["-0", &pid.to_string()])
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false);
    if !kill_ok {
        return false;
    }
    match process_state(pid) {
        Some(s) => !state_is_zombie(&s),
        None => false,
    }
}

pub fn elapsed_since(started_at: &str) -> String {
    let Ok(started) = chrono::DateTime::parse_from_rfc3339(started_at)
        .or_else(|_| {
            chrono::DateTime::parse_from_rfc3339(&started_at.replace('Z', "+00:00"))
        })
    else {
        return "".to_string();
    };
    let now = chrono::Utc::now();
    let secs = (now.timestamp() - started.timestamp()).max(0) as u64;
    if secs < 60 {
        format!("{secs}s")
    } else if secs < 3600 {
        format!("{}m", secs / 60)
    } else {
        let h = secs / 3600;
        let m = (secs % 3600) / 60;
        if m == 0 {
            format!("{h}h")
        } else {
            format!("{h}h {m}m")
        }
    }
}

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

    #[test]
    fn state_is_zombie_z_is_zombie() {
        assert!(state_is_zombie("Z"));
    }

    #[test]
    fn state_is_zombie_z_plus_is_zombie() {
        assert!(state_is_zombie("Z+"));
    }

    #[test]
    fn state_is_zombie_z_with_leading_whitespace_is_zombie() {
        assert!(state_is_zombie("  Z  "));
    }

    #[test]
    fn state_is_zombie_s_is_not_zombie() {
        assert!(!state_is_zombie("S"));
    }

    #[test]
    fn state_is_zombie_r_is_not_zombie() {
        assert!(!state_is_zombie("R"));
    }

    #[test]
    fn state_is_zombie_empty_is_not_zombie() {
        assert!(!state_is_zombie(""));
    }

    #[test]
    fn is_alive_returns_true_for_current_process() {
        assert!(is_alive(std::process::id()));
    }

    #[test]
    fn is_alive_returns_false_for_dead_pid() {
        assert!(!is_alive(99999999));
    }

    #[test]
    fn is_alive_returns_false_for_zombie() {
        use std::process::Command;
        let mut child = Command::new("true").spawn().expect("spawn true");
        let pid = child.id();
        // Give the process time to exit and become a zombie
        std::thread::sleep(std::time::Duration::from_millis(100));
        // Parent has not called wait() yet, so the child is now a zombie
        assert!(!is_alive(pid), "zombie process should not be considered alive");
        // Reap the zombie
        child.wait().ok();
    }

    #[test]
    fn read_pid_file_parses_json() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("test.pid");
        std::fs::write(&path, r#"{"pid":12345,"ticket_id":"0042","started_at":"2026-01-01T00:00Z"}"#).unwrap();
        let (pid, pf) = read_pid_file(&path).unwrap();
        assert_eq!(pid, 12345);
        assert_eq!(pf.ticket_id, "0042");
    }

    #[test]
    fn elapsed_since_seconds() {
        let now = chrono::Utc::now();
        let started = (now - chrono::Duration::seconds(30))
            .format("%Y-%m-%dT%H:%M:%S+00:00")
            .to_string();
        let s = elapsed_since(&started);
        assert!(s.ends_with('s'), "expected seconds, got: {s}");
    }

    #[test]
    fn elapsed_since_minutes() {
        let now = chrono::Utc::now();
        let started = (now - chrono::Duration::minutes(42))
            .format("%Y-%m-%dT%H:%M:%S+00:00")
            .to_string();
        let s = elapsed_since(&started);
        assert_eq!(s, "42m");
    }

    #[test]
    fn elapsed_since_hours() {
        let now = chrono::Utc::now();
        let started = (now - chrono::Duration::hours(2) - chrono::Duration::minutes(15))
            .format("%Y-%m-%dT%H:%M:%S+00:00")
            .to_string();
        let s = elapsed_since(&started);
        assert_eq!(s, "2h 15m");
    }

    #[test]
    fn elapsed_since_invalid_returns_dash() {
        assert_eq!(elapsed_since("not-a-date"), "");
    }
}