use crate::constants::FastHashSet;
use crate::models::TimeRange;
use anyhow::Result;
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
pub struct FileInfo {
pub path: PathBuf,
pub modified_date: String,
}
#[derive(Debug)]
pub(crate) struct FileDiscoveryFailure {
pub path: PathBuf,
pub error: String,
}
pub(crate) struct FileDiscovery {
pub files: Vec<FileInfo>,
pub failures: Vec<FileDiscoveryFailure>,
}
pub fn collect_files_with_dates<P, F>(
dir: P,
filter_fn: F,
time_range: TimeRange,
) -> Result<Vec<FileInfo>>
where
P: AsRef<Path>,
F: Fn(&Path) -> bool,
{
collect_files_with_max_depth(dir, filter_fn, time_range, None)
}
pub fn collect_files_with_max_depth<P, F>(
dir: P,
filter_fn: F,
time_range: TimeRange,
max_depth: Option<usize>,
) -> Result<Vec<FileInfo>>
where
P: AsRef<Path>,
F: Fn(&Path) -> bool,
{
Ok(collect_files_with_max_depth_diagnostics(dir, filter_fn, time_range, max_depth).files)
}
pub(crate) fn collect_files_with_max_depth_diagnostics<P, F>(
dir: P,
filter_fn: F,
time_range: TimeRange,
max_depth: Option<usize>,
) -> FileDiscovery
where
P: AsRef<Path>,
F: Fn(&Path) -> bool,
{
let dir = dir.as_ref();
match fs::metadata(dir) {
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return FileDiscovery {
files: Vec::new(),
failures: Vec::new(),
};
}
Err(error) => {
return FileDiscovery {
files: Vec::new(),
failures: vec![FileDiscoveryFailure {
path: dir.to_path_buf(),
error: error.to_string(),
}],
};
}
}
let cutoff = time_range
.cutoff_date()
.map(|d| d.format("%Y-%m-%d").to_string());
let mut results = Vec::with_capacity(20);
let mut failures = Vec::new();
let mut walker = WalkDir::new(dir);
if let Some(depth) = max_depth {
walker = walker.max_depth(depth);
}
for entry in walker {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
failures.push(FileDiscoveryFailure {
path: error.path().unwrap_or(dir).to_path_buf(),
error: error.to_string(),
});
continue;
}
};
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
if !filter_fn(path) {
continue;
}
let metadata = match entry.metadata() {
Ok(metadata) => metadata,
Err(error) => {
failures.push(FileDiscoveryFailure {
path: path.to_path_buf(),
error: error.to_string(),
});
continue;
}
};
let modified = match metadata.modified() {
Ok(modified) => modified,
Err(error) => {
failures.push(FileDiscoveryFailure {
path: path.to_path_buf(),
error: error.to_string(),
});
continue;
}
};
let datetime: chrono::DateTime<chrono::Local> = modified.into();
let date_key = datetime.format("%Y-%m-%d").to_string();
if let Some(ref cutoff_str) = cutoff
&& date_key.as_str() < cutoff_str.as_str()
{
continue;
}
results.push(FileInfo {
path: path.to_path_buf(),
modified_date: date_key,
});
}
failures.sort_by(|left, right| {
left.path
.cmp(&right.path)
.then_with(|| left.error.cmp(&right.error))
});
FileDiscovery {
files: results,
failures,
}
}
pub(crate) fn collect_provider_files_diagnostics<F>(
dirs: &[&Path],
filter_fn: F,
time_range: TimeRange,
max_depth: Option<usize>,
) -> FileDiscovery
where
F: Fn(&Path) -> bool,
{
let Some((first, rest)) = dirs.split_first() else {
return FileDiscovery {
files: Vec::new(),
failures: Vec::new(),
};
};
let mut discovery =
collect_files_with_max_depth_diagnostics(first, &filter_fn, time_range, max_depth);
if rest.is_empty() {
return discovery;
}
for dir in rest {
let mut found =
collect_files_with_max_depth_diagnostics(dir, &filter_fn, time_range, max_depth);
discovery.failures.append(&mut found.failures);
if found.files.is_empty() {
continue;
}
{
let seen: FastHashSet<&OsStr> = discovery
.files
.iter()
.filter_map(|file| file.path.file_name())
.collect();
found.files.retain(|file| {
let duplicate = file
.path
.file_name()
.is_some_and(|name| seen.contains(name));
if duplicate {
log::debug!(
"skipping {}: already discovered under an earlier session root",
file.path.display()
);
}
!duplicate
});
}
discovery.files.append(&mut found.files);
}
discovery.failures.sort_by(|left, right| {
left.path
.cmp(&right.path)
.then_with(|| left.error.cmp(&right.error))
});
discovery
}
pub const COPILOT_SESSION_MAX_DEPTH: usize = 2;
pub const GROK_SESSION_MAX_DEPTH: usize = 3;
pub fn is_codex_session_file(path: &Path) -> bool {
if is_meta_sidecar_file(path) {
return false;
}
if let Some(ext) = path.extension() {
ext == "jsonl" || ext == "json"
} else {
false
}
}
pub fn is_claude_session_file(path: &Path) -> bool {
if is_meta_sidecar_file(path) {
return false;
}
path.extension().is_some_and(|ext| ext == "jsonl")
}
pub fn is_gemini_session_file(path: &Path) -> bool {
if path.extension() != Some(std::ffi::OsStr::new("jsonl")) {
false
} else {
path.parent().is_some_and(|parent| {
parent.file_name() == Some(std::ffi::OsStr::new("chats"))
|| parent.parent().is_some_and(|grandparent| {
grandparent.file_name() == Some(std::ffi::OsStr::new("chats"))
})
})
}
}
pub fn is_copilot_session_file(path: &Path) -> bool {
if path.file_name() != Some(std::ffi::OsStr::new("events.jsonl")) {
return false;
}
path.parent()
.and_then(|p| p.parent())
.map(|pp| pp.file_name() == Some(std::ffi::OsStr::new("session-state")))
.unwrap_or(false)
}
pub fn is_grok_session_file(path: &Path) -> bool {
if path.file_name() != Some(std::ffi::OsStr::new("signals.json")) {
return false;
}
path.parent()
.and_then(Path::parent)
.and_then(Path::parent)
.is_some_and(|sessions| sessions.file_name() == Some(std::ffi::OsStr::new("sessions")))
}
fn is_meta_sidecar_file(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| name.ends_with(".meta.json") || name.ends_with(".meta.jsonl"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, File};
use std::io::Write;
use tempfile::tempdir;
#[test]
fn test_is_codex_session_file_jsonl() {
let path = std::path::Path::new("test.jsonl");
assert!(is_codex_session_file(path));
}
#[test]
fn test_is_codex_session_file_json() {
let path = std::path::Path::new("test.json");
assert!(is_codex_session_file(path));
}
#[test]
fn test_is_codex_session_file_txt() {
let path = std::path::Path::new("test.txt");
assert!(!is_codex_session_file(path));
}
#[test]
fn test_is_codex_session_file_no_extension() {
let path = std::path::Path::new("test");
assert!(!is_codex_session_file(path));
}
#[test]
fn test_is_codex_session_file_uppercase() {
let path = std::path::Path::new("test.JSON");
assert!(!is_codex_session_file(path)); }
#[test]
fn test_is_gemini_session_file_rejects_legacy_json() {
let path = std::path::Path::new("/home/user/.gemini/tmp/hash/chats/chat.json");
assert!(!is_gemini_session_file(path));
}
#[test]
fn test_is_gemini_session_file_accepts_jsonl() {
let path = std::path::Path::new(
"/home/user/.gemini/tmp/proj/chats/session-2026-04-23T12-52.jsonl",
);
assert!(is_gemini_session_file(path));
}
#[test]
fn test_is_gemini_session_file_accepts_nested_subagent() {
let path =
std::path::Path::new("/home/user/.gemini/tmp/proj/chats/parent-session/subagent.jsonl");
assert!(is_gemini_session_file(path));
}
#[test]
fn test_is_gemini_session_file_rejects_deeper_jsonl() {
let path = std::path::Path::new(
"/home/user/.gemini/tmp/proj/chats/parent-session/artifacts/data.jsonl",
);
assert!(!is_gemini_session_file(path));
}
#[test]
fn test_is_gemini_session_file_wrong_parent() {
let path = std::path::Path::new("/home/user/.gemini/tmp/hash/other/file.json");
assert!(!is_gemini_session_file(path));
}
#[test]
fn test_is_gemini_session_file_wrong_extension() {
let path = std::path::Path::new("/home/user/.gemini/tmp/hash/chats/file.txt");
assert!(!is_gemini_session_file(path));
}
#[test]
fn test_is_gemini_session_file_no_parent() {
let path = std::path::Path::new("file.json");
assert!(!is_gemini_session_file(path));
}
#[test]
fn test_is_gemini_session_file_excludes_sibling_dirs() {
let sibling = std::path::Path::new("/home/user/.gemini/tmp/discordbot/logs.json");
assert!(!is_gemini_session_file(sibling));
let binary = std::path::Path::new("/home/user/.gemini/tmp/bin/rg");
assert!(!is_gemini_session_file(binary));
}
#[test]
fn test_is_copilot_session_file_accepts_events_jsonl() {
let path = std::path::Path::new(
"/home/user/.copilot/session-state/d2e098d0-e0d6-4d6b-914b-c4c5543b17e3/events.jsonl",
);
assert!(is_copilot_session_file(path));
}
#[test]
fn test_is_copilot_session_file_rejects_snapshots() {
let snapshot_index = std::path::Path::new(
"/home/user/.copilot/session-state/d2e098d0/rewind-snapshots/index.json",
);
assert!(!is_copilot_session_file(snapshot_index));
let snapshot_backup = std::path::Path::new(
"/home/user/.copilot/session-state/d2e098d0/rewind-snapshots/backups/2ee575c19132c8bd-1776949007337",
);
assert!(!is_copilot_session_file(snapshot_backup));
let workspace =
std::path::Path::new("/home/user/.copilot/session-state/d2e098d0/workspace.yaml");
assert!(!is_copilot_session_file(workspace));
}
#[test]
fn test_is_copilot_session_file_rejects_nested_events_jsonl() {
let nested = std::path::Path::new(
"/home/user/.copilot/session-state/d2e098d0/rewind-snapshots/events.jsonl",
);
assert!(!is_copilot_session_file(nested));
}
#[test]
fn test_is_copilot_session_file_rejects_other_files() {
let path1 = std::path::Path::new("/tmp/events.jsonl");
assert!(!is_copilot_session_file(path1));
let path2 = std::path::Path::new("/home/user/.copilot/logs.json");
assert!(!is_copilot_session_file(path2));
}
#[test]
fn test_is_grok_session_file_accepts_exact_layout() {
let path =
std::path::Path::new("/home/user/.grok/sessions/workspace/session-id/signals.json");
assert!(is_grok_session_file(path));
}
#[test]
fn test_is_grok_session_file_rejects_siblings_and_nested_files() {
let summary =
std::path::Path::new("/home/user/.grok/sessions/workspace/session-id/summary.json");
let nested = std::path::Path::new(
"/home/user/.grok/sessions/workspace/session-id/snapshots/signals.json",
);
let shallow = std::path::Path::new("/home/user/.grok/sessions/session-id/signals.json");
assert!(!is_grok_session_file(summary));
assert!(!is_grok_session_file(nested));
assert!(!is_grok_session_file(shallow));
}
#[test]
fn test_grok_max_depth_collects_only_signals_entry_point() {
let dir = tempdir().unwrap();
let sessions = dir.path().join("sessions");
let session = sessions.join("workspace").join("session-id");
let nested = session.join("snapshots");
fs::create_dir_all(&nested).unwrap();
File::create(session.join("signals.json")).unwrap();
File::create(session.join("summary.json")).unwrap();
File::create(nested.join("signals.json")).unwrap();
let files = collect_files_with_max_depth(
&sessions,
is_grok_session_file,
TimeRange::All,
Some(GROK_SESSION_MAX_DEPTH),
)
.unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].path.ends_with("workspace/session-id/signals.json"));
}
#[test]
fn test_collect_files_with_max_depth_respects_bound() {
let dir = tempdir().unwrap();
let session = dir.path().join("session-state").join("sess-abc");
let backups = session.join("rewind-snapshots").join("backups");
fs::create_dir_all(&backups).unwrap();
File::create(session.join("events.jsonl")).unwrap();
File::create(backups.join("deadbeef-123")).unwrap();
File::create(backups.join("events.jsonl")).unwrap(); File::create(session.join("workspace.yaml")).unwrap();
let unbounded = collect_files_with_dates(
dir.path().join("session-state"),
is_copilot_session_file,
TimeRange::All,
)
.unwrap();
let unbounded_names: Vec<&str> = unbounded
.iter()
.filter_map(|f| f.path.file_name()?.to_str())
.collect();
assert!(unbounded_names.contains(&"events.jsonl"));
let bounded = collect_files_with_max_depth(
dir.path().join("session-state"),
is_copilot_session_file,
TimeRange::All,
Some(2),
)
.unwrap();
assert_eq!(bounded.len(), 1);
assert!(bounded[0].path.ends_with("sess-abc/events.jsonl"));
}
#[test]
fn test_collect_provider_files_deduplicates_only_across_roots() {
let dir = tempdir().unwrap();
let active = dir
.path()
.join("sessions")
.join("2026")
.join("06")
.join("06");
let archived = dir.path().join("archived_sessions");
fs::create_dir_all(&active).unwrap();
fs::create_dir_all(&archived).unwrap();
File::create(active.join("rollout-live.jsonl")).unwrap();
File::create(active.join("rollout-moving.jsonl")).unwrap();
File::create(archived.join("rollout-moving.jsonl")).unwrap();
File::create(archived.join("rollout-old.jsonl")).unwrap();
let roots = [dir.path().join("sessions"), archived.clone()];
let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
let files =
collect_provider_files_diagnostics(&roots, is_codex_session_file, TimeRange::All, None)
.files;
let mut found: Vec<PathBuf> = files.into_iter().map(|file| file.path).collect();
found.sort();
assert_eq!(found.len(), 3, "the moving session must be counted once");
assert!(found.contains(&active.join("rollout-moving.jsonl")));
assert!(!found.contains(&archived.join("rollout-moving.jsonl")));
assert!(found.contains(&archived.join("rollout-old.jsonl")));
}
#[test]
fn test_collect_provider_files_keeps_same_named_files_in_one_root() {
let dir = tempdir().unwrap();
let sessions = dir.path().join("session-state");
for session in ["sess-a", "sess-b"] {
let path = sessions.join(session);
fs::create_dir_all(&path).unwrap();
File::create(path.join("events.jsonl")).unwrap();
}
let roots = [sessions.as_path()];
let files = collect_provider_files_diagnostics(
&roots,
is_copilot_session_file,
TimeRange::All,
Some(COPILOT_SESSION_MAX_DEPTH),
)
.files;
assert_eq!(files.len(), 2);
}
#[test]
fn test_collect_provider_files_skips_missing_roots() {
let dir = tempdir().unwrap();
let archived = dir.path().join("archived_sessions");
fs::create_dir_all(&archived).unwrap();
File::create(archived.join("rollout-old.jsonl")).unwrap();
let roots = [dir.path().join("sessions"), archived];
let roots: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect();
let discovery =
collect_provider_files_diagnostics(&roots, is_codex_session_file, TimeRange::All, None);
assert_eq!(discovery.files.len(), 1);
assert!(discovery.failures.is_empty());
assert!(
collect_provider_files_diagnostics(&[], is_codex_session_file, TimeRange::All, None)
.files
.is_empty()
);
}
#[test]
fn test_collect_files_with_dates_empty_dir() {
let dir = tempdir().unwrap();
let results =
collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
assert_eq!(results.len(), 0);
}
#[test]
fn test_collect_files_with_dates_nonexistent_dir() {
let results =
collect_files_with_dates("/nonexistent/path", is_codex_session_file, TimeRange::All)
.unwrap();
assert_eq!(results.len(), 0);
}
#[test]
fn test_collect_files_with_dates_with_files() {
let dir = tempdir().unwrap();
File::create(dir.path().join("file1.json")).unwrap();
File::create(dir.path().join("file2.jsonl")).unwrap();
File::create(dir.path().join("file3.txt")).unwrap();
let results =
collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
assert_eq!(results.len(), 2);
for file_info in &results {
assert!(!file_info.modified_date.is_empty());
assert!(file_info.modified_date.contains('-')); }
}
#[test]
fn test_collect_files_with_dates_nested_directories() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("subdir1")).unwrap();
fs::create_dir_all(dir.path().join("subdir2")).unwrap();
File::create(dir.path().join("file1.json")).unwrap();
File::create(dir.path().join("subdir1/file2.json")).unwrap();
File::create(dir.path().join("subdir2/file3.jsonl")).unwrap();
let results =
collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
assert_eq!(results.len(), 3);
}
#[test]
fn test_collect_files_with_dates_filter_function() {
let dir = tempdir().unwrap();
File::create(dir.path().join("file1.json")).unwrap();
File::create(dir.path().join("file2.jsonl")).unwrap();
File::create(dir.path().join("file3.txt")).unwrap();
let results = collect_files_with_dates(
dir.path(),
|p| p.extension().is_some_and(|e| e == "txt"),
TimeRange::All,
)
.unwrap();
assert_eq!(results.len(), 1);
}
#[test]
fn test_collect_files_with_dates_no_matching_files() {
let dir = tempdir().unwrap();
File::create(dir.path().join("file1.txt")).unwrap();
File::create(dir.path().join("file2.md")).unwrap();
let results =
collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
assert_eq!(results.len(), 0);
}
#[test]
fn test_file_info_path() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.json");
File::create(&file_path).unwrap();
let results =
collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].path, file_path);
}
#[test]
fn test_file_info_date_format() {
let dir = tempdir().unwrap();
File::create(dir.path().join("test.json")).unwrap();
let results =
collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
assert_eq!(results.len(), 1);
let date = &results[0].modified_date;
assert_eq!(date.len(), 10); assert_eq!(date.chars().filter(|&c| c == '-').count(), 2); }
#[test]
fn test_collect_files_ignores_directories() {
let dir = tempdir().unwrap();
fs::create_dir(dir.path().join("test.json")).unwrap();
File::create(dir.path().join("real.json")).unwrap();
let results =
collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
assert_eq!(results.len(), 1); }
#[test]
fn test_is_codex_session_file_with_dots_in_name() {
let path = std::path::Path::new("my.test.file.json");
assert!(is_codex_session_file(path));
let path2 = std::path::Path::new("my.test.file.jsonl");
assert!(is_codex_session_file(path2));
}
#[test]
fn test_is_gemini_session_file_multiple_levels() {
let path = std::path::Path::new("/a/b/c/d/chats/file.jsonl");
assert!(is_gemini_session_file(path));
}
#[test]
fn test_collect_files_with_content() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.json");
let mut file = File::create(&file_path).unwrap();
writeln!(file, r#"{{"key": "value"}}"#).unwrap();
let results =
collect_files_with_dates(dir.path(), is_codex_session_file, TimeRange::All).unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].path.exists());
}
#[test]
fn test_is_codex_session_file_excludes_meta_sidecars() {
let path = std::path::Path::new("agent-afda1991051a0eb93.meta.json");
assert!(!is_codex_session_file(path));
let nested = std::path::Path::new(
"/home/user/.claude/projects/proj/sess/subagents/agent-x.meta.json",
);
assert!(!is_codex_session_file(nested));
let meta_jsonl = std::path::Path::new(
"/home/user/.claude/projects/proj/sess/subagents/agent-x.meta.jsonl",
);
assert!(!is_codex_session_file(meta_jsonl));
}
#[test]
fn test_is_claude_session_file_accepts_jsonl() {
let top_level = std::path::Path::new("/home/user/.claude/projects/proj/sess.jsonl");
assert!(is_claude_session_file(top_level));
let subagent = std::path::Path::new(
"/home/user/.claude/projects/proj/sess/subagents/agent-afda1991051a0eb93.jsonl",
);
assert!(is_claude_session_file(subagent));
}
#[test]
fn test_is_claude_session_file_rejects_non_jsonl() {
let meta = std::path::Path::new(
"/home/user/.claude/projects/proj/sess/subagents/agent-afda1991051a0eb93.meta.json",
);
assert!(!is_claude_session_file(meta));
let meta_jsonl = std::path::Path::new(
"/home/user/.claude/projects/proj/sess/subagents/agent-afda1991051a0eb93.meta.jsonl",
);
assert!(!is_claude_session_file(meta_jsonl));
let plain_json = std::path::Path::new("/home/user/.claude/projects/proj/notes.json");
assert!(!is_claude_session_file(plain_json));
let image = std::path::Path::new("/home/user/.claude/projects/proj/sess/paste.png");
assert!(!is_claude_session_file(image));
}
#[test]
fn test_collect_claude_session_files_includes_subagents() {
let dir = tempdir().unwrap();
let project = dir.path().join("-home-user-repo");
let session_subdir = project.join("sess-a");
let subagents = session_subdir.join("subagents");
fs::create_dir_all(&subagents).unwrap();
File::create(project.join("sess-a.jsonl")).unwrap();
File::create(subagents.join("agent-one.jsonl")).unwrap();
File::create(subagents.join("agent-one.meta.json")).unwrap();
File::create(subagents.join("agent-two.meta.jsonl")).unwrap();
File::create(session_subdir.join("screenshot.png")).unwrap();
let results =
collect_files_with_dates(dir.path(), is_claude_session_file, TimeRange::All).unwrap();
let names: Vec<String> = results
.iter()
.filter_map(|f| f.path.file_name()?.to_str().map(String::from))
.collect();
assert_eq!(results.len(), 2, "collected: {:?}", names);
assert!(names.contains(&"sess-a.jsonl".to_string()));
assert!(names.contains(&"agent-one.jsonl".to_string()));
assert!(!names.iter().any(|n| n.contains(".meta.")));
assert!(!names.iter().any(|n| n.ends_with(".png")));
}
}