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
22pub fn is_alive(pid: u32) -> bool {
23    std::process::Command::new("kill")
24        .args(["-0", &pid.to_string()])
25        .output()
26        .map(|o| o.status.success())
27        .unwrap_or(false)
28}
29
30pub fn elapsed_since(started_at: &str) -> String {
31    let Ok(started) = chrono::DateTime::parse_from_rfc3339(started_at)
32        .or_else(|_| {
33            chrono::DateTime::parse_from_rfc3339(&started_at.replace('Z', "+00:00"))
34        })
35    else {
36        return "—".to_string();
37    };
38    let now = chrono::Utc::now();
39    let secs = (now.timestamp() - started.timestamp()).max(0) as u64;
40    if secs < 60 {
41        format!("{secs}s")
42    } else if secs < 3600 {
43        format!("{}m", secs / 60)
44    } else {
45        let h = secs / 3600;
46        let m = (secs % 3600) / 60;
47        if m == 0 {
48            format!("{h}h")
49        } else {
50            format!("{h}h {m}m")
51        }
52    }
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[test]
60    fn is_alive_returns_true_for_current_process() {
61        assert!(is_alive(std::process::id()));
62    }
63
64    #[test]
65    fn is_alive_returns_false_for_dead_pid() {
66        assert!(!is_alive(99999999));
67    }
68
69    #[test]
70    fn read_pid_file_parses_json() {
71        let dir = tempfile::tempdir().unwrap();
72        let path = dir.path().join("test.pid");
73        std::fs::write(&path, r#"{"pid":12345,"ticket_id":"0042","started_at":"2026-01-01T00:00Z"}"#).unwrap();
74        let (pid, pf) = read_pid_file(&path).unwrap();
75        assert_eq!(pid, 12345);
76        assert_eq!(pf.ticket_id, "0042");
77    }
78
79    #[test]
80    fn elapsed_since_seconds() {
81        let now = chrono::Utc::now();
82        let started = (now - chrono::Duration::seconds(30))
83            .format("%Y-%m-%dT%H:%M:%S+00:00")
84            .to_string();
85        let s = elapsed_since(&started);
86        assert!(s.ends_with('s'), "expected seconds, got: {s}");
87    }
88
89    #[test]
90    fn elapsed_since_minutes() {
91        let now = chrono::Utc::now();
92        let started = (now - chrono::Duration::minutes(42))
93            .format("%Y-%m-%dT%H:%M:%S+00:00")
94            .to_string();
95        let s = elapsed_since(&started);
96        assert_eq!(s, "42m");
97    }
98
99    #[test]
100    fn elapsed_since_hours() {
101        let now = chrono::Utc::now();
102        let started = (now - chrono::Duration::hours(2) - chrono::Duration::minutes(15))
103            .format("%Y-%m-%dT%H:%M:%S+00:00")
104            .to_string();
105        let s = elapsed_since(&started);
106        assert_eq!(s, "2h 15m");
107    }
108
109    #[test]
110    fn elapsed_since_invalid_returns_dash() {
111        assert_eq!(elapsed_since("not-a-date"), "—");
112    }
113}