use std::path::{Path, PathBuf};
use crate::error::{CoderError, Result};
const SCAN_EXTENSIONS: &[&str] = &[
"rs", "toml", "py", "js", "ts", "go", "java", "c", "h", "cpp", "hpp", "md",
];
const SKIP_DIRS: &[&str] = &["target", "node_modules"];
pub fn scan_workspace_for_files(workspace: &Path, task: &str) -> Result<Vec<PathBuf>> {
let all = scan_all_source_files(workspace)?;
let mentioned = filter_mentioned(&all, task);
if !mentioned.is_empty() {
Ok(mentioned)
} else {
Ok(all)
}
}
fn filter_mentioned(all: &[PathBuf], task: &str) -> Vec<PathBuf> {
let mut hits = Vec::new();
for path in all {
let rel = path.display().to_string();
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let fname = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
if task.contains(&rel)
|| (!fname.is_empty() && task.contains(fname))
|| (!stem.is_empty() && task.contains(stem))
{
hits.push(path.clone());
}
}
hits
}
fn scan_all_source_files(workspace: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
walk(workspace, workspace, &mut out)?;
out.sort();
Ok(out)
}
fn walk(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
let entries = std::fs::read_dir(dir)
.map_err(|e| CoderError::Workspace(format!("read_dir {}: {e}", dir.display())))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') || SKIP_DIRS.contains(&name_str.as_ref()) {
continue;
}
if path.is_dir() {
walk(root, &path, out)?;
} else if path.is_file() {
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
if SCAN_EXTENSIONS.contains(&ext) {
let rel = path.strip_prefix(root).map_err(|e| {
CoderError::Workspace(format!("strip_prefix {}: {e}", path.display()))
})?;
out.push(rel.to_path_buf());
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write(dir: &Path, rel: &str, contents: &str) {
let abs = dir.join(rel);
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(abs, contents).unwrap();
}
#[test]
fn finds_rust_sources_and_skips_target() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "src/lib.rs", "pub fn x() {}\n");
write(tmp.path(), "src/main.rs", "fn main() {}\n");
write(tmp.path(), "target/debug/junk.rs", "fn junk() {}\n");
let files = scan_all_source_files(tmp.path()).unwrap();
let paths: Vec<String> = files.iter().map(|p| p.display().to_string()).collect();
assert!(paths.contains(&"src/lib.rs".to_string()));
assert!(paths.contains(&"src/main.rs".to_string()));
assert!(
!paths.iter().any(|p| p.starts_with("target/")),
"target/ leaked into the scan: {paths:?}"
);
}
#[test]
fn skips_hidden_dirs() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "src/lib.rs", "pub fn x() {}\n");
write(tmp.path(), ".git/config", "[core]\n");
write(tmp.path(), ".hidden/file.rs", "fn x() {}\n");
let files = scan_all_source_files(tmp.path()).unwrap();
let paths: Vec<String> = files.iter().map(|p| p.display().to_string()).collect();
assert!(paths.contains(&"src/lib.rs".to_string()));
assert!(
!paths
.iter()
.any(|p| p.starts_with(".git/") || p.starts_with(".hidden/")),
"hidden dir leaked: {paths:?}"
);
}
#[test]
fn scan_prefers_mentioned_files() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "src/lib.rs", "pub fn greet() {}\n");
write(tmp.path(), "src/other.rs", "pub fn other() {}\n");
write(tmp.path(), "Cargo.toml", "[package]\n");
let hits = scan_workspace_for_files(tmp.path(), "Rename greet in src/lib.rs").unwrap();
let paths: Vec<String> = hits.iter().map(|p| p.display().to_string()).collect();
assert!(paths.contains(&"src/lib.rs".to_string()));
assert!(
!paths.contains(&"src/other.rs".to_string()),
"unrelated file leaked into mentioned-only scan: {paths:?}"
);
}
#[test]
fn scan_falls_back_to_all_when_nothing_mentioned() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "src/lib.rs", "pub fn a() {}\n");
write(tmp.path(), "src/other.rs", "pub fn b() {}\n");
let hits = scan_workspace_for_files(tmp.path(), "Add a license header everywhere").unwrap();
let paths: Vec<String> = hits.iter().map(|p| p.display().to_string()).collect();
assert!(paths.contains(&"src/lib.rs".to_string()));
assert!(paths.contains(&"src/other.rs".to_string()));
}
#[test]
fn ignores_unknown_extensions() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "src/lib.rs", "pub fn x() {}\n");
write(tmp.path(), "binary.bin", "junk");
write(tmp.path(), "image.png", "fake png bytes");
let files = scan_all_source_files(tmp.path()).unwrap();
let paths: Vec<String> = files.iter().map(|p| p.display().to_string()).collect();
assert!(paths.contains(&"src/lib.rs".to_string()));
assert!(!paths
.iter()
.any(|p| p.ends_with(".bin") || p.ends_with(".png")));
}
#[test]
fn file_stem_match_triggers_mention() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "src/parser.rs", "pub fn parse() {}\n");
let hits =
scan_workspace_for_files(tmp.path(), "Update the parser to handle commas").unwrap();
let paths: Vec<String> = hits.iter().map(|p| p.display().to_string()).collect();
assert!(paths.contains(&"src/parser.rs".to_string()));
}
}