use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::paths::{canonical_spellings, canonicalize_deep, normalize_lexically};
use crate::transcript::{
TranscriptBlock, TranscriptEvent, TranscriptRole, edited_path, is_edit_tool, parse_line,
read_tail,
};
pub const TRANSCRIPT_SCAN_BYTES: u64 = 2 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Focus {
pub dir: String,
pub edits: u32,
pub total: u32,
}
impl Focus {
pub fn path_under(&self, root: &Path) -> PathBuf {
if self.dir == "." {
root.to_path_buf()
} else {
root.join(&self.dir)
}
}
}
pub fn focus_from_edits<'a>(
edits: impl IntoIterator<Item = (&'a str, u32)>,
root: &Path,
) -> Option<Focus> {
let roots = root_forms(root);
let mut per_dir: HashMap<String, u32> = HashMap::new();
let mut total = 0u32;
for (file, count) in edits {
let Some(rel) = relative_to_any(file, &roots) else {
continue;
};
let Some(dir) = parent_dir(&rel) else {
continue;
};
total = total.saturating_add(count);
for ancestor in ancestors(dir) {
*per_dir.entry(ancestor).or_insert(0) += count;
}
}
if total == 0 {
return None;
}
per_dir
.into_iter()
.filter(|(_, n)| u64::from(*n) * 2 > u64::from(total))
.max_by_key(|(dir, _)| depth(dir))
.map(|(dir, edits)| Focus { dir, edits, total })
}
pub fn edits_from_transcript(jsonl_path: &Path, cap: u64) -> HashMap<String, u32> {
let text = read_tail(jsonl_path, cap).unwrap_or_default();
let mut edits = HashMap::new();
for line in text.lines() {
if !line.contains("tool_use") {
continue;
}
let Some(TranscriptEvent::Message(msg)) = parse_line(line) else {
continue;
};
if msg.role != TranscriptRole::Assistant {
continue;
}
for block in &msg.content {
let TranscriptBlock::ToolUse { name, input } = block else {
continue;
};
if !is_edit_tool(name) {
continue;
}
if let Some(p) = edited_path(input) {
*edits.entry(p.to_string()).or_insert(0) += 1;
}
}
}
edits
}
pub fn session_focus(jsonl_path: Option<&Path>, root: &Path) -> Option<Focus> {
session_focus_within(
jsonl_path?,
root,
TRANSCRIPT_SCAN_BYTES,
TRANSCRIPT_SCAN_MAX_BYTES,
)
}
pub const TRANSCRIPT_SCAN_MAX_BYTES: u64 = 32 * 1024 * 1024;
pub fn session_focus_within(jsonl_path: &Path, root: &Path, cap: u64, max: u64) -> Option<Focus> {
let len = std::fs::metadata(jsonl_path).map(|m| m.len()).unwrap_or(0);
let mut cap = cap.max(1);
loop {
let edits = edits_from_transcript(jsonl_path, cap);
let focus = focus_from_edits(edits.iter().map(|(p, n)| (p.as_str(), *n)), root);
if focus.is_some() || cap >= len || cap >= max {
return focus;
}
cap = cap.saturating_mul(4).min(max);
}
}
fn root_forms(root: &Path) -> Vec<String> {
let mut forms = vec![trim_slash(&root.to_string_lossy())];
let c = trim_slash(&canonicalize_deep(root).to_string_lossy());
if !forms.contains(&c) {
forms.push(c);
}
forms
}
fn trim_slash(s: &str) -> String {
let t = s.trim_end_matches('/');
if t.is_empty() {
"/".to_string()
} else {
t.to_string()
}
}
fn relative_to_any(file: &str, roots: &[String]) -> Option<String> {
let normalized = normalize_lexically(Path::new(file));
let literal = normalized.to_string_lossy();
if let Some(rel) = roots.iter().find_map(|root| strip_root(&literal, root)) {
return Some(rel.to_string());
}
canonical_spellings(&normalized)
.iter()
.find_map(|f| roots.iter().find_map(|root| strip_root(f, root)))
.map(str::to_string)
}
fn strip_root<'a>(file: &'a str, root: &str) -> Option<&'a str> {
if root == "/" {
return file.strip_prefix('/');
}
file.strip_prefix(root)
.and_then(|rest| rest.strip_prefix('/'))
}
fn parent_dir(rel: &str) -> Option<&str> {
let rel = rel.trim_end_matches('/');
if rel.is_empty() {
return None;
}
Some(match rel.rfind('/') {
Some(i) => &rel[..i],
None => ".",
})
}
fn ancestors(dir: &str) -> Vec<String> {
let mut out = vec![".".to_string()];
if dir == "." {
return out;
}
let mut acc = String::new();
for seg in dir.split('/').filter(|s| !s.is_empty()) {
if !acc.is_empty() {
acc.push('/');
}
acc.push_str(seg);
out.push(acc.clone());
}
out
}
fn depth(dir: &str) -> usize {
if dir == "." {
0
} else {
dir.split('/').count()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn focus(edits: &[(&str, u32)], root: &str) -> Option<Focus> {
focus_from_edits(edits.iter().copied(), Path::new(root))
}
#[test]
fn the_deepest_majority_directory_is_the_focus() {
let f = focus(
&[
("/repo/src/relay/peer.rs", 3),
("/repo/src/relay/mesh.rs", 2),
("/repo/src/bus/mcp.rs", 1),
],
"/repo",
)
.unwrap();
assert_eq!(f.dir, "src/relay");
assert_eq!((f.edits, f.total), (5, 6));
}
#[test]
fn an_even_split_stops_at_the_common_parent() {
let f = focus(
&[("/repo/src/relay/peer.rs", 2), ("/repo/src/bus/mcp.rs", 2)],
"/repo",
)
.unwrap();
assert_eq!(f.dir, "src", "neither sibling holds a strict majority");
}
#[test]
fn work_spread_across_the_tree_is_the_root() {
let f = focus(
&[
("/repo/README.md", 1),
("/repo/src/a.rs", 1),
("/repo/docs/b.md", 1),
],
"/repo",
)
.unwrap();
assert_eq!(f.dir, ".");
assert_eq!(f.path_under(Path::new("/repo")), PathBuf::from("/repo"));
}
#[test]
fn edits_outside_the_root_do_not_count() {
let f = focus(
&[
("/home/me/.claude/CLAUDE.md", 10),
("/repo-other/src/x.rs", 10),
("/repo/src/relay/peer.rs", 1),
],
"/repo",
)
.unwrap();
assert_eq!(f.dir, "src/relay");
assert_eq!(f.total, 1, "a sibling repo with the same prefix is outside");
}
#[test]
fn no_in_tree_edits_is_no_focus() {
assert_eq!(focus(&[], "/repo"), None);
assert_eq!(focus(&[("/elsewhere/x.rs", 4)], "/repo"), None);
}
#[test]
fn a_trailing_slash_on_the_root_is_harmless() {
let f = focus(&[("/repo/src/a.rs", 1)], "/repo/").unwrap();
assert_eq!(f.dir, "src");
assert_eq!(f.path_under(Path::new("/repo")), PathBuf::from("/repo/src"));
}
#[test]
fn the_canonical_root_matches_too() {
let tmp = std::env::temp_dir().join(format!("focus-{}", std::process::id()));
std::fs::create_dir_all(tmp.join("src")).unwrap();
let canon = std::fs::canonicalize(&tmp).unwrap();
let file = format!("{}/src/lib.rs", canon.display());
let f = focus(&[(file.as_str(), 2)], &tmp.to_string_lossy()).unwrap();
assert_eq!(f.dir, "src");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn a_canonical_root_matches_a_file_spelled_through_the_symlink() {
let tmp = std::env::temp_dir().join(format!("focus-rev-{}", std::process::id()));
std::fs::create_dir_all(tmp.join("src")).unwrap();
let canon_root = std::fs::canonicalize(&tmp).unwrap();
let file = format!("{}/src/new.rs", tmp.display());
let f = focus(&[(file.as_str(), 1)], &canon_root.to_string_lossy()).unwrap();
assert_eq!(f.dir, "src");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn transcript_edits_count_only_editing_tools_by_the_assistant() {
let tmp = std::env::temp_dir().join(format!("focus-jsonl-{}", std::process::id()));
let lines = [
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/repo/src/relay/peer.rs"}}]}}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"/repo/src/bus/mcp.rs"}}]}}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"NotebookEdit","input":{"notebook_path":"/repo/src/relay/nb.ipynb"}}]}}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Write","input":{"file_path":"/repo/src/relay/peer.rs"}}]}}"#,
r#"{"type":"user","message":{"role":"user","content":"please edit /repo/src/bus/x.rs"}}"#,
];
std::fs::write(&tmp, lines.join("\n")).unwrap();
let edits = edits_from_transcript(&tmp, TRANSCRIPT_SCAN_BYTES);
assert_eq!(edits.get("/repo/src/relay/peer.rs"), Some(&2));
assert_eq!(edits.get("/repo/src/relay/nb.ipynb"), Some(&1));
assert_eq!(
edits.get("/repo/src/bus/mcp.rs"),
None,
"reads are not edits"
);
assert_eq!(edits.len(), 2);
let f = session_focus(Some(&tmp), Path::new("/repo")).unwrap();
assert_eq!(f.dir, "src/relay");
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn a_capped_read_keeps_the_tail() {
let tmp = std::env::temp_dir().join(format!("focus-tail-{}", std::process::id()));
let old = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/repo/old/a.rs"}}]}}"#;
let new = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/repo/new/b.rs"}}]}}"#;
std::fs::write(&tmp, format!("{old}\n{new}\n")).unwrap();
let edits = edits_from_transcript(&tmp, (new.len() + 1) as u64);
assert_eq!(edits.get("/repo/new/b.rs"), Some(&1));
assert_eq!(
edits.get("/repo/old/a.rs"),
None,
"the head fell outside the cap"
);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn a_path_that_escapes_the_root_is_outside_and_dots_do_not_double_count() {
assert_eq!(
focus(&[("/repo/../sibling/x.rs", 3)], "/repo"),
None,
"`..` out of the root is not in the tree"
);
let f = focus(
&[("/repo/./src/./x.rs", 1), ("/repo/src/../src/y.rs", 1)],
"/repo",
)
.unwrap();
assert_eq!(f.dir, "src");
assert_eq!((f.edits, f.total), (2, 2));
}
#[test]
fn an_out_of_tree_edit_in_the_window_does_not_stop_the_search() {
let tmp = std::env::temp_dir().join(format!("focus-grow2-{}", std::process::id()));
let in_tree = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/repo/src/relay/peer.rs"}}]}}"#;
let log = "x".repeat(4096);
let result = format!(
r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"tool_result","content":"{log}"}}]}}}}"#
);
let out_of_tree = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/home/me/.claude/CLAUDE.md"}}]}}"#;
std::fs::write(&tmp, format!("{in_tree}\n{result}\n{out_of_tree}\n")).unwrap();
let small = (result.len() + out_of_tree.len() + 2) as u64;
assert_eq!(
edits_from_transcript(&tmp, small).len(),
1,
"only the out-of-tree edit fits"
);
let f = session_focus_within(&tmp, Path::new("/repo"), small, small * 16).unwrap();
assert_eq!(f.dir, "src/relay");
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn a_window_full_of_one_huge_tool_result_grows_until_it_finds_the_edits() {
let tmp = std::env::temp_dir().join(format!("focus-grow-{}", std::process::id()));
let edit = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/repo/src/relay/peer.rs"}}]}}"#;
let log = "x".repeat(4096);
let result = format!(
r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"tool_result","content":"{log}"}}]}}}}"#
);
std::fs::write(&tmp, format!("{edit}\n{result}\n")).unwrap();
let small = (result.len() + 1) as u64;
assert_eq!(edits_from_transcript(&tmp, small).len(), 0);
let f = session_focus_within(&tmp, Path::new("/repo"), small, small * 16).unwrap();
assert_eq!(f.dir, "src/relay");
assert_eq!(
session_focus_within(&tmp, Path::new("/repo"), small, small),
None
);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn a_missing_transcript_is_no_focus() {
assert_eq!(session_focus(None, Path::new("/repo")), None);
assert_eq!(
session_focus(Some(Path::new("/nonexistent/x.jsonl")), Path::new("/repo")),
None
);
}
}