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}