use std::path::{Path, PathBuf};
use walkdir::WalkDir;
pub(crate) fn collect_rust_files(path: &PathBuf) -> Result<Vec<PathBuf>, String> {
let mut files = Vec::new();
if path.is_file() {
if path.is_symlink() {
return Err(format!(
"Refusing to process symlink: {}",
sanitize_path_for_error(path)
));
}
if path.extension().is_some_and(|ext| ext == "rs") {
files.push(path.clone());
}
} else if path.is_dir() {
let base_dir = path
.canonicalize()
.map_err(|e| format!("Failed to resolve base path: {}", e.kind()))?;
for entry in WalkDir::new(path)
.follow_links(false) .into_iter()
.filter_map(|result| match result {
Ok(entry) => Some(entry),
Err(e) => {
eprintln!(
"Warning: skipping directory entry due to error: {}",
e
);
None
}
}) {
let entry_path = entry.path();
if entry.path_is_symlink() {
continue;
}
if entry_path.is_file()
&& entry_path.extension().is_some_and(|ext| ext == "rs")
&& !entry_path
.components()
.any(|c| c.as_os_str() == "target" || c.as_os_str() == ".git")
{
if is_within_directory(entry_path, &base_dir) {
files.push(entry_path.to_path_buf());
}
}
}
} else {
return Err(format!(
"Path does not exist: {}",
sanitize_path_for_error(path)
));
}
Ok(files)
}
pub(crate) fn nested_workspace_roots(project_root: &Path) -> Vec<PathBuf> {
let mut roots = Vec::new();
for entry in WalkDir::new(project_root)
.follow_links(false)
.into_iter()
.filter_map(Result::ok)
{
if entry.path_is_symlink() {
continue;
}
let path = entry.path();
let is_manifest =
path.is_file() && path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml");
if !is_manifest {
continue;
}
if path
.components()
.any(|component| component.as_os_str() == "target" || component.as_os_str() == ".git")
{
continue;
}
let Some(dir) = path.parent() else {
continue;
};
if dir == project_root {
continue;
}
if manifest_declares_workspace(path) {
roots.push(dir.to_path_buf());
}
}
roots
}
fn manifest_declares_workspace(manifest: &Path) -> bool {
let Ok(content) = std::fs::read_to_string(manifest) else {
return false;
};
content.lines().any(|line| {
let trimmed = line.trim_start();
trimmed == "[workspace]" || trimmed.starts_with("[workspace.")
})
}
fn sanitize_path_for_error(path: &Path) -> String {
path.file_name()
.map(|name| format!("<...>/{}", name.to_string_lossy()))
.unwrap_or_else(|| "<path>".to_string())
}
fn is_within_directory(path: &Path, base_dir: &Path) -> bool {
path.canonicalize()
.map(|resolved| resolved.starts_with(base_dir))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
fn collect_rust_files_rejects_nonexistent_path() {
let path = PathBuf::from("/nonexistent/path/that/does/not/exist");
let result = collect_rust_files(&path);
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
#[rstest]
fn collect_rust_files_accepts_single_file() {
use std::io::Write;
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("test_collect_rust.rs");
{
let mut file = std::fs::File::create(&test_file).unwrap();
writeln!(file, "fn main() {{}}").unwrap();
}
let result = collect_rust_files(&test_file).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0], test_file);
std::fs::remove_file(&test_file).ok();
}
#[rstest]
fn collect_rust_files_skips_non_rust_files() {
use std::io::Write;
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("test_collect.txt");
{
let mut file = std::fs::File::create(&test_file).unwrap();
writeln!(file, "not rust").unwrap();
}
let result = collect_rust_files(&test_file).unwrap();
assert!(result.is_empty());
std::fs::remove_file(&test_file).ok();
}
#[cfg(unix)]
#[rstest]
fn collect_rust_files_skips_symlinks() {
use std::io::Write;
let temp_dir = tempfile::TempDir::new().unwrap();
let real_file = temp_dir.path().join("real.rs");
{
let mut file = std::fs::File::create(&real_file).unwrap();
writeln!(file, "fn main() {{}}").unwrap();
}
let symlink_file = temp_dir.path().join("symlink.rs");
std::os::unix::fs::symlink(&real_file, &symlink_file).unwrap();
let result = collect_rust_files(&temp_dir.path().to_path_buf()).unwrap();
assert_eq!(result.len(), 1);
let real_canonical = real_file.canonicalize().unwrap();
let result_canonical = result[0].canonicalize().unwrap();
assert_eq!(result_canonical, real_canonical);
}
#[cfg(unix)]
#[rstest]
fn collect_rust_files_rejects_symlink_as_single_file() {
use std::io::Write;
let temp_dir = tempfile::TempDir::new().unwrap();
let real_file = temp_dir.path().join("real.rs");
{
let mut file = std::fs::File::create(&real_file).unwrap();
writeln!(file, "fn main() {{}}").unwrap();
}
let symlink_file = temp_dir.path().join("symlink.rs");
std::os::unix::fs::symlink(&real_file, &symlink_file).unwrap();
let result = collect_rust_files(&symlink_file);
assert!(result.is_err());
assert!(result.unwrap_err().contains("symlink"));
}
#[rstest]
fn nested_workspace_roots_flags_only_separate_workspaces() {
use std::io::Write;
let temp_dir = tempfile::TempDir::new().unwrap();
let root = temp_dir.path();
{
let mut file = std::fs::File::create(root.join("Cargo.toml")).unwrap();
writeln!(file, "[workspace]\nmembers = [\"member-crate\"]").unwrap();
}
let member = root.join("member-crate");
std::fs::create_dir_all(&member).unwrap();
{
let mut file = std::fs::File::create(member.join("Cargo.toml")).unwrap();
writeln!(file, "[package]\nname = \"member-crate\"").unwrap();
}
let examples = root.join("examples");
std::fs::create_dir_all(&examples).unwrap();
{
let mut file = std::fs::File::create(examples.join("Cargo.toml")).unwrap();
writeln!(file, "[workspace]\nmembers = [\"demo\"]").unwrap();
}
let tooling = root.join("tooling");
std::fs::create_dir_all(&tooling).unwrap();
{
let mut file = std::fs::File::create(tooling.join("Cargo.toml")).unwrap();
writeln!(file, "[workspace.dependencies]\nserde = \"1\"").unwrap();
}
let mut roots = nested_workspace_roots(root);
roots.sort();
let mut expected = vec![examples, tooling];
expected.sort();
assert_eq!(roots, expected);
}
}