Skip to main content

open_loops/sessions/
claude_code.rs

1//! Adapter for Claude Code sessions (~/.claude/projects/<path-encoded>/*.jsonl).
2//! WARNING: internal Claude Code format, not a public API — may change.
3//! Parsing is therefore tolerant: a bad line is skipped, never aborts (spec risk 1).
4use 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
13/// Claude Code encodes the project path by replacing path separators and '.' with '-'.
14/// e.g. /home/g/repo/x -> -home-g-repo-x, C:\Users\g\app -> C--Users-g-app
15pub fn encode_project_path(p: &Path) -> String {
16    let raw = p.to_string_lossy();
17    // Windows canonicalize() may add \\?\ — Claude Code encodes the normal path.
18    let raw = raw.strip_prefix(r"\\?\").unwrap_or(&raw);
19    raw.replace(['/', '\\', '.', ':'], "-")
20}
21
22/// Extracts text from a session jsonl line. None for non-message,
23/// corrupted, or empty lines (tolerant parsing).
24pub 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
48/// Reads the last `max_bytes` of the file and extracts message text.
49/// The end of the conversation concentrates the "where I left off" signal (spec decision).
50fn 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(); // first line may be cut mid-way
57    }
58    Ok(lines
59        .filter_map(extract_text)
60        .collect::<Vec<_>>()
61        .join("\n"))
62}
63
64impl SessionSource for ClaudeCode {
65    /// Excerpts of the sessions most relevant to the branch.
66    ///
67    /// # Errors
68    ///
69    /// Returns an error if the project directory cannot be read.
70    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            // spec heuristic: in the time window OR mentions the branch
100            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)); // most recent first
105        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        // files of other formats are ignored
190        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        // window two years ago — file mtime is now (outside the window)
238        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        // padding with summary lines (not extracted) to force file > 1 KB
254        let pad_line = format!("{{\"type\":\"summary\",\"x\":\"{}\"}}\n", "A".repeat(80));
255        let mut content = pad_line.repeat(15); // ~1500 bytes
256        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        // max_kb=1 forces truncation: start > 0 → first line of the tail is skipped
268        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        // only summary and tool_result lines — extract_text returns None for all of them
281        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}