use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct AttentionEntry {
pub ts: i64,
pub kind: String,
pub source: String,
pub title: String,
pub body: String,
pub session_id: String,
pub cwd: PathBuf,
pub tmux_target: String,
pub tmux_session: String,
}
#[derive(serde::Deserialize)]
struct Raw {
ts: i64,
#[serde(default)]
kind: String,
#[serde(default)]
source: String,
#[serde(default)]
title: String,
#[serde(default)]
body: String,
#[serde(default)]
session_id: String,
#[serde(default)]
cwd: String,
#[serde(default)]
tmux_target: String,
#[serde(default)]
tmux_session: String,
}
pub fn queue_path() -> Option<PathBuf> {
let cache = std::env::var("XDG_CACHE_HOME")
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".cache")))?;
Some(cache.join("claude-attention/queue.jsonl"))
}
pub fn read_recent(cutoff_secs: i64) -> Vec<AttentionEntry> {
let Some(path) = queue_path() else {
return Vec::new();
};
let Ok(bytes) = fs::read(&path) else {
return Vec::new();
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let cutoff = now.saturating_sub(cutoff_secs);
parse_recent(&bytes, cutoff)
}
pub fn parse_recent(bytes: &[u8], cutoff: i64) -> Vec<AttentionEntry> {
let mut entries: Vec<AttentionEntry> = bytes
.split(|b| *b == b'\n')
.filter(|line| !line.is_empty())
.filter_map(|line| serde_json::from_slice::<Raw>(line).ok())
.filter(|r| r.ts >= cutoff)
.map(|r| AttentionEntry {
ts: r.ts,
kind: r.kind,
source: r.source,
title: r.title,
body: r.body,
session_id: r.session_id,
cwd: PathBuf::from(r.cwd),
tmux_target: r.tmux_target,
tmux_session: r.tmux_session,
})
.collect();
entries.sort_by_key(|e| std::cmp::Reverse(e.ts));
entries
}
pub fn human_age(ts: i64, now: i64) -> String {
let secs = (now - ts).max(0);
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86_400 {
format!("{}h", secs / 3600)
} else {
format!("{}d", secs / 86_400)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn line(ts: i64, kind: &str, body: &str) -> String {
format!(
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":""}}"#
)
}
#[test]
fn parse_recent_drops_entries_below_cutoff() {
let now: i64 = 10_000;
let cutoff = now - 3600;
let raw = format!(
"{}\n{}\n{}\n",
line(now - 60, "notification", "fresh"),
line(now - 7200, "stop", "stale"),
line(now - 300, "codex-turn", "also-fresh"),
);
let entries = parse_recent(raw.as_bytes(), cutoff);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].body, "fresh");
assert_eq!(entries[1].body, "also-fresh");
}
#[test]
fn parse_recent_tolerates_malformed_lines() {
let raw = b"\
{\"ts\":100,\"kind\":\"notification\",\"body\":\"ok\"}
not-json
{\"ts\":200,\"kind\":\"stop\",\"body\":\"ok2\"}
"
.to_vec();
let entries = parse_recent(&raw, 0);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].body, "ok2");
assert_eq!(entries[1].body, "ok");
}
#[test]
fn parse_recent_empty_returns_empty() {
assert!(parse_recent(b"", 0).is_empty());
assert!(parse_recent(b"\n\n", 0).is_empty());
}
#[test]
fn parse_recent_missing_ts_skipped() {
let raw = br#"{"kind":"stop","body":"no-ts"}
{"ts":42,"kind":"stop","body":"ok"}
"#;
let entries = parse_recent(raw, 0);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].body, "ok");
}
#[test]
fn human_age_buckets() {
assert_eq!(human_age(0, 30), "30s");
assert_eq!(human_age(0, 120), "2m");
assert_eq!(human_age(0, 7200), "2h");
assert_eq!(human_age(0, 200_000), "2d");
assert_eq!(human_age(100, 0), "0s");
}
#[test]
fn queue_path_respects_xdg_cache_home() {
let saved = std::env::var("XDG_CACHE_HOME").ok();
unsafe {
std::env::set_var("XDG_CACHE_HOME", "/tmp/_tmx_test_cache");
}
let p = queue_path().unwrap();
assert!(p.ends_with("claude-attention/queue.jsonl"));
assert!(p.starts_with("/tmp/_tmx_test_cache"));
unsafe {
match saved {
Some(v) => std::env::set_var("XDG_CACHE_HOME", v),
None => std::env::remove_var("XDG_CACHE_HOME"),
}
}
}
}