toolpath_copilot/
reader.rs1use 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 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 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 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 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 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 let res = EventReader::read_lines_impl(&dir.join("events.jsonl"), true);
149 assert!(res.is_err());
150 assert!(
152 EventReader::read_lines_impl(&dir.join("events.jsonl"), false)
153 .unwrap()
154 .is_empty()
155 );
156 }
157}