Skip to main content

toolpath_copilot/
io.rs

1//! Higher-level filesystem operations over [`PathResolver`].
2
3use crate::error::Result;
4use crate::paths::PathResolver;
5use crate::reader::EventReader;
6use crate::types::{Session, SessionMetadata};
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, Default)]
10pub struct ConvoIO {
11    resolver: PathResolver,
12}
13
14impl ConvoIO {
15    pub fn new() -> Self {
16        Self {
17            resolver: PathResolver::new(),
18        }
19    }
20
21    pub fn with_resolver(resolver: PathResolver) -> Self {
22        Self { resolver }
23    }
24
25    pub fn resolver(&self) -> &PathResolver {
26        &self.resolver
27    }
28
29    pub fn exists(&self) -> bool {
30        self.resolver.exists()
31    }
32
33    pub fn copilot_dir_path(&self) -> Result<PathBuf> {
34        self.resolver.copilot_dir()
35    }
36
37    /// List every session directory (current + legacy), newest first.
38    pub fn list_session_dirs(&self) -> Result<Vec<PathBuf>> {
39        self.resolver.list_session_dirs()
40    }
41
42    /// Return lightweight metadata for every session, newest first.
43    pub fn list_sessions(&self) -> Result<Vec<SessionMetadata>> {
44        let dirs = self.list_session_dirs()?;
45        let mut metas = Vec::with_capacity(dirs.len());
46        for dir in dirs {
47            match self.read_metadata(&dir) {
48                Ok(m) => metas.push(m),
49                Err(e) => eprintln!("Warning: failed to read {}: {}", dir.display(), e),
50            }
51        }
52        metas.sort_by_key(|m| std::cmp::Reverse(m.last_activity));
53        Ok(metas)
54    }
55
56    /// Read one session by id (exact or unique prefix).
57    pub fn read_session(&self, session_id: &str) -> Result<Session> {
58        let dir = self.resolver.find_session_dir(session_id)?;
59        EventReader::read_session_dir(&dir)
60    }
61
62    /// Read one session by its directory path.
63    pub fn read_session_dir<P: AsRef<std::path::Path>>(&self, dir: P) -> Result<Session> {
64        EventReader::read_session_dir(dir)
65    }
66
67    /// Cheap per-session metadata. Copilot session files have no compact
68    /// header, so this walks the file (sessions are small).
69    pub fn read_metadata<P: AsRef<std::path::Path>>(&self, dir: P) -> Result<SessionMetadata> {
70        let session = EventReader::read_session_dir(dir)?;
71        Ok(SessionMetadata {
72            id: session.id.clone(),
73            dir_path: session.dir_path.clone(),
74            started_at: session.started_at(),
75            last_activity: session.last_activity(),
76            cwd: session.cwd(),
77            version: session.version(),
78            first_user_message: session.first_user_text(),
79            line_count: session.lines.len(),
80        })
81    }
82
83    pub fn session_exists(&self, session_id: &str) -> bool {
84        self.resolver.find_session_dir(session_id).is_ok()
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use std::fs;
92    use tempfile::TempDir;
93
94    fn setup() -> (TempDir, ConvoIO) {
95        let temp = TempDir::new().unwrap();
96        let copilot = temp.path().join(".copilot");
97        let dir = copilot.join("session-state").join("sess-abc");
98        fs::create_dir_all(&dir).unwrap();
99        let body = [
100            r#"{"type":"session.start","timestamp":"2026-06-30T10:00:00.000Z","data":{"copilotVersion":"1.0.66","context":{"cwd":"/tmp/proj"}}}"#,
101            r#"{"type":"user.message","timestamp":"2026-06-30T10:00:01.000Z","data":{"content":"hi there"}}"#,
102        ]
103        .join("\n");
104        fs::write(dir.join("events.jsonl"), body).unwrap();
105        let resolver = PathResolver::new().with_copilot_dir(&copilot);
106        (temp, ConvoIO::with_resolver(resolver))
107    }
108
109    #[test]
110    fn lists_sessions_with_metadata() {
111        let (_t, io) = setup();
112        let sessions = io.list_sessions().unwrap();
113        assert_eq!(sessions.len(), 1);
114        assert_eq!(sessions[0].id, "sess-abc");
115        assert_eq!(sessions[0].cwd.as_deref(), Some("/tmp/proj"));
116        assert_eq!(sessions[0].version.as_deref(), Some("1.0.66"));
117        assert_eq!(sessions[0].first_user_message.as_deref(), Some("hi there"));
118        assert_eq!(sessions[0].line_count, 2);
119    }
120
121    #[test]
122    fn reads_session_by_id_and_prefix() {
123        let (_t, io) = setup();
124        assert_eq!(io.read_session("sess-abc").unwrap().lines.len(), 2);
125        assert_eq!(io.read_session("sess").unwrap().id, "sess-abc");
126    }
127
128    #[test]
129    fn session_exists_reflects_disk() {
130        let (_t, io) = setup();
131        assert!(io.session_exists("sess-abc"));
132        assert!(!io.session_exists("nope"));
133    }
134}