1use std::collections::HashSet;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use md5::{Digest, Md5};
13
14#[derive(Debug, Default, Clone)]
16pub struct ClaudeStateMarkers {
17 pub reviewed_repo_hashes: HashSet<String>,
19 pub intent_repo_hashes: HashSet<String>,
21 pub blocked_count: usize,
24}
25
26pub fn state_dir() -> Option<PathBuf> {
27 dirs::home_dir().map(|h| h.join(".claude/state"))
28}
29
30pub fn scan() -> ClaudeStateMarkers {
31 let Some(dir) = state_dir() else {
32 return ClaudeStateMarkers::default();
33 };
34 scan_dir(&dir)
35}
36
37pub fn scan_dir(dir: &Path) -> ClaudeStateMarkers {
38 let Ok(read) = fs::read_dir(dir) else {
39 return ClaudeStateMarkers::default();
40 };
41 let mut markers = ClaudeStateMarkers::default();
42 for entry in read.flatten() {
43 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
44 continue;
45 };
46 classify(&name, &mut markers);
47 }
48 markers
49}
50
51fn classify(name: &str, markers: &mut ClaudeStateMarkers) {
52 if let Some(hash) = name.strip_prefix("reviewed-") {
53 if is_hex(hash) {
57 markers.reviewed_repo_hashes.insert(hash.to_string());
58 }
59 return;
60 }
61 if let Some(rest) = name.strip_prefix("intent-active-")
62 && let Some(stem) = rest.strip_suffix(".path")
63 {
64 if let Some((_, tail)) = stem.rsplit_once('-')
68 && is_hex(tail)
69 {
70 markers.intent_repo_hashes.insert(tail.to_string());
71 }
72 return;
73 }
74 if name.starts_with("stop-blocked-") {
75 markers.blocked_count += 1;
76 }
77}
78
79fn is_hex(s: &str) -> bool {
80 !s.is_empty() && s.chars().all(|c| c.is_ascii_hexdigit())
81}
82
83pub fn repo_hash(repo_root: &Path) -> String {
85 let s = repo_root.to_string_lossy();
86 let mut hasher = Md5::new();
87 hasher.update(s.as_bytes());
88 let digest = hasher.finalize();
89 let mut out = String::with_capacity(12);
90 for byte in digest.iter().take(6) {
91 out.push_str(&format!("{byte:02x}"));
92 }
93 out
94}
95
96pub fn find_repo_root(path: &Path) -> Option<PathBuf> {
99 let mut cur = if path.is_absolute() {
100 path.to_path_buf()
101 } else {
102 std::env::current_dir().ok()?.join(path)
103 };
104 loop {
105 if cur.join(".git").exists() {
106 return Some(cur);
107 }
108 cur = cur.parent()?.to_path_buf();
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct CodexJob {
118 pub id: String,
121 pub title: String,
122 pub kind_label: String,
123 pub workspace_root: PathBuf,
125 pub status: String,
126 pub started_at_ms: Option<i64>,
127 pub updated_at_ms: Option<i64>,
131 pub pid: Option<u32>,
134}
135
136impl CodexJob {
137 pub fn repo_name(&self) -> String {
140 self.workspace_root
141 .file_name()
142 .and_then(|n| n.to_str())
143 .unwrap_or("")
144 .to_string()
145 }
146
147 pub fn is_active(&self) -> bool {
152 !matches!(
153 self.status.as_str(),
154 "completed" | "failed" | "cancelled" | "canceled"
155 )
156 }
157}
158
159pub const CODEX_JOB_MAX_AGE_MS: i64 = 3600 * 1000;
164
165#[derive(serde::Deserialize)]
166struct StateFile {
167 jobs: Option<Vec<RawJob>>,
168}
169
170#[derive(serde::Deserialize)]
171struct RawJob {
172 id: String,
173 #[serde(default)]
174 title: String,
175 #[serde(rename = "kindLabel", default)]
176 kind_label: String,
177 #[serde(rename = "workspaceRoot", default)]
178 workspace_root: String,
179 #[serde(default)]
180 status: String,
181 #[serde(rename = "startedAt", default)]
182 started_at: String,
183 #[serde(rename = "updatedAt", default)]
184 updated_at: String,
185 #[serde(default)]
187 pid: Option<u32>,
188}
189
190pub fn codex_jobs() -> Vec<CodexJob> {
194 let Some(home) = dirs::home_dir() else {
195 return Vec::new();
196 };
197 let dir = home.join(".claude/state/codex-companion/state");
198 codex_jobs_in(&dir)
199}
200
201pub fn codex_jobs_in(dir: &Path) -> Vec<CodexJob> {
202 let Ok(read) = fs::read_dir(dir) else {
203 return Vec::new();
204 };
205 let mut jobs: Vec<CodexJob> = Vec::new();
206 for entry in read.flatten() {
207 let ok = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
208 if !ok {
209 continue;
210 }
211 jobs.extend(read_workspace_jobs(&entry.path()));
212 }
213 jobs.retain(CodexJob::is_active);
214 jobs.sort_by_key(|j| std::cmp::Reverse(j.started_at_ms));
215 jobs
216}
217
218fn read_workspace_jobs(workspace_dir: &Path) -> Vec<CodexJob> {
219 let state_path = workspace_dir.join("state.json");
220 let Ok(bytes) = fs::read(&state_path) else {
221 return Vec::new();
222 };
223 let Ok(file): Result<StateFile, _> = serde_json::from_slice(&bytes) else {
224 return Vec::new();
225 };
226 file.jobs
227 .unwrap_or_default()
228 .into_iter()
229 .map(|j| CodexJob {
230 id: j.id,
231 title: j.title,
232 kind_label: j.kind_label,
233 workspace_root: PathBuf::from(j.workspace_root),
234 status: j.status,
235 started_at_ms: parse_iso8601_millis(&j.started_at),
236 updated_at_ms: parse_iso8601_millis(&j.updated_at),
237 pid: j.pid,
238 })
239 .collect()
240}
241
242fn parse_iso8601_millis(s: &str) -> Option<i64> {
246 if s.is_empty() {
247 return None;
248 }
249 let bytes = s.as_bytes();
251 if bytes.len() < 20 {
252 return None;
253 }
254 let year: i64 = s.get(0..4)?.parse().ok()?;
255 let month: u32 = s.get(5..7)?.parse().ok()?;
256 let day: u32 = s.get(8..10)?.parse().ok()?;
257 let hour: i64 = s.get(11..13)?.parse().ok()?;
258 let min: i64 = s.get(14..16)?.parse().ok()?;
259 let sec: i64 = s.get(17..19)?.parse().ok()?;
260 let mut millis: i64 = 0;
261 if let Some(dot) = s.get(19..20)
262 && dot == "."
263 {
264 let frac = s.get(20..23)?;
265 millis = frac.parse().ok()?;
266 }
267 let days = days_from_civil(year, month, day);
268 let epoch_days = days - 719_468; let secs = epoch_days * 86_400 + hour * 3600 + min * 60 + sec;
270 Some(secs * 1000 + millis)
271}
272
273fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
275 let y = if m <= 2 { y - 1 } else { y };
276 let era = y.div_euclid(400);
277 let yoe = (y - era * 400) as u32; let m_adj = if m > 2 { m - 3 } else { m + 9 };
279 let doy = (153 * m_adj + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe as i64
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use std::fs::File;
288 use tempfile::tempdir;
289
290 #[test]
291 fn classify_reviewed_marker() {
292 let mut m = ClaudeStateMarkers::default();
293 classify("reviewed-0cc68405c025", &mut m);
294 assert!(m.reviewed_repo_hashes.contains("0cc68405c025"));
295 }
296
297 #[test]
298 fn classify_reviewed_longer_hash_still_hex() {
299 let mut m = ClaudeStateMarkers::default();
300 classify("reviewed-6e6c4ed60ca111a1ec98145c09ff9d9426b78f54", &mut m);
301 assert_eq!(m.reviewed_repo_hashes.len(), 1);
302 }
303
304 #[test]
305 fn classify_reviewed_rejects_non_hex() {
306 let mut m = ClaudeStateMarkers::default();
307 classify("reviewed-not_a_hash", &mut m);
308 assert!(m.reviewed_repo_hashes.is_empty());
309 }
310
311 #[test]
312 fn classify_intent_active_extracts_trailing_hash() {
313 let mut m = ClaudeStateMarkers::default();
314 classify(
315 "intent-active-flow-test-1779031295-b2c6a07f1836.path",
316 &mut m,
317 );
318 assert!(m.intent_repo_hashes.contains("b2c6a07f1836"));
319 }
320
321 #[test]
322 fn classify_blocked_increments_count() {
323 let mut m = ClaudeStateMarkers::default();
324 classify("stop-blocked-abc123", &mut m);
325 classify("stop-blocked-xyz789", &mut m);
326 assert_eq!(m.blocked_count, 2);
327 }
328
329 #[test]
330 fn classify_ignores_unrelated() {
331 let mut m = ClaudeStateMarkers::default();
332 classify("dirty-some-uuid.log", &mut m);
333 classify("auto-review-disabled", &mut m);
334 assert!(m.reviewed_repo_hashes.is_empty());
335 assert!(m.intent_repo_hashes.is_empty());
336 assert_eq!(m.blocked_count, 0);
337 }
338
339 #[test]
340 fn repo_hash_stable() {
341 let h1 = repo_hash(Path::new("/home/me/dev/tmx"));
342 let h2 = repo_hash(Path::new("/home/me/dev/tmx"));
343 assert_eq!(h1, h2);
344 assert_eq!(h1.len(), 12);
345 assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
346 }
347
348 #[test]
349 fn repo_hash_differs_by_path() {
350 let h1 = repo_hash(Path::new("/home/me/dev/tmx"));
351 let h2 = repo_hash(Path::new("/home/me/dev/nestty"));
352 assert_ne!(h1, h2);
353 }
354
355 #[test]
356 fn scan_dir_collects_all_marker_kinds() {
357 let tmp = tempdir().unwrap();
358 File::create(tmp.path().join("reviewed-deadbeef0123")).unwrap();
359 File::create(tmp.path().join("intent-active-sid-uuid-cafef00d1234.path")).unwrap();
360 File::create(tmp.path().join("stop-blocked-some-session")).unwrap();
361 File::create(tmp.path().join("dirty-some-session.log")).unwrap();
362
363 let markers = scan_dir(tmp.path());
364 assert!(markers.reviewed_repo_hashes.contains("deadbeef0123"));
365 assert!(markers.intent_repo_hashes.contains("cafef00d1234"));
366 assert_eq!(markers.blocked_count, 1);
367 }
368
369 #[test]
370 fn scan_dir_missing_dir_is_empty() {
371 let markers = scan_dir(Path::new("/nonexistent/path/xyz/123"));
372 assert!(markers.reviewed_repo_hashes.is_empty());
373 assert_eq!(markers.blocked_count, 0);
374 }
375
376 #[test]
377 fn find_repo_root_walks_up_to_git_dir() {
378 let tmp = tempdir().unwrap();
379 let nested = tmp.path().join("a/b/c");
380 fs::create_dir_all(&nested).unwrap();
381 fs::create_dir(tmp.path().join(".git")).unwrap();
382 let root = find_repo_root(&nested).unwrap();
383 assert_eq!(
384 root.canonicalize().unwrap(),
385 tmp.path().canonicalize().unwrap()
386 );
387 }
388
389 #[test]
390 fn codex_job_repo_name_uses_workspace_basename() {
391 let j = CodexJob {
392 id: "task-x".into(),
393 title: "Codex Task".into(),
394 kind_label: "rescue".into(),
395 workspace_root: PathBuf::from("/home/me/dev/tmx"),
396 status: "running".into(),
397 started_at_ms: Some(0),
398 updated_at_ms: Some(0),
399 pid: None,
400 };
401 assert_eq!(j.repo_name(), "tmx");
402 }
403
404 #[test]
405 fn codex_job_is_active_excludes_terminal_states() {
406 let base = CodexJob {
407 id: "task-x".into(),
408 title: String::new(),
409 kind_label: String::new(),
410 workspace_root: PathBuf::new(),
411 status: "running".into(),
412 started_at_ms: None,
413 updated_at_ms: None,
414 pid: None,
415 };
416 assert!(base.is_active());
417 for s in ["completed", "failed", "cancelled", "canceled"] {
418 assert!(
419 !CodexJob {
420 status: s.into(),
421 ..base.clone()
422 }
423 .is_active(),
424 "{s} should be terminal"
425 );
426 }
427 }
428
429 #[test]
430 fn codex_jobs_in_filters_to_active_and_parses_state_json() {
431 let tmp = tempdir().unwrap();
432 let ws = tmp.path().join("tmx-abcd1234");
433 fs::create_dir_all(&ws).unwrap();
434 let state = r#"{
435 "version": 1,
436 "jobs": [
437 {"id": "task-running", "title": "Live", "kindLabel": "rescue",
438 "workspaceRoot": "/home/me/dev/tmx", "status": "running",
439 "startedAt": "2026-05-19T01:00:00.000Z",
440 "updatedAt": "2026-05-19T01:01:00.000Z",
441 "pid": 12345},
442 {"id": "task-done", "title": "Old", "kindLabel": "rescue",
443 "workspaceRoot": "/home/me/dev/tmx", "status": "completed",
444 "startedAt": "2026-05-18T01:00:00.000Z",
445 "updatedAt": "2026-05-18T01:00:01.000Z",
446 "pid": null}
447 ]
448 }"#;
449 fs::write(ws.join("state.json"), state).unwrap();
450
451 let jobs = codex_jobs_in(tmp.path());
452 assert_eq!(jobs.len(), 1);
453 assert_eq!(jobs[0].id, "task-running");
454 assert_eq!(jobs[0].status, "running");
455 assert_eq!(jobs[0].pid, Some(12345));
456 assert!(jobs[0].updated_at_ms.is_some());
457 }
458
459 #[test]
460 fn codex_jobs_in_skips_missing_state_json() {
461 let tmp = tempdir().unwrap();
462 fs::create_dir_all(tmp.path().join("empty-workspace")).unwrap();
463 let jobs = codex_jobs_in(tmp.path());
464 assert!(jobs.is_empty());
465 }
466
467 #[test]
468 fn parse_iso8601_known_value() {
469 let ms = parse_iso8601_millis("2026-05-19T01:56:52.454Z").unwrap();
471 let days = days_from_civil(2026, 5, 19) - 719_468;
474 let secs = days * 86_400 + 3600 + 56 * 60 + 52;
475 assert_eq!(ms, secs * 1000 + 454);
476 }
477
478 #[test]
479 fn parse_iso8601_rejects_garbage() {
480 assert!(parse_iso8601_millis("").is_none());
481 assert!(parse_iso8601_millis("not a date").is_none());
482 assert!(parse_iso8601_millis("2026-13-99T99:99:99.999Z").is_some()); }
484}