1use std::fs;
17use std::path::PathBuf;
18use std::time::{SystemTime, UNIX_EPOCH};
19
20#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
22pub struct AttentionEntry {
23 pub ts: i64,
24 pub kind: String,
25 pub source: String,
26 pub title: String,
27 pub body: String,
28 pub session_id: String,
29 pub cwd: PathBuf,
30 pub tmux_target: String,
31 pub tmux_session: String,
32}
33
34#[derive(serde::Deserialize)]
35struct Raw {
36 ts: i64,
37 #[serde(default)]
38 kind: String,
39 #[serde(default)]
40 source: String,
41 #[serde(default)]
42 title: String,
43 #[serde(default)]
44 body: String,
45 #[serde(default)]
46 session_id: String,
47 #[serde(default)]
48 cwd: String,
49 #[serde(default)]
50 tmux_target: String,
51 #[serde(default)]
52 tmux_session: String,
53}
54
55pub fn queue_path() -> Option<PathBuf> {
60 let cache = std::env::var("XDG_CACHE_HOME")
61 .ok()
62 .filter(|s| !s.is_empty())
63 .map(PathBuf::from)
64 .or_else(|| dirs::home_dir().map(|h| h.join(".cache")))?;
65 Some(cache.join("claude-attention/queue.jsonl"))
66}
67
68pub fn read_recent(cutoff_secs: i64) -> Vec<AttentionEntry> {
71 let Some(path) = queue_path() else {
72 return Vec::new();
73 };
74 let Ok(bytes) = fs::read(&path) else {
75 return Vec::new();
76 };
77 let now = SystemTime::now()
78 .duration_since(UNIX_EPOCH)
79 .map(|d| d.as_secs() as i64)
80 .unwrap_or(0);
81 let cutoff = now.saturating_sub(cutoff_secs);
82 parse_recent(&bytes, cutoff)
83}
84
85pub fn parse_recent(bytes: &[u8], cutoff: i64) -> Vec<AttentionEntry> {
86 let mut entries: Vec<AttentionEntry> = bytes
87 .split(|b| *b == b'\n')
88 .filter(|line| !line.is_empty())
89 .filter_map(|line| serde_json::from_slice::<Raw>(line).ok())
90 .filter(|r| r.ts >= cutoff)
91 .map(|r| AttentionEntry {
92 ts: r.ts,
93 kind: r.kind,
94 source: r.source,
95 title: r.title,
96 body: r.body,
97 session_id: r.session_id,
98 cwd: PathBuf::from(r.cwd),
99 tmux_target: r.tmux_target,
100 tmux_session: r.tmux_session,
101 })
102 .collect();
103 entries.sort_by_key(|e| std::cmp::Reverse(e.ts));
106 entries
107}
108
109pub fn human_age(ts: i64, now: i64) -> String {
113 let secs = (now - ts).max(0);
114 if secs < 60 {
115 format!("{secs}s")
116 } else if secs < 3600 {
117 format!("{}m", secs / 60)
118 } else if secs < 86_400 {
119 format!("{}h", secs / 3600)
120 } else {
121 format!("{}d", secs / 86_400)
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 fn line(ts: i64, kind: &str, body: &str) -> String {
130 format!(
131 r#"{{"ts":{ts},"kind":"{kind}","source":"claude","title":"t","body":"{body}","session_id":"sid","cwd":"/x","tmux_session":"s","tmux_window_idx":"0","tmux_window_name":"n","tmux_target":"s:0","tmux_socket":"","tmux_bin":"","tmux_client_pid":"","terminal_pid":""}}"#
132 )
133 }
134
135 #[test]
136 fn parse_recent_drops_entries_below_cutoff() {
137 let now: i64 = 10_000;
138 let cutoff = now - 3600;
139 let raw = format!(
140 "{}\n{}\n{}\n",
141 line(now - 60, "notification", "fresh"),
142 line(now - 7200, "stop", "stale"),
143 line(now - 300, "codex-turn", "also-fresh"),
144 );
145 let entries = parse_recent(raw.as_bytes(), cutoff);
146 assert_eq!(entries.len(), 2);
147 assert_eq!(entries[0].body, "fresh");
149 assert_eq!(entries[1].body, "also-fresh");
150 }
151
152 #[test]
153 fn parse_recent_tolerates_malformed_lines() {
154 let raw = b"\
155{\"ts\":100,\"kind\":\"notification\",\"body\":\"ok\"}
156not-json
157{\"ts\":200,\"kind\":\"stop\",\"body\":\"ok2\"}
158"
159 .to_vec();
160 let entries = parse_recent(&raw, 0);
161 assert_eq!(entries.len(), 2);
162 assert_eq!(entries[0].body, "ok2");
163 assert_eq!(entries[1].body, "ok");
164 }
165
166 #[test]
167 fn parse_recent_empty_returns_empty() {
168 assert!(parse_recent(b"", 0).is_empty());
169 assert!(parse_recent(b"\n\n", 0).is_empty());
170 }
171
172 #[test]
173 fn parse_recent_missing_ts_skipped() {
174 let raw = br#"{"kind":"stop","body":"no-ts"}
177{"ts":42,"kind":"stop","body":"ok"}
178"#;
179 let entries = parse_recent(raw, 0);
180 assert_eq!(entries.len(), 1);
181 assert_eq!(entries[0].body, "ok");
182 }
183
184 #[test]
185 fn human_age_buckets() {
186 assert_eq!(human_age(0, 30), "30s");
187 assert_eq!(human_age(0, 120), "2m");
188 assert_eq!(human_age(0, 7200), "2h");
189 assert_eq!(human_age(0, 200_000), "2d");
190 assert_eq!(human_age(100, 0), "0s");
192 }
193
194 #[test]
195 fn queue_path_respects_xdg_cache_home() {
196 let saved = std::env::var("XDG_CACHE_HOME").ok();
197 unsafe {
202 std::env::set_var("XDG_CACHE_HOME", "/tmp/_tmx_test_cache");
203 }
204 let p = queue_path().unwrap();
205 assert!(p.ends_with("claude-attention/queue.jsonl"));
206 assert!(p.starts_with("/tmp/_tmx_test_cache"));
207 unsafe {
209 match saved {
210 Some(v) => std::env::set_var("XDG_CACHE_HOME", v),
211 None => std::env::remove_var("XDG_CACHE_HOME"),
212 }
213 }
214 }
215}