toolpath_copilot/
paths.rs1use crate::error::{ConvoError, Result};
10use std::fs;
11use std::path::{Path, PathBuf};
12
13const COPILOT_SUBDIR: &str = ".copilot";
14const SESSION_STATE_SUBDIR: &str = "session-state";
15const LEGACY_SESSION_STATE_SUBDIR: &str = "history-session-state";
16const EVENTS_FILE: &str = "events.jsonl";
17const WORKSPACE_FILE: &str = "workspace.yaml";
18const SESSION_STORE_DB: &str = "session-store.db";
19
20#[derive(Debug, Clone)]
22pub struct PathResolver {
23 home_dir: Option<PathBuf>,
24 copilot_dir: Option<PathBuf>,
25}
26
27impl Default for PathResolver {
28 fn default() -> Self {
29 Self::new()
30 }
31}
32
33impl PathResolver {
34 pub fn new() -> Self {
35 Self {
36 home_dir: dirs::home_dir(),
37 copilot_dir: std::env::var_os("COPILOT_HOME").map(PathBuf::from),
39 }
40 }
41
42 pub fn with_home<P: Into<PathBuf>>(mut self, home: P) -> Self {
43 self.home_dir = Some(home.into());
44 self
45 }
46
47 pub fn with_copilot_dir<P: Into<PathBuf>>(mut self, copilot_dir: P) -> Self {
50 self.copilot_dir = Some(copilot_dir.into());
51 self
52 }
53
54 pub fn home_dir(&self) -> Result<&Path> {
55 self.home_dir.as_deref().ok_or(ConvoError::NoHomeDirectory)
56 }
57
58 pub fn copilot_dir(&self) -> Result<PathBuf> {
59 if let Some(d) = &self.copilot_dir {
60 return Ok(d.clone());
61 }
62 Ok(self.home_dir()?.join(COPILOT_SUBDIR))
63 }
64
65 pub fn session_state_dir(&self) -> Result<PathBuf> {
66 Ok(self.copilot_dir()?.join(SESSION_STATE_SUBDIR))
67 }
68
69 pub fn legacy_session_state_dir(&self) -> Result<PathBuf> {
70 Ok(self.copilot_dir()?.join(LEGACY_SESSION_STATE_SUBDIR))
71 }
72
73 pub fn session_store_db(&self) -> Result<PathBuf> {
74 Ok(self.copilot_dir()?.join(SESSION_STORE_DB))
75 }
76
77 pub fn exists(&self) -> bool {
78 self.copilot_dir().map(|p| p.exists()).unwrap_or(false)
79 }
80
81 pub fn events_file(&self, session_id: &str) -> Result<PathBuf> {
83 Ok(self.find_session_dir(session_id)?.join(EVENTS_FILE))
84 }
85
86 pub fn workspace_file(&self, session_id: &str) -> Result<PathBuf> {
88 Ok(self.find_session_dir(session_id)?.join(WORKSPACE_FILE))
89 }
90
91 pub fn list_session_dirs(&self) -> Result<Vec<PathBuf>> {
94 let mut dirs = Vec::new();
95 for root in [self.session_state_dir()?, self.legacy_session_state_dir()?] {
96 collect_session_dirs(&root, &mut dirs);
97 }
98 dirs.sort_by_key(|p| {
99 let events = p.join(EVENTS_FILE);
100 fs::metadata(&events)
101 .and_then(|m| m.modified())
102 .ok()
103 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
104 .map(|d| std::cmp::Reverse(d.as_secs()))
105 .unwrap_or(std::cmp::Reverse(0))
106 });
107 Ok(dirs)
108 }
109
110 pub fn find_session_dir(&self, session_id: &str) -> Result<PathBuf> {
116 let all = self.list_session_dirs()?;
117 for p in &all {
119 if dir_name(p) == Some(session_id) {
120 return Ok(p.clone());
121 }
122 }
123 let matches: Vec<&PathBuf> = all
125 .iter()
126 .filter(|p| {
127 dir_name(p)
128 .map(|n| n.starts_with(session_id))
129 .unwrap_or(false)
130 })
131 .collect();
132 match matches.len() {
133 0 => Err(ConvoError::SessionNotFound(session_id.to_string())),
134 1 => Ok(matches[0].clone()),
135 _ => Err(ConvoError::SessionNotFound(format!(
136 "{} (ambiguous — {} matches)",
137 session_id,
138 matches.len()
139 ))),
140 }
141 }
142}
143
144fn dir_name(p: &Path) -> Option<&str> {
145 p.file_name().and_then(|n| n.to_str())
146}
147
148fn collect_session_dirs(root: &Path, out: &mut Vec<PathBuf>) {
150 let Ok(entries) = fs::read_dir(root) else {
151 return;
152 };
153 for entry in entries.flatten() {
154 let path = entry.path();
155 if entry.file_type().map(|t| t.is_dir()).unwrap_or(false)
156 && path.join(EVENTS_FILE).is_file()
157 {
158 out.push(path);
159 }
160 }
161}
162
163mod dirs {
164 use std::env;
165 use std::path::PathBuf;
166
167 pub fn home_dir() -> Option<PathBuf> {
168 env::var_os("HOME")
169 .or_else(|| env::var_os("USERPROFILE"))
170 .map(PathBuf::from)
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use tempfile::TempDir;
178
179 fn write_session(copilot: &Path, subdir: &str, id: &str, body: &str) {
181 let dir = copilot.join(subdir).join(id);
182 fs::create_dir_all(&dir).unwrap();
183 fs::write(dir.join(EVENTS_FILE), body).unwrap();
184 }
185
186 fn setup() -> (TempDir, PathResolver) {
187 let temp = TempDir::new().unwrap();
188 let copilot = temp.path().join(".copilot");
189 fs::create_dir_all(&copilot).unwrap();
190 let resolver = PathResolver::new()
191 .with_home(temp.path())
192 .with_copilot_dir(&copilot);
193 (temp, resolver)
194 }
195
196 #[test]
197 fn copilot_dir_defaults_to_home() {
198 let temp = TempDir::new().unwrap();
199 let r = PathResolver {
201 home_dir: Some(temp.path().to_path_buf()),
202 copilot_dir: None,
203 };
204 assert_eq!(r.copilot_dir().unwrap(), temp.path().join(".copilot"));
205 }
206
207 #[test]
208 fn session_state_dir_under_copilot_dir() {
209 let (_t, r) = setup();
210 assert!(
211 r.session_state_dir()
212 .unwrap()
213 .ends_with(".copilot/session-state")
214 );
215 }
216
217 #[test]
218 fn lists_session_dirs() {
219 let (t, r) = setup();
220 let copilot = t.path().join(".copilot");
221 write_session(&copilot, "session-state", "abc-123", "{}");
222 write_session(&copilot, "session-state", "def-456", "{}");
223 fs::create_dir_all(copilot.join("session-state/empty")).unwrap();
225 let dirs = r.list_session_dirs().unwrap();
226 assert_eq!(dirs.len(), 2);
227 }
228
229 #[test]
230 fn lists_legacy_sessions_too() {
231 let (t, r) = setup();
232 let copilot = t.path().join(".copilot");
233 write_session(&copilot, "session-state", "new-1", "{}");
234 write_session(&copilot, "history-session-state", "old-1", "{}");
235 let dirs = r.list_session_dirs().unwrap();
236 assert_eq!(dirs.len(), 2);
237 }
238
239 #[test]
240 fn find_by_exact_id() {
241 let (t, r) = setup();
242 let copilot = t.path().join(".copilot");
243 write_session(&copilot, "session-state", "abc-123", "{}");
244 let dir = r.find_session_dir("abc-123").unwrap();
245 assert_eq!(dir_name(&dir), Some("abc-123"));
246 assert_eq!(r.events_file("abc-123").unwrap(), dir.join("events.jsonl"));
247 }
248
249 #[test]
250 fn find_by_unique_prefix() {
251 let (t, r) = setup();
252 let copilot = t.path().join(".copilot");
253 write_session(&copilot, "session-state", "abc-123", "{}");
254 let dir = r.find_session_dir("abc").unwrap();
255 assert_eq!(dir_name(&dir), Some("abc-123"));
256 }
257
258 #[test]
259 fn find_missing_errors() {
260 let (_t, r) = setup();
261 assert!(matches!(
262 r.find_session_dir("nope").unwrap_err(),
263 ConvoError::SessionNotFound(_)
264 ));
265 }
266
267 #[test]
268 fn find_ambiguous_prefix_errors() {
269 let (t, r) = setup();
270 let copilot = t.path().join(".copilot");
271 write_session(&copilot, "session-state", "abc-1", "{}");
272 write_session(&copilot, "session-state", "abc-2", "{}");
273 assert!(matches!(
274 r.find_session_dir("abc").unwrap_err(),
275 ConvoError::SessionNotFound(_)
276 ));
277 }
278
279 #[test]
280 fn list_empty_when_no_root() {
281 let (_t, r) = setup();
282 assert!(r.list_session_dirs().unwrap().is_empty());
283 }
284}