1use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21
22use crate::paths::{canonical_spellings, canonicalize_deep, normalize_lexically};
23use crate::transcript::{
24 TranscriptBlock, TranscriptEvent, TranscriptRole, edited_path, is_edit_tool, parse_line,
25 read_tail,
26};
27
28pub const TRANSCRIPT_SCAN_BYTES: u64 = 2 * 1024 * 1024;
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Focus {
36 pub dir: String,
38 pub edits: u32,
40 pub total: u32,
42}
43
44impl Focus {
45 pub fn path_under(&self, root: &Path) -> PathBuf {
47 if self.dir == "." {
48 root.to_path_buf()
49 } else {
50 root.join(&self.dir)
51 }
52 }
53}
54
55pub fn focus_from_edits<'a>(
61 edits: impl IntoIterator<Item = (&'a str, u32)>,
62 root: &Path,
63) -> Option<Focus> {
64 let roots = root_forms(root);
65 let mut per_dir: HashMap<String, u32> = HashMap::new();
66 let mut total = 0u32;
67 for (file, count) in edits {
68 let Some(rel) = relative_to_any(file, &roots) else {
69 continue;
70 };
71 let Some(dir) = parent_dir(&rel) else {
72 continue;
73 };
74 total = total.saturating_add(count);
75 for ancestor in ancestors(dir) {
76 *per_dir.entry(ancestor).or_insert(0) += count;
77 }
78 }
79 if total == 0 {
80 return None;
81 }
82 per_dir
83 .into_iter()
84 .filter(|(_, n)| u64::from(*n) * 2 > u64::from(total))
85 .max_by_key(|(dir, _)| depth(dir))
86 .map(|(dir, edits)| Focus { dir, edits, total })
87}
88
89pub fn edits_from_transcript(jsonl_path: &Path, cap: u64) -> HashMap<String, u32> {
93 let text = read_tail(jsonl_path, cap).unwrap_or_default();
94 let mut edits = HashMap::new();
95 for line in text.lines() {
96 if !line.contains("tool_use") {
100 continue;
101 }
102 let Some(TranscriptEvent::Message(msg)) = parse_line(line) else {
103 continue;
104 };
105 if msg.role != TranscriptRole::Assistant {
106 continue;
107 }
108 for block in &msg.content {
109 let TranscriptBlock::ToolUse { name, input } = block else {
110 continue;
111 };
112 if !is_edit_tool(name) {
113 continue;
114 }
115 if let Some(p) = edited_path(input) {
116 *edits.entry(p.to_string()).or_insert(0) += 1;
117 }
118 }
119 }
120 edits
121}
122
123pub fn session_focus(jsonl_path: Option<&Path>, root: &Path) -> Option<Focus> {
131 session_focus_within(
132 jsonl_path?,
133 root,
134 TRANSCRIPT_SCAN_BYTES,
135 TRANSCRIPT_SCAN_MAX_BYTES,
136 )
137}
138
139pub const TRANSCRIPT_SCAN_MAX_BYTES: u64 = 32 * 1024 * 1024;
142
143pub fn session_focus_within(jsonl_path: &Path, root: &Path, cap: u64, max: u64) -> Option<Focus> {
146 let len = std::fs::metadata(jsonl_path).map(|m| m.len()).unwrap_or(0);
147 let mut cap = cap.max(1);
148 loop {
149 let edits = edits_from_transcript(jsonl_path, cap);
150 let focus = focus_from_edits(edits.iter().map(|(p, n)| (p.as_str(), *n)), root);
154 if focus.is_some() || cap >= len || cap >= max {
155 return focus;
156 }
157 cap = cap.saturating_mul(4).min(max);
158 }
159}
160
161fn root_forms(root: &Path) -> Vec<String> {
164 let mut forms = vec![trim_slash(&root.to_string_lossy())];
165 let c = trim_slash(&canonicalize_deep(root).to_string_lossy());
166 if !forms.contains(&c) {
167 forms.push(c);
168 }
169 forms
170}
171
172fn trim_slash(s: &str) -> String {
173 let t = s.trim_end_matches('/');
174 if t.is_empty() {
175 "/".to_string()
176 } else {
177 t.to_string()
178 }
179}
180
181fn relative_to_any(file: &str, roots: &[String]) -> Option<String> {
186 let normalized = normalize_lexically(Path::new(file));
191 let literal = normalized.to_string_lossy();
192 if let Some(rel) = roots.iter().find_map(|root| strip_root(&literal, root)) {
193 return Some(rel.to_string());
194 }
195 canonical_spellings(&normalized)
196 .iter()
197 .find_map(|f| roots.iter().find_map(|root| strip_root(f, root)))
198 .map(str::to_string)
199}
200
201fn strip_root<'a>(file: &'a str, root: &str) -> Option<&'a str> {
202 if root == "/" {
203 return file.strip_prefix('/');
204 }
205 file.strip_prefix(root)
206 .and_then(|rest| rest.strip_prefix('/'))
207}
208
209fn parent_dir(rel: &str) -> Option<&str> {
211 let rel = rel.trim_end_matches('/');
212 if rel.is_empty() {
213 return None;
214 }
215 Some(match rel.rfind('/') {
216 Some(i) => &rel[..i],
217 None => ".",
218 })
219}
220
221fn ancestors(dir: &str) -> Vec<String> {
223 let mut out = vec![".".to_string()];
224 if dir == "." {
225 return out;
226 }
227 let mut acc = String::new();
228 for seg in dir.split('/').filter(|s| !s.is_empty()) {
229 if !acc.is_empty() {
230 acc.push('/');
231 }
232 acc.push_str(seg);
233 out.push(acc.clone());
234 }
235 out
236}
237
238fn depth(dir: &str) -> usize {
239 if dir == "." {
240 0
241 } else {
242 dir.split('/').count()
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 fn focus(edits: &[(&str, u32)], root: &str) -> Option<Focus> {
251 focus_from_edits(edits.iter().copied(), Path::new(root))
252 }
253
254 #[test]
255 fn the_deepest_majority_directory_is_the_focus() {
256 let f = focus(
257 &[
258 ("/repo/src/relay/peer.rs", 3),
259 ("/repo/src/relay/mesh.rs", 2),
260 ("/repo/src/bus/mcp.rs", 1),
261 ],
262 "/repo",
263 )
264 .unwrap();
265 assert_eq!(f.dir, "src/relay");
266 assert_eq!((f.edits, f.total), (5, 6));
267 }
268
269 #[test]
270 fn an_even_split_stops_at_the_common_parent() {
271 let f = focus(
272 &[("/repo/src/relay/peer.rs", 2), ("/repo/src/bus/mcp.rs", 2)],
273 "/repo",
274 )
275 .unwrap();
276 assert_eq!(f.dir, "src", "neither sibling holds a strict majority");
277 }
278
279 #[test]
280 fn work_spread_across_the_tree_is_the_root() {
281 let f = focus(
282 &[
283 ("/repo/README.md", 1),
284 ("/repo/src/a.rs", 1),
285 ("/repo/docs/b.md", 1),
286 ],
287 "/repo",
288 )
289 .unwrap();
290 assert_eq!(f.dir, ".");
291 assert_eq!(f.path_under(Path::new("/repo")), PathBuf::from("/repo"));
292 }
293
294 #[test]
295 fn edits_outside_the_root_do_not_count() {
296 let f = focus(
297 &[
298 ("/home/me/.claude/CLAUDE.md", 10),
299 ("/repo-other/src/x.rs", 10),
300 ("/repo/src/relay/peer.rs", 1),
301 ],
302 "/repo",
303 )
304 .unwrap();
305 assert_eq!(f.dir, "src/relay");
306 assert_eq!(f.total, 1, "a sibling repo with the same prefix is outside");
307 }
308
309 #[test]
310 fn no_in_tree_edits_is_no_focus() {
311 assert_eq!(focus(&[], "/repo"), None);
312 assert_eq!(focus(&[("/elsewhere/x.rs", 4)], "/repo"), None);
313 }
314
315 #[test]
316 fn a_trailing_slash_on_the_root_is_harmless() {
317 let f = focus(&[("/repo/src/a.rs", 1)], "/repo/").unwrap();
318 assert_eq!(f.dir, "src");
319 assert_eq!(f.path_under(Path::new("/repo")), PathBuf::from("/repo/src"));
320 }
321
322 #[test]
323 fn the_canonical_root_matches_too() {
324 let tmp = std::env::temp_dir().join(format!("focus-{}", std::process::id()));
327 std::fs::create_dir_all(tmp.join("src")).unwrap();
328 let canon = std::fs::canonicalize(&tmp).unwrap();
329 let file = format!("{}/src/lib.rs", canon.display());
330 let f = focus(&[(file.as_str(), 2)], &tmp.to_string_lossy()).unwrap();
331 assert_eq!(f.dir, "src");
332 let _ = std::fs::remove_dir_all(&tmp);
333 }
334
335 #[test]
336 fn a_canonical_root_matches_a_file_spelled_through_the_symlink() {
337 let tmp = std::env::temp_dir().join(format!("focus-rev-{}", std::process::id()));
343 std::fs::create_dir_all(tmp.join("src")).unwrap();
344 let canon_root = std::fs::canonicalize(&tmp).unwrap();
345 let file = format!("{}/src/new.rs", tmp.display());
346 let f = focus(&[(file.as_str(), 1)], &canon_root.to_string_lossy()).unwrap();
347 assert_eq!(f.dir, "src");
348 let _ = std::fs::remove_dir_all(&tmp);
349 }
350
351 #[test]
352 fn transcript_edits_count_only_editing_tools_by_the_assistant() {
353 let tmp = std::env::temp_dir().join(format!("focus-jsonl-{}", std::process::id()));
354 let lines = [
355 r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/repo/src/relay/peer.rs"}}]}}"#,
356 r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"/repo/src/bus/mcp.rs"}}]}}"#,
357 r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"NotebookEdit","input":{"notebook_path":"/repo/src/relay/nb.ipynb"}}]}}"#,
358 r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Write","input":{"file_path":"/repo/src/relay/peer.rs"}}]}}"#,
359 r#"{"type":"user","message":{"role":"user","content":"please edit /repo/src/bus/x.rs"}}"#,
360 ];
361 std::fs::write(&tmp, lines.join("\n")).unwrap();
362 let edits = edits_from_transcript(&tmp, TRANSCRIPT_SCAN_BYTES);
363 assert_eq!(edits.get("/repo/src/relay/peer.rs"), Some(&2));
364 assert_eq!(edits.get("/repo/src/relay/nb.ipynb"), Some(&1));
365 assert_eq!(
366 edits.get("/repo/src/bus/mcp.rs"),
367 None,
368 "reads are not edits"
369 );
370 assert_eq!(edits.len(), 2);
371 let f = session_focus(Some(&tmp), Path::new("/repo")).unwrap();
372 assert_eq!(f.dir, "src/relay");
373 let _ = std::fs::remove_file(&tmp);
374 }
375
376 #[test]
377 fn a_capped_read_keeps_the_tail() {
378 let tmp = std::env::temp_dir().join(format!("focus-tail-{}", std::process::id()));
379 let old = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/repo/old/a.rs"}}]}}"#;
380 let new = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/repo/new/b.rs"}}]}}"#;
381 std::fs::write(&tmp, format!("{old}\n{new}\n")).unwrap();
382 let edits = edits_from_transcript(&tmp, (new.len() + 1) as u64);
383 assert_eq!(edits.get("/repo/new/b.rs"), Some(&1));
384 assert_eq!(
385 edits.get("/repo/old/a.rs"),
386 None,
387 "the head fell outside the cap"
388 );
389 let _ = std::fs::remove_file(&tmp);
390 }
391
392 #[test]
393 fn a_path_that_escapes_the_root_is_outside_and_dots_do_not_double_count() {
394 assert_eq!(
395 focus(&[("/repo/../sibling/x.rs", 3)], "/repo"),
396 None,
397 "`..` out of the root is not in the tree"
398 );
399 let f = focus(
400 &[("/repo/./src/./x.rs", 1), ("/repo/src/../src/y.rs", 1)],
401 "/repo",
402 )
403 .unwrap();
404 assert_eq!(f.dir, "src");
405 assert_eq!((f.edits, f.total), (2, 2));
406 }
407
408 #[test]
409 fn an_out_of_tree_edit_in_the_window_does_not_stop_the_search() {
410 let tmp = std::env::temp_dir().join(format!("focus-grow2-{}", std::process::id()));
411 let in_tree = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/repo/src/relay/peer.rs"}}]}}"#;
412 let log = "x".repeat(4096);
413 let result = format!(
414 r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"tool_result","content":"{log}"}}]}}}}"#
415 );
416 let out_of_tree = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/home/me/.claude/CLAUDE.md"}}]}}"#;
417 std::fs::write(&tmp, format!("{in_tree}\n{result}\n{out_of_tree}\n")).unwrap();
418 let small = (result.len() + out_of_tree.len() + 2) as u64;
419 assert_eq!(
420 edits_from_transcript(&tmp, small).len(),
421 1,
422 "only the out-of-tree edit fits"
423 );
424 let f = session_focus_within(&tmp, Path::new("/repo"), small, small * 16).unwrap();
425 assert_eq!(f.dir, "src/relay");
426 let _ = std::fs::remove_file(&tmp);
427 }
428
429 #[test]
430 fn a_window_full_of_one_huge_tool_result_grows_until_it_finds_the_edits() {
431 let tmp = std::env::temp_dir().join(format!("focus-grow-{}", std::process::id()));
432 let edit = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/repo/src/relay/peer.rs"}}]}}"#;
433 let log = "x".repeat(4096);
434 let result = format!(
435 r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"tool_result","content":"{log}"}}]}}}}"#
436 );
437 std::fs::write(&tmp, format!("{edit}\n{result}\n")).unwrap();
438 let small = (result.len() + 1) as u64;
440 assert_eq!(edits_from_transcript(&tmp, small).len(), 0);
441 let f = session_focus_within(&tmp, Path::new("/repo"), small, small * 16).unwrap();
443 assert_eq!(f.dir, "src/relay");
444 assert_eq!(
446 session_focus_within(&tmp, Path::new("/repo"), small, small),
447 None
448 );
449 let _ = std::fs::remove_file(&tmp);
450 }
451
452 #[test]
453 fn a_missing_transcript_is_no_focus() {
454 assert_eq!(session_focus(None, Path::new("/repo")), None);
455 assert_eq!(
456 session_focus(Some(Path::new("/nonexistent/x.jsonl")), Path::new("/repo")),
457 None
458 );
459 }
460}