Skip to main content

toolpath_copilot/
reader.rs

1//! Read a Copilot CLI session directory into a [`Session`].
2//!
3//! The reader is tolerant: a malformed `events.jsonl` line is logged to
4//! stderr and skipped (a crash can leave the final line truncated), unless
5//! `COPILOT_EVENTS_STRICT` is set, in which case the first bad line errors.
6
7use crate::error::{ConvoError, Result};
8use crate::types::{EventLine, Session, Workspace, parse_workspace};
9use std::io::{BufRead, BufReader};
10use std::path::Path;
11
12const EVENTS_FILE: &str = "events.jsonl";
13const WORKSPACE_FILE: &str = "workspace.yaml";
14
15pub struct EventReader;
16
17impl EventReader {
18    /// Read a `session-state/<id>/` directory. The session id is the
19    /// directory name.
20    pub fn read_session_dir<P: AsRef<Path>>(dir: P) -> Result<Session> {
21        let dir = dir.as_ref();
22        let id = dir
23            .file_name()
24            .and_then(|n| n.to_str())
25            .ok_or_else(|| ConvoError::InvalidFormat(dir.to_path_buf()))?
26            .to_string();
27        let events_path = dir.join(EVENTS_FILE);
28        let lines = Self::read_lines(&events_path)?;
29        let workspace = Self::read_workspace(dir);
30        Ok(Session {
31            id,
32            dir_path: dir.to_path_buf(),
33            lines,
34            workspace,
35        })
36    }
37
38    /// Read the sibling `workspace.yaml`, if present and non-empty. Any read
39    /// or parse trouble is treated as "no workspace context" (best-effort).
40    pub fn read_workspace<P: AsRef<Path>>(dir: P) -> Option<Workspace> {
41        let path = dir.as_ref().join(WORKSPACE_FILE);
42        let content = std::fs::read_to_string(&path).ok()?;
43        let ws = parse_workspace(&content);
44        if ws.is_empty() { None } else { Some(ws) }
45    }
46
47    /// Parse the JSONL lines of an `events.jsonl` file. Malformed-line
48    /// tolerance is controlled by `COPILOT_EVENTS_STRICT`.
49    pub fn read_lines<P: AsRef<Path>>(path: P) -> Result<Vec<EventLine>> {
50        let strict = std::env::var_os("COPILOT_EVENTS_STRICT").is_some();
51        Self::read_lines_impl(path.as_ref(), strict)
52    }
53
54    /// Parse the JSONL lines with `strict` passed explicitly (env-independent,
55    /// so tests don't race on a process-global var).
56    fn read_lines_impl(path: &Path, strict: bool) -> Result<Vec<EventLine>> {
57        let file = std::fs::File::open(path)?;
58        let reader = BufReader::new(file);
59        let mut lines = Vec::new();
60        for (idx, line) in reader.lines().enumerate() {
61            let line = line?;
62            let trimmed = line.trim();
63            if trimmed.is_empty() {
64                continue;
65            }
66            match serde_json::from_str::<EventLine>(trimmed) {
67                Ok(ev) => lines.push(ev),
68                Err(e) => {
69                    if strict {
70                        return Err(ConvoError::Json(e));
71                    }
72                    eprintln!(
73                        "Warning: skipping malformed line {} in {}: {}",
74                        idx + 1,
75                        path.display(),
76                        e
77                    );
78                }
79            }
80        }
81        Ok(lines)
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use std::fs;
89    use tempfile::TempDir;
90
91    fn session_dir(id: &str, body: &str) -> (TempDir, std::path::PathBuf) {
92        let temp = TempDir::new().unwrap();
93        let dir = temp.path().join("session-state").join(id);
94        fs::create_dir_all(&dir).unwrap();
95        fs::write(dir.join("events.jsonl"), body).unwrap();
96        (temp, dir)
97    }
98
99    #[test]
100    fn reads_id_from_dir_name() {
101        let body = r#"{"type":"session.start","timestamp":"2026-06-30T10:00:00.000Z","data":{"cwd":"/tmp/p","model":"m"}}"#;
102        let (_t, dir) = session_dir("sess-abc", body);
103        let s = EventReader::read_session_dir(&dir).unwrap();
104        assert_eq!(s.id, "sess-abc");
105        assert_eq!(s.lines.len(), 1);
106        assert_eq!(s.cwd().as_deref(), Some("/tmp/p"));
107    }
108
109    #[test]
110    fn skips_blank_and_malformed_lines() {
111        let body = "\n{not json}\n{\"type\":\"user.message\",\"data\":{\"text\":\"hi\"}}\n";
112        let (_t, dir) = session_dir("sess-1", body);
113        let s = EventReader::read_session_dir(&dir).unwrap();
114        // blank skipped, malformed skipped, one good line kept
115        assert_eq!(s.lines.len(), 1);
116        assert_eq!(s.first_user_text().as_deref(), Some("hi"));
117    }
118
119    #[test]
120    fn reads_sibling_workspace_yaml() {
121        let body = r#"{"type":"session.start","data":{"cwd":"/tmp/p"}}"#;
122        let (_t, dir) = session_dir("sess-ws", body);
123        std::fs::write(
124            dir.join("workspace.yaml"),
125            "git_root: /tmp/p\nrepository: git@github.com:o/r.git\nbranch: main\n",
126        )
127        .unwrap();
128        let s = EventReader::read_session_dir(&dir).unwrap();
129        let ws = s.workspace.expect("workspace parsed");
130        assert_eq!(ws.branch.as_deref(), Some("main"));
131        assert_eq!(ws.repository.as_deref(), Some("git@github.com:o/r.git"));
132    }
133
134    #[test]
135    fn no_workspace_yaml_is_none() {
136        let body = r#"{"type":"user.message","data":{"text":"hi"}}"#;
137        let (_t, dir) = session_dir("sess-no-ws", body);
138        let s = EventReader::read_session_dir(&dir).unwrap();
139        assert!(s.workspace.is_none());
140    }
141
142    #[test]
143    fn strict_mode_errors_on_malformed() {
144        let body = "{bad}\n";
145        let (_t, dir) = session_dir("sess-2", body);
146        // Exercise strict mode directly (no process-global env mutation, which
147        // would race the concurrent non-strict test).
148        let res = EventReader::read_lines_impl(&dir.join("events.jsonl"), true);
149        assert!(res.is_err());
150        // Non-strict tolerates the same file.
151        assert!(
152            EventReader::read_lines_impl(&dir.join("events.jsonl"), false)
153                .unwrap()
154                .is_empty()
155        );
156    }
157}