use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use callisto_model::{workspace_relative, Ecosystem, PackageId, ProjectRoot};
use ignore::WalkBuilder;
use crate::locate::membership;
use crate::locate::{find_workspace_root, LocateError, ProjectLocator};
pub struct IgnoreWalkLocator {
root: PathBuf,
skip: BTreeSet<&'static str>,
}
impl IgnoreWalkLocator {
pub fn new(root: &Path) -> Self {
let mut skip = BTreeSet::new();
skip.insert("target");
skip.insert("node_modules");
skip.insert(".git");
skip.insert(".moon");
skip.insert("dist");
let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
IgnoreWalkLocator { root: canonical, skip }
}
pub fn discover(start: &Path) -> Result<Self, LocateError> {
let root = find_workspace_root(start)?;
Ok(Self::new(&root))
}
}
impl ProjectLocator for IgnoreWalkLocator {
fn projects(&self) -> Result<Vec<ProjectRoot>, LocateError> {
let mut results = Vec::new();
let cargo_membership = membership::read_cargo_membership(&self.root);
let npm_membership = membership::read_npm_membership(&self.root);
let python_membership = membership::read_python_membership(&self.root);
let walker = WalkBuilder::new(&self.root)
.hidden(true)
.git_ignore(true)
.parents(false)
.max_depth(Some(32))
.follow_links(true)
.filter_entry({
let skip = self.skip.clone();
move |entry| {
if let Some(name) = entry.file_name().to_str() {
if skip.contains(name) {
return false;
}
}
true
}
})
.build();
for entry_res in walker {
let entry = entry_res.map_err(|e| LocateError::Walk {
path: self.root.clone(),
message: e.to_string(),
})?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let cargo_toml = path.join("Cargo.toml");
if cargo_toml.exists() {
if let Ok(content) = fs::read_to_string(&cargo_toml) {
if content.contains("[package]") {
if let Ok(doc) = content.parse::<toml_edit::DocumentMut>() {
if let Some(name) = doc.get("package").and_then(|p| p.get("name")).and_then(|n| n.as_str())
{
let rel = to_workspace_relative(path, &self.root)?;
let is_root = rel == Path::new(".");
if cargo_membership.admits(&rel, is_root) {
let id =
PackageId::parse(name).unwrap_or_else(|_| PackageId::Bare(name.to_string()));
results.push(ProjectRoot {
id,
path: rel,
ecosystem: Ecosystem::Cargo,
});
}
}
}
}
}
}
let pkg_json = path.join("package.json");
if pkg_json.exists() {
if let Ok(content) = fs::read_to_string(&pkg_json) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(name) = val.get("name").and_then(|n| n.as_str()) {
let rel = to_workspace_relative(path, &self.root)?;
let is_root = rel == Path::new(".");
if npm_membership.admits(&rel, is_root) {
let id = PackageId::parse(name).unwrap_or_else(|_| PackageId::Bare(name.to_string()));
results.push(ProjectRoot {
id,
path: rel,
ecosystem: Ecosystem::Npm,
});
}
}
}
}
}
let pyproject_toml = path.join("pyproject.toml");
if pyproject_toml.exists() {
if let Ok(content) = fs::read_to_string(&pyproject_toml) {
if let Ok(doc) = content.parse::<toml_edit::DocumentMut>() {
let name = doc
.get("project")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.or_else(|| {
doc.get("tool")
.and_then(|t| t.get("poetry"))
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
});
if let Some(n) = name {
let rel = to_workspace_relative(path, &self.root)?;
let is_root = rel == Path::new(".");
if python_membership.admits(&rel, is_root) {
let id = PackageId::parse(n).unwrap_or_else(|_| PackageId::Bare(n.to_string()));
results.push(ProjectRoot {
id,
path: rel,
ecosystem: Ecosystem::Pypi,
});
}
}
}
}
}
}
results.sort_by(|a, b| (&a.path, a.ecosystem).cmp(&(&b.path, b.ecosystem)));
Ok(results)
}
}
fn to_workspace_relative(path: &Path, root: &Path) -> Result<PathBuf, LocateError> {
if !path.starts_with(root) {
return Err(LocateError::OutsideWorkspaceRoot {
path: path.to_path_buf(),
root: root.to_path_buf(),
});
}
let rel = path.strip_prefix(root).unwrap();
if rel.as_os_str().is_empty() {
Ok(PathBuf::from("."))
} else {
workspace_relative(rel).map_err(|_e| LocateError::OutsideWorkspaceRoot {
path: path.to_path_buf(),
root: root.to_path_buf(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn ignore_walk_locator_does_not_traverse_beyond_32_levels() {
let root = tempdir().unwrap();
let mut deep_dir = root.path().to_path_buf();
for _ in 0..33 {
deep_dir = deep_dir.join("sub");
}
fs::create_dir_all(&deep_dir).unwrap();
fs::write(
deep_dir.join("Cargo.toml"),
"[package]\nname = \"deep-pkg\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let locator = IgnoreWalkLocator::new(root.path());
let projects = locator.projects().unwrap();
assert!(
projects.is_empty(),
"no projects should be found beyond 32 levels deep, found: {projects:?}"
);
}
#[test]
fn ignore_walk_locator_discovers_package_nested_inside_a_symlinked_directory() {
let root = tempdir().unwrap();
let external = tempdir().unwrap();
let nested = external.path().join("nested-pkg");
fs::create_dir_all(&nested).unwrap();
fs::write(
nested.join("Cargo.toml"),
"[package]\nname = \"linked-nested-pkg\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::os::unix::fs::symlink(external.path(), root.path().join("vendor-link"))
.expect("failed to create symlink for test fixture");
let locator = IgnoreWalkLocator::new(root.path());
let projects = locator.projects().unwrap();
assert!(
projects
.iter()
.any(|p| p.id == PackageId::Bare("linked-nested-pkg".to_string())),
"expected linked-nested-pkg discovered through the symlinked directory, got: {projects:?}"
);
}
#[test]
fn ignore_walk_locator_honors_python_workspace_exclude() {
let tmp = tempdir().unwrap();
let root = tmp.path();
fs::write(
root.join("pyproject.toml"),
"[project]\nname = \"root-pkg\"\nversion = \"0.1.0\"\n\n[tool.uv.workspace]\nmembers = [\"packages/*\"]\nexclude = [\"packages/examples/demo\"]\n",
)
.unwrap();
fs::create_dir_all(root.join("packages/examples/demo")).unwrap();
fs::write(
root.join("packages/examples/demo/pyproject.toml"),
"[project]\nname = \"demo\"\nversion = \"0.1.0\"\n",
)
.unwrap();
fs::create_dir_all(root.join("packages/kept")).unwrap();
fs::write(
root.join("packages/kept/pyproject.toml"),
"[project]\nname = \"kept\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let locator = IgnoreWalkLocator::new(root);
let projects = locator.projects().unwrap();
assert!(
!projects.iter().any(|p| p.path == Path::new("packages/examples/demo")),
"excluded Python package must not be discovered, got: {projects:?}"
);
assert!(
projects.iter().any(|p| p.path == Path::new("packages/kept")),
"non-excluded Python package must still be discovered, got: {projects:?}"
);
}
#[test]
fn discover_returns_workspace_root_not_found_for_non_workspace_dir() {
let tmp = tempfile::tempdir().unwrap();
let result = IgnoreWalkLocator::discover(tmp.path());
let is_correct = matches!(result, Err(LocateError::WorkspaceRootNotFound { .. }));
let err_display = result
.as_ref()
.err()
.map(|e| e.to_string())
.unwrap_or_else(|| "<Ok(...)>".to_string());
assert!(
is_correct,
"expected Err(LocateError::WorkspaceRootNotFound) for a directory with \
no workspace manifest markers, got: {err_display}"
);
}
#[test]
fn projects_returns_cargo_before_npm_when_both_manifests_present() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
std::fs::write(root.join("package.json"), r#"{"name":"my-npm-pkg","version":"0.1.0"}"#).unwrap();
let locator = IgnoreWalkLocator::new(root);
let projects = locator.projects().unwrap();
let cargo_pos = projects.iter().position(|p| p.ecosystem == Ecosystem::Cargo);
let npm_pos = projects.iter().position(|p| p.ecosystem == Ecosystem::Npm);
assert!(
cargo_pos.is_some(),
"expected a Cargo project to be discovered in the results"
);
assert!(
npm_pos.is_some(),
"expected an Npm project to be discovered in the results"
);
assert!(
cargo_pos.unwrap() < npm_pos.unwrap(),
"Cargo must be sorted before Npm (explicit Cargo > npm precedence); \
cargo_pos={:?}, npm_pos={:?}, projects={:?}",
cargo_pos,
npm_pos,
projects
.iter()
.map(|p| format!("{:?}:{}", p.ecosystem, p.id.name()))
.collect::<Vec<_>>()
);
}
#[test]
fn ac01_ac02_excludes_scratch_example_and_includes_kept_example_via_cargo_workspace_exclude() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/*\"]\nexclude = [\"crates/scratch-example\"]\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/scratch-example")).unwrap();
std::fs::write(
root.join("crates/scratch-example/Cargo.toml"),
"[package]\nname = \"scratch-example\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/kept-example")).unwrap();
std::fs::write(
root.join("crates/kept-example/Cargo.toml"),
"[package]\nname = \"kept-example\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
!projects.iter().any(|p| p.path == Path::new("crates/scratch-example")),
"AC-01: crates/scratch-example must be excluded, got: {projects:?}"
);
let kept_count = projects
.iter()
.filter(|p| p.path == Path::new("crates/kept-example"))
.count();
assert_eq!(
kept_count, 1,
"AC-02: exactly one entry for crates/kept-example, got: {projects:?}"
);
let kept = projects
.iter()
.find(|p| p.path == Path::new("crates/kept-example"))
.unwrap();
assert_eq!(kept.ecosystem, Ecosystem::Cargo);
}
#[test]
fn ac03_excludes_package_outside_npm_workspaces_glob() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("package.json"), r#"{"workspaces": ["packages/*"]}"#).unwrap();
std::fs::create_dir_all(root.join("tools/helper")).unwrap();
std::fs::write(root.join("tools/helper/package.json"), r#"{"name":"helper"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
!projects.iter().any(|p| p.path == Path::new("tools/helper")),
"AC-03: tools/helper must not be discovered, got: {projects:?}"
);
}
#[test]
fn ac04_pnpm_workspace_yaml_governs_npm_membership_when_no_workspaces_field() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("pnpm-workspace.yaml"), "packages:\n - \"packages/*\"\n").unwrap();
std::fs::create_dir_all(root.join("packages/kept")).unwrap();
std::fs::write(root.join("packages/kept/package.json"), r#"{"name":"kept"}"#).unwrap();
std::fs::create_dir_all(root.join("tools/outside")).unwrap();
std::fs::write(root.join("tools/outside/package.json"), r#"{"name":"outside"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects
.iter()
.any(|p| p.path == Path::new("packages/kept") && p.ecosystem == Ecosystem::Npm),
"AC-04: packages/kept must be discovered as Npm, got: {projects:?}"
);
assert!(
!projects.iter().any(|p| p.path == Path::new("tools/outside")),
"AC-04: tools/outside must not be discovered, got: {projects:?}"
);
}
#[test]
fn ac05_admits_all_cargo_candidates_when_root_has_no_workspace_table() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"solo\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/child")).unwrap();
std::fs::write(
root.join("crates/child/Cargo.toml"),
"[package]\nname = \"child\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects.iter().any(|p| p.path == Path::new(".")),
"root package must be admitted, got: {projects:?}"
);
assert!(
projects.iter().any(|p| p.path == Path::new("crates/child")),
"crates/child must be admitted, got: {projects:?}"
);
}
#[test]
fn ac05b_admits_cargo_candidates_when_root_has_no_cargo_toml_file_at_all() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("crates/kept")).unwrap();
std::fs::write(
root.join("crates/kept/Cargo.toml"),
"[package]\nname = \"kept\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects
.iter()
.any(|p| p.path == Path::new("crates/kept") && p.ecosystem == Ecosystem::Cargo),
"crates/kept must be admitted as Cargo, got: {projects:?}"
);
}
#[test]
fn ac07_includes_root_package_as_implicit_member_even_when_exclude_would_match_it() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"root-crate\"\nversion = \"0.1.0\"\n\n[workspace]\nmembers = [\"crates/*\"]\nexclude = [\".\"]\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/child")).unwrap();
std::fs::write(
root.join("crates/child/Cargo.toml"),
"[package]\nname = \"child\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
let root_entry = projects
.iter()
.find(|p| p.path == Path::new(".") && p.ecosystem == Ecosystem::Cargo);
assert!(
root_entry.is_some(),
"root package must never be silently dropped, got: {projects:?}"
);
assert!(
projects
.iter()
.any(|p| p.path == Path::new("crates/child") && p.ecosystem == Ecosystem::Cargo),
"the members filter must still admit crates/child normally alongside the root exemption, got: {projects:?}"
);
}
#[test]
fn ac10c_empty_members_array_admits_zero_cargo_entries() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("Cargo.toml"), "[workspace]\nmembers = []\n").unwrap();
std::fs::create_dir_all(root.join("crates/other")).unwrap();
std::fs::write(
root.join("crates/other/Cargo.toml"),
"[package]\nname = \"other\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
!projects.iter().any(|p| p.ecosystem == Ecosystem::Cargo),
"empty members = [] must exclude every Cargo candidate, got: {projects:?}"
);
}
#[test]
fn ac15_workspace_table_with_exclude_only_and_no_members_key_admits_non_excluded() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("Cargo.toml"), "[workspace]\nexclude = [\"crates/foo\"]\n").unwrap();
std::fs::create_dir_all(root.join("crates/foo")).unwrap();
std::fs::write(
root.join("crates/foo/Cargo.toml"),
"[package]\nname = \"foo\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/kept")).unwrap();
std::fs::write(
root.join("crates/kept/Cargo.toml"),
"[package]\nname = \"kept\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
!projects.iter().any(|p| p.path == Path::new("crates/foo")),
"excluded crate must not appear, got: {projects:?}"
);
assert!(
projects.iter().any(|p| p.path == Path::new("crates/kept")),
"non-excluded crate must appear, got: {projects:?}"
);
}
#[test]
fn ac06_admits_all_npm_candidates_when_no_workspaces_field_and_no_pnpm_yaml() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("package.json"), r#"{"name":"root"}"#).unwrap();
std::fs::create_dir_all(root.join("packages/child")).unwrap();
std::fs::write(root.join("packages/child/package.json"), r#"{"name":"child"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects
.iter()
.any(|p| p.path == Path::new(".") && p.ecosystem == Ecosystem::Npm),
"AC-06: root package must be admitted as Npm, got: {projects:?}"
);
assert!(
projects
.iter()
.any(|p| p.path == Path::new("packages/child") && p.ecosystem == Ecosystem::Npm),
"AC-06: packages/child must be admitted as Npm, got: {projects:?}"
);
}
#[test]
fn ac06b_admits_npm_candidates_when_root_has_no_package_json_file_at_all() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("packages/kept")).unwrap();
std::fs::write(root.join("packages/kept/package.json"), r#"{"name":"kept"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects
.iter()
.any(|p| p.path == Path::new("packages/kept") && p.ecosystem == Ecosystem::Npm),
"AC-06b: packages/kept must be admitted as Npm, got: {projects:?}"
);
}
#[test]
fn ac10_empty_npm_workspaces_array_admits_zero_npm_entries() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("package.json"), r#"{"workspaces": []}"#).unwrap();
std::fs::create_dir_all(root.join("packages/other")).unwrap();
std::fs::write(root.join("packages/other/package.json"), r#"{"name":"other"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
!projects.iter().any(|p| p.ecosystem == Ecosystem::Npm),
"AC-10: no npm entries expected when workspaces = [], got: {projects:?}"
);
}
#[test]
fn ac10_empty_npm_workspaces_array_still_admits_hybrid_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("package.json"),
r#"{"name": "root-package", "workspaces": []}"#,
)
.unwrap();
std::fs::create_dir_all(root.join("packages/other")).unwrap();
std::fs::write(root.join("packages/other/package.json"), r#"{"name":"other"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects
.iter()
.any(|p| p.path == Path::new(".") && p.ecosystem == Ecosystem::Npm),
"AC-10: hybrid root at '.' must still be admitted, got: {projects:?}"
);
assert!(
!projects
.iter()
.any(|p| p.path == Path::new("packages/other") && p.ecosystem == Ecosystem::Npm),
"AC-10: non-root packages/other must remain excluded, got: {projects:?}"
);
}
#[test]
fn ac10_empty_workspaces_array_distinguished_from_absent_workspaces_field() {
let tmp_empty = tempfile::tempdir().unwrap();
let root_empty = tmp_empty.path();
std::fs::write(root_empty.join("package.json"), r#"{"workspaces": []}"#).unwrap();
std::fs::create_dir_all(root_empty.join("packages/other")).unwrap();
std::fs::write(root_empty.join("packages/other/package.json"), r#"{"name":"other"}"#).unwrap();
let tmp_absent = tempfile::tempdir().unwrap();
let root_absent = tmp_absent.path();
std::fs::write(root_absent.join("package.json"), r#"{"name":"root"}"#).unwrap();
std::fs::create_dir_all(root_absent.join("packages/other")).unwrap();
std::fs::write(root_absent.join("packages/other/package.json"), r#"{"name":"other"}"#).unwrap();
let projects_empty = IgnoreWalkLocator::new(root_empty).projects().unwrap();
let projects_absent = IgnoreWalkLocator::new(root_absent).projects().unwrap();
assert!(
!projects_empty
.iter()
.any(|p| p.path == Path::new("packages/other") && p.ecosystem == Ecosystem::Npm),
"AC-10: empty workspaces = [] must exclude packages/other, got: {projects_empty:?}"
);
assert!(
projects_absent
.iter()
.any(|p| p.path == Path::new("packages/other") && p.ecosystem == Ecosystem::Npm),
"AC-06: absent workspaces field must admit packages/other, got: {projects_absent:?}"
);
}
#[test]
fn ac08_pnpm_workspace_yaml_takes_precedence_over_package_json_workspaces_field() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("package.json"), r#"{"workspaces": ["packages/*"]}"#).unwrap();
std::fs::write(root.join("pnpm-workspace.yaml"), "packages:\n - \"tools/*\"\n").unwrap();
std::fs::create_dir_all(root.join("tools/x")).unwrap();
std::fs::write(root.join("tools/x/package.json"), r#"{"name":"x"}"#).unwrap();
std::fs::create_dir_all(root.join("packages/y")).unwrap();
std::fs::write(root.join("packages/y/package.json"), r#"{"name":"y"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects
.iter()
.any(|p| p.path == Path::new("tools/x") && p.ecosystem == Ecosystem::Npm),
"AC-08: tools/x must be discovered as Npm, got: {projects:?}"
);
assert!(
!projects.iter().any(|p| p.path == Path::new("packages/y")),
"AC-08: packages/y must not be discovered, got: {projects:?}"
);
}
#[test]
fn ac12_cargo_workspace_membership_regression_group() {
{
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/*\"]\nexclude = [\"crates/scratch-example\"]\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/scratch-example")).unwrap();
std::fs::write(
root.join("crates/scratch-example/Cargo.toml"),
"[package]\nname = \"scratch-example\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/kept-example")).unwrap();
std::fs::write(
root.join("crates/kept-example/Cargo.toml"),
"[package]\nname = \"kept-example\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(!projects.iter().any(|p| p.path == Path::new("crates/scratch-example")));
let kept_count = projects
.iter()
.filter(|p| p.path == Path::new("crates/kept-example"))
.count();
assert_eq!(kept_count, 1);
let kept = projects
.iter()
.find(|p| p.path == Path::new("crates/kept-example"))
.unwrap();
assert_eq!(kept.ecosystem, Ecosystem::Cargo);
}
{
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"solo\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects.iter().any(|p| p.path == Path::new(".")));
}
{
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("crates/kept")).unwrap();
std::fs::write(
root.join("crates/kept/Cargo.toml"),
"[package]\nname = \"kept\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects.iter().any(|p| p.path == Path::new("crates/kept")));
}
{
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"root-crate\"\nversion = \"0.1.0\"\n\n[workspace]\nmembers = [\"crates/*\"]\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects
.iter()
.any(|p| p.path == Path::new(".") && p.ecosystem == Ecosystem::Cargo));
}
{
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"root-crate\"\nversion = \"0.1.0\"\n\n[workspace]\nmembers = [\"crates/*\"]\nexclude = [\".\"]\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects
.iter()
.any(|p| p.path == Path::new(".") && p.ecosystem == Ecosystem::Cargo));
}
{
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("Cargo.toml"), "[workspace]\nmembers = []\n").unwrap();
std::fs::create_dir_all(root.join("crates/other")).unwrap();
std::fs::write(
root.join("crates/other/Cargo.toml"),
"[package]\nname = \"other\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(!projects.iter().any(|p| p.ecosystem == Ecosystem::Cargo));
}
{
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("Cargo.toml"), "[workspace]\nexclude = [\"crates/foo\"]\n").unwrap();
std::fs::create_dir_all(root.join("crates/foo")).unwrap();
std::fs::write(
root.join("crates/foo/Cargo.toml"),
"[package]\nname = \"foo\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/kept")).unwrap();
std::fs::write(
root.join("crates/kept/Cargo.toml"),
"[package]\nname = \"kept\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(!projects.iter().any(|p| p.path == Path::new("crates/foo")));
assert!(projects.iter().any(|p| p.path == Path::new("crates/kept")));
}
}
#[test]
fn ac10b_pnpm_empty_packages_list_admits_zero_npm_entries() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("pnpm-workspace.yaml"), "packages: []\n").unwrap();
std::fs::create_dir_all(root.join("packages/other")).unwrap();
std::fs::write(root.join("packages/other/package.json"), r#"{"name":"other"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
!projects.iter().any(|p| p.ecosystem == Ecosystem::Npm),
"AC-10b: no non-root npm entries expected, got: {projects:?}"
);
}
#[test]
fn ac10b_pnpm_empty_packages_list_still_admits_hybrid_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("pnpm-workspace.yaml"), "packages: []\n").unwrap();
std::fs::write(root.join("package.json"), r#"{"name":"root-pkg"}"#).unwrap();
std::fs::create_dir_all(root.join("packages/other")).unwrap();
std::fs::write(root.join("packages/other/package.json"), r#"{"name":"other"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects
.iter()
.any(|p| p.path == Path::new(".") && p.ecosystem == Ecosystem::Npm),
"AC-10b: hybrid root at '.' must still be admitted, got: {projects:?}"
);
assert!(
!projects
.iter()
.any(|p| p.path == Path::new("packages/other") && p.ecosystem == Ecosystem::Npm),
"AC-10b: non-root packages/other must remain excluded, got: {projects:?}"
);
}
#[test]
fn ac11c_malformed_pnpm_yaml_does_not_count_as_present_for_precedence() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("package.json"), r#"{"workspaces": ["packages/*"]}"#).unwrap();
std::fs::write(root.join("pnpm-workspace.yaml"), "packages: [\"packages/*\"\n").unwrap();
std::fs::create_dir_all(root.join("packages/y")).unwrap();
std::fs::write(root.join("packages/y/package.json"), r#"{"name":"y"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects.iter().any(|p| p.path == Path::new("packages/y")),
"AC-11c: package.json workspaces field must still govern membership when \
pnpm-workspace.yaml is malformed YAML, got: {projects:?}"
);
}
#[test]
fn ac11_malformed_pnpm_yaml_with_no_package_json_workspaces_falls_back_to_absent_filter() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("package.json"), r#"{"name":"root"}"#).unwrap();
std::fs::write(root.join("pnpm-workspace.yaml"), "packages: [\"packages/*\"\n").unwrap();
std::fs::create_dir_all(root.join("packages/kept")).unwrap();
std::fs::write(root.join("packages/kept/package.json"), r#"{"name":"kept"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects.iter().any(|p| p.path == Path::new("packages/kept")),
"AC-11: malformed pnpm-workspace.yaml with no package.json workspaces field \
must fall back to admit-all npm membership, got: {projects:?}"
);
}
#[test]
fn ac13_npm_pnpm_workspace_membership_regression_group() {
{
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("package.json"), r#"{"workspaces": ["packages/*"]}"#).unwrap();
std::fs::create_dir_all(root.join("tools/helper")).unwrap();
std::fs::write(root.join("tools/helper/package.json"), r#"{"name":"helper"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(!projects.iter().any(|p| p.path == Path::new("tools/helper")));
}
{
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("pnpm-workspace.yaml"), "packages:\n - \"packages/*\"\n").unwrap();
std::fs::create_dir_all(root.join("packages/kept")).unwrap();
std::fs::write(root.join("packages/kept/package.json"), r#"{"name":"kept"}"#).unwrap();
std::fs::create_dir_all(root.join("tools/outside")).unwrap();
std::fs::write(root.join("tools/outside/package.json"), r#"{"name":"outside"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects.iter().any(|p| p.path == Path::new("packages/kept")));
assert!(!projects.iter().any(|p| p.path == Path::new("tools/outside")));
}
{
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("package.json"), r#"{"name":"root"}"#).unwrap();
std::fs::create_dir_all(root.join("packages/child")).unwrap();
std::fs::write(root.join("packages/child/package.json"), r#"{"name":"child"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects
.iter()
.any(|p| p.path == Path::new("packages/child") && p.ecosystem == Ecosystem::Npm));
}
{
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("packages/kept")).unwrap();
std::fs::write(root.join("packages/kept/package.json"), r#"{"name":"kept"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects.iter().any(|p| p.path == Path::new("packages/kept")));
}
{
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("package.json"), r#"{"workspaces": ["packages/*"]}"#).unwrap();
std::fs::write(root.join("pnpm-workspace.yaml"), "packages:\n - \"tools/*\"\n").unwrap();
std::fs::create_dir_all(root.join("tools/x")).unwrap();
std::fs::write(root.join("tools/x/package.json"), r#"{"name":"x"}"#).unwrap();
std::fs::create_dir_all(root.join("packages/y")).unwrap();
std::fs::write(root.join("packages/y/package.json"), r#"{"name":"y"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects.iter().any(|p| p.path == Path::new("tools/x")));
assert!(!projects.iter().any(|p| p.path == Path::new("packages/y")));
}
{
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("pnpm-workspace.yaml"), "packages: []\n").unwrap();
std::fs::create_dir_all(root.join("packages/other")).unwrap();
std::fs::write(root.join("packages/other/package.json"), r#"{"name":"other"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(!projects.iter().any(|p| p.ecosystem == Ecosystem::Npm));
}
{
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("package.json"), r#"{"workspaces": ["packages/*"]}"#).unwrap();
std::fs::write(root.join("pnpm-workspace.yaml"), "packages: [\"packages/*\"\n").unwrap();
std::fs::create_dir_all(root.join("packages/y")).unwrap();
std::fs::write(root.join("packages/y/package.json"), r#"{"name":"y"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects.iter().any(|p| p.path == Path::new("packages/y")));
}
{
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("package.json"), r#"{"workspaces": []}"#).unwrap();
std::fs::create_dir_all(root.join("packages/other")).unwrap();
std::fs::write(root.join("packages/other/package.json"), r#"{"name":"other"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(!projects.iter().any(|p| p.ecosystem == Ecosystem::Npm));
}
{
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("package.json"),
r#"{"name": "root-package", "workspaces": ["packages/*"]}"#,
)
.unwrap();
std::fs::create_dir_all(root.join("packages/child")).unwrap();
std::fs::write(root.join("packages/child/package.json"), r#"{"name":"child"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects
.iter()
.any(|p| p.path == Path::new(".") && p.ecosystem == Ecosystem::Npm));
assert!(projects
.iter()
.any(|p| p.path == Path::new("packages/child") && p.ecosystem == Ecosystem::Npm));
}
{
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("package.json"),
r#"{"name": "root-package", "workspaces": ["packages/*"]}"#,
)
.unwrap();
std::fs::write(root.join("pnpm-workspace.yaml"), "packages: [\"packages/*\"\n").unwrap();
std::fs::create_dir_all(root.join("packages/child")).unwrap();
std::fs::write(root.join("packages/child/package.json"), r#"{"name":"child"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects
.iter()
.any(|p| p.path == Path::new(".") && p.ecosystem == Ecosystem::Npm));
assert!(projects
.iter()
.any(|p| p.path == Path::new("packages/child") && p.ecosystem == Ecosystem::Npm));
}
{
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("package.json"),
r#"{"name": "root-package", "workspaces": []}"#,
)
.unwrap();
std::fs::create_dir_all(root.join("packages/other")).unwrap();
std::fs::write(root.join("packages/other/package.json"), r#"{"name":"other"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
let npm_entries: Vec<_> = projects.iter().filter(|p| p.ecosystem == Ecosystem::Npm).collect();
assert_eq!(npm_entries.len(), 1);
assert_eq!(npm_entries[0].path, Path::new("."));
}
{
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("package.json"), r#"{"name": "root-package"}"#).unwrap();
std::fs::write(root.join("pnpm-workspace.yaml"), "packages:\n - \"packages/*\"\n").unwrap();
std::fs::create_dir_all(root.join("packages/child")).unwrap();
std::fs::write(root.join("packages/child/package.json"), r#"{"name":"child"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects
.iter()
.any(|p| p.path == Path::new(".") && p.ecosystem == Ecosystem::Npm));
assert!(projects
.iter()
.any(|p| p.path == Path::new("packages/child") && p.ecosystem == Ecosystem::Npm));
}
}
#[test]
fn ac16_npm_hybrid_root_admitted_when_workspaces_field_governs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("package.json"),
r#"{"name": "root-package", "workspaces": ["packages/*"]}"#,
)
.unwrap();
std::fs::create_dir_all(root.join("packages/child")).unwrap();
std::fs::write(root.join("packages/child/package.json"), r#"{"name":"child"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects
.iter()
.any(|p| p.path == Path::new(".") && p.ecosystem == Ecosystem::Npm),
"AC-16: root package must be admitted as Npm, got: {projects:?}"
);
assert!(
projects
.iter()
.any(|p| p.path == Path::new("packages/child") && p.ecosystem == Ecosystem::Npm),
"AC-16: packages/child must be admitted as Npm, got: {projects:?}"
);
}
#[test]
fn ac16b_npm_hybrid_root_admitted_when_sibling_pnpm_workspace_yaml_is_malformed() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("package.json"),
r#"{"name": "root-package", "workspaces": ["packages/*"]}"#,
)
.unwrap();
std::fs::write(root.join("pnpm-workspace.yaml"), b": {\x00 invalid yaml \xff\xfe").unwrap();
std::fs::create_dir_all(root.join("packages/child")).unwrap();
std::fs::write(root.join("packages/child/package.json"), r#"{"name":"child"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects
.iter()
.any(|p| p.path == Path::new(".") && p.ecosystem == Ecosystem::Npm),
"AC-16b: root package must be admitted as Npm despite malformed pnpm-workspace.yaml, got: {projects:?}"
);
assert!(
projects
.iter()
.any(|p| p.path == Path::new("packages/child") && p.ecosystem == Ecosystem::Npm),
"AC-16b: packages/child must be admitted as Npm, got: {projects:?}"
);
}
#[test]
fn ac17_npm_hybrid_root_admitted_when_pnpm_workspace_yaml_governs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("package.json"), r#"{"name": "root-package"}"#).unwrap();
std::fs::write(root.join("pnpm-workspace.yaml"), "packages:\n - \"packages/*\"\n").unwrap();
std::fs::create_dir_all(root.join("packages/child")).unwrap();
std::fs::write(root.join("packages/child/package.json"), r#"{"name":"child"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects
.iter()
.any(|p| p.path == Path::new(".") && p.ecosystem == Ecosystem::Npm),
"AC-17: root package must be admitted as Npm when pnpm-workspace.yaml governs, got: {projects:?}"
);
assert!(
projects
.iter()
.any(|p| p.path == Path::new("packages/child") && p.ecosystem == Ecosystem::Npm),
"AC-17: packages/child must be admitted as Npm, got: {projects:?}"
);
}
#[test]
fn ac10d_npm_hybrid_root_admitted_despite_empty_workspaces_array() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("package.json"),
r#"{"name": "root-package", "workspaces": []}"#,
)
.unwrap();
std::fs::create_dir_all(root.join("packages/child")).unwrap();
std::fs::write(root.join("packages/child/package.json"), r#"{"name":"child"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
let npm_projects: Vec<_> = projects.iter().filter(|p| p.ecosystem == Ecosystem::Npm).collect();
assert_eq!(
npm_projects.len(),
1,
"AC-10d: empty workspaces array must admit exactly one Npm entry (the hybrid root), got: {npm_projects:?}"
);
assert_eq!(
npm_projects[0].path,
Path::new("."),
"AC-10d: the single Npm entry must be the root package at path '.', got: {npm_projects:?}"
);
}
#[test]
fn ac09_malformed_cargo_members_bare_string_falls_back_to_absent_filter() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("Cargo.toml"), "[workspace]\nmembers = \"crates/*\"\n").unwrap();
std::fs::create_dir_all(root.join("tools/outside")).unwrap();
std::fs::write(
root.join("tools/outside/Cargo.toml"),
"[package]\nname = \"outside\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects.iter().any(|p| p.path == Path::new("tools/outside")));
}
#[test]
fn ac09_malformed_cargo_members_non_string_entry_falls_back_to_absent_filter() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("Cargo.toml"), "[workspace]\nmembers = [\"crates/*\", 42]\n").unwrap();
std::fs::create_dir_all(root.join("tools/outside")).unwrap();
std::fs::write(
root.join("tools/outside/Cargo.toml"),
"[package]\nname = \"outside\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects.iter().any(|p| p.path == Path::new("tools/outside")));
}
#[test]
fn ac09d_malformed_root_cargo_toml_syntax_falls_back_to_absent_cargo_filter() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("Cargo.toml"), "[workspace\nmembers = [\n").unwrap();
std::fs::create_dir_all(root.join("crates/kept")).unwrap();
std::fs::write(
root.join("crates/kept/Cargo.toml"),
"[package]\nname = \"kept\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects.iter().any(|p| p.path == Path::new("crates/kept")));
}
#[test]
fn ac09b_malformed_cargo_exclude_bare_string_falls_back_to_excluding_nothing() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/*\"]\nexclude = \"crates/scratch-example\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/scratch-example")).unwrap();
std::fs::write(
root.join("crates/scratch-example/Cargo.toml"),
"[package]\nname = \"scratch-example\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/kept-example")).unwrap();
std::fs::write(
root.join("crates/kept-example/Cargo.toml"),
"[package]\nname = \"kept-example\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("tools/outside")).unwrap();
std::fs::write(
root.join("tools/outside/Cargo.toml"),
"[package]\nname = \"outside\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects.iter().any(|p| p.path == Path::new("crates/scratch-example")));
assert!(projects.iter().any(|p| p.path == Path::new("crates/kept-example")));
assert!(!projects.iter().any(|p| p.path == Path::new("tools/outside")));
}
#[test]
fn ac09b_malformed_cargo_exclude_non_string_entry_falls_back_to_excluding_nothing() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/*\"]\nexclude = [\"crates/scratch-example\", 42]\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/scratch-example")).unwrap();
std::fs::write(
root.join("crates/scratch-example/Cargo.toml"),
"[package]\nname = \"scratch-example\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/kept-example")).unwrap();
std::fs::write(
root.join("crates/kept-example/Cargo.toml"),
"[package]\nname = \"kept-example\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("tools/outside")).unwrap();
std::fs::write(
root.join("tools/outside/Cargo.toml"),
"[package]\nname = \"outside\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects.iter().any(|p| p.path == Path::new("crates/scratch-example")));
assert!(projects.iter().any(|p| p.path == Path::new("crates/kept-example")));
assert!(!projects.iter().any(|p| p.path == Path::new("tools/outside")));
}
#[test]
fn ac09f_invalid_glob_in_cargo_members_is_skipped_without_disabling_sibling_entries() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/kept-example\", \"crates/[unterminated\"]\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/kept-example")).unwrap();
std::fs::write(
root.join("crates/kept-example/Cargo.toml"),
"[package]\nname = \"kept-example\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects.iter().any(|p| p.path == Path::new("crates/kept-example")));
}
#[test]
fn ac09g_invalid_glob_in_npm_workspaces_is_skipped_without_disabling_sibling_entries() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("package.json"),
r#"{"workspaces": ["packages/kept-example", "packages/[unterminated"]}"#,
)
.unwrap();
std::fs::create_dir_all(root.join("packages/kept-example")).unwrap();
std::fs::write(
root.join("packages/kept-example/package.json"),
r#"{"name":"kept-example"}"#,
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects.iter().any(|p| p.path == Path::new("packages/kept-example")));
}
#[test]
fn ac09i_invalid_glob_in_cargo_exclude_is_skipped_without_disabling_sibling_entries() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/*\"]\nexclude = [\"crates/scratch-example\", \"crates/[unterminated\"]\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/scratch-example")).unwrap();
std::fs::write(
root.join("crates/scratch-example/Cargo.toml"),
"[package]\nname = \"scratch-example\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/kept-example")).unwrap();
std::fs::write(
root.join("crates/kept-example/Cargo.toml"),
"[package]\nname = \"kept-example\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(!projects.iter().any(|p| p.path == Path::new("crates/scratch-example")));
assert!(projects.iter().any(|p| p.path == Path::new("crates/kept-example")));
}
#[test]
fn ac09h_invalid_glob_in_pnpm_packages_is_skipped_without_disabling_sibling_entries() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("pnpm-workspace.yaml"),
"packages:\n - \"packages/kept-example\"\n - \"packages/[unterminated\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("packages/kept-example")).unwrap();
std::fs::write(
root.join("packages/kept-example/package.json"),
r#"{"name":"kept-example"}"#,
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects.iter().any(|p| p.path == Path::new("packages/kept-example")));
}
#[test]
fn ac09c_malformed_npm_workspaces_bare_string_falls_back_to_absent_filter() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("package.json"), r#"{"workspaces": "packages/*"}"#).unwrap();
std::fs::create_dir_all(root.join("tools/outside")).unwrap();
std::fs::write(root.join("tools/outside/package.json"), r#"{"name":"outside"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects.iter().any(|p| p.path == Path::new("tools/outside")));
}
#[test]
fn ac09c_malformed_npm_workspaces_non_string_entry_falls_back_to_absent_filter() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("package.json"), r#"{"workspaces": ["packages/*", 42]}"#).unwrap();
std::fs::create_dir_all(root.join("tools/outside")).unwrap();
std::fs::write(root.join("tools/outside/package.json"), r#"{"name":"outside"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects.iter().any(|p| p.path == Path::new("tools/outside")));
}
#[test]
fn ac09e_malformed_root_package_json_syntax_falls_back_to_absent_npm_filter() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("package.json"), "{\"workspaces\": [\"packages/*\"],}").unwrap();
std::fs::create_dir_all(root.join("packages/kept")).unwrap();
std::fs::write(root.join("packages/kept/package.json"), r#"{"name":"kept"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(projects.iter().any(|p| p.path == Path::new("packages/kept")));
}
#[test]
fn ac11b_pnpm_packages_wrong_shape_falls_back_to_absent_filter() {
let dir_missing = tempfile::tempdir().unwrap();
let root_missing = dir_missing.path();
std::fs::write(root_missing.join("pnpm-workspace.yaml"), "other_key: true\n").unwrap();
std::fs::create_dir_all(root_missing.join("packages/kept")).unwrap();
std::fs::write(root_missing.join("packages/kept/package.json"), r#"{"name":"kept"}"#).unwrap();
let projects_missing = IgnoreWalkLocator::new(root_missing).projects().unwrap();
assert!(
projects_missing.iter().any(|p| p.path == Path::new("packages/kept")),
"AC-11b (missing key): packages/kept must be admitted, got: {projects_missing:?}"
);
let dir_scalar = tempfile::tempdir().unwrap();
let root_scalar = dir_scalar.path();
std::fs::write(root_scalar.join("pnpm-workspace.yaml"), "packages: \"packages/*\"\n").unwrap();
std::fs::create_dir_all(root_scalar.join("packages/kept")).unwrap();
std::fs::write(root_scalar.join("packages/kept/package.json"), r#"{"name":"kept"}"#).unwrap();
let projects_scalar = IgnoreWalkLocator::new(root_scalar).projects().unwrap();
assert!(
projects_scalar.iter().any(|p| p.path == Path::new("packages/kept")),
"AC-11b (wrong scalar type): packages/kept must be admitted, got: {projects_scalar:?}"
);
let dir_mapping = tempfile::tempdir().unwrap();
let root_mapping = dir_mapping.path();
std::fs::write(root_mapping.join("pnpm-workspace.yaml"), "packages:\n foo: bar\n").unwrap();
std::fs::create_dir_all(root_mapping.join("packages/kept")).unwrap();
std::fs::write(root_mapping.join("packages/kept/package.json"), r#"{"name":"kept"}"#).unwrap();
let projects_mapping = IgnoreWalkLocator::new(root_mapping).projects().unwrap();
assert!(
projects_mapping.iter().any(|p| p.path == Path::new("packages/kept")),
"AC-11b (mapping value): packages/kept must be admitted, got: {projects_mapping:?}"
);
let dir_nonstring = tempfile::tempdir().unwrap();
let root_nonstring = dir_nonstring.path();
std::fs::write(
root_nonstring.join("pnpm-workspace.yaml"),
"packages:\n - \"a\"\n - 42\n",
)
.unwrap();
std::fs::create_dir_all(root_nonstring.join("packages/kept")).unwrap();
std::fs::write(root_nonstring.join("packages/kept/package.json"), r#"{"name":"kept"}"#).unwrap();
let projects_nonstring = IgnoreWalkLocator::new(root_nonstring).projects().unwrap();
assert!(
projects_nonstring.iter().any(|p| p.path == Path::new("packages/kept")),
"AC-11b (non-string sequence element): packages/kept must be admitted, got: {projects_nonstring:?}"
);
}
#[test]
fn ac11d_zero_yaml_documents_falls_back_to_absent_filter_without_panic() {
let dir_empty = tempfile::tempdir().unwrap();
let root_empty = dir_empty.path();
std::fs::write(root_empty.join("pnpm-workspace.yaml"), "").unwrap();
std::fs::create_dir_all(root_empty.join("packages/kept")).unwrap();
std::fs::write(root_empty.join("packages/kept/package.json"), r#"{"name":"kept"}"#).unwrap();
let projects_empty = IgnoreWalkLocator::new(root_empty).projects().unwrap();
assert!(
projects_empty.iter().any(|p| p.path == Path::new("packages/kept")),
"AC-11d (zero bytes): packages/kept must be admitted, got: {projects_empty:?}"
);
let dir_whitespace = tempfile::tempdir().unwrap();
let root_whitespace = dir_whitespace.path();
std::fs::write(root_whitespace.join("pnpm-workspace.yaml"), " \n\t\n \n").unwrap();
std::fs::create_dir_all(root_whitespace.join("packages/kept")).unwrap();
std::fs::write(root_whitespace.join("packages/kept/package.json"), r#"{"name":"kept"}"#).unwrap();
let projects_whitespace = IgnoreWalkLocator::new(root_whitespace).projects().unwrap();
assert!(
projects_whitespace.iter().any(|p| p.path == Path::new("packages/kept")),
"AC-11d (whitespace only): packages/kept must be admitted, got: {projects_whitespace:?}"
);
let dir_comments = tempfile::tempdir().unwrap();
let root_comments = dir_comments.path();
std::fs::write(
root_comments.join("pnpm-workspace.yaml"),
"# no packages here\n# another comment\n",
)
.unwrap();
std::fs::create_dir_all(root_comments.join("packages/kept")).unwrap();
std::fs::write(root_comments.join("packages/kept/package.json"), r#"{"name":"kept"}"#).unwrap();
let projects_comments = IgnoreWalkLocator::new(root_comments).projects().unwrap();
assert!(
projects_comments.iter().any(|p| p.path == Path::new("packages/kept")),
"AC-11d (comments only): packages/kept must be admitted, got: {projects_comments:?}"
);
}
#[test]
fn ac14_cargo_malformed_edge_case_regression_slice() {
{
let tmp = tempdir().unwrap();
let root = tmp.path();
fs::write(root.join("Cargo.toml"), "[workspace]\nmembers = \"crates/*\"\n").unwrap();
fs::create_dir_all(root.join("tools/outside")).unwrap();
fs::write(
root.join("tools/outside/Cargo.toml"),
"[package]\nname = \"outside\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects.iter().any(|p| p.path == Path::new("tools/outside")),
"AC-09: tools/outside must be admitted, got: {projects:?}"
);
}
{
let tmp = tempdir().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/*\"]\nexclude = \"crates/scratch-example\"\n",
)
.unwrap();
fs::create_dir_all(root.join("crates/scratch-example")).unwrap();
fs::write(
root.join("crates/scratch-example/Cargo.toml"),
"[package]\nname = \"scratch-example\"\nversion = \"0.1.0\"\n",
)
.unwrap();
fs::create_dir_all(root.join("crates/kept-example")).unwrap();
fs::write(
root.join("crates/kept-example/Cargo.toml"),
"[package]\nname = \"kept-example\"\nversion = \"0.1.0\"\n",
)
.unwrap();
fs::create_dir_all(root.join("tools/outside")).unwrap();
fs::write(
root.join("tools/outside/Cargo.toml"),
"[package]\nname = \"outside\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects.iter().any(|p| p.path == Path::new("crates/scratch-example")),
"AC-09b: crates/scratch-example must be admitted, got: {projects:?}"
);
assert!(
projects.iter().any(|p| p.path == Path::new("crates/kept-example")),
"AC-09b: crates/kept-example must be admitted, got: {projects:?}"
);
assert!(
!projects.iter().any(|p| p.path == Path::new("tools/outside")),
"AC-09b: tools/outside must remain excluded by members, got: {projects:?}"
);
}
{
let tmp = tempdir().unwrap();
let root = tmp.path();
fs::write(root.join("Cargo.toml"), "[workspace\nmembers = [\n").unwrap();
fs::create_dir_all(root.join("crates/kept")).unwrap();
fs::write(
root.join("crates/kept/Cargo.toml"),
"[package]\nname = \"kept\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects.iter().any(|p| p.path == Path::new("crates/kept")),
"AC-09d: crates/kept must be admitted, got: {projects:?}"
);
}
{
let tmp = tempdir().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/kept-example\", \"crates/[unterminated\"]\n",
)
.unwrap();
fs::create_dir_all(root.join("crates/kept-example")).unwrap();
fs::write(
root.join("crates/kept-example/Cargo.toml"),
"[package]\nname = \"kept-example\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects.iter().any(|p| p.path == Path::new("crates/kept-example")),
"AC-09f: crates/kept-example must be admitted, got: {projects:?}"
);
}
{
let tmp = tempdir().unwrap();
let root = tmp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/*\"]\nexclude = [\"crates/scratch-example\", \"crates/[unterminated\"]\n",
)
.unwrap();
fs::create_dir_all(root.join("crates/scratch-example")).unwrap();
fs::write(
root.join("crates/scratch-example/Cargo.toml"),
"[package]\nname = \"scratch-example\"\nversion = \"0.1.0\"\n",
)
.unwrap();
fs::create_dir_all(root.join("crates/kept-example")).unwrap();
fs::write(
root.join("crates/kept-example/Cargo.toml"),
"[package]\nname = \"kept-example\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
!projects.iter().any(|p| p.path == Path::new("crates/scratch-example")),
"AC-09i: crates/scratch-example must remain excluded, got: {projects:?}"
);
assert!(
projects.iter().any(|p| p.path == Path::new("crates/kept-example")),
"AC-09i: crates/kept-example must be admitted, got: {projects:?}"
);
}
}
#[test]
fn ac14_npm_malformed_edge_case_regression_slice() {
{
let tmp = tempdir().unwrap();
let root = tmp.path();
fs::write(root.join("package.json"), r#"{"workspaces": "packages/*"}"#).unwrap();
fs::create_dir_all(root.join("tools/outside")).unwrap();
fs::write(root.join("tools/outside/package.json"), r#"{"name":"outside"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects.iter().any(|p| p.path == Path::new("tools/outside")),
"AC-09c: tools/outside must be admitted, got: {projects:?}"
);
}
{
let tmp = tempdir().unwrap();
let root = tmp.path();
fs::write(root.join("package.json"), "{\"workspaces\": [\"packages/*\"],}").unwrap();
fs::create_dir_all(root.join("packages/kept")).unwrap();
fs::write(root.join("packages/kept/package.json"), r#"{"name":"kept"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects.iter().any(|p| p.path == Path::new("packages/kept")),
"AC-09e: packages/kept must be admitted, got: {projects:?}"
);
}
{
let tmp = tempdir().unwrap();
let root = tmp.path();
fs::write(
root.join("package.json"),
r#"{"workspaces": ["packages/kept-example", "packages/[unterminated"]}"#,
)
.unwrap();
fs::create_dir_all(root.join("packages/kept-example")).unwrap();
fs::write(
root.join("packages/kept-example/package.json"),
r#"{"name":"kept-example"}"#,
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects.iter().any(|p| p.path == Path::new("packages/kept-example")),
"AC-09g: packages/kept-example must be admitted, got: {projects:?}"
);
}
{
let tmp = tempdir().unwrap();
let root = tmp.path();
fs::write(root.join("package.json"), r#"{"workspaces": []}"#).unwrap();
fs::create_dir_all(root.join("packages/other")).unwrap();
fs::write(root.join("packages/other/package.json"), r#"{"name":"other"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
!projects.iter().any(|p| p.ecosystem == Ecosystem::Npm),
"AC-10: no Npm entries expected, got: {projects:?}"
);
}
}
#[test]
fn ac14_pnpm_malformed_edge_case_regression_slice() {
{
let tmp = tempdir().unwrap();
let root = tmp.path();
fs::write(
root.join("pnpm-workspace.yaml"),
"packages:\n - \"packages/kept-example\"\n - \"packages/[unterminated\"\n",
)
.unwrap();
fs::create_dir_all(root.join("packages/kept-example")).unwrap();
fs::write(
root.join("packages/kept-example/package.json"),
r#"{"name":"kept-example"}"#,
)
.unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects.iter().any(|p| p.path == Path::new("packages/kept-example")),
"AC-09h: packages/kept-example must be admitted, got: {projects:?}"
);
}
{
let tmp = tempdir().unwrap();
let root = tmp.path();
fs::write(root.join("package.json"), r#"{"name":"root"}"#).unwrap();
fs::write(root.join("pnpm-workspace.yaml"), "packages: [\"packages/*\"\n").unwrap();
fs::create_dir_all(root.join("packages/kept")).unwrap();
fs::write(root.join("packages/kept/package.json"), r#"{"name":"kept"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects.iter().any(|p| p.path == Path::new("packages/kept")),
"AC-11: packages/kept must be admitted, got: {projects:?}"
);
}
{
let tmp = tempdir().unwrap();
let root = tmp.path();
fs::write(root.join("pnpm-workspace.yaml"), "packages: \"packages/*\"\n").unwrap();
fs::create_dir_all(root.join("packages/kept")).unwrap();
fs::write(root.join("packages/kept/package.json"), r#"{"name":"kept"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects.iter().any(|p| p.path == Path::new("packages/kept")),
"AC-11b: packages/kept must be admitted, got: {projects:?}"
);
}
{
let tmp = tempdir().unwrap();
let root = tmp.path();
fs::write(root.join("pnpm-workspace.yaml"), "# no packages here\n").unwrap();
fs::create_dir_all(root.join("packages/kept")).unwrap();
fs::write(root.join("packages/kept/package.json"), r#"{"name":"kept"}"#).unwrap();
let projects = IgnoreWalkLocator::new(root).projects().unwrap();
assert!(
projects.iter().any(|p| p.path == Path::new("packages/kept")),
"AC-11d: packages/kept must be admitted, got: {projects:?}"
);
}
}
}