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 std::thread::sleep(std::time::Duration::from_millis(100));
129 assert!(!is_alive(pid), "zombie process should not be considered alive");
131 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}