Skip to main content

vct_core/utils/
directory.rs

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
9/// A file matched during directory traversal, paired with its modification date.
10pub struct FileInfo {
11    /// Absolute path to the matched file.
12    pub path: PathBuf,
13    /// Local modification date formatted as `YYYY-MM-DD`, used for grouping.
14    pub modified_date: String,
15}
16
17/// One directory traversal or metadata error encountered during discovery.
18#[derive(Debug)]
19pub(crate) struct FileDiscoveryFailure {
20    /// Path whose directory entry or metadata could not be read.
21    pub path: PathBuf,
22    /// Content-safe error summary.
23    pub error: String,
24}
25
26/// Files discovered successfully plus any partial traversal failures.
27pub(crate) struct FileDiscovery {
28    /// Matched files that could be inspected successfully.
29    pub files: Vec<FileInfo>,
30    /// Traversal, metadata, or mtime failures in deterministic path order.
31    pub failures: Vec<FileDiscoveryFailure>,
32}
33
34/// Recursively collects the files under `dir` that pass `filter_fn`, tagging
35/// each with its modification date.
36///
37/// `filter_fn` decides whether a given path is included; `time_range` drops
38/// files whose modification date falls before its cutoff. Equivalent to
39/// [`collect_files_with_max_depth`] with no depth cap.
40///
41/// # Errors
42///
43/// Returns `Result` for caller ergonomics, but the current implementation
44/// never produces an error: a missing directory yields an empty list, and
45/// per-entry traversal or metadata failures are silently skipped.
46pub 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
58/// Same as [`collect_files_with_dates`] but with an optional traversal depth cap.
59///
60/// `max_depth` is counted from the root directory: `None` walks the whole
61/// tree, `Some(2)` only descends two levels. Callers that know the exact
62/// nesting of the file they want (e.g. Copilot's
63/// `session-state/<sessionId>/events.jsonl` is always at depth 2) can use
64/// this to avoid paying the cost of `WalkDir` visiting large sibling
65/// subtrees such as `rewind-snapshots/backups/` that never contain a
66/// match but can hold hundreds of files per session.
67///
68/// # Errors
69///
70/// Returns `Result` for caller ergonomics, but the current implementation
71/// never produces an error: a missing directory yields an empty list, and
72/// per-entry traversal or metadata failures are silently skipped.
73pub 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
86/// Discovers matching files while retaining partial traversal failures.
87pub(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    // Pre-allocate Vec with estimated capacity (typical: 10-50 session files)
122    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        // Apply filter
148        if !filter_fn(path) {
149            continue;
150        }
151
152        // Get file modification time for date grouping
153        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        // Apply time range filter
178        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
201/// Discovers matching files across every root one provider's sessions live in.
202///
203/// Roots are walked in order and their results concatenated, so a provider with
204/// a single root behaves exactly as [`collect_files_with_max_depth_diagnostics`]
205/// does. With more than one root, a file name already found in an **earlier**
206/// root is dropped from a later one: Codex archives a session by moving its
207/// rollout log from `sessions/` to `archived_sessions/`, and a scan that races
208/// that move would otherwise fold the same session twice.
209///
210/// Declaring more than one root therefore asserts that a repeated file name means
211/// a repeated session. Codex satisfies that: its rollout logs are named
212/// `rollout-<timestamp>-<uuid>.jsonl` and keep that name across the move. Names
213/// are compared only across roots, never within one, because a provider may
214/// legitimately give every session the same file name (Copilot writes
215/// `events.jsonl` for all of them) — such a provider has to stay single-root.
216/// Each drop is logged at debug level so a wrong assertion is diagnosable rather
217/// than a silently missing session.
218pub(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        // The overwhelmingly common shape is an absent or empty later root (no
244        // session has been archived yet), so skip building the name index for it.
245        if found.files.is_empty() {
246            continue;
247        }
248        // Scoped so the borrow of the already-merged names ends before the append.
249        {
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
280/// Maximum traversal depth for Copilot CLI session scans.
281///
282/// Copilot writes `~/.copilot/session-state/<sessionId>/events.jsonl`, so
283/// the event log is always exactly two directory levels below
284/// `session-state/`. Anything deeper belongs to companion subtrees we
285/// intentionally ignore (`rewind-snapshots/backups/`, `checkpoints/`,
286/// `files/`, `research/`) — some of which can hold dozens of files per
287/// session and would otherwise make `WalkDir` visit hundreds of entries
288/// per session just to have `is_copilot_session_file` reject them.
289pub const COPILOT_SESSION_MAX_DEPTH: usize = 2;
290
291/// Maximum traversal depth for Grok CLI session scans.
292///
293/// Grok writes `$GROK_HOME/sessions/<workspace>/<session>/signals.json`, so
294/// each signals file is exactly three levels below the sessions root.
295pub const GROK_SESSION_MAX_DEPTH: usize = 3;
296
297/// Filter for Codex session files under `~/.codex/sessions/YYYY/MM/DD/`.
298///
299/// Codex writes `rollout-*.jsonl` files directly into the dated sub-folders.
300/// We also accept `.json` defensively in case an older dump ever gets dropped
301/// into the tree, but reject Claude Code's `*.meta.json` / `*.meta.jsonl`
302/// subagent sidecar files (those live under `~/.claude/projects/` in
303/// practice, but the filter still guards against the unlikely collision).
304///
305/// Intentionally separated from the other per-provider filters
306/// (`is_claude_session_file`, `is_copilot_session_file`,
307/// `is_gemini_session_file`) even though the body is currently trivial:
308/// keeping one filter per provider means future format changes on one
309/// provider do not require teasing apart a shared implementation.
310pub 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
321/// Filter for Claude Code session files (`.jsonl` only).
322///
323/// Matches both top-level sessions (`~/.claude/projects/<project>/<session>.jsonl`)
324/// and subagent sessions (`~/.claude/projects/<project>/<session>/subagents/agent-*.jsonl`).
325/// Rejects meta sidecars (`*.meta.json` / `*.meta.jsonl`) and any non-JSONL artifact
326/// that ends up under the projects directory (e.g. screenshots pasted into prompts).
327pub 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
334/// Filter for Gemini CLI session files.
335///
336/// Current Gemini CLI stores each chat as a line-delimited event stream at
337/// `~/.gemini/tmp/<project>/chats/session-*.jsonl`. Subagent sessions sit one
338/// level deeper at `chats/<parent-session>/<subagent>.jsonl`. Sibling artifacts
339/// (`discordbot/logs.json`, the `bin/rg` binary, `.project_root`) are rejected.
340///
341/// Legacy single-object exports (`chats/<session>.json`) are intentionally
342/// not matched: the JSONL format is the only shape the analyzer understands
343/// today, and silently scanning `.json` files we can no longer parse just
344/// yields `Warning: Failed to analyze ...` noise on every run.
345pub 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
358/// Filter for Copilot CLI session files.
359///
360/// Copilot CLI stores each session as a directory under
361/// `~/.copilot/session-state/<sessionId>/` containing the event log
362/// (`events.jsonl`) plus sibling subdirectories for file snapshots
363/// (`rewind-snapshots/`, `files/`, `research/`, `checkpoints/`) and a
364/// per-workspace YAML file. Only `events.jsonl` carries the conversation
365/// stream we want to analyze — if we fell back to the generic JSON filter,
366/// `rewind-snapshots/index.json` would be mis-picked up as a session log
367/// and fail to parse.
368///
369/// The historical single-file layout
370/// (`~/.copilot/history-session-state/<sessionId>.json`) is not matched
371/// and no longer supported by the analyzer at all.
372pub fn is_copilot_session_file(path: &Path) -> bool {
373    // Compare raw `OsStr` rather than going through `to_str()` so paths with
374    // non-UTF-8 bytes elsewhere in the tree do not silently reject the file.
375    // The chosen constants (`events.jsonl`, `session-state`) are pure ASCII,
376    // so the comparison is safe on every platform we care about and keeps
377    // the style consistent with `is_gemini_session_file`'s `OsStr::new("chats")`
378    // check above.
379    if path.file_name() != Some(std::ffi::OsStr::new("events.jsonl")) {
380        return false;
381    }
382
383    // Must sit one level under `session-state/<sessionId>/events.jsonl` —
384    // reject anything that just happens to be called `events.jsonl` in a
385    // nested subfolder (e.g. `rewind-snapshots/events.jsonl`).
386    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
392/// Filter for Grok CLI session signal files.
393///
394/// Only `$GROK_HOME/sessions/<workspace>/<session>/signals.json` is a session
395/// entry point. Sibling files such as `summary.json` and `updates.jsonl` are
396/// consumed by the Grok parser after the signals file has been selected.
397pub 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
408/// Returns true if the path is a Claude Code meta sidecar file.
409///
410/// Claude Code writes these next to subagent session logs with metadata like
411/// `agentType` / `description`. Today they're `*.meta.json`; we also reject
412/// `*.meta.jsonl` pre-emptively so the filter stays correct if the format
413/// ever switches to line-delimited JSON.
414fn 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        // Test JSONL file extension
430        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        // Test JSON file extension
437        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        // Test non-JSON file extension
444        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        // Test file without extension
451        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        // Test uppercase extension
458        let path = std::path::Path::new("test.JSON");
459        assert!(!is_codex_session_file(path)); // Case-sensitive
460    }
461
462    #[test]
463    fn test_is_gemini_session_file_rejects_legacy_json() {
464        // Legacy single-object exports (`chats/<session>.json`) are intentionally
465        // no longer matched — the analyzer only handles the JSONL event stream.
466        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        // Current Gemini CLI writes each event as a JSONL line under `chats/`.
473        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        // Test file not in chats directory
497        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        // Test file in chats directory but wrong extension
504        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        // Test file without parent
511        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        // `tmp/discordbot/logs.json` lives next to the `chats/` folder but
518        // must not be picked up because its parent is not `chats`.
519        let sibling = std::path::Path::new("/home/user/.gemini/tmp/discordbot/logs.json");
520        assert!(!is_gemini_session_file(sibling));
521
522        // The `bin/rg` binary that Gemini CLI drops alongside session data has no
523        // JSON extension at all.
524        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        // Current layout: `session-state/<sessionId>/events.jsonl`
531        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        // Rewind snapshots also emit JSON files; they must not be picked up as
540        // session logs.
541        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        // A stray `events.jsonl` inside a rewind snapshot must not count as a
559        // session log — the parent of the parent must be `session-state`.
560        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        // Plain JSON / JSONL files outside the `session-state/<uuid>/` layout
569        // should never pass.
570        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        // Layout mirrors a real Copilot `session-state/` tree: `events.jsonl` sits
624        // at depth 2 and must be collected, while snapshot artifacts deeper in
625        // the tree (`rewind-snapshots/backups/<hash>`) must be skipped entirely
626        // by a max_depth(2) walk so they never even hit `is_copilot_session_file`.
627        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(); // still valid JSONL
635        File::create(session.join("workspace.yaml")).unwrap();
636
637        // Unbounded walk picks up every file whose filter passes...
638        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        // `rewind-snapshots/backups/events.jsonl` fails the filter (parent-of-parent
649        // is `rewind-snapshots`, not `session-state`), so the filter already blocks
650        // it. But we must have picked up the real one at depth 2.
651        assert!(unbounded_names.contains(&"events.jsonl"));
652
653        // Bounded walk (depth 2) must not even descend into backups/.
654        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        // Mirrors Codex: a dated active root plus the flat archived root the
668        // rollout log is moved into, with one session momentarily in both.
669        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        // The active copy wins, so the archived duplicate is the one dropped.
694        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        // Every Copilot session log is named `events.jsonl`; a single-root
702        // provider must never have those collapsed into one.
703        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        // A Codex home that has only ever archived: the active root is absent.
731        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        // Test collecting files from empty directory
748        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        // Test collecting files from non-existent directory
758        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        // Test collecting JSON files from directory
767        let dir = tempdir().unwrap();
768
769        // Create some JSON files
770        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(); // Should be filtered out
773
774        let results =
775            collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
776        assert_eq!(results.len(), 2);
777
778        // Check that date fields are set
779        for file_info in &results {
780            assert!(!file_info.modified_date.is_empty());
781            assert!(file_info.modified_date.contains('-')); // Should be YYYY-MM-DD format
782        }
783    }
784
785    #[test]
786    fn test_collect_files_with_dates_nested_directories() {
787        // Test collecting files from nested directories
788        let dir = tempdir().unwrap();
789
790        // Create nested structure
791        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        // Test that filter function works correctly
806        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        // Custom filter: only .txt files
813        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        // Test when no files match filter
826        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        // Test that FileInfo contains correct paths
839        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        // Test that date format is YYYY-MM-DD
852        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); // YYYY-MM-DD is 10 chars
861        assert_eq!(date.chars().filter(|&c| c == '-').count(), 2); // Two dashes
862    }
863
864    #[test]
865    fn test_collect_files_ignores_directories() {
866        // Test that directories are not included in results
867        let dir = tempdir().unwrap();
868
869        // Create a directory with .json in name
870        fs::create_dir(dir.path().join("test.json")).unwrap();
871
872        // Create an actual file
873        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); // Only the file, not the directory
878    }
879
880    #[test]
881    fn test_is_codex_session_file_with_dots_in_name() {
882        // Test files with dots in name
883        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        // Depth of the enclosing directory does not matter as long as the
893        // *immediate* parent is `chats/` and the extension is `.jsonl`.
894        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        // Test that files with content are collected
901        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        // *.meta.json sidecars live next to Claude Code subagent sessions and must not
916        // be picked up by the generic json filter (they would otherwise be parsed and
917        // mis-detected as Codex logs).
918        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        // Pre-emptive defense: reject `.meta.jsonl` too, in case Claude Code ever
927        // switches the sidecar format to line-delimited JSON.
928        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        // Top-level Claude session logs
937        let top_level = std::path::Path::new("/home/user/.claude/projects/proj/sess.jsonl");
938        assert!(is_claude_session_file(top_level));
939
940        // Subagent session logs sit one extra level deeper
941        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        // Metadata sidecars — current format (`.meta.json`)
950        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        // Metadata sidecars — hypothetical future format (`.meta.jsonl`). Has `.jsonl`
956        // extension, so without the sidecar check it would slip through.
957        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        // Plain .json files (not something Claude Code writes in this tree)
963        let plain_json = std::path::Path::new("/home/user/.claude/projects/proj/notes.json");
964        assert!(!is_claude_session_file(plain_json));
965
966        // Other pasted artifacts
967        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        // Simulates the `~/.claude/projects/<project>/<session>/subagents/` layout:
974        //   projects/<project>/
975        //     sess-a.jsonl                          <- top-level session
976        //     sess-a/subagents/agent-one.jsonl      <- subagent session
977        //     sess-a/subagents/agent-one.meta.json  <- metadata sidecar (ignored)
978        //     sess-a/screenshot.png                 <- pasted artifact (ignored)
979        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}