1use crate::{Agent, DiscoverError, DiscoverOperation, FileKind, Roots};
2use std::{
3 fs, io,
4 path::{Path, PathBuf},
5 time::SystemTime,
6};
7
8#[derive(Debug, Clone, Default)]
9pub struct DiscoverFilter {
10 pub agents: Vec<Agent>,
11 pub include_subagents: bool,
12 pub modified_after: Option<SystemTime>,
13}
14#[derive(Debug, Clone)]
15pub struct SessionFile {
16 pub agent: Agent,
17 pub path: PathBuf,
18 pub kind: FileKind,
19 pub size: u64,
20 pub modified: SystemTime,
21}
22#[derive(Debug, Default)]
23pub struct Discovery {
24 pub files: Vec<SessionFile>,
25 pub errors: Vec<DiscoverError>,
26}
27
28impl SessionFile {
29 pub fn inspect(agent: Agent, path: impl Into<PathBuf>) -> io::Result<Self> {
31 let path = path.into();
32 let meta = fs::symlink_metadata(&path)?;
33 if !meta.is_file() {
34 return Err(io::Error::new(
35 io::ErrorKind::InvalidInput,
36 "session path is not a regular file",
37 ));
38 }
39 let kind = if path.components().any(|p| p.as_os_str() == "subagents") {
40 FileKind::Subagent
41 } else {
42 FileKind::Main
43 };
44 Ok(Self {
45 agent,
46 path,
47 kind,
48 size: meta.len(),
49 modified: meta.modified()?,
50 })
51 }
52}
53
54fn scan_roots(roots: &Roots) -> Vec<(Agent, PathBuf)> {
55 let mut result = Vec::new();
56 if let Some(root) = &roots.claude {
57 result.push((Agent::ClaudeCode, root.join("projects")));
58 }
59 if let Some(root) = &roots.codex {
60 for subdir in ["sessions", "archived_sessions"] {
61 result.push((Agent::Codex, root.join(subdir)));
62 }
63 }
64 result
65}
66
67pub fn classify(path: &Path, roots: &Roots) -> io::Result<Option<SessionFile>> {
70 if path
71 .components()
72 .any(|c| matches!(c, std::path::Component::ParentDir))
73 {
74 return Err(io::Error::new(
75 io::ErrorKind::InvalidInput,
76 "parent path component",
77 ));
78 }
79 if path.extension().is_none_or(|e| e != "jsonl") {
80 return Ok(None);
81 }
82 for (agent, root) in scan_roots(roots) {
83 if path.starts_with(root) {
84 return SessionFile::inspect(agent, path).map(Some);
85 }
86 }
87 Ok(None)
88}
89
90pub fn discover(roots: &Roots, filter: &DiscoverFilter) -> Discovery {
91 let mut result = Discovery::default();
92 for (agent, root) in scan_roots(roots) {
93 scan_directory(agent, &root, filter, true, &mut result);
94 }
95 sort_files(&mut result);
96 result
97}
98
99pub fn discover_directory(agent: Agent, root: &Path, filter: &DiscoverFilter) -> Discovery {
104 let mut result = Discovery::default();
105 scan_directory(agent, root, filter, false, &mut result);
106 sort_files(&mut result);
107 result
108}
109
110fn scan_directory(
111 agent: Agent,
112 root: &Path,
113 filter: &DiscoverFilter,
114 optional: bool,
115 result: &mut Discovery,
116) {
117 if !filter.agents.is_empty() && !filter.agents.contains(&agent) {
118 return;
119 }
120 let skip_tagged_subagents = optional && !filter.include_subagents;
121 let mut pending = vec![(root.to_path_buf(), optional)];
122 while let Some((dir, optional)) = pending.pop() {
123 let metadata = match fs::symlink_metadata(&dir) {
124 Ok(m) => m,
125 Err(e) if optional && e.kind() == io::ErrorKind::NotFound => continue,
126 Err(source) => {
127 result.errors.push(DiscoverError {
128 agent,
129 path: dir,
130 source,
131 operation: DiscoverOperation::DirectoryMetadata,
132 });
133 continue;
134 }
135 };
136 if metadata.is_symlink() {
137 continue;
138 }
139 let entries = match fs::read_dir(&dir) {
140 Ok(entries) => entries,
141 Err(source) => {
142 result.errors.push(DiscoverError {
143 agent,
144 path: dir,
145 source,
146 operation: DiscoverOperation::ReadDirectory,
147 });
148 continue;
149 }
150 };
151 for entry in entries {
152 let entry = match entry {
153 Ok(e) => e,
154 Err(source) => {
155 result.errors.push(DiscoverError {
156 agent,
157 path: dir.clone(),
158 source,
159 operation: DiscoverOperation::ReadEntry,
160 });
161 continue;
162 }
163 };
164 let path = entry.path();
165 let ty = match entry.file_type() {
166 Ok(ty) => ty,
167 Err(source) => {
168 result.errors.push(DiscoverError {
169 agent,
170 path,
171 source,
172 operation: DiscoverOperation::FileType,
173 });
174 continue;
175 }
176 };
177 if ty.is_dir() {
178 if filter.include_subagents || entry.file_name() != "subagents" {
179 pending.push((path, false));
180 }
181 } else if ty.is_file() && path.extension().is_some_and(|e| e == "jsonl") {
182 match SessionFile::inspect(agent, &path) {
183 Ok(file)
184 if filter.modified_after.is_none_or(|t| file.modified >= t)
185 && (!skip_tagged_subagents || file.kind == FileKind::Main) =>
186 {
187 result.files.push(file)
188 }
189 Ok(_) => {}
190 Err(source) => result.errors.push(DiscoverError {
191 agent,
192 path,
193 source,
194 operation: DiscoverOperation::InspectFile,
195 }),
196 }
197 }
198 }
199 }
200}
201fn sort_files(result: &mut Discovery) {
202 result.files.sort_by(|a, b| a.path.cmp(&b.path));
203 result
204 .files
205 .dedup_by(|a, b| a.path == b.path && a.agent == b.agent);
206}