1use crate::models::TimeRange;
2use anyhow::Result;
3use std::fs;
4use std::path::{Path, PathBuf};
5use walkdir::WalkDir;
6
7pub struct FileInfo {
9 pub path: PathBuf,
11 pub modified_date: String,
13}
14
15#[derive(Debug)]
17pub(crate) struct FileDiscoveryFailure {
18 pub path: PathBuf,
20 pub error: String,
22}
23
24pub(crate) struct FileDiscovery {
26 pub files: Vec<FileInfo>,
28 pub failures: Vec<FileDiscoveryFailure>,
30}
31
32pub fn collect_files_with_dates<P, F>(
45 dir: P,
46 filter_fn: F,
47 time_range: TimeRange,
48) -> Result<Vec<FileInfo>>
49where
50 P: AsRef<Path>,
51 F: Fn(&Path) -> bool,
52{
53 collect_files_with_max_depth(dir, filter_fn, time_range, None)
54}
55
56pub fn collect_files_with_max_depth<P, F>(
72 dir: P,
73 filter_fn: F,
74 time_range: TimeRange,
75 max_depth: Option<usize>,
76) -> Result<Vec<FileInfo>>
77where
78 P: AsRef<Path>,
79 F: Fn(&Path) -> bool,
80{
81 Ok(collect_files_with_max_depth_diagnostics(dir, filter_fn, time_range, max_depth).files)
82}
83
84pub(crate) fn collect_files_with_max_depth_diagnostics<P, F>(
86 dir: P,
87 filter_fn: F,
88 time_range: TimeRange,
89 max_depth: Option<usize>,
90) -> FileDiscovery
91where
92 P: AsRef<Path>,
93 F: Fn(&Path) -> bool,
94{
95 let dir = dir.as_ref();
96 match fs::metadata(dir) {
97 Ok(_) => {}
98 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
99 return FileDiscovery {
100 files: Vec::new(),
101 failures: Vec::new(),
102 };
103 }
104 Err(error) => {
105 return FileDiscovery {
106 files: Vec::new(),
107 failures: vec![FileDiscoveryFailure {
108 path: dir.to_path_buf(),
109 error: error.to_string(),
110 }],
111 };
112 }
113 }
114
115 let cutoff = time_range
116 .cutoff_date()
117 .map(|d| d.format("%Y-%m-%d").to_string());
118
119 let mut results = Vec::with_capacity(20);
121 let mut failures = Vec::new();
122
123 let mut walker = WalkDir::new(dir);
124 if let Some(depth) = max_depth {
125 walker = walker.max_depth(depth);
126 }
127
128 for entry in walker {
129 let entry = match entry {
130 Ok(entry) => entry,
131 Err(error) => {
132 failures.push(FileDiscoveryFailure {
133 path: error.path().unwrap_or(dir).to_path_buf(),
134 error: error.to_string(),
135 });
136 continue;
137 }
138 };
139 if !entry.file_type().is_file() {
140 continue;
141 }
142
143 let path = entry.path();
144
145 if !filter_fn(path) {
147 continue;
148 }
149
150 let metadata = match entry.metadata() {
152 Ok(metadata) => metadata,
153 Err(error) => {
154 failures.push(FileDiscoveryFailure {
155 path: path.to_path_buf(),
156 error: error.to_string(),
157 });
158 continue;
159 }
160 };
161
162 let modified = match metadata.modified() {
163 Ok(modified) => modified,
164 Err(error) => {
165 failures.push(FileDiscoveryFailure {
166 path: path.to_path_buf(),
167 error: error.to_string(),
168 });
169 continue;
170 }
171 };
172 let datetime: chrono::DateTime<chrono::Local> = modified.into();
173 let date_key = datetime.format("%Y-%m-%d").to_string();
174
175 if let Some(ref cutoff_str) = cutoff
177 && date_key.as_str() < cutoff_str.as_str()
178 {
179 continue;
180 }
181
182 results.push(FileInfo {
183 path: path.to_path_buf(),
184 modified_date: date_key,
185 });
186 }
187
188 failures.sort_by(|left, right| {
189 left.path
190 .cmp(&right.path)
191 .then_with(|| left.error.cmp(&right.error))
192 });
193 FileDiscovery {
194 files: results,
195 failures,
196 }
197}
198
199pub const COPILOT_SESSION_MAX_DEPTH: usize = 2;
209
210pub const GROK_SESSION_MAX_DEPTH: usize = 3;
215
216pub fn is_codex_session_file(path: &Path) -> bool {
230 if is_meta_sidecar_file(path) {
231 return false;
232 }
233 if let Some(ext) = path.extension() {
234 ext == "jsonl" || ext == "json"
235 } else {
236 false
237 }
238}
239
240pub fn is_claude_session_file(path: &Path) -> bool {
247 if is_meta_sidecar_file(path) {
248 return false;
249 }
250 path.extension().is_some_and(|ext| ext == "jsonl")
251}
252
253pub fn is_gemini_session_file(path: &Path) -> bool {
265 if path.extension() != Some(std::ffi::OsStr::new("jsonl")) {
266 false
267 } else {
268 path.parent().is_some_and(|parent| {
269 parent.file_name() == Some(std::ffi::OsStr::new("chats"))
270 || parent.parent().is_some_and(|grandparent| {
271 grandparent.file_name() == Some(std::ffi::OsStr::new("chats"))
272 })
273 })
274 }
275}
276
277pub fn is_copilot_session_file(path: &Path) -> bool {
292 if path.file_name() != Some(std::ffi::OsStr::new("events.jsonl")) {
299 return false;
300 }
301
302 path.parent()
306 .and_then(|p| p.parent())
307 .map(|pp| pp.file_name() == Some(std::ffi::OsStr::new("session-state")))
308 .unwrap_or(false)
309}
310
311pub fn is_grok_session_file(path: &Path) -> bool {
317 if path.file_name() != Some(std::ffi::OsStr::new("signals.json")) {
318 return false;
319 }
320
321 path.parent()
322 .and_then(Path::parent)
323 .and_then(Path::parent)
324 .is_some_and(|sessions| sessions.file_name() == Some(std::ffi::OsStr::new("sessions")))
325}
326
327fn is_meta_sidecar_file(path: &Path) -> bool {
334 path.file_name()
335 .and_then(|n| n.to_str())
336 .is_some_and(|name| name.ends_with(".meta.json") || name.ends_with(".meta.jsonl"))
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use std::fs::{self, File};
343 use std::io::Write;
344 use tempfile::tempdir;
345
346 #[test]
347 fn test_is_codex_session_file_jsonl() {
348 let path = std::path::Path::new("test.jsonl");
350 assert!(is_codex_session_file(path));
351 }
352
353 #[test]
354 fn test_is_codex_session_file_json() {
355 let path = std::path::Path::new("test.json");
357 assert!(is_codex_session_file(path));
358 }
359
360 #[test]
361 fn test_is_codex_session_file_txt() {
362 let path = std::path::Path::new("test.txt");
364 assert!(!is_codex_session_file(path));
365 }
366
367 #[test]
368 fn test_is_codex_session_file_no_extension() {
369 let path = std::path::Path::new("test");
371 assert!(!is_codex_session_file(path));
372 }
373
374 #[test]
375 fn test_is_codex_session_file_uppercase() {
376 let path = std::path::Path::new("test.JSON");
378 assert!(!is_codex_session_file(path)); }
380
381 #[test]
382 fn test_is_gemini_session_file_rejects_legacy_json() {
383 let path = std::path::Path::new("/home/user/.gemini/tmp/hash/chats/chat.json");
386 assert!(!is_gemini_session_file(path));
387 }
388
389 #[test]
390 fn test_is_gemini_session_file_accepts_jsonl() {
391 let path = std::path::Path::new(
393 "/home/user/.gemini/tmp/proj/chats/session-2026-04-23T12-52.jsonl",
394 );
395 assert!(is_gemini_session_file(path));
396 }
397
398 #[test]
399 fn test_is_gemini_session_file_accepts_nested_subagent() {
400 let path =
401 std::path::Path::new("/home/user/.gemini/tmp/proj/chats/parent-session/subagent.jsonl");
402 assert!(is_gemini_session_file(path));
403 }
404
405 #[test]
406 fn test_is_gemini_session_file_rejects_deeper_jsonl() {
407 let path = std::path::Path::new(
408 "/home/user/.gemini/tmp/proj/chats/parent-session/artifacts/data.jsonl",
409 );
410 assert!(!is_gemini_session_file(path));
411 }
412
413 #[test]
414 fn test_is_gemini_session_file_wrong_parent() {
415 let path = std::path::Path::new("/home/user/.gemini/tmp/hash/other/file.json");
417 assert!(!is_gemini_session_file(path));
418 }
419
420 #[test]
421 fn test_is_gemini_session_file_wrong_extension() {
422 let path = std::path::Path::new("/home/user/.gemini/tmp/hash/chats/file.txt");
424 assert!(!is_gemini_session_file(path));
425 }
426
427 #[test]
428 fn test_is_gemini_session_file_no_parent() {
429 let path = std::path::Path::new("file.json");
431 assert!(!is_gemini_session_file(path));
432 }
433
434 #[test]
435 fn test_is_gemini_session_file_excludes_sibling_dirs() {
436 let sibling = std::path::Path::new("/home/user/.gemini/tmp/discordbot/logs.json");
439 assert!(!is_gemini_session_file(sibling));
440
441 let binary = std::path::Path::new("/home/user/.gemini/tmp/bin/rg");
444 assert!(!is_gemini_session_file(binary));
445 }
446
447 #[test]
448 fn test_is_copilot_session_file_accepts_events_jsonl() {
449 let path = std::path::Path::new(
451 "/home/user/.copilot/session-state/d2e098d0-e0d6-4d6b-914b-c4c5543b17e3/events.jsonl",
452 );
453 assert!(is_copilot_session_file(path));
454 }
455
456 #[test]
457 fn test_is_copilot_session_file_rejects_snapshots() {
458 let snapshot_index = std::path::Path::new(
461 "/home/user/.copilot/session-state/d2e098d0/rewind-snapshots/index.json",
462 );
463 assert!(!is_copilot_session_file(snapshot_index));
464
465 let snapshot_backup = std::path::Path::new(
466 "/home/user/.copilot/session-state/d2e098d0/rewind-snapshots/backups/2ee575c19132c8bd-1776949007337",
467 );
468 assert!(!is_copilot_session_file(snapshot_backup));
469
470 let workspace =
471 std::path::Path::new("/home/user/.copilot/session-state/d2e098d0/workspace.yaml");
472 assert!(!is_copilot_session_file(workspace));
473 }
474
475 #[test]
476 fn test_is_copilot_session_file_rejects_nested_events_jsonl() {
477 let nested = std::path::Path::new(
480 "/home/user/.copilot/session-state/d2e098d0/rewind-snapshots/events.jsonl",
481 );
482 assert!(!is_copilot_session_file(nested));
483 }
484
485 #[test]
486 fn test_is_copilot_session_file_rejects_other_files() {
487 let path1 = std::path::Path::new("/tmp/events.jsonl");
490 assert!(!is_copilot_session_file(path1));
491
492 let path2 = std::path::Path::new("/home/user/.copilot/logs.json");
493 assert!(!is_copilot_session_file(path2));
494 }
495
496 #[test]
497 fn test_is_grok_session_file_accepts_exact_layout() {
498 let path =
499 std::path::Path::new("/home/user/.grok/sessions/workspace/session-id/signals.json");
500 assert!(is_grok_session_file(path));
501 }
502
503 #[test]
504 fn test_is_grok_session_file_rejects_siblings_and_nested_files() {
505 let summary =
506 std::path::Path::new("/home/user/.grok/sessions/workspace/session-id/summary.json");
507 let nested = std::path::Path::new(
508 "/home/user/.grok/sessions/workspace/session-id/snapshots/signals.json",
509 );
510 let shallow = std::path::Path::new("/home/user/.grok/sessions/session-id/signals.json");
511
512 assert!(!is_grok_session_file(summary));
513 assert!(!is_grok_session_file(nested));
514 assert!(!is_grok_session_file(shallow));
515 }
516
517 #[test]
518 fn test_grok_max_depth_collects_only_signals_entry_point() {
519 let dir = tempdir().unwrap();
520 let sessions = dir.path().join("sessions");
521 let session = sessions.join("workspace").join("session-id");
522 let nested = session.join("snapshots");
523 fs::create_dir_all(&nested).unwrap();
524 File::create(session.join("signals.json")).unwrap();
525 File::create(session.join("summary.json")).unwrap();
526 File::create(nested.join("signals.json")).unwrap();
527
528 let files = collect_files_with_max_depth(
529 &sessions,
530 is_grok_session_file,
531 TimeRange::All,
532 Some(GROK_SESSION_MAX_DEPTH),
533 )
534 .unwrap();
535
536 assert_eq!(files.len(), 1);
537 assert!(files[0].path.ends_with("workspace/session-id/signals.json"));
538 }
539
540 #[test]
541 fn test_collect_files_with_max_depth_respects_bound() {
542 let dir = tempdir().unwrap();
547 let session = dir.path().join("session-state").join("sess-abc");
548 let backups = session.join("rewind-snapshots").join("backups");
549 fs::create_dir_all(&backups).unwrap();
550
551 File::create(session.join("events.jsonl")).unwrap();
552 File::create(backups.join("deadbeef-123")).unwrap();
553 File::create(backups.join("events.jsonl")).unwrap(); File::create(session.join("workspace.yaml")).unwrap();
555
556 let unbounded = collect_files_with_dates(
558 dir.path().join("session-state"),
559 is_copilot_session_file,
560 TimeRange::All,
561 )
562 .unwrap();
563 let unbounded_names: Vec<&str> = unbounded
564 .iter()
565 .filter_map(|f| f.path.file_name()?.to_str())
566 .collect();
567 assert!(unbounded_names.contains(&"events.jsonl"));
571
572 let bounded = collect_files_with_max_depth(
574 dir.path().join("session-state"),
575 is_copilot_session_file,
576 TimeRange::All,
577 Some(2),
578 )
579 .unwrap();
580 assert_eq!(bounded.len(), 1);
581 assert!(bounded[0].path.ends_with("sess-abc/events.jsonl"));
582 }
583
584 #[test]
585 fn test_collect_files_with_dates_empty_dir() {
586 let dir = tempdir().unwrap();
588
589 let results =
590 collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
591 assert_eq!(results.len(), 0);
592 }
593
594 #[test]
595 fn test_collect_files_with_dates_nonexistent_dir() {
596 let results =
598 collect_files_with_dates("/nonexistent/path", is_codex_session_file, TimeRange::All)
599 .unwrap();
600 assert_eq!(results.len(), 0);
601 }
602
603 #[test]
604 fn test_collect_files_with_dates_with_files() {
605 let dir = tempdir().unwrap();
607
608 File::create(dir.path().join("file1.json")).unwrap();
610 File::create(dir.path().join("file2.jsonl")).unwrap();
611 File::create(dir.path().join("file3.txt")).unwrap(); let results =
614 collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
615 assert_eq!(results.len(), 2);
616
617 for file_info in &results {
619 assert!(!file_info.modified_date.is_empty());
620 assert!(file_info.modified_date.contains('-')); }
622 }
623
624 #[test]
625 fn test_collect_files_with_dates_nested_directories() {
626 let dir = tempdir().unwrap();
628
629 fs::create_dir_all(dir.path().join("subdir1")).unwrap();
631 fs::create_dir_all(dir.path().join("subdir2")).unwrap();
632
633 File::create(dir.path().join("file1.json")).unwrap();
634 File::create(dir.path().join("subdir1/file2.json")).unwrap();
635 File::create(dir.path().join("subdir2/file3.jsonl")).unwrap();
636
637 let results =
638 collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
639 assert_eq!(results.len(), 3);
640 }
641
642 #[test]
643 fn test_collect_files_with_dates_filter_function() {
644 let dir = tempdir().unwrap();
646
647 File::create(dir.path().join("file1.json")).unwrap();
648 File::create(dir.path().join("file2.jsonl")).unwrap();
649 File::create(dir.path().join("file3.txt")).unwrap();
650
651 let results = collect_files_with_dates(
653 dir.path(),
654 |p| p.extension().is_some_and(|e| e == "txt"),
655 TimeRange::All,
656 )
657 .unwrap();
658
659 assert_eq!(results.len(), 1);
660 }
661
662 #[test]
663 fn test_collect_files_with_dates_no_matching_files() {
664 let dir = tempdir().unwrap();
666
667 File::create(dir.path().join("file1.txt")).unwrap();
668 File::create(dir.path().join("file2.md")).unwrap();
669
670 let results =
671 collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
672 assert_eq!(results.len(), 0);
673 }
674
675 #[test]
676 fn test_file_info_path() {
677 let dir = tempdir().unwrap();
679 let file_path = dir.path().join("test.json");
680 File::create(&file_path).unwrap();
681
682 let results =
683 collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
684 assert_eq!(results.len(), 1);
685 assert_eq!(results[0].path, file_path);
686 }
687
688 #[test]
689 fn test_file_info_date_format() {
690 let dir = tempdir().unwrap();
692 File::create(dir.path().join("test.json")).unwrap();
693
694 let results =
695 collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
696 assert_eq!(results.len(), 1);
697
698 let date = &results[0].modified_date;
699 assert_eq!(date.len(), 10); assert_eq!(date.chars().filter(|&c| c == '-').count(), 2); }
702
703 #[test]
704 fn test_collect_files_ignores_directories() {
705 let dir = tempdir().unwrap();
707
708 fs::create_dir(dir.path().join("test.json")).unwrap();
710
711 File::create(dir.path().join("real.json")).unwrap();
713
714 let results =
715 collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
716 assert_eq!(results.len(), 1); }
718
719 #[test]
720 fn test_is_codex_session_file_with_dots_in_name() {
721 let path = std::path::Path::new("my.test.file.json");
723 assert!(is_codex_session_file(path));
724
725 let path2 = std::path::Path::new("my.test.file.jsonl");
726 assert!(is_codex_session_file(path2));
727 }
728
729 #[test]
730 fn test_is_gemini_session_file_multiple_levels() {
731 let path = std::path::Path::new("/a/b/c/d/chats/file.jsonl");
734 assert!(is_gemini_session_file(path));
735 }
736
737 #[test]
738 fn test_collect_files_with_content() {
739 let dir = tempdir().unwrap();
741 let file_path = dir.path().join("test.json");
742
743 let mut file = File::create(&file_path).unwrap();
744 writeln!(file, r#"{{"key": "value"}}"#).unwrap();
745
746 let results =
747 collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
748 assert_eq!(results.len(), 1);
749 assert!(results[0].path.exists());
750 }
751
752 #[test]
753 fn test_is_codex_session_file_excludes_meta_sidecars() {
754 let path = std::path::Path::new("agent-afda1991051a0eb93.meta.json");
758 assert!(!is_codex_session_file(path));
759
760 let nested = std::path::Path::new(
761 "/home/user/.claude/projects/proj/sess/subagents/agent-x.meta.json",
762 );
763 assert!(!is_codex_session_file(nested));
764
765 let meta_jsonl = std::path::Path::new(
768 "/home/user/.claude/projects/proj/sess/subagents/agent-x.meta.jsonl",
769 );
770 assert!(!is_codex_session_file(meta_jsonl));
771 }
772
773 #[test]
774 fn test_is_claude_session_file_accepts_jsonl() {
775 let top_level = std::path::Path::new("/home/user/.claude/projects/proj/sess.jsonl");
777 assert!(is_claude_session_file(top_level));
778
779 let subagent = std::path::Path::new(
781 "/home/user/.claude/projects/proj/sess/subagents/agent-afda1991051a0eb93.jsonl",
782 );
783 assert!(is_claude_session_file(subagent));
784 }
785
786 #[test]
787 fn test_is_claude_session_file_rejects_non_jsonl() {
788 let meta = std::path::Path::new(
790 "/home/user/.claude/projects/proj/sess/subagents/agent-afda1991051a0eb93.meta.json",
791 );
792 assert!(!is_claude_session_file(meta));
793
794 let meta_jsonl = std::path::Path::new(
797 "/home/user/.claude/projects/proj/sess/subagents/agent-afda1991051a0eb93.meta.jsonl",
798 );
799 assert!(!is_claude_session_file(meta_jsonl));
800
801 let plain_json = std::path::Path::new("/home/user/.claude/projects/proj/notes.json");
803 assert!(!is_claude_session_file(plain_json));
804
805 let image = std::path::Path::new("/home/user/.claude/projects/proj/sess/paste.png");
807 assert!(!is_claude_session_file(image));
808 }
809
810 #[test]
811 fn test_collect_claude_session_files_includes_subagents() {
812 let dir = tempdir().unwrap();
819 let project = dir.path().join("-home-user-repo");
820 let session_subdir = project.join("sess-a");
821 let subagents = session_subdir.join("subagents");
822 fs::create_dir_all(&subagents).unwrap();
823
824 File::create(project.join("sess-a.jsonl")).unwrap();
825 File::create(subagents.join("agent-one.jsonl")).unwrap();
826 File::create(subagents.join("agent-one.meta.json")).unwrap();
827 File::create(subagents.join("agent-two.meta.jsonl")).unwrap();
828 File::create(session_subdir.join("screenshot.png")).unwrap();
829
830 let results =
831 collect_files_with_dates(dir.path(), is_claude_session_file, TimeRange::All).unwrap();
832 let names: Vec<String> = results
833 .iter()
834 .filter_map(|f| f.path.file_name()?.to_str().map(String::from))
835 .collect();
836
837 assert_eq!(results.len(), 2, "collected: {:?}", names);
838 assert!(names.contains(&"sess-a.jsonl".to_string()));
839 assert!(names.contains(&"agent-one.jsonl".to_string()));
840 assert!(!names.iter().any(|n| n.contains(".meta.")));
841 assert!(!names.iter().any(|n| n.ends_with(".png")));
842 }
843}