use anyhow::Result;
use ignore::WalkBuilder;
use std::path::PathBuf;
use tracing::{debug, info};
const CORE_EXTENSIONS: &[&str] = &[
"java", "ts", "tsx", "cts", "js", "mjs", "cjs", "jsx", "kt", "kts", "py", "pyi", "pyw", "html",
"htm", "css", "scss", "sass", "rs", "groovy", "gradle", "c", "h", "cpp", "hpp", "cc", "cxx",
"hh", "hxx", "md",
];
const CONFIG_EXTENSIONS: &[&str] = &["yml", "yaml", "json", "properties", "tpl"];
pub const SUPPORTED_EXTENSIONS: &[&str] = &[
"java",
"ts",
"tsx",
"cts",
"js",
"mjs",
"cjs",
"jsx",
"kt",
"kts",
"py",
"pyi",
"pyw",
"html",
"htm",
"css",
"scss",
"sass",
"rs",
"groovy",
"gradle",
"c",
"h",
"cpp",
"hpp",
"cc",
"cxx",
"hh",
"hxx",
"md",
"yml",
"yaml",
"json",
"properties",
"tpl",
];
const EXCLUDED_NAMES: &[&str] = &[
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"Cargo.lock",
"composer.lock",
"Gemfile.lock",
"poetry.lock",
"Pipfile.lock",
];
const MAX_FILE_SIZE: u64 = 500 * 1024;
pub(crate) const BUILD_SYSTEM_NAMES: &[&str] = &[
"Jenkinsfile",
"pom.xml",
"Cargo.toml",
"package.json",
"tsconfig.json",
];
pub(crate) fn is_build_system_json(filename: &str) -> bool {
filename == "package.json" || filename == "tsconfig.json"
}
pub(crate) fn is_config_extension(ext: &str) -> bool {
CONFIG_EXTENSIONS.contains(&ext)
}
pub fn discover_files(repo_path: &str, include_config_files: bool) -> Result<Vec<PathBuf>> {
use std::collections::HashSet;
let mut files: Vec<PathBuf> = WalkBuilder::new(repo_path)
.hidden(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.build()
.filter_map(|entry| {
let entry = entry.ok()?;
let path = entry.path().to_path_buf();
if !path.is_file() {
return None;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str())
&& EXCLUDED_NAMES.contains(&name)
{
debug!("Skipping excluded file: {}", name);
return None;
}
if let Ok(metadata) = std::fs::metadata(&path)
&& metadata.len() > MAX_FILE_SIZE
{
debug!(
"Skipping file over size limit ({} bytes): {}",
metadata.len(),
path.display()
);
return None;
}
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if CORE_EXTENSIONS.contains(&ext) {
return Some(path);
}
if is_config_extension(ext) {
if ext == "json" && !include_config_files {
if let Some(name) = path.file_name().and_then(|n| n.to_str())
&& is_build_system_json(name)
{
return Some(path);
}
return None;
}
if include_config_files {
return Some(path);
}
return None;
}
}
if let Some(name) = path.file_name().and_then(|n| n.to_str())
&& BUILD_SYSTEM_NAMES.contains(&name)
{
return Some(path);
}
None
})
.collect();
let mut seen = HashSet::new();
files.retain(|p| seen.insert(p.clone()));
files.sort();
info!(
"Discovered {} source files under '{}'",
files.len(),
repo_path
);
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_discover_files_basic() {
let dir = tempdir().unwrap();
let repo_path = dir.path().to_str().unwrap();
fs::write(dir.path().join("test.java"), "public class Test {}").unwrap();
fs::write(dir.path().join("app.ts"), "export class App {}").unwrap();
fs::write(
dir.path().join("component.tsx"),
"export const Comp = () => {}",
)
.unwrap();
fs::write(dir.path().join("legacy.cts"), "module.exports = {}").unwrap();
fs::write(dir.path().join("vanilla.js"), "console.log('test')").unwrap();
fs::write(dir.path().join("module.mjs"), "export {}").unwrap();
fs::write(dir.path().join("service.kt"), "class Service {}").unwrap();
fs::write(dir.path().join("main.py"), "def main(): pass").unwrap();
fs::write(dir.path().join("stub.pyi"), "def foo() -> None: ...").unwrap();
fs::write(dir.path().join("gui.pyw"), "import tkinter").unwrap();
fs::write(dir.path().join("readme.md"), "# Readme").unwrap();
fs::write(dir.path().join("data.xml"), "<root/>").unwrap();
let src_dir = dir.path().join("src");
fs::create_dir(&src_dir).unwrap();
fs::write(src_dir.join("utils.ts"), "export {}").unwrap();
let files = discover_files(repo_path, true).unwrap();
assert_eq!(files.len(), 12);
for path in files {
let ext = path.extension().unwrap().to_str().unwrap();
assert!(SUPPORTED_EXTENSIONS.contains(&ext));
}
}
#[test]
fn test_discover_files_with_gitignore() {
let dir = tempdir().unwrap();
let repo_path = dir.path().to_str().unwrap();
fs::create_dir(dir.path().join(".git")).unwrap();
fs::write(dir.path().join("tracked.java"), "public class Tracked {}").unwrap();
fs::write(dir.path().join("ignored.java"), "public class Ignored {}").unwrap();
fs::write(dir.path().join(".gitignore"), "ignored.java").unwrap();
let files = discover_files(repo_path, true).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].to_str().unwrap().contains("tracked.java"));
}
#[test]
fn test_discover_files_empty() {
let dir = tempdir().unwrap();
let repo_path = dir.path().to_str().unwrap();
let files = discover_files(repo_path, true).unwrap();
assert!(files.is_empty());
}
#[test]
fn test_discover_files_config_excluded_by_default() {
let dir = tempdir().unwrap();
let repo_path = dir.path().to_str().unwrap();
fs::write(dir.path().join("config.yaml"), "key: value").unwrap();
fs::write(dir.path().join("settings.json"), r#"{"key":"value"}"#).unwrap();
fs::write(dir.path().join("app.properties"), "key=value").unwrap();
fs::write(dir.path().join("template.tpl"), "{{ .Values.x }}").unwrap();
fs::write(dir.path().join("Main.java"), "class Main {}").unwrap();
let files = discover_files(repo_path, false).unwrap();
assert_eq!(files.len(), 1, "Only core files should be discovered");
assert!(files[0].to_str().unwrap().ends_with("Main.java"));
let files = discover_files(repo_path, true).unwrap();
assert_eq!(
files.len(),
5,
"All files should be discovered when flag is on"
);
}
#[test]
fn test_discover_files_build_system_json_always_included() {
let dir = tempdir().unwrap();
let repo_path = dir.path().to_str().unwrap();
fs::write(dir.path().join("package.json"), r#"{"name":"test"}"#).unwrap();
fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
fs::write(dir.path().join("random.json"), r#"{"a":1}"#).unwrap();
fs::write(dir.path().join("Main.java"), "class Main {}").unwrap();
let files = discover_files(repo_path, false).unwrap();
let filenames: Vec<&str> = files
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap())
.collect();
assert!(
filenames.contains(&"package.json"),
"package.json should always be included"
);
assert!(
filenames.contains(&"tsconfig.json"),
"tsconfig.json should always be included"
);
assert!(
!filenames.contains(&"random.json"),
"generic JSON should be excluded"
);
assert!(
filenames.contains(&"Main.java"),
"core file should be included"
);
let files = discover_files(repo_path, true).unwrap();
assert_eq!(files.len(), 4);
}
}