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