1use super::{SessionExcerpt, SessionSource};
5use anyhow::Result;
6use chrono::{DateTime, Duration, Utc};
7use std::path::{Path, PathBuf};
8
9pub struct ClaudeCode {
10 pub projects_dir: PathBuf,
11}
12
13pub fn encode_project_path(p: &Path) -> String {
16 let raw = p.to_string_lossy();
17 let raw = raw.strip_prefix(r"\\?\").unwrap_or(&raw);
19 raw.replace(['/', '\\', '.', ':'], "-")
20}
21
22pub fn extract_text(line: &str) -> Option<String> {
25 let v: serde_json::Value = serde_json::from_str(line).ok()?;
26 let role = v.get("type")?.as_str()?;
27 if role != "user" && role != "assistant" {
28 return None;
29 }
30 let content = v.get("message")?.get("content")?;
31 let text = match content {
32 serde_json::Value::String(s) => s.clone(),
33 serde_json::Value::Array(parts) => parts
34 .iter()
35 .filter_map(|p| p.get("text").and_then(|t| t.as_str()))
36 .collect::<Vec<_>>()
37 .join("\n"),
38 _ => return None,
39 };
40 let text = text.trim();
41 if text.is_empty() {
42 None
43 } else {
44 Some(format!("[{role}] {text}"))
45 }
46}
47
48fn read_tail_text(path: &Path, max_bytes: u64) -> Result<String> {
51 let raw = std::fs::read(path)?;
52 let start = raw.len().saturating_sub(max_bytes as usize);
53 let tail = String::from_utf8_lossy(&raw[start..]);
54 let mut lines = tail.lines();
55 if start > 0 {
56 lines.next(); }
58 Ok(lines
59 .filter_map(extract_text)
60 .collect::<Vec<_>>()
61 .join("\n"))
62}
63
64impl SessionSource for ClaudeCode {
65 fn excerpts(
71 &self,
72 repo_path: &Path,
73 branch: &str,
74 window: (DateTime<Utc>, DateTime<Utc>),
75 max_sessions: usize,
76 max_kb: u64,
77 ) -> Result<Vec<SessionExcerpt>> {
78 let dir = self.projects_dir.join(encode_project_path(repo_path));
79 if !dir.is_dir() {
80 return Ok(vec![]);
81 }
82 let pad = Duration::days(7);
83 let (start, end) = (window.0 - pad, window.1 + pad);
84 let mut candidates: Vec<(DateTime<Utc>, PathBuf, bool, bool)> = Vec::new();
85 for entry in std::fs::read_dir(&dir)?.flatten() {
86 let path = entry.path();
87 if path.extension().is_none_or(|e| e != "jsonl") {
88 continue;
89 }
90 let Ok(meta) = entry.metadata() else { continue };
91 let Ok(modified) = meta.modified() else {
92 continue;
93 };
94 let modified: DateTime<Utc> = modified.into();
95 let in_window = modified >= start && modified <= end;
96 let mentions_branch = std::fs::read_to_string(&path)
97 .map(|c| c.contains(branch))
98 .unwrap_or(false);
99 if in_window || mentions_branch {
101 candidates.push((modified, path, in_window, mentions_branch));
102 }
103 }
104 candidates.sort_by(|a, b| b.0.cmp(&a.0)); candidates.truncate(max_sessions);
106 let mut out = Vec::new();
107 for (modified, path, in_window, mentions_branch) in candidates {
108 let text = read_tail_text(&path, max_kb * 1024)?;
109 if text.is_empty() {
110 continue;
111 }
112 let source = path
113 .file_name()
114 .map(|n| n.to_string_lossy().into_owned())
115 .unwrap_or_default();
116 out.push(SessionExcerpt {
117 source,
118 modified,
119 text,
120 in_window,
121 mentions_branch,
122 });
123 }
124 Ok(out)
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use crate::sessions::SessionSource;
132 use chrono::{Duration, Utc};
133 use std::path::Path;
134
135 #[test]
136 fn encode_project_path_matches_claude_code_format() {
137 assert_eq!(
138 encode_project_path(Path::new("/home/g/repo/me/open-loops")),
139 "-home-g-repo-me-open-loops"
140 );
141 assert_eq!(
142 encode_project_path(Path::new("/home/g/my.app")),
143 "-home-g-my-app"
144 );
145 }
146
147 #[test]
148 #[cfg(windows)]
149 fn encode_project_path_handles_windows_separators() {
150 assert_eq!(
151 encode_project_path(Path::new(r"C:\Users\g\app")),
152 "C--Users-g-app"
153 );
154 }
155
156 #[test]
157 fn extract_text_captures_user_assistant_and_ignores_rest() {
158 let user = r#"{"type":"user","message":{"content":"quero implementar login"}}"#;
159 let asst = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"vou criar feat/login"}]}}"#;
160 let meta = r#"{"type":"summary","summary":"x"}"#;
161 assert_eq!(
162 extract_text(user).unwrap(),
163 "[user] quero implementar login"
164 );
165 assert_eq!(
166 extract_text(asst).unwrap(),
167 "[assistant] vou criar feat/login"
168 );
169 assert!(extract_text(meta).is_none());
170 assert!(extract_text("corrupted non-json line").is_none());
171 }
172
173 #[test]
174 fn excerpts_selects_by_window_tolerates_garbage_and_limits_count() {
175 let tmp = tempfile::tempdir().unwrap();
176 let projects = tmp.path().to_path_buf();
177 let repo = Path::new("/home/g/app");
178 let dir = projects.join(encode_project_path(repo));
179 std::fs::create_dir_all(&dir).unwrap();
180 std::fs::write(
181 dir.join("sessao1.jsonl"),
182 concat!(
183 r#"{"type":"user","message":{"content":"quero implementar login"}}"#, "\n",
184 "lixo nao-json\n",
185 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"proximo passo: validar token"}]}}"#, "\n",
186 ),
187 )
188 .unwrap();
189 std::fs::write(dir.join("nota.txt"), "nada").unwrap();
191
192 let src = ClaudeCode {
193 projects_dir: projects,
194 };
195 let now = Utc::now();
196 let window = (now - Duration::days(1), now + Duration::days(1));
197 let ex = src.excerpts(repo, "feat/login", window, 3, 50).unwrap();
198 assert_eq!(ex.len(), 1);
199 assert!(ex[0].text.contains("[user] quero implementar login"));
200 assert!(ex[0].text.contains("proximo passo: validar token"));
201 assert_eq!(ex[0].source, "sessao1.jsonl");
202 }
203
204 #[test]
205 fn excerpts_empty_when_project_dir_does_not_exist() {
206 let tmp = tempfile::tempdir().unwrap();
207 let src = ClaudeCode {
208 projects_dir: tmp.path().to_path_buf(),
209 };
210 let now = Utc::now();
211 let ex = src
212 .excerpts(Path::new("/nao/existe"), "b", (now, now), 3, 50)
213 .unwrap();
214 assert!(ex.is_empty());
215 }
216
217 #[test]
218 fn excerpts_includes_session_outside_window_if_it_mentions_branch() {
219 let tmp = tempfile::tempdir().unwrap();
220 let projects = tmp.path().to_path_buf();
221 let repo = Path::new("/home/g/app");
222 let dir = projects.join(encode_project_path(repo));
223 std::fs::create_dir_all(&dir).unwrap();
224 std::fs::write(
225 dir.join("antiga.jsonl"),
226 concat!(
227 r#"{"type":"user","message":{"content":"implementando feat/login agora"}}"#,
228 "\n",
229 ),
230 )
231 .unwrap();
232
233 let src = ClaudeCode {
234 projects_dir: projects,
235 };
236 let now = Utc::now();
237 let passado = now - Duration::days(730);
239 let window = (passado - Duration::days(1), passado);
240 let ex = src.excerpts(repo, "feat/login", window, 3, 50).unwrap();
241 assert_eq!(ex.len(), 1, "mention heuristic must include the session");
242 assert!(ex[0].text.contains("feat/login"));
243 }
244
245 #[test]
246 fn excerpts_truncates_large_file_and_skips_cut_line() {
247 let tmp = tempfile::tempdir().unwrap();
248 let projects = tmp.path().to_path_buf();
249 let repo = Path::new("/home/g/app");
250 let dir = projects.join(encode_project_path(repo));
251 std::fs::create_dir_all(&dir).unwrap();
252
253 let pad_line = format!("{{\"type\":\"summary\",\"x\":\"{}\"}}\n", "A".repeat(80));
255 let mut content = pad_line.repeat(15); content.push_str(r#"{"type":"user","message":{"content":"contexto final"}}"#);
257 content.push('\n');
258 assert!(content.len() > 1024);
259
260 std::fs::write(dir.join("grande.jsonl"), &content).unwrap();
261
262 let src = ClaudeCode {
263 projects_dir: projects,
264 };
265 let now = Utc::now();
266 let window = (now - Duration::days(1), now + Duration::days(1));
267 let ex = src.excerpts(repo, "feat/x", window, 3, 1).unwrap();
269 assert_eq!(ex.len(), 1);
270 assert!(ex[0].text.contains("contexto final"));
271 }
272
273 #[test]
274 fn excerpts_skips_session_with_only_messages_without_text() {
275 let tmp = tempfile::tempdir().unwrap();
276 let projects = tmp.path().to_path_buf();
277 let repo = Path::new("/home/g/app");
278 let dir = projects.join(encode_project_path(repo));
279 std::fs::create_dir_all(&dir).unwrap();
280 std::fs::write(
282 dir.join("vazia.jsonl"),
283 concat!(
284 r#"{"type":"summary","summary":"nada util"}"#,
285 "\n",
286 r#"{"type":"tool_result","content":[]}"#,
287 "\n",
288 ),
289 )
290 .unwrap();
291
292 let src = ClaudeCode {
293 projects_dir: projects,
294 };
295 let now = Utc::now();
296 let window = (now - Duration::days(1), now + Duration::days(1));
297 let ex = src.excerpts(repo, "feat/x", window, 3, 50).unwrap();
298 assert!(
299 ex.is_empty(),
300 "session with no extractable text must be skipped"
301 );
302 }
303}