Skip to main content

toolpath_copilot/
paths.rs

1//! Filesystem layout for GitHub Copilot CLI state.
2//!
3//! Sessions live at `~/.copilot/session-state/<session-id>/events.jsonl`
4//! (see `docs/agents/formats/copilot-cli/directory-layout.md`). The root is
5//! overridable with the `COPILOT_HOME` environment variable. Older sessions
6//! may sit under the legacy `history-session-state/` directory; we glance at
7//! it as a secondary location.
8
9use 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/// Builder-style resolver over the `~/.copilot/` filesystem.
21#[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_HOME` replaces the entire `~/.copilot` root.
38            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    /// Override the copilot directory directly (defaults to `~/.copilot`,
48    /// or `$COPILOT_HOME` when set).
49    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    /// `events.jsonl` path for a resolved session id.
82    pub fn events_file(&self, session_id: &str) -> Result<PathBuf> {
83        Ok(self.find_session_dir(session_id)?.join(EVENTS_FILE))
84    }
85
86    /// `workspace.yaml` path for a resolved session id.
87    pub fn workspace_file(&self, session_id: &str) -> Result<PathBuf> {
88        Ok(self.find_session_dir(session_id)?.join(WORKSPACE_FILE))
89    }
90
91    /// Enumerate every session directory (current + legacy) that contains an
92    /// `events.jsonl`, newest first by `events.jsonl` mtime.
93    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    /// Resolve a session identifier to its `session-state/<id>/` directory.
111    ///
112    /// Accepts an exact session-id directory name or a unique prefix of one.
113    /// (Name-based resolution would require reading `session-store.db`; see
114    /// `docs/agents/formats/copilot-cli/resume-and-sessions.md`.)
115    pub fn find_session_dir(&self, session_id: &str) -> Result<PathBuf> {
116        let all = self.list_session_dirs()?;
117        // Exact directory-name match first.
118        for p in &all {
119            if dir_name(p) == Some(session_id) {
120                return Ok(p.clone());
121            }
122        }
123        // Unique prefix match.
124        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
148/// Collect immediate subdirectories of `root` that contain an `events.jsonl`.
149fn 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    /// Create `<copilot>/session-state/<id>/events.jsonl` with `body`.
180    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        // Avoid COPILOT_HOME leaking in from the environment.
200        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        // A directory without events.jsonl is ignored.
224        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}