dynamo_bench/coding/claude/
discovery.rs1use crate::coding::common::{dedupe_paths, expand_user_path, home_dir};
5use anyhow::{Result, bail};
6use std::collections::VecDeque;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10const IGNORED_FILENAMES: &[&str] = &["history.jsonl"];
11
12pub fn iter_ancestor_roots(start: &Path) -> Vec<PathBuf> {
13 let mut roots = Vec::new();
14 let mut current = start.to_path_buf();
15 loop {
16 roots.push(current.clone());
17 let Some(parent) = current.parent() else {
18 break;
19 };
20 if parent == current {
21 break;
22 }
23 current = parent.to_path_buf();
24 }
25 dedupe_paths(roots)
26}
27
28pub fn claude_project_dir_for_root(root: &Path, home_dir: &Path) -> PathBuf {
29 let encoded = root.to_string_lossy().replace('/', "-");
30 home_dir.join(".claude").join("projects").join(encoded)
31}
32
33pub fn discover_trace_files(explicit_inputs: &[String], start_dir: &Path) -> Result<Vec<PathBuf>> {
34 let Some(home_dir) = home_dir() else {
35 bail!("could not resolve HOME for Claude trace discovery");
36 };
37 let claude_projects_root = home_dir.join(".claude").join("projects");
38 let mut discovered = Vec::new();
39
40 if !explicit_inputs.is_empty() {
41 for raw_path in explicit_inputs {
42 let input_path = expand_user_path(raw_path);
43 let input_path = input_path.canonicalize().unwrap_or(input_path);
44 if input_path.is_file() {
45 if !is_trace_path(&input_path) {
46 bail!("not a Claude session trace file: {}", input_path.display());
47 }
48 discovered.push(input_path.clone());
49 if let (Some(parent), Some(stem)) = (input_path.parent(), input_path.file_stem()) {
50 discovered.extend(scan_trace_dir(&parent.join(stem).join("subagents"))?);
51 }
52 continue;
53 }
54
55 if !input_path.exists() {
56 bail!("input path does not exist: {}", input_path.display());
57 }
58
59 if !input_path.is_dir() {
60 bail!("unsupported input path: {}", input_path.display());
61 }
62
63 let in_claude_tree =
64 input_path == claude_projects_root || input_path.starts_with(&claude_projects_root);
65 if in_claude_tree {
66 let directory_hits = scan_trace_dir(&input_path)?;
67 if !directory_hits.is_empty() {
68 discovered.extend(directory_hits);
69 continue;
70 }
71 } else {
72 let repo_hits =
73 scan_trace_dir(&claude_project_dir_for_root(&input_path, &home_dir))?;
74 if !repo_hits.is_empty() {
75 discovered.extend(repo_hits);
76 continue;
77 }
78
79 let directory_hits = scan_trace_dir(&input_path)?;
80 if !directory_hits.is_empty() {
81 discovered.extend(directory_hits);
82 continue;
83 }
84 }
85
86 bail!(
87 "no Claude session traces found under input path or its encoded Claude project directory: {}",
88 input_path.display()
89 );
90 }
91
92 return Ok(dedupe_paths(discovered));
93 }
94
95 for candidate_root in iter_ancestor_roots(start_dir) {
96 discovered.extend(scan_trace_dir(&claude_project_dir_for_root(
97 &candidate_root,
98 &home_dir,
99 ))?);
100 }
101 discovered.extend(scan_trace_dir(&claude_projects_root)?);
102
103 Ok(dedupe_paths(discovered))
104}
105
106fn scan_trace_dir(root: &Path) -> Result<Vec<PathBuf>> {
107 if !root.exists() {
108 return Ok(Vec::new());
109 }
110
111 let mut queue = VecDeque::from([root.to_path_buf()]);
112 let mut discovered = Vec::new();
113 while let Some(directory) = queue.pop_front() {
114 for entry in fs::read_dir(&directory)? {
115 let entry = entry?;
116 let path = entry.path();
117 let file_type = entry.file_type()?;
118 if file_type.is_dir() {
119 queue.push_back(path);
120 continue;
121 }
122 if file_type.is_file() && is_trace_path(&path) {
123 discovered.push(path);
124 }
125 }
126 }
127
128 discovered.sort();
129 Ok(discovered)
130}
131
132fn is_trace_path(path: &Path) -> bool {
133 path.extension().and_then(|value| value.to_str()) == Some("jsonl")
134 && !IGNORED_FILENAMES
135 .iter()
136 .any(|ignored| path.file_name().and_then(|value| value.to_str()) == Some(ignored))
137}
138
139#[cfg(test)]
140mod tests {
141 use super::{iter_ancestor_roots, scan_trace_dir};
142 use tempfile::TempDir;
143
144 #[test]
145 fn ancestors_walk_to_root() {
146 let temp = TempDir::new().unwrap();
147 let nested = temp.path().join("a").join("b").join("c");
148 std::fs::create_dir_all(&nested).unwrap();
149
150 let roots = iter_ancestor_roots(&nested);
151 assert_eq!(roots.first().unwrap(), &nested.canonicalize().unwrap());
152 let last = roots.last().unwrap();
153 assert!(
154 last.parent().is_none(),
155 "expected filesystem root, got {}",
156 last.display()
157 );
158 }
159
160 #[test]
161 fn scan_includes_subagent_traces() {
162 let temp = TempDir::new().unwrap();
163 let subagents = temp.path().join("session-1/subagents");
164 std::fs::create_dir_all(&subagents).unwrap();
165 std::fs::write(temp.path().join("session-1.jsonl"), "").unwrap();
166 std::fs::write(subagents.join("agent-child.jsonl"), "").unwrap();
167
168 let traces = scan_trace_dir(temp.path()).unwrap();
169
170 assert_eq!(traces.len(), 2);
171 assert!(
172 traces
173 .iter()
174 .any(|path| path.ends_with("subagents/agent-child.jsonl"))
175 );
176 }
177}