Skip to main content

dynamo_bench/coding/claude/
discovery.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use 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}