newt_coder/
workspace_scan.rs1use std::path::{Path, PathBuf};
21
22use crate::error::{CoderError, Result};
23
24const SCAN_EXTENSIONS: &[&str] = &[
28 "rs", "toml", "py", "js", "ts", "go", "java", "c", "h", "cpp", "hpp", "md",
29];
30
31const SKIP_DIRS: &[&str] = &["target", "node_modules"];
34
35pub 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
46fn 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
66fn 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 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 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 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 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}