1use crate::constants::FastHashSet;
2use crate::models::TimeRange;
3use anyhow::Result;
4use std::ffi::OsStr;
5use std::fs;
6use std::path::{Path, PathBuf};
7use walkdir::WalkDir;
8
9pub struct FileInfo {
11 pub path: PathBuf,
13 pub modified_date: String,
15}
16
17#[derive(Debug)]
19pub(crate) struct FileDiscoveryFailure {
20 pub path: PathBuf,
22 pub error: String,
24}
25
26pub(crate) struct FileDiscovery {
28 pub files: Vec<FileInfo>,
30 pub failures: Vec<FileDiscoveryFailure>,
32}
33
34pub fn collect_files_with_dates<P, F>(
47 dir: P,
48 filter_fn: F,
49 time_range: TimeRange,
50) -> Result<Vec<FileInfo>>
51where
52 P: AsRef<Path>,
53 F: Fn(&Path) -> bool,
54{
55 collect_files_with_max_depth(dir, filter_fn, time_range, None)
56}
57
58pub fn collect_files_with_max_depth<P, F>(
74 dir: P,
75 filter_fn: F,
76 time_range: TimeRange,
77 max_depth: Option<usize>,
78) -> Result<Vec<FileInfo>>
79where
80 P: AsRef<Path>,
81 F: Fn(&Path) -> bool,
82{
83 Ok(collect_files_with_max_depth_diagnostics(dir, filter_fn, time_range, max_depth).files)
84}
85
86pub(crate) fn collect_files_with_max_depth_diagnostics<P, F>(
88 dir: P,
89 filter_fn: F,
90 time_range: TimeRange,
91 max_depth: Option<usize>,
92) -> FileDiscovery
93where
94 P: AsRef<Path>,
95 F: Fn(&Path) -> bool,
96{
97 let dir = dir.as_ref();
98 match fs::metadata(dir) {
99 Ok(_) => {}
100 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
101 return FileDiscovery {
102 files: Vec::new(),
103 failures: Vec::new(),
104 };
105 }
106 Err(error) => {
107 return FileDiscovery {
108 files: Vec::new(),
109 failures: vec![FileDiscoveryFailure {
110 path: dir.to_path_buf(),
111 error: error.to_string(),
112 }],
113 };
114 }
115 }
116
117 let cutoff = time_range
118 .cutoff_date()
119 .map(|d| d.format("%Y-%m-%d").to_string());
120
121 let mut results = Vec::with_capacity(20);
123 let mut failures = Vec::new();
124
125 let mut walker = WalkDir::new(dir);
126 if let Some(depth) = max_depth {
127 walker = walker.max_depth(depth);
128 }
129
130 for entry in walker {
131 let entry = match entry {
132 Ok(entry) => entry,
133 Err(error) => {
134 failures.push(FileDiscoveryFailure {
135 path: error.path().unwrap_or(dir).to_path_buf(),
136 error: error.to_string(),
137 });
138 continue;
139 }
140 };
141 if !entry.file_type().is_file() {
142 continue;
143 }
144
145 let path = entry.path();
146
147 if !filter_fn(path) {
149 continue;
150 }
151
152 let metadata = match entry.metadata() {
154 Ok(metadata) => metadata,
155 Err(error) => {
156 failures.push(FileDiscoveryFailure {
157 path: path.to_path_buf(),
158 error: error.to_string(),
159 });
160 continue;
161 }
162 };
163
164 let modified = match metadata.modified() {
165 Ok(modified) => modified,
166 Err(error) => {
167 failures.push(FileDiscoveryFailure {
168 path: path.to_path_buf(),
169 error: error.to_string(),
170 });
171 continue;
172 }
173 };
174 let datetime: chrono::DateTime<chrono::Local> = modified.into();
175 let date_key = datetime.format("%Y-%m-%d").to_string();
176
177 if let Some(ref cutoff_str) = cutoff
179 && date_key.as_str() < cutoff_str.as_str()
180 {
181 continue;
182 }
183
184 results.push(FileInfo {
185 path: path.to_path_buf(),
186 modified_date: date_key,
187 });
188 }
189
190 failures.sort_by(|left, right| {
191 left.path
192 .cmp(&right.path)
193 .then_with(|| left.error.cmp(&right.error))
194 });
195 FileDiscovery {
196 files: results,
197 failures,
198 }
199}
200
201pub(crate) fn collect_provider_files_diagnostics<F>(
219 dirs: &[&Path],
220 filter_fn: F,
221 time_range: TimeRange,
222 max_depth: Option<usize>,
223) -> FileDiscovery
224where
225 F: Fn(&Path) -> bool,
226{
227 let Some((first, rest)) = dirs.split_first() else {
228 return FileDiscovery {
229 files: Vec::new(),
230 failures: Vec::new(),
231 };
232 };
233 let mut discovery =
234 collect_files_with_max_depth_diagnostics(first, &filter_fn, time_range, max_depth);
235 if rest.is_empty() {
236 return discovery;
237 }
238
239 for dir in rest {
240 let mut found =
241 collect_files_with_max_depth_diagnostics(dir, &filter_fn, time_range, max_depth);
242 discovery.failures.append(&mut found.failures);
243 if found.files.is_empty() {
246 continue;
247 }
248 {
250 let seen: FastHashSet<&OsStr> = discovery
251 .files
252 .iter()
253 .filter_map(|file| file.path.file_name())
254 .collect();
255 found.files.retain(|file| {
256 let duplicate = file
257 .path
258 .file_name()
259 .is_some_and(|name| seen.contains(name));
260 if duplicate {
261 log::debug!(
262 "skipping {}: already discovered under an earlier session root",
263 file.path.display()
264 );
265 }
266 !duplicate
267 });
268 }
269 discovery.files.append(&mut found.files);
270 }
271
272 discovery.failures.sort_by(|left, right| {
273 left.path
274 .cmp(&right.path)
275 .then_with(|| left.error.cmp(&right.error))
276 });
277 discovery
278}
279
280pub const COPILOT_SESSION_MAX_DEPTH: usize = 2;
290
291pub const GROK_SESSION_MAX_DEPTH: usize = 3;
296
297pub fn is_codex_session_file(path: &Path) -> bool {
311 if is_meta_sidecar_file(path) {
312 return false;
313 }
314 if let Some(ext) = path.extension() {
315 ext == "jsonl" || ext == "json"
316 } else {
317 false
318 }
319}
320
321pub fn is_claude_session_file(path: &Path) -> bool {
328 if is_meta_sidecar_file(path) {
329 return false;
330 }
331 path.extension().is_some_and(|ext| ext == "jsonl")
332}
333
334pub fn is_gemini_session_file(path: &Path) -> bool {
346 if path.extension() != Some(std::ffi::OsStr::new("jsonl")) {
347 false
348 } else {
349 path.parent().is_some_and(|parent| {
350 parent.file_name() == Some(std::ffi::OsStr::new("chats"))
351 || parent.parent().is_some_and(|grandparent| {
352 grandparent.file_name() == Some(std::ffi::OsStr::new("chats"))
353 })
354 })
355 }
356}
357
358pub fn is_copilot_session_file(path: &Path) -> bool {
373 if path.file_name() != Some(std::ffi::OsStr::new("events.jsonl")) {
380 return false;
381 }
382
383 path.parent()
387 .and_then(|p| p.parent())
388 .map(|pp| pp.file_name() == Some(std::ffi::OsStr::new("session-state")))
389 .unwrap_or(false)
390}
391
392pub fn is_grok_session_file(path: &Path) -> bool {
398 if path.file_name() != Some(std::ffi::OsStr::new("signals.json")) {
399 return false;
400 }
401
402 path.parent()
403 .and_then(Path::parent)
404 .and_then(Path::parent)
405 .is_some_and(|sessions| sessions.file_name() == Some(std::ffi::OsStr::new("sessions")))
406}
407
408fn is_meta_sidecar_file(path: &Path) -> bool {
415 path.file_name()
416 .and_then(|n| n.to_str())
417 .is_some_and(|name| name.ends_with(".meta.json") || name.ends_with(".meta.jsonl"))
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use std::fs::{self, File};
424 use std::io::Write;
425 use tempfile::tempdir;
426
427 #[test]
428 fn test_is_codex_session_file_jsonl() {
429 let path = std::path::Path::new("test.jsonl");
431 assert!(is_codex_session_file(path));
432 }
433
434 #[test]
435 fn test_is_codex_session_file_json() {
436 let path = std::path::Path::new("test.json");
438 assert!(is_codex_session_file(path));
439 }
440
441 #[test]
442 fn test_is_codex_session_file_txt() {
443 let path = std::path::Path::new("test.txt");
445 assert!(!is_codex_session_file(path));
446 }
447
448 #[test]
449 fn test_is_codex_session_file_no_extension() {
450 let path = std::path::Path::new("test");
452 assert!(!is_codex_session_file(path));
453 }
454
455 #[test]
456 fn test_is_codex_session_file_uppercase() {
457 let path = std::path::Path::new("test.JSON");
459 assert!(!is_codex_session_file(path)); }
461
462 #[test]
463 fn test_is_gemini_session_file_rejects_legacy_json() {
464 let path = std::path::Path::new("/home/user/.gemini/tmp/hash/chats/chat.json");
467 assert!(!is_gemini_session_file(path));
468 }
469
470 #[test]
471 fn test_is_gemini_session_file_accepts_jsonl() {
472 let path = std::path::Path::new(
474 "/home/user/.gemini/tmp/proj/chats/session-2026-04-23T12-52.jsonl",
475 );
476 assert!(is_gemini_session_file(path));
477 }
478
479 #[test]
480 fn test_is_gemini_session_file_accepts_nested_subagent() {
481 let path =
482 std::path::Path::new("/home/user/.gemini/tmp/proj/chats/parent-session/subagent.jsonl");
483 assert!(is_gemini_session_file(path));
484 }
485
486 #[test]
487 fn test_is_gemini_session_file_rejects_deeper_jsonl() {
488 let path = std::path::Path::new(
489 "/home/user/.gemini/tmp/proj/chats/parent-session/artifacts/data.jsonl",
490 );
491 assert!(!is_gemini_session_file(path));
492 }
493
494 #[test]
495 fn test_is_gemini_session_file_wrong_parent() {
496 let path = std::path::Path::new("/home/user/.gemini/tmp/hash/other/file.json");
498 assert!(!is_gemini_session_file(path));
499 }
500
501 #[test]
502 fn test_is_gemini_session_file_wrong_extension() {
503 let path = std::path::Path::new("/home/user/.gemini/tmp/hash/chats/file.txt");
505 assert!(!is_gemini_session_file(path));
506 }
507
508 #[test]
509 fn test_is_gemini_session_file_no_parent() {
510 let path = std::path::Path::new("file.json");
512 assert!(!is_gemini_session_file(path));
513 }
514
515 #[test]
516 fn test_is_gemini_session_file_excludes_sibling_dirs() {
517 let sibling = std::path::Path::new("/home/user/.gemini/tmp/discordbot/logs.json");
520 assert!(!is_gemini_session_file(sibling));
521
522 let binary = std::path::Path::new("/home/user/.gemini/tmp/bin/rg");
525 assert!(!is_gemini_session_file(binary));
526 }
527
528 #[test]
529 fn test_is_copilot_session_file_accepts_events_jsonl() {
530 let path = std::path::Path::new(
532 "/home/user/.copilot/session-state/d2e098d0-e0d6-4d6b-914b-c4c5543b17e3/events.jsonl",
533 );
534 assert!(is_copilot_session_file(path));
535 }
536
537 #[test]
538 fn test_is_copilot_session_file_rejects_snapshots() {
539 let snapshot_index = std::path::Path::new(
542 "/home/user/.copilot/session-state/d2e098d0/rewind-snapshots/index.json",
543 );
544 assert!(!is_copilot_session_file(snapshot_index));
545
546 let snapshot_backup = std::path::Path::new(
547 "/home/user/.copilot/session-state/d2e098d0/rewind-snapshots/backups/2ee575c19132c8bd-1776949007337",
548 );
549 assert!(!is_copilot_session_file(snapshot_backup));
550
551 let workspace =
552 std::path::Path::new("/home/user/.copilot/session-state/d2e098d0/workspace.yaml");
553 assert!(!is_copilot_session_file(workspace));
554 }
555
556 #[test]
557 fn test_is_copilot_session_file_rejects_nested_events_jsonl() {
558 let nested = std::path::Path::new(
561 "/home/user/.copilot/session-state/d2e098d0/rewind-snapshots/events.jsonl",
562 );
563 assert!(!is_copilot_session_file(nested));
564 }
565
566 #[test]
567 fn test_is_copilot_session_file_rejects_other_files() {
568 let path1 = std::path::Path::new("/tmp/events.jsonl");
571 assert!(!is_copilot_session_file(path1));
572
573 let path2 = std::path::Path::new("/home/user/.copilot/logs.json");
574 assert!(!is_copilot_session_file(path2));
575 }
576
577 #[test]
578 fn test_is_grok_session_file_accepts_exact_layout() {
579 let path =
580 std::path::Path::new("/home/user/.grok/sessions/workspace/session-id/signals.json");
581 assert!(is_grok_session_file(path));
582 }
583
584 #[test]
585 fn test_is_grok_session_file_rejects_siblings_and_nested_files() {
586 let summary =
587 std::path::Path::new("/home/user/.grok/sessions/workspace/session-id/summary.json");
588 let nested = std::path::Path::new(
589 "/home/user/.grok/sessions/workspace/session-id/snapshots/signals.json",
590 );
591 let shallow = std::path::Path::new("/home/user/.grok/sessions/session-id/signals.json");
592
593 assert!(!is_grok_session_file(summary));
594 assert!(!is_grok_session_file(nested));
595 assert!(!is_grok_session_file(shallow));
596 }
597
598 #[test]
599 fn test_grok_max_depth_collects_only_signals_entry_point() {
600 let dir = tempdir().unwrap();
601 let sessions = dir.path().join("sessions");
602 let session = sessions.join("workspace").join("session-id");
603 let nested = session.join("snapshots");
604 fs::create_dir_all(&nested).unwrap();
605 File::create(session.join("signals.json")).unwrap();
606 File::create(session.join("summary.json")).unwrap();
607 File::create(nested.join("signals.json")).unwrap();
608
609 let files = collect_files_with_max_depth(
610 &sessions,
611 is_grok_session_file,
612 TimeRange::All,
613 Some(GROK_SESSION_MAX_DEPTH),
614 )
615 .unwrap();
616
617 assert_eq!(files.len(), 1);
618 assert!(files[0].path.ends_with("workspace/session-id/signals.json"));
619 }
620
621 #[test]
622 fn test_collect_files_with_max_depth_respects_bound() {
623 let dir = tempdir().unwrap();
628 let session = dir.path().join("session-state").join("sess-abc");
629 let backups = session.join("rewind-snapshots").join("backups");
630 fs::create_dir_all(&backups).unwrap();
631
632 File::create(session.join("events.jsonl")).unwrap();
633 File::create(backups.join("deadbeef-123")).unwrap();
634 File::create(backups.join("events.jsonl")).unwrap(); File::create(session.join("workspace.yaml")).unwrap();
636
637 let unbounded = collect_files_with_dates(
639 dir.path().join("session-state"),
640 is_copilot_session_file,
641 TimeRange::All,
642 )
643 .unwrap();
644 let unbounded_names: Vec<&str> = unbounded
645 .iter()
646 .filter_map(|f| f.path.file_name()?.to_str())
647 .collect();
648 assert!(unbounded_names.contains(&"events.jsonl"));
652
653 let bounded = collect_files_with_max_depth(
655 dir.path().join("session-state"),
656 is_copilot_session_file,
657 TimeRange::All,
658 Some(2),
659 )
660 .unwrap();
661 assert_eq!(bounded.len(), 1);
662 assert!(bounded[0].path.ends_with("sess-abc/events.jsonl"));
663 }
664
665 #[test]
666 fn test_collect_provider_files_deduplicates_only_across_roots() {
667 let dir = tempdir().unwrap();
670 let active = dir
671 .path()
672 .join("sessions")
673 .join("2026")
674 .join("06")
675 .join("06");
676 let archived = dir.path().join("archived_sessions");
677 fs::create_dir_all(&active).unwrap();
678 fs::create_dir_all(&archived).unwrap();
679 File::create(active.join("rollout-live.jsonl")).unwrap();
680 File::create(active.join("rollout-moving.jsonl")).unwrap();
681 File::create(archived.join("rollout-moving.jsonl")).unwrap();
682 File::create(archived.join("rollout-old.jsonl")).unwrap();
683
684 let roots = [dir.path().join("sessions"), archived.clone()];
685 let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
686 let files =
687 collect_provider_files_diagnostics(&roots, is_codex_session_file, TimeRange::All, None)
688 .files;
689
690 let mut found: Vec<PathBuf> = files.into_iter().map(|file| file.path).collect();
691 found.sort();
692 assert_eq!(found.len(), 3, "the moving session must be counted once");
693 assert!(found.contains(&active.join("rollout-moving.jsonl")));
695 assert!(!found.contains(&archived.join("rollout-moving.jsonl")));
696 assert!(found.contains(&archived.join("rollout-old.jsonl")));
697 }
698
699 #[test]
700 fn test_collect_provider_files_keeps_same_named_files_in_one_root() {
701 let dir = tempdir().unwrap();
704 let sessions = dir.path().join("session-state");
705 for session in ["sess-a", "sess-b"] {
706 let path = sessions.join(session);
707 fs::create_dir_all(&path).unwrap();
708 File::create(path.join("events.jsonl")).unwrap();
709 }
710
711 let roots = [sessions.as_path()];
712 let files = collect_provider_files_diagnostics(
713 &roots,
714 is_copilot_session_file,
715 TimeRange::All,
716 Some(COPILOT_SESSION_MAX_DEPTH),
717 )
718 .files;
719
720 assert_eq!(files.len(), 2);
721 }
722
723 #[test]
724 fn test_collect_provider_files_skips_missing_roots() {
725 let dir = tempdir().unwrap();
726 let archived = dir.path().join("archived_sessions");
727 fs::create_dir_all(&archived).unwrap();
728 File::create(archived.join("rollout-old.jsonl")).unwrap();
729
730 let roots = [dir.path().join("sessions"), archived];
732 let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
733 let discovery =
734 collect_provider_files_diagnostics(&roots, is_codex_session_file, TimeRange::All, None);
735
736 assert_eq!(discovery.files.len(), 1);
737 assert!(discovery.failures.is_empty());
738 assert!(
739 collect_provider_files_diagnostics(&[], is_codex_session_file, TimeRange::All, None)
740 .files
741 .is_empty()
742 );
743 }
744
745 #[test]
746 fn test_collect_files_with_dates_empty_dir() {
747 let dir = tempdir().unwrap();
749
750 let results =
751 collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
752 assert_eq!(results.len(), 0);
753 }
754
755 #[test]
756 fn test_collect_files_with_dates_nonexistent_dir() {
757 let results =
759 collect_files_with_dates("/nonexistent/path", is_codex_session_file, TimeRange::All)
760 .unwrap();
761 assert_eq!(results.len(), 0);
762 }
763
764 #[test]
765 fn test_collect_files_with_dates_with_files() {
766 let dir = tempdir().unwrap();
768
769 File::create(dir.path().join("file1.json")).unwrap();
771 File::create(dir.path().join("file2.jsonl")).unwrap();
772 File::create(dir.path().join("file3.txt")).unwrap(); let results =
775 collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
776 assert_eq!(results.len(), 2);
777
778 for file_info in &results {
780 assert!(!file_info.modified_date.is_empty());
781 assert!(file_info.modified_date.contains('-')); }
783 }
784
785 #[test]
786 fn test_collect_files_with_dates_nested_directories() {
787 let dir = tempdir().unwrap();
789
790 fs::create_dir_all(dir.path().join("subdir1")).unwrap();
792 fs::create_dir_all(dir.path().join("subdir2")).unwrap();
793
794 File::create(dir.path().join("file1.json")).unwrap();
795 File::create(dir.path().join("subdir1/file2.json")).unwrap();
796 File::create(dir.path().join("subdir2/file3.jsonl")).unwrap();
797
798 let results =
799 collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
800 assert_eq!(results.len(), 3);
801 }
802
803 #[test]
804 fn test_collect_files_with_dates_filter_function() {
805 let dir = tempdir().unwrap();
807
808 File::create(dir.path().join("file1.json")).unwrap();
809 File::create(dir.path().join("file2.jsonl")).unwrap();
810 File::create(dir.path().join("file3.txt")).unwrap();
811
812 let results = collect_files_with_dates(
814 dir.path(),
815 |p| p.extension().is_some_and(|e| e == "txt"),
816 TimeRange::All,
817 )
818 .unwrap();
819
820 assert_eq!(results.len(), 1);
821 }
822
823 #[test]
824 fn test_collect_files_with_dates_no_matching_files() {
825 let dir = tempdir().unwrap();
827
828 File::create(dir.path().join("file1.txt")).unwrap();
829 File::create(dir.path().join("file2.md")).unwrap();
830
831 let results =
832 collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
833 assert_eq!(results.len(), 0);
834 }
835
836 #[test]
837 fn test_file_info_path() {
838 let dir = tempdir().unwrap();
840 let file_path = dir.path().join("test.json");
841 File::create(&file_path).unwrap();
842
843 let results =
844 collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
845 assert_eq!(results.len(), 1);
846 assert_eq!(results[0].path, file_path);
847 }
848
849 #[test]
850 fn test_file_info_date_format() {
851 let dir = tempdir().unwrap();
853 File::create(dir.path().join("test.json")).unwrap();
854
855 let results =
856 collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
857 assert_eq!(results.len(), 1);
858
859 let date = &results[0].modified_date;
860 assert_eq!(date.len(), 10); assert_eq!(date.chars().filter(|&c| c == '-').count(), 2); }
863
864 #[test]
865 fn test_collect_files_ignores_directories() {
866 let dir = tempdir().unwrap();
868
869 fs::create_dir(dir.path().join("test.json")).unwrap();
871
872 File::create(dir.path().join("real.json")).unwrap();
874
875 let results =
876 collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
877 assert_eq!(results.len(), 1); }
879
880 #[test]
881 fn test_is_codex_session_file_with_dots_in_name() {
882 let path = std::path::Path::new("my.test.file.json");
884 assert!(is_codex_session_file(path));
885
886 let path2 = std::path::Path::new("my.test.file.jsonl");
887 assert!(is_codex_session_file(path2));
888 }
889
890 #[test]
891 fn test_is_gemini_session_file_multiple_levels() {
892 let path = std::path::Path::new("/a/b/c/d/chats/file.jsonl");
895 assert!(is_gemini_session_file(path));
896 }
897
898 #[test]
899 fn test_collect_files_with_content() {
900 let dir = tempdir().unwrap();
902 let file_path = dir.path().join("test.json");
903
904 let mut file = File::create(&file_path).unwrap();
905 writeln!(file, r#"{{"key": "value"}}"#).unwrap();
906
907 let results =
908 collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
909 assert_eq!(results.len(), 1);
910 assert!(results[0].path.exists());
911 }
912
913 #[test]
914 fn test_is_codex_session_file_excludes_meta_sidecars() {
915 let path = std::path::Path::new("agent-afda1991051a0eb93.meta.json");
919 assert!(!is_codex_session_file(path));
920
921 let nested = std::path::Path::new(
922 "/home/user/.claude/projects/proj/sess/subagents/agent-x.meta.json",
923 );
924 assert!(!is_codex_session_file(nested));
925
926 let meta_jsonl = std::path::Path::new(
929 "/home/user/.claude/projects/proj/sess/subagents/agent-x.meta.jsonl",
930 );
931 assert!(!is_codex_session_file(meta_jsonl));
932 }
933
934 #[test]
935 fn test_is_claude_session_file_accepts_jsonl() {
936 let top_level = std::path::Path::new("/home/user/.claude/projects/proj/sess.jsonl");
938 assert!(is_claude_session_file(top_level));
939
940 let subagent = std::path::Path::new(
942 "/home/user/.claude/projects/proj/sess/subagents/agent-afda1991051a0eb93.jsonl",
943 );
944 assert!(is_claude_session_file(subagent));
945 }
946
947 #[test]
948 fn test_is_claude_session_file_rejects_non_jsonl() {
949 let meta = std::path::Path::new(
951 "/home/user/.claude/projects/proj/sess/subagents/agent-afda1991051a0eb93.meta.json",
952 );
953 assert!(!is_claude_session_file(meta));
954
955 let meta_jsonl = std::path::Path::new(
958 "/home/user/.claude/projects/proj/sess/subagents/agent-afda1991051a0eb93.meta.jsonl",
959 );
960 assert!(!is_claude_session_file(meta_jsonl));
961
962 let plain_json = std::path::Path::new("/home/user/.claude/projects/proj/notes.json");
964 assert!(!is_claude_session_file(plain_json));
965
966 let image = std::path::Path::new("/home/user/.claude/projects/proj/sess/paste.png");
968 assert!(!is_claude_session_file(image));
969 }
970
971 #[test]
972 fn test_collect_claude_session_files_includes_subagents() {
973 let dir = tempdir().unwrap();
980 let project = dir.path().join("-home-user-repo");
981 let session_subdir = project.join("sess-a");
982 let subagents = session_subdir.join("subagents");
983 fs::create_dir_all(&subagents).unwrap();
984
985 File::create(project.join("sess-a.jsonl")).unwrap();
986 File::create(subagents.join("agent-one.jsonl")).unwrap();
987 File::create(subagents.join("agent-one.meta.json")).unwrap();
988 File::create(subagents.join("agent-two.meta.jsonl")).unwrap();
989 File::create(session_subdir.join("screenshot.png")).unwrap();
990
991 let results =
992 collect_files_with_dates(dir.path(), is_claude_session_file, TimeRange::All).unwrap();
993 let names: Vec<String> = results
994 .iter()
995 .filter_map(|f| f.path.file_name()?.to_str().map(String::from))
996 .collect();
997
998 assert_eq!(results.len(), 2, "collected: {:?}", names);
999 assert!(names.contains(&"sess-a.jsonl".to_string()));
1000 assert!(names.contains(&"agent-one.jsonl".to_string()));
1001 assert!(!names.iter().any(|n| n.contains(".meta.")));
1002 assert!(!names.iter().any(|n| n.ends_with(".png")));
1003 }
1004}