Skip to main content

apm_core/
worker.rs

1use anyhow::Result;
2use std::path::Path;
3
4#[derive(serde::Deserialize, serde::Serialize)]
5pub struct PidFile {
6    pub ticket_id: String,
7    pub started_at: String,
8}
9
10pub fn read_pid_file(path: &Path) -> Result<(u32, PidFile)> {
11    #[derive(serde::Deserialize)]
12    struct Raw {
13        pid: u32,
14        ticket_id: String,
15        started_at: String,
16    }
17    let content = std::fs::read_to_string(path)?;
18    let raw: Raw = serde_json::from_str(&content)?;
19    Ok((raw.pid, PidFile { ticket_id: raw.ticket_id, started_at: raw.started_at }))
20}
21
22fn state_is_zombie(state: &str) -> bool {
23    state.trim_start().starts_with('Z')
24}
25
26fn process_state(pid: u32) -> Option<String> {
27    let out = std::process::Command::new("ps")
28        .args(["-p", &pid.to_string(), "-o", "state="])
29        .output()
30        .ok()?;
31    if out.status.success() {
32        Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
33    } else {
34        None
35    }
36}
37
38pub fn is_alive(pid: u32) -> bool {
39    let kill_ok = std::process::Command::new("kill")
40        .args(["-0", &pid.to_string()])
41        .output()
42        .map(|o| o.status.success())
43        .unwrap_or(false);
44    if !kill_ok {
45        return false;
46    }
47    match process_state(pid) {
48        Some(s) => !state_is_zombie(&s),
49        None => false,
50    }
51}
52
53pub fn elapsed_since(started_at: &str) -> String {
54    let Ok(started) = chrono::DateTime::parse_from_rfc3339(started_at)
55        .or_else(|_| {
56            chrono::DateTime::parse_from_rfc3339(&started_at.replace('Z', "+00:00"))
57        })
58    else {
59        return "—".to_string();
60    };
61    let now = chrono::Utc::now();
62    let secs = (now.timestamp() - started.timestamp()).max(0) as u64;
63    if secs < 60 {
64        format!("{secs}s")
65    } else if secs < 3600 {
66        format!("{}m", secs / 60)
67    } else {
68        let h = secs / 3600;
69        let m = (secs % 3600) / 60;
70        if m == 0 {
71            format!("{h}h")
72        } else {
73            format!("{h}h {m}m")
74        }
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn state_is_zombie_z_is_zombie() {
84        assert!(state_is_zombie("Z"));
85    }
86
87    #[test]
88    fn state_is_zombie_z_plus_is_zombie() {
89        assert!(state_is_zombie("Z+"));
90    }
91
92    #[test]
93    fn state_is_zombie_z_with_leading_whitespace_is_zombie() {
94        assert!(state_is_zombie("  Z  "));
95    }
96
97    #[test]
98    fn state_is_zombie_s_is_not_zombie() {
99        assert!(!state_is_zombie("S"));
100    }
101
102    #[test]
103    fn state_is_zombie_r_is_not_zombie() {
104        assert!(!state_is_zombie("R"));
105    }
106
107    #[test]
108    fn state_is_zombie_empty_is_not_zombie() {
109        assert!(!state_is_zombie(""));
110    }
111
112    #[test]
113    fn is_alive_returns_true_for_current_process() {
114        assert!(is_alive(std::process::id()));
115    }
116
117    #[test]
118    fn is_alive_returns_false_for_dead_pid() {
119        assert!(!is_alive(99999999));
120    }
121
122    #[test]
123    fn is_alive_returns_false_for_zombie() {
124        use std::process::Command;
125        let mut child = Command::new("true").spawn().expect("spawn true");
126        let pid = child.id();
127        // Give the process time to exit and become a zombie
128        std::thread::sleep(std::time::Duration::from_millis(100));
129        // Parent has not called wait() yet, so the child is now a zombie
130        assert!(!is_alive(pid), "zombie process should not be considered alive");
131        // Reap the zombie
132        child.wait().ok();
133    }
134
135    #[test]
136    fn read_pid_file_parses_json() {
137        let dir = tempfile::tempdir().unwrap();
138        let path = dir.path().join("test.pid");
139        std::fs::write(&path, r#"{"pid":12345,"ticket_id":"0042","started_at":"2026-01-01T00:00Z"}"#).unwrap();
140        let (pid, pf) = read_pid_file(&path).unwrap();
141        assert_eq!(pid, 12345);
142        assert_eq!(pf.ticket_id, "0042");
143    }
144
145    #[test]
146    fn elapsed_since_seconds() {
147        let now = chrono::Utc::now();
148        let started = (now - chrono::Duration::seconds(30))
149            .format("%Y-%m-%dT%H:%M:%S+00:00")
150            .to_string();
151        let s = elapsed_since(&started);
152        assert!(s.ends_with('s'), "expected seconds, got: {s}");
153    }
154
155    #[test]
156    fn elapsed_since_minutes() {
157        let now = chrono::Utc::now();
158        let started = (now - chrono::Duration::minutes(42))
159            .format("%Y-%m-%dT%H:%M:%S+00:00")
160            .to_string();
161        let s = elapsed_since(&started);
162        assert_eq!(s, "42m");
163    }
164
165    #[test]
166    fn elapsed_since_hours() {
167        let now = chrono::Utc::now();
168        let started = (now - chrono::Duration::hours(2) - chrono::Duration::minutes(15))
169            .format("%Y-%m-%dT%H:%M:%S+00:00")
170            .to_string();
171        let s = elapsed_since(&started);
172        assert_eq!(s, "2h 15m");
173    }
174
175    #[test]
176    fn elapsed_since_invalid_returns_dash() {
177        assert_eq!(elapsed_since("not-a-date"), "—");
178    }
179}