Skip to main content

newt_coder/
workspace_scan.rs

1//! Scan the workspace for files relevant to the task.
2//!
3//! Two-pass strategy:
4//!
5//! 1. **Mentioned files.** Walk the workspace once for source files
6//!    (extensions in `SCAN_EXTENSIONS`); if the task prompt mentions
7//!    any of their relative paths, file names, or stems, return only
8//!    those (precision wins for targeted refactors like
9//!    "rename greet to hello in src/lib.rs").
10//!
11//! 2. **Fallback.** If no file matches, return every source file in
12//!    the workspace (rg-style: skip `target/`, `node_modules/`, and
13//!    hidden dirs). The prompt builder applies the
14//!    `DEFAULT_CONTEXT_CAP_CHARS` budget on top, so even a large
15//!    workspace gets bounded context.
16//!
17//! Paths returned are **relative to the workspace root** so the
18//! prompt and the apply step can share the same identifiers.
19
20use std::path::{Path, PathBuf};
21
22use crate::error::{CoderError, Result};
23
24/// File extensions considered "source" for the scan. Heavy enough to
25/// cover the languages the bake-off targets (Rust + adjacent ecosystem
26/// + docs); light enough to skip vendored binaries.
27const SCAN_EXTENSIONS: &[&str] = &[
28    "rs", "toml", "py", "js", "ts", "go", "java", "c", "h", "cpp", "hpp", "md",
29];
30
31/// Directories to skip during the walk. Matching is exact on the
32/// directory component name.
33const SKIP_DIRS: &[&str] = &["target", "node_modules"];
34
35/// Two-pass scan: prefer mentioned files; fall back to all source.
36pub fn scan_workspace_for_files(workspace: &Path, task: &str) -> Result<Vec<PathBuf>> {
37    let all = scan_all_source_files(workspace)?;
38    let mentioned = filter_mentioned(&all, task);
39    if !mentioned.is_empty() {
40        Ok(mentioned)
41    } else {
42        Ok(all)
43    }
44}
45
46/// Return the subset of `all` whose path, file name, or file stem
47/// appears anywhere in `task`. Empty stems / names are skipped so we
48/// don't accidentally match on every file when an extensionless dotfile
49/// slips through.
50fn filter_mentioned(all: &[PathBuf], task: &str) -> Vec<PathBuf> {
51    let mut hits = Vec::new();
52    for path in all {
53        let rel = path.display().to_string();
54        let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
55        let fname = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
56        if task.contains(&rel)
57            || (!fname.is_empty() && task.contains(fname))
58            || (!stem.is_empty() && task.contains(stem))
59        {
60            hits.push(path.clone());
61        }
62    }
63    hits
64}
65
66/// Walk the workspace and collect every source file (extension in
67/// `SCAN_EXTENSIONS`, not under a skipped dir, not hidden).
68fn scan_all_source_files(workspace: &Path) -> Result<Vec<PathBuf>> {
69    let mut out = Vec::new();
70    walk(workspace, workspace, &mut out)?;
71    out.sort();
72    Ok(out)
73}
74
75fn repo_relative_path(path: &Path) -> PathBuf {
76    PathBuf::from(
77        path.components()
78            .map(|component| component.as_os_str().to_string_lossy())
79            .collect::<Vec<_>>()
80            .join("/"),
81    )
82}
83
84fn walk(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
85    let entries = std::fs::read_dir(dir)
86        .map_err(|e| CoderError::Workspace(format!("read_dir {}: {e}", dir.display())))?;
87    for entry in entries {
88        let entry = entry?;
89        let path = entry.path();
90        let name = entry.file_name();
91        let name_str = name.to_string_lossy();
92
93        if name_str.starts_with('.') || SKIP_DIRS.contains(&name_str.as_ref()) {
94            continue;
95        }
96
97        if path.is_dir() {
98            walk(root, &path, out)?;
99        } else if path.is_file() {
100            if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
101                if SCAN_EXTENSIONS.contains(&ext) {
102                    let rel = path.strip_prefix(root).map_err(|e| {
103                        CoderError::Workspace(format!("strip_prefix {}: {e}", path.display()))
104                    })?;
105                    out.push(repo_relative_path(rel));
106                }
107            }
108        }
109    }
110    Ok(())
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use std::fs;
117    use tempfile::TempDir;
118
119    fn write(dir: &Path, rel: &str, contents: &str) {
120        let abs = dir.join(rel);
121        if let Some(parent) = abs.parent() {
122            fs::create_dir_all(parent).unwrap();
123        }
124        fs::write(abs, contents).unwrap();
125    }
126
127    #[test]
128    fn finds_rust_sources_and_skips_target() {
129        let tmp = TempDir::new().unwrap();
130        write(tmp.path(), "src/lib.rs", "pub fn x() {}\n");
131        write(tmp.path(), "src/main.rs", "fn main() {}\n");
132        // target/ artifacts should NOT appear in the scan.
133        write(tmp.path(), "target/debug/junk.rs", "fn junk() {}\n");
134
135        let files = scan_all_source_files(tmp.path()).unwrap();
136        let paths: Vec<String> = files.iter().map(|p| p.display().to_string()).collect();
137        assert!(paths.contains(&"src/lib.rs".to_string()));
138        assert!(paths.contains(&"src/main.rs".to_string()));
139        assert!(
140            !paths.iter().any(|p| p.starts_with("target/")),
141            "target/ leaked into the scan: {paths:?}"
142        );
143    }
144
145    #[test]
146    fn skips_hidden_dirs() {
147        let tmp = TempDir::new().unwrap();
148        write(tmp.path(), "src/lib.rs", "pub fn x() {}\n");
149        write(tmp.path(), ".git/config", "[core]\n");
150        write(tmp.path(), ".hidden/file.rs", "fn x() {}\n");
151
152        let files = scan_all_source_files(tmp.path()).unwrap();
153        let paths: Vec<String> = files.iter().map(|p| p.display().to_string()).collect();
154        assert!(paths.contains(&"src/lib.rs".to_string()));
155        assert!(
156            !paths
157                .iter()
158                .any(|p| p.starts_with(".git/") || p.starts_with(".hidden/")),
159            "hidden dir leaked: {paths:?}"
160        );
161    }
162
163    #[test]
164    fn scan_prefers_mentioned_files() {
165        let tmp = TempDir::new().unwrap();
166        write(tmp.path(), "src/lib.rs", "pub fn greet() {}\n");
167        write(tmp.path(), "src/other.rs", "pub fn other() {}\n");
168        write(tmp.path(), "Cargo.toml", "[package]\n");
169
170        let hits = scan_workspace_for_files(tmp.path(), "Rename greet in src/lib.rs").unwrap();
171        let paths: Vec<String> = hits.iter().map(|p| p.display().to_string()).collect();
172        assert!(paths.contains(&"src/lib.rs".to_string()));
173        // other.rs is not mentioned, so it must not be included.
174        assert!(
175            !paths.contains(&"src/other.rs".to_string()),
176            "unrelated file leaked into mentioned-only scan: {paths:?}"
177        );
178    }
179
180    #[test]
181    fn scan_falls_back_to_all_when_nothing_mentioned() {
182        let tmp = TempDir::new().unwrap();
183        write(tmp.path(), "src/lib.rs", "pub fn a() {}\n");
184        write(tmp.path(), "src/other.rs", "pub fn b() {}\n");
185
186        // A task that doesn't mention any path/name/stem in the workspace.
187        let hits = scan_workspace_for_files(tmp.path(), "Add a license header everywhere").unwrap();
188        let paths: Vec<String> = hits.iter().map(|p| p.display().to_string()).collect();
189        assert!(paths.contains(&"src/lib.rs".to_string()));
190        assert!(paths.contains(&"src/other.rs".to_string()));
191    }
192
193    #[test]
194    fn ignores_unknown_extensions() {
195        let tmp = TempDir::new().unwrap();
196        write(tmp.path(), "src/lib.rs", "pub fn x() {}\n");
197        write(tmp.path(), "binary.bin", "junk");
198        write(tmp.path(), "image.png", "fake png bytes");
199
200        let files = scan_all_source_files(tmp.path()).unwrap();
201        let paths: Vec<String> = files.iter().map(|p| p.display().to_string()).collect();
202        assert!(paths.contains(&"src/lib.rs".to_string()));
203        assert!(!paths
204            .iter()
205            .any(|p| p.ends_with(".bin") || p.ends_with(".png")));
206    }
207
208    #[test]
209    fn file_stem_match_triggers_mention() {
210        let tmp = TempDir::new().unwrap();
211        write(tmp.path(), "src/parser.rs", "pub fn parse() {}\n");
212
213        // "parser" is the file stem.
214        let hits =
215            scan_workspace_for_files(tmp.path(), "Update the parser to handle commas").unwrap();
216        let paths: Vec<String> = hits.iter().map(|p| p.display().to_string()).collect();
217        assert!(paths.contains(&"src/parser.rs".to_string()));
218    }
219}