use std::ffi::OsStr;
use std::fs::{self, DirEntry};
use std::io;
use std::path::Path;
use crate::repo;
pub const SKIP_DIRS: &[&str] = &[
"node_modules",
".git",
".scout",
"bin",
"obj",
"dist",
"coverage",
".next",
"target",
];
pub const SOURCE_EXT: &[&str] = &[
".ts", ".tsx", ".js", ".jsx", ".cs", ".json", ".md", ".xaml", ".resw", ".resx",
];
pub fn list_source_files(root: &Path, dirs: &[String]) -> io::Result<Vec<String>> {
list_files_with_ext(root, dirs, SOURCE_EXT)
}
pub fn list_files_with_ext(root: &Path, dirs: &[String], exts: &[&str]) -> io::Result<Vec<String>> {
let mut out = Vec::new();
for d in dirs {
let abs = if d == "." {
root.to_path_buf()
} else {
root.join(d)
};
if abs.exists() {
walk_with(root, &abs, exts, &mut out)?;
}
}
Ok(out)
}
fn walk_with(root: &Path, dir: &Path, exts: &[&str], out: &mut Vec<String>) -> io::Result<()> {
let mut entries: Vec<DirEntry> = fs::read_dir(dir)?.collect::<io::Result<Vec<_>>>()?;
entries.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
for entry in entries {
let file_type = entry.file_type()?;
let name = entry.file_name();
if file_type.is_dir() {
if is_skip_dir(&name) {
continue;
}
walk_with(root, &entry.path(), exts, out)?;
} else if file_type.is_file() {
let name_str = name.to_string_lossy();
if exts.contains(&extension_of(&name_str)) {
out.push(repo::rel_path(root, &entry.path()));
}
}
}
Ok(())
}
fn is_skip_dir(name: &OsStr) -> bool {
match name.to_str() {
Some(s) => SKIP_DIRS.contains(&s),
None => false,
}
}
fn extension_of(name: &str) -> &str {
match name.rfind('.') {
Some(idx) => &name[idx..],
None => "",
}
}
pub struct DefaultPurposeDetail {
pub text: String,
pub is_comment: bool,
}
pub fn default_purpose_detailed(root: &Path, rel: &str) -> DefaultPurposeDetail {
let text = match fs::read(root.join(rel)) {
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(_) => {
return DefaultPurposeDetail {
text: String::new(),
is_comment: false,
}
}
};
for raw in text.split('\n').take(15) {
let line = raw.trim_matches(|c: char| c.is_whitespace() || c == '\u{FEFF}');
if line.is_empty() {
continue;
}
if let Some(m) = match_comment(line) {
return DefaultPurposeDetail {
text: truncate(m),
is_comment: true,
};
}
if starts_with_namespace_ws(line) {
return DefaultPurposeDetail {
text: truncate(line),
is_comment: false,
};
}
if starts_with_any(
line,
&[
"export",
"public",
"class",
"interface",
"def ",
"function ",
],
) {
return DefaultPurposeDetail {
text: truncate(line),
is_comment: false,
};
}
return DefaultPurposeDetail {
text: truncate(line),
is_comment: false,
};
}
DefaultPurposeDetail {
text: String::new(),
is_comment: false,
}
}
pub fn default_purpose(root: &Path, rel: &str) -> String {
default_purpose_detailed(root, rel).text
}
fn match_comment(line: &str) -> Option<&str> {
let rest = if let Some(r) = line.strip_prefix("///") {
r
} else if let Some(r) = line.strip_prefix("//") {
r
} else if let Some(r) = line.strip_prefix('*') {
r
} else if let Some(r) = line.strip_prefix('#') {
r
} else {
return None;
};
let trimmed = rest.trim_start();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
fn starts_with_namespace_ws(line: &str) -> bool {
line.strip_prefix("namespace")
.is_some_and(|rest| rest.starts_with(|c: char| c.is_whitespace()))
}
fn starts_with_any(line: &str, prefixes: &[&str]) -> bool {
prefixes.iter().any(|p| line.starts_with(p))
}
fn truncate(s: &str) -> String {
let char_count = s.chars().count();
if char_count > 100 {
let truncated: String = s.chars().take(97).collect();
format!("{truncated}...")
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn scratch_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"scout-walk-test-{}-{}-{}",
label,
std::process::id(),
nanos
));
fs::create_dir_all(&dir).expect("create scratch dir");
dir
}
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent dirs");
}
fs::write(path, contents).expect("write file");
}
#[test]
fn skips_configured_dirs_and_filters_by_extension() {
let root = scratch_dir("basic");
write_file(&root.join("src/a.ts"), "export const x = 1;\n");
write_file(
&root.join("src/sub/b.cs"),
"namespace Foo.Bar;\nclass B {}\n",
);
write_file(&root.join("src/node_modules/pkg/c.js"), "ignored");
write_file(&root.join("src/bin/d.ts"), "ignored");
let mut files = list_source_files(&root, &["src".to_string()]).unwrap();
files.sort();
assert_eq!(
files,
vec!["src/a.ts".to_string(), "src/sub/b.cs".to_string()]
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn list_files_with_ext_lists_csproj_files_in_the_same_deterministic_order_as_source_files() {
let root = scratch_dir("ext-order");
write_file(&root.join("src/A/A.cs"), "class A {}\n");
write_file(&root.join("src/A/A.csproj"), "<Project />\n");
write_file(&root.join("src/B.csproj"), "<Project />\n");
write_file(&root.join("src/Z.cs"), "class Z {}\n");
write_file(&root.join("src/bin/Skip.csproj"), "ignored");
let sources = list_source_files(&root, &["src".to_string()]).unwrap();
let csproj = list_files_with_ext(&root, &["src".to_string()], &[".csproj"]).unwrap();
assert_eq!(
sources,
vec!["src/A/A.cs".to_string(), "src/Z.cs".to_string()]
);
assert_eq!(
csproj,
vec!["src/A/A.csproj".to_string(), "src/B.csproj".to_string()]
);
let mixed = list_files_with_ext(&root, &["src".to_string()], &[".cs", ".csproj"]).unwrap();
assert_eq!(
mixed,
vec![
"src/A/A.cs".to_string(),
"src/A/A.csproj".to_string(),
"src/B.csproj".to_string(),
"src/Z.cs".to_string(),
]
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn default_purpose_extracts_leading_comment_and_namespace() {
let root = scratch_dir("purpose");
write_file(
&root.join("src/a.ts"),
"// Handles group creation\nexport const x = 1;\n",
);
write_file(
&root.join("src/sub/b.cs"),
"namespace Foo.Bar;\nclass B {}\n",
);
let a = default_purpose(&root, "src/a.ts");
assert!(a.to_lowercase().contains("group creation"), "got {a:?}");
let b = default_purpose(&root, "src/sub/b.cs");
assert!(b.contains("Foo.Bar"), "got {b:?}");
fs::remove_dir_all(&root).ok();
}
#[test]
fn default_purpose_detailed_flags_a_comment_derived_match() {
let root = scratch_dir("purpose-detailed-comment");
write_file(
&root.join("src/a.ts"),
"// Handles group creation\nexport const x = 1;\n",
);
let d = default_purpose_detailed(&root, "src/a.ts");
assert!(
d.is_comment,
"leading `//` line must be flagged comment-derived"
);
assert!(
d.text.to_lowercase().contains("group creation"),
"got {:?}",
d.text
);
assert_eq!(default_purpose(&root, "src/a.ts"), d.text);
fs::remove_dir_all(&root).ok();
}
#[test]
fn default_purpose_detailed_does_not_flag_a_code_first_line() {
let root = scratch_dir("purpose-detailed-code");
write_file(
&root.join("src/a.ts"),
"export const x = 1;\n// a comment on line 2, never reached\n",
);
let d = default_purpose_detailed(&root, "src/a.ts");
assert!(
!d.is_comment,
"a code first line must not be flagged comment-derived"
);
assert_eq!(d.text, "export const x = 1;");
fs::remove_dir_all(&root).ok();
}
#[test]
fn default_purpose_detailed_on_unreadable_file_is_empty_and_not_comment() {
let root = scratch_dir("purpose-detailed-missing");
let d = default_purpose_detailed(&root, "src/does-not-exist.ts");
assert_eq!(d.text, "");
assert!(!d.is_comment);
fs::remove_dir_all(&root).ok();
}
#[test]
fn skip_dir_names_are_exact_match_only() {
let root = scratch_dir("skip-exact");
write_file(&root.join("bin2/kept.ts"), "kept");
write_file(&root.join("bin/dropped.ts"), "dropped");
let files = list_source_files(&root, &[".".to_string()]).unwrap();
assert_eq!(files, vec!["bin2/kept.ts".to_string()]);
fs::remove_dir_all(&root).ok();
}
#[test]
fn nonexistent_scope_element_is_skipped_silently() {
let root = scratch_dir("nonexistent");
write_file(&root.join("src/a.ts"), "x");
let files =
list_source_files(&root, &["src".to_string(), "does-not-exist".to_string()]).unwrap();
assert_eq!(files, vec!["src/a.ts".to_string()]);
fs::remove_dir_all(&root).ok();
}
#[test]
fn symlinked_entries_are_neither_followed_nor_emitted() {
let root = scratch_dir("symlinks");
write_file(&root.join("real/real.ts"), "real");
write_file(&root.join("outside.ts"), "outside");
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
symlink(root.join("real"), root.join("linkdir")).unwrap();
symlink(root.join("outside.ts"), root.join("linkfile.ts")).unwrap();
symlink(root.join("does-not-exist"), root.join("broken.ts")).unwrap();
let mut files = list_source_files(&root, &[".".to_string()]).unwrap();
files.sort();
assert_eq!(
files,
vec!["outside.ts".to_string(), "real/real.ts".to_string()]
);
}
fs::remove_dir_all(&root).ok();
}
#[test]
fn unreadable_directory_propagates_as_error() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let root = scratch_dir("unreadable");
write_file(&root.join("readable/a.ts"), "x");
write_file(&root.join("noperm/b.ts"), "x");
fs::set_permissions(root.join("noperm"), fs::Permissions::from_mode(0o000)).unwrap();
let result = list_source_files(&root, &[".".to_string()]);
assert!(result.is_err(), "expected an error, got {result:?}");
fs::set_permissions(root.join("noperm"), fs::Permissions::from_mode(0o755)).unwrap();
fs::remove_dir_all(&root).ok();
}
}
}