Skip to main content

vct_core/utils/
directory.rs

1use crate::models::TimeRange;
2use anyhow::Result;
3use std::fs;
4use std::path::{Path, PathBuf};
5use walkdir::WalkDir;
6
7/// A file matched during directory traversal, paired with its modification date.
8pub struct FileInfo {
9    /// Absolute path to the matched file.
10    pub path: PathBuf,
11    /// Local modification date formatted as `YYYY-MM-DD`, used for grouping.
12    pub modified_date: String,
13}
14
15/// One directory traversal or metadata error encountered during discovery.
16#[derive(Debug)]
17pub(crate) struct FileDiscoveryFailure {
18    /// Path whose directory entry or metadata could not be read.
19    pub path: PathBuf,
20    /// Content-safe error summary.
21    pub error: String,
22}
23
24/// Files discovered successfully plus any partial traversal failures.
25pub(crate) struct FileDiscovery {
26    /// Matched files that could be inspected successfully.
27    pub files: Vec<FileInfo>,
28    /// Traversal, metadata, or mtime failures in deterministic path order.
29    pub failures: Vec<FileDiscoveryFailure>,
30}
31
32/// Recursively collects the files under `dir` that pass `filter_fn`, tagging
33/// each with its modification date.
34///
35/// `filter_fn` decides whether a given path is included; `time_range` drops
36/// files whose modification date falls before its cutoff. Equivalent to
37/// [`collect_files_with_max_depth`] with no depth cap.
38///
39/// # Errors
40///
41/// Returns `Result` for caller ergonomics, but the current implementation
42/// never produces an error: a missing directory yields an empty list, and
43/// per-entry traversal or metadata failures are silently skipped.
44pub 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
56/// Same as [`collect_files_with_dates`] but with an optional traversal depth cap.
57///
58/// `max_depth` is counted from the root directory: `None` walks the whole
59/// tree, `Some(2)` only descends two levels. Callers that know the exact
60/// nesting of the file they want (e.g. Copilot's
61/// `session-state/<sessionId>/events.jsonl` is always at depth 2) can use
62/// this to avoid paying the cost of `WalkDir` visiting large sibling
63/// subtrees such as `rewind-snapshots/backups/` that never contain a
64/// match but can hold hundreds of files per session.
65///
66/// # Errors
67///
68/// Returns `Result` for caller ergonomics, but the current implementation
69/// never produces an error: a missing directory yields an empty list, and
70/// per-entry traversal or metadata failures are silently skipped.
71pub 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
84/// Discovers matching files while retaining partial traversal failures.
85pub(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    // Pre-allocate Vec with estimated capacity (typical: 10-50 session files)
120    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        // Apply filter
146        if !filter_fn(path) {
147            continue;
148        }
149
150        // Get file modification time for date grouping
151        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        // Apply time range filter
176        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
199/// Maximum traversal depth for Copilot CLI session scans.
200///
201/// Copilot writes `~/.copilot/session-state/<sessionId>/events.jsonl`, so
202/// the event log is always exactly two directory levels below
203/// `session-state/`. Anything deeper belongs to companion subtrees we
204/// intentionally ignore (`rewind-snapshots/backups/`, `checkpoints/`,
205/// `files/`, `research/`) — some of which can hold dozens of files per
206/// session and would otherwise make `WalkDir` visit hundreds of entries
207/// per session just to have `is_copilot_session_file` reject them.
208pub const COPILOT_SESSION_MAX_DEPTH: usize = 2;
209
210/// Maximum traversal depth for Grok CLI session scans.
211///
212/// Grok writes `$GROK_HOME/sessions/<workspace>/<session>/signals.json`, so
213/// each signals file is exactly three levels below the sessions root.
214pub const GROK_SESSION_MAX_DEPTH: usize = 3;
215
216/// Filter for Codex session files under `~/.codex/sessions/YYYY/MM/DD/`.
217///
218/// Codex writes `rollout-*.jsonl` files directly into the dated sub-folders.
219/// We also accept `.json` defensively in case an older dump ever gets dropped
220/// into the tree, but reject Claude Code's `*.meta.json` / `*.meta.jsonl`
221/// subagent sidecar files (those live under `~/.claude/projects/` in
222/// practice, but the filter still guards against the unlikely collision).
223///
224/// Intentionally separated from the other per-provider filters
225/// (`is_claude_session_file`, `is_copilot_session_file`,
226/// `is_gemini_session_file`) even though the body is currently trivial:
227/// keeping one filter per provider means future format changes on one
228/// provider do not require teasing apart a shared implementation.
229pub 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
240/// Filter for Claude Code session files (`.jsonl` only).
241///
242/// Matches both top-level sessions (`~/.claude/projects/<project>/<session>.jsonl`)
243/// and subagent sessions (`~/.claude/projects/<project>/<session>/subagents/agent-*.jsonl`).
244/// Rejects meta sidecars (`*.meta.json` / `*.meta.jsonl`) and any non-JSONL artifact
245/// that ends up under the projects directory (e.g. screenshots pasted into prompts).
246pub 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
253/// Filter for Gemini CLI session files.
254///
255/// Current Gemini CLI stores each chat as a line-delimited event stream at
256/// `~/.gemini/tmp/<project>/chats/session-*.jsonl`. Subagent sessions sit one
257/// level deeper at `chats/<parent-session>/<subagent>.jsonl`. Sibling artifacts
258/// (`discordbot/logs.json`, the `bin/rg` binary, `.project_root`) are rejected.
259///
260/// Legacy single-object exports (`chats/<session>.json`) are intentionally
261/// not matched: the JSONL format is the only shape the analyzer understands
262/// today, and silently scanning `.json` files we can no longer parse just
263/// yields `Warning: Failed to analyze ...` noise on every run.
264pub 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
277/// Filter for Copilot CLI session files.
278///
279/// Copilot CLI stores each session as a directory under
280/// `~/.copilot/session-state/<sessionId>/` containing the event log
281/// (`events.jsonl`) plus sibling subdirectories for file snapshots
282/// (`rewind-snapshots/`, `files/`, `research/`, `checkpoints/`) and a
283/// per-workspace YAML file. Only `events.jsonl` carries the conversation
284/// stream we want to analyze — if we fell back to the generic JSON filter,
285/// `rewind-snapshots/index.json` would be mis-picked up as a session log
286/// and fail to parse.
287///
288/// The historical single-file layout
289/// (`~/.copilot/history-session-state/<sessionId>.json`) is not matched
290/// and no longer supported by the analyzer at all.
291pub fn is_copilot_session_file(path: &Path) -> bool {
292    // Compare raw `OsStr` rather than going through `to_str()` so paths with
293    // non-UTF-8 bytes elsewhere in the tree do not silently reject the file.
294    // The chosen constants (`events.jsonl`, `session-state`) are pure ASCII,
295    // so the comparison is safe on every platform we care about and keeps
296    // the style consistent with `is_gemini_session_file`'s `OsStr::new("chats")`
297    // check above.
298    if path.file_name() != Some(std::ffi::OsStr::new("events.jsonl")) {
299        return false;
300    }
301
302    // Must sit one level under `session-state/<sessionId>/events.jsonl` —
303    // reject anything that just happens to be called `events.jsonl` in a
304    // nested subfolder (e.g. `rewind-snapshots/events.jsonl`).
305    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
311/// Filter for Grok CLI session signal files.
312///
313/// Only `$GROK_HOME/sessions/<workspace>/<session>/signals.json` is a session
314/// entry point. Sibling files such as `summary.json` and `updates.jsonl` are
315/// consumed by the Grok parser after the signals file has been selected.
316pub 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
327/// Returns true if the path is a Claude Code meta sidecar file.
328///
329/// Claude Code writes these next to subagent session logs with metadata like
330/// `agentType` / `description`. Today they're `*.meta.json`; we also reject
331/// `*.meta.jsonl` pre-emptively so the filter stays correct if the format
332/// ever switches to line-delimited JSON.
333fn 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        // Test JSONL file extension
349        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        // Test JSON file extension
356        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        // Test non-JSON file extension
363        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        // Test file without extension
370        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        // Test uppercase extension
377        let path = std::path::Path::new("test.JSON");
378        assert!(!is_codex_session_file(path)); // Case-sensitive
379    }
380
381    #[test]
382    fn test_is_gemini_session_file_rejects_legacy_json() {
383        // Legacy single-object exports (`chats/<session>.json`) are intentionally
384        // no longer matched — the analyzer only handles the JSONL event stream.
385        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        // Current Gemini CLI writes each event as a JSONL line under `chats/`.
392        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        // Test file not in chats directory
416        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        // Test file in chats directory but wrong extension
423        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        // Test file without parent
430        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        // `tmp/discordbot/logs.json` lives next to the `chats/` folder but
437        // must not be picked up because its parent is not `chats`.
438        let sibling = std::path::Path::new("/home/user/.gemini/tmp/discordbot/logs.json");
439        assert!(!is_gemini_session_file(sibling));
440
441        // The `bin/rg` binary that Gemini CLI drops alongside session data has no
442        // JSON extension at all.
443        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        // Current layout: `session-state/<sessionId>/events.jsonl`
450        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        // Rewind snapshots also emit JSON files; they must not be picked up as
459        // session logs.
460        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        // A stray `events.jsonl` inside a rewind snapshot must not count as a
478        // session log — the parent of the parent must be `session-state`.
479        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        // Plain JSON / JSONL files outside the `session-state/<uuid>/` layout
488        // should never pass.
489        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        // Layout mirrors a real Copilot `session-state/` tree: `events.jsonl` sits
543        // at depth 2 and must be collected, while snapshot artifacts deeper in
544        // the tree (`rewind-snapshots/backups/<hash>`) must be skipped entirely
545        // by a max_depth(2) walk so they never even hit `is_copilot_session_file`.
546        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(); // still valid JSONL
554        File::create(session.join("workspace.yaml")).unwrap();
555
556        // Unbounded walk picks up every file whose filter passes...
557        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        // `rewind-snapshots/backups/events.jsonl` fails the filter (parent-of-parent
568        // is `rewind-snapshots`, not `session-state`), so the filter already blocks
569        // it. But we must have picked up the real one at depth 2.
570        assert!(unbounded_names.contains(&"events.jsonl"));
571
572        // Bounded walk (depth 2) must not even descend into backups/.
573        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        // Test collecting files from empty directory
587        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        // Test collecting files from non-existent directory
597        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        // Test collecting JSON files from directory
606        let dir = tempdir().unwrap();
607
608        // Create some JSON files
609        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(); // Should be filtered out
612
613        let results =
614            collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
615        assert_eq!(results.len(), 2);
616
617        // Check that date fields are set
618        for file_info in &results {
619            assert!(!file_info.modified_date.is_empty());
620            assert!(file_info.modified_date.contains('-')); // Should be YYYY-MM-DD format
621        }
622    }
623
624    #[test]
625    fn test_collect_files_with_dates_nested_directories() {
626        // Test collecting files from nested directories
627        let dir = tempdir().unwrap();
628
629        // Create nested structure
630        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        // Test that filter function works correctly
645        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        // Custom filter: only .txt files
652        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        // Test when no files match filter
665        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        // Test that FileInfo contains correct paths
678        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        // Test that date format is YYYY-MM-DD
691        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); // YYYY-MM-DD is 10 chars
700        assert_eq!(date.chars().filter(|&c| c == '-').count(), 2); // Two dashes
701    }
702
703    #[test]
704    fn test_collect_files_ignores_directories() {
705        // Test that directories are not included in results
706        let dir = tempdir().unwrap();
707
708        // Create a directory with .json in name
709        fs::create_dir(dir.path().join("test.json")).unwrap();
710
711        // Create an actual file
712        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); // Only the file, not the directory
717    }
718
719    #[test]
720    fn test_is_codex_session_file_with_dots_in_name() {
721        // Test files with dots in name
722        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        // Depth of the enclosing directory does not matter as long as the
732        // *immediate* parent is `chats/` and the extension is `.jsonl`.
733        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        // Test that files with content are collected
740        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        // *.meta.json sidecars live next to Claude Code subagent sessions and must not
755        // be picked up by the generic json filter (they would otherwise be parsed and
756        // mis-detected as Codex logs).
757        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        // Pre-emptive defense: reject `.meta.jsonl` too, in case Claude Code ever
766        // switches the sidecar format to line-delimited JSON.
767        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        // Top-level Claude session logs
776        let top_level = std::path::Path::new("/home/user/.claude/projects/proj/sess.jsonl");
777        assert!(is_claude_session_file(top_level));
778
779        // Subagent session logs sit one extra level deeper
780        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        // Metadata sidecars — current format (`.meta.json`)
789        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        // Metadata sidecars — hypothetical future format (`.meta.jsonl`). Has `.jsonl`
795        // extension, so without the sidecar check it would slip through.
796        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        // Plain .json files (not something Claude Code writes in this tree)
802        let plain_json = std::path::Path::new("/home/user/.claude/projects/proj/notes.json");
803        assert!(!is_claude_session_file(plain_json));
804
805        // Other pasted artifacts
806        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        // Simulates the `~/.claude/projects/<project>/<session>/subagents/` layout:
813        //   projects/<project>/
814        //     sess-a.jsonl                          <- top-level session
815        //     sess-a/subagents/agent-one.jsonl      <- subagent session
816        //     sess-a/subagents/agent-one.meta.json  <- metadata sidecar (ignored)
817        //     sess-a/screenshot.png                 <- pasted artifact (ignored)
818        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}