use std::collections::BTreeSet;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::config::FiletypeConfig;
#[derive(Debug, Default, Eq, PartialEq)]
pub struct DetectionResult {
pub filetypes: BTreeSet<String>,
pub filenames: BTreeSet<String>,
}
pub fn detect_workspace(path: &Path, filetypes: &[FiletypeConfig]) -> io::Result<DetectionResult> {
let mut detection = DetectionResult::default();
scan_path(path, filetypes, &mut detection)?;
Ok(detection)
}
pub fn matching_files(
path: &Path,
filetypes: &[FiletypeConfig],
allowed_filetypes: &BTreeSet<String>,
) -> io::Result<Vec<PathBuf>> {
let mut matches = Vec::new();
collect_matching_files(path, filetypes, allowed_filetypes, &mut matches)?;
matches.sort();
Ok(matches)
}
fn scan_path(
path: &Path,
filetypes: &[FiletypeConfig],
detection: &mut DetectionResult,
) -> io::Result<()> {
let metadata = fs::symlink_metadata(path).map_err(path_error_fn(path))?;
let file_type = metadata.file_type();
if file_type.is_symlink() {
return Ok(());
}
if file_type.is_file() {
detect_file(path, filetypes, detection);
return Ok(());
}
if file_type.is_dir() {
let entries = fs::read_dir(path).map_err(path_error_fn(path))?;
for entry in entries {
let entry = entry.map_err(path_error_fn(path))?;
scan_path(&entry.path(), filetypes, detection)?;
}
}
Ok(())
}
fn collect_matching_files(
path: &Path,
filetypes: &[FiletypeConfig],
allowed_filetypes: &BTreeSet<String>,
matches: &mut Vec<PathBuf>,
) -> io::Result<()> {
let metadata = fs::symlink_metadata(path).map_err(path_error_fn(path))?;
let file_type = metadata.file_type();
if file_type.is_symlink() {
return Ok(());
}
if file_type.is_file() {
if file_matches(path, filetypes, allowed_filetypes) {
matches.push(path.to_path_buf());
}
return Ok(());
}
if file_type.is_dir() {
let entries = fs::read_dir(path).map_err(path_error_fn(path))?;
for entry in entries {
let entry = entry.map_err(path_error_fn(path))?;
collect_matching_files(&entry.path(), filetypes, allowed_filetypes, matches)?;
}
}
Ok(())
}
fn detect_file(path: &Path, filetypes: &[FiletypeConfig], detection: &mut DetectionResult) {
let file_name = match path.file_name() {
Some(file_name) => file_name.to_string_lossy().into_owned(),
None => return,
};
detection.filenames.insert(file_name.clone());
let extension = path
.extension()
.and_then(|value| value.to_str())
.map(str::to_ascii_lowercase);
for filetype in filetypes {
let extension_match = extension
.as_deref()
.is_some_and(|value| filetype.extensions.iter().any(|ext| ext == value));
let pattern_match = filetype
.patterns
.iter()
.any(|pattern| pattern.is_match(&file_name));
if extension_match || pattern_match {
detection.filetypes.insert(filetype.id.clone());
}
}
}
fn file_matches(
path: &Path,
filetypes: &[FiletypeConfig],
allowed_filetypes: &BTreeSet<String>,
) -> bool {
matching_filetypes(path, filetypes)
.into_iter()
.any(|filetype| allowed_filetypes.contains(&filetype))
}
fn matching_filetypes(path: &Path, filetypes: &[FiletypeConfig]) -> Vec<String> {
let file_name = match path.file_name() {
Some(file_name) => file_name.to_string_lossy().into_owned(),
None => return Vec::new(),
};
let extension = path
.extension()
.and_then(|value| value.to_str())
.map(str::to_ascii_lowercase);
filetypes
.iter()
.filter(|filetype| {
let extension_match = extension
.as_deref()
.is_some_and(|value| filetype.extensions.iter().any(|ext| ext == value));
let pattern_match = filetype
.patterns
.iter()
.any(|pattern| pattern.is_match(&file_name));
extension_match || pattern_match
})
.map(|filetype| filetype.id.clone())
.collect()
}
fn path_error_fn(path: &Path) -> impl FnOnce(io::Error) -> io::Error + '_ {
move |error| io::Error::new(error.kind(), crate::fs::format_path_error(path, &error))
}
#[cfg(test)]
mod tests {
use super::{DetectionResult, detect_workspace, matching_files};
use crate::config::FiletypeConfig;
use crate::test_support::TestDir;
use regex::Regex;
use std::collections::BTreeSet;
use std::io;
#[cfg(unix)]
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::symlink;
fn write_file(dir: &TestDir, relative: &str) {
dir.write_file(relative, "test");
}
#[cfg(unix)]
fn write_symlink(dir: &TestDir, target: &str, link: &str) {
let link_path = dir.path().join(link);
if let Some(parent) = link_path.parent() {
fs::create_dir_all(parent).expect("parent dirs should be created");
}
symlink(target, link_path).expect("symlink should be created");
}
fn filetype(id: &str, extensions: &[&str], patterns: &[&str]) -> FiletypeConfig {
FiletypeConfig {
id: id.to_string(),
extensions: extensions
.iter()
.map(std::string::ToString::to_string)
.collect(),
patterns: patterns
.iter()
.map(|value| Regex::new(value).expect("pattern should compile"))
.collect(),
}
}
#[test]
fn detects_filetypes_by_extension() {
let dir = TestDir::new("detect");
write_file(&dir, "src/main.foo");
let detection = detect_workspace(dir.path(), &[filetype("alpha", &["foo"], &[])])
.expect("scan should succeed");
assert_eq!(
detection,
DetectionResult {
filetypes: BTreeSet::from(["alpha".to_string()]),
filenames: BTreeSet::from(["main.foo".to_string()]),
}
);
}
#[test]
fn detects_filetypes_by_pattern() {
let dir = TestDir::new("detect");
write_file(&dir, "src/tooling.config");
let detection = detect_workspace(
dir.path(),
&[filetype("alpha", &[], &[r"^tooling\.config$"])],
)
.expect("scan should succeed");
assert_eq!(detection.filetypes, BTreeSet::from(["alpha".to_string()]));
}
#[test]
fn detects_multiple_extensions_for_one_filetype() {
let dir = TestDir::new("detect");
write_file(&dir, "src/main.bar");
write_file(&dir, "include/main.baz");
let detection = detect_workspace(dir.path(), &[filetype("beta", &["bar", "baz"], &[])])
.expect("scan should succeed");
assert_eq!(detection.filetypes, BTreeSet::from(["beta".to_string()]));
}
#[test]
fn scans_nested_directories() {
let dir = TestDir::new("detect");
write_file(&dir, "deeply/nested/project/source.foo");
let detection = detect_workspace(dir.path(), &[filetype("alpha", &["foo"], &[])])
.expect("scan should succeed");
assert_eq!(detection.filetypes, BTreeSet::from(["alpha".to_string()]));
}
#[test]
fn returns_empty_when_no_supported_files_exist() {
let dir = TestDir::new("detect");
write_file(&dir, "README.md");
let detection = detect_workspace(dir.path(), &[filetype("alpha", &["foo"], &[])])
.expect("scan should succeed");
assert!(detection.filetypes.is_empty());
}
#[cfg(unix)]
#[test]
fn skips_broken_symlinks() {
let dir = TestDir::new("detect");
write_symlink(&dir, "missing-target", "broken-link");
write_file(&dir, "src/main.foo");
let detection = detect_workspace(dir.path(), &[filetype("alpha", &["foo"], &[])])
.expect("scan should succeed");
assert_eq!(detection.filetypes, BTreeSet::from(["alpha".to_string()]));
}
#[cfg(unix)]
#[test]
fn skips_symlinked_directories() {
let dir = TestDir::new("detect");
write_file(&dir, "real-src/main.foo");
write_symlink(&dir, "real-src", "linked-src");
let detection = detect_workspace(
&dir.path().join("linked-src"),
&[filetype("alpha", &["foo"], &[])],
)
.expect("scan should succeed");
assert!(detection.filetypes.is_empty());
}
#[cfg(unix)]
#[test]
fn skips_symlinked_files() {
let dir = TestDir::new("detect");
write_file(&dir, "real.foo");
write_symlink(&dir, "real.foo", "linked.foo");
let detection = detect_workspace(
&dir.path().join("linked.foo"),
&[filetype("alpha", &["foo"], &[])],
)
.expect("scan should succeed");
assert!(detection.filetypes.is_empty());
}
#[test]
fn returns_error_for_missing_root_path() {
let dir = TestDir::new("detect-missing");
let missing = dir.path().join("missing");
let error = detect_workspace(&missing, &[]).expect_err("scan should fail");
assert_eq!(error.kind(), io::ErrorKind::NotFound);
assert!(error.to_string().contains(&missing.display().to_string()));
}
#[test]
fn collects_matching_files_for_allowed_filetypes() {
let dir = TestDir::new("detect");
write_file(&dir, "src/main.foo");
write_file(&dir, "src/lib.bar");
write_file(&dir, "README.md");
let matches = matching_files(
dir.path(),
&[
filetype("alpha", &["foo"], &[]),
filetype("beta", &["bar"], &[]),
],
&BTreeSet::from(["alpha".to_string()]),
)
.expect("matching files should succeed");
assert_eq!(matches, vec![dir.path().join("src/main.foo")]);
}
}