mod package_json;
mod parsers;
use std::path::{Path, PathBuf};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub use package_json::PackageJson;
pub use parsers::parse_tsconfig_root_dir;
use parsers::{expand_workspace_glob, parse_pnpm_workspace_yaml, parse_tsconfig_references};
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WorkspaceConfig {
#[serde(default)]
pub patterns: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct WorkspaceInfo {
pub root: PathBuf,
pub name: String,
pub is_internal_dependency: bool,
}
#[derive(Debug, Clone)]
pub struct WorkspaceDiagnostic {
pub path: PathBuf,
pub message: String,
}
#[must_use]
pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
let patterns = collect_workspace_patterns(root);
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let mut workspaces = expand_patterns_to_workspaces(root, &patterns, &canonical_root);
workspaces.extend(collect_tsconfig_workspaces(root, &canonical_root));
if workspaces.is_empty() {
return Vec::new();
}
mark_internal_dependencies(&mut workspaces);
workspaces.into_iter().map(|(ws, _)| ws).collect()
}
#[must_use]
pub fn find_undeclared_workspaces(
root: &Path,
declared: &[WorkspaceInfo],
) -> Vec<WorkspaceDiagnostic> {
let patterns = collect_workspace_patterns(root);
if patterns.is_empty() {
return Vec::new();
}
let declared_roots: rustc_hash::FxHashSet<PathBuf> = declared
.iter()
.map(|w| dunce::canonicalize(&w.root).unwrap_or_else(|_| w.root.clone()))
.collect();
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let mut undeclared = Vec::new();
let Ok(top_entries) = std::fs::read_dir(root) else {
return Vec::new();
};
for entry in top_entries.filter_map(Result::ok) {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') || name_str == "node_modules" || name_str == "build" {
continue;
}
check_undeclared(
&path,
root,
&canonical_root,
&declared_roots,
&mut undeclared,
);
let Ok(child_entries) = std::fs::read_dir(&path) else {
continue;
};
for child in child_entries.filter_map(Result::ok) {
let child_path = child.path();
if !child_path.is_dir() {
continue;
}
let child_name = child.file_name();
let child_name_str = child_name.to_string_lossy();
if child_name_str.starts_with('.')
|| child_name_str == "node_modules"
|| child_name_str == "build"
{
continue;
}
check_undeclared(
&child_path,
root,
&canonical_root,
&declared_roots,
&mut undeclared,
);
}
}
undeclared
}
fn check_undeclared(
dir: &Path,
root: &Path,
canonical_root: &Path,
declared_roots: &rustc_hash::FxHashSet<PathBuf>,
undeclared: &mut Vec<WorkspaceDiagnostic>,
) {
if !dir.join("package.json").exists() {
return;
}
let canonical = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
if canonical == *canonical_root {
return;
}
if declared_roots.contains(&canonical) {
return;
}
let relative = dir.strip_prefix(root).unwrap_or(dir);
undeclared.push(WorkspaceDiagnostic {
path: dir.to_path_buf(),
message: format!(
"Directory '{}' contains package.json but is not declared as a workspace",
relative.display()
),
});
}
fn collect_workspace_patterns(root: &Path) -> Vec<String> {
let mut patterns = Vec::new();
let pkg_path = root.join("package.json");
if let Ok(pkg) = PackageJson::load(&pkg_path) {
patterns.extend(pkg.workspace_patterns());
}
let pnpm_workspace = root.join("pnpm-workspace.yaml");
if pnpm_workspace.exists()
&& let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
{
patterns.extend(parse_pnpm_workspace_yaml(&content));
}
patterns
}
fn expand_patterns_to_workspaces(
root: &Path,
patterns: &[String],
canonical_root: &Path,
) -> Vec<(WorkspaceInfo, Vec<String>)> {
if patterns.is_empty() {
return Vec::new();
}
let mut workspaces = Vec::new();
let (positive, negative): (Vec<&String>, Vec<&String>) =
patterns.iter().partition(|p| !p.starts_with('!'));
let negation_matchers: Vec<globset::GlobMatcher> = negative
.iter()
.filter_map(|p| {
let stripped = p.strip_prefix('!').unwrap_or(p);
globset::Glob::new(stripped)
.ok()
.map(|g| g.compile_matcher())
})
.collect();
for pattern in &positive {
let glob_pattern = if pattern.ends_with('/') {
format!("{pattern}*")
} else if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('{') {
(*pattern).clone()
} else {
(*pattern).clone()
};
let matched_dirs = expand_workspace_glob(root, &glob_pattern, canonical_root);
for (dir, canonical_dir) in matched_dirs {
if canonical_dir == *canonical_root {
continue;
}
let relative = dir.strip_prefix(root).unwrap_or(&dir);
let relative_str = relative.to_string_lossy();
if negation_matchers
.iter()
.any(|m| m.is_match(relative_str.as_ref()))
{
continue;
}
let ws_pkg_path = dir.join("package.json");
if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
let dep_names = pkg.all_dependency_names();
let name = pkg.name.unwrap_or_else(|| {
dir.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default()
});
workspaces.push((
WorkspaceInfo {
root: dir,
name,
is_internal_dependency: false,
},
dep_names,
));
}
}
}
workspaces
}
fn collect_tsconfig_workspaces(
root: &Path,
canonical_root: &Path,
) -> Vec<(WorkspaceInfo, Vec<String>)> {
let mut workspaces = Vec::new();
for dir in parse_tsconfig_references(root) {
let canonical_dir = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
continue;
}
let ws_pkg_path = dir.join("package.json");
let (name, dep_names) = if ws_pkg_path.exists() {
if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
let deps = pkg.all_dependency_names();
let n = pkg.name.unwrap_or_else(|| dir_name(&dir));
(n, deps)
} else {
(dir_name(&dir), Vec::new())
}
} else {
(dir_name(&dir), Vec::new())
};
workspaces.push((
WorkspaceInfo {
root: dir,
name,
is_internal_dependency: false,
},
dep_names,
));
}
workspaces
}
fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
{
let mut seen = rustc_hash::FxHashSet::default();
workspaces.retain(|(ws, _)| {
let canonical = dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone());
seen.insert(canonical)
});
}
let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
.iter()
.flat_map(|(_, deps)| deps.iter().cloned())
.collect();
for (ws, _) in &mut *workspaces {
ws.is_internal_dependency = all_dep_names.contains(&ws.name);
}
}
fn dir_name(dir: &Path) -> String {
dir.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discover_workspaces_from_tsconfig_references() {
let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
std::fs::write(
temp_dir.join("tsconfig.json"),
r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
)
.unwrap();
std::fs::write(
temp_dir.join("packages/core/package.json"),
r#"{"name": "@project/core"}"#,
)
.unwrap();
let workspaces = discover_workspaces(&temp_dir);
assert_eq!(workspaces.len(), 2);
assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
assert!(workspaces.iter().any(|ws| ws.name == "ui"));
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn tsconfig_references_outside_root_rejected() {
let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
std::fs::write(
temp_dir.join("project/tsconfig.json"),
r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
)
.unwrap();
let workspaces = discover_workspaces(&temp_dir.join("project"));
assert_eq!(
workspaces.len(),
1,
"reference outside project root should be rejected: {workspaces:?}"
);
assert!(
workspaces[0]
.root
.to_string_lossy()
.contains("packages/core")
);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn dir_name_extracts_last_component() {
assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
assert_eq!(dir_name(Path::new("/my-app")), "my-app");
}
#[test]
fn dir_name_empty_for_root_path() {
assert_eq!(dir_name(Path::new("/")), "");
}
#[test]
fn workspace_config_deserialize_json() {
let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
}
#[test]
fn workspace_config_deserialize_empty_patterns() {
let json = r#"{"patterns": []}"#;
let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
assert!(config.patterns.is_empty());
}
#[test]
fn workspace_config_default_patterns() {
let json = "{}";
let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
assert!(config.patterns.is_empty());
}
#[test]
fn workspace_info_default_not_internal() {
let ws = WorkspaceInfo {
root: PathBuf::from("/project/packages/a"),
name: "a".to_string(),
is_internal_dependency: false,
};
assert!(!ws.is_internal_dependency);
}
#[test]
fn mark_internal_deps_detects_cross_references() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let pkg_a = temp_dir.path().join("a");
let pkg_b = temp_dir.path().join("b");
std::fs::create_dir_all(&pkg_a).unwrap();
std::fs::create_dir_all(&pkg_b).unwrap();
let mut workspaces = vec![
(
WorkspaceInfo {
root: pkg_a,
name: "@scope/a".to_string(),
is_internal_dependency: false,
},
vec!["@scope/b".to_string()], ),
(
WorkspaceInfo {
root: pkg_b,
name: "@scope/b".to_string(),
is_internal_dependency: false,
},
vec!["lodash".to_string()], ),
];
mark_internal_dependencies(&mut workspaces);
let ws_a = workspaces
.iter()
.find(|(ws, _)| ws.name == "@scope/a")
.unwrap();
assert!(
!ws_a.0.is_internal_dependency,
"a is not depended on by others"
);
let ws_b = workspaces
.iter()
.find(|(ws, _)| ws.name == "@scope/b")
.unwrap();
assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
}
#[test]
fn mark_internal_deps_no_cross_references() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let pkg_a = temp_dir.path().join("a");
let pkg_b = temp_dir.path().join("b");
std::fs::create_dir_all(&pkg_a).unwrap();
std::fs::create_dir_all(&pkg_b).unwrap();
let mut workspaces = vec![
(
WorkspaceInfo {
root: pkg_a,
name: "a".to_string(),
is_internal_dependency: false,
},
vec!["react".to_string()],
),
(
WorkspaceInfo {
root: pkg_b,
name: "b".to_string(),
is_internal_dependency: false,
},
vec!["lodash".to_string()],
),
];
mark_internal_dependencies(&mut workspaces);
assert!(!workspaces[0].0.is_internal_dependency);
assert!(!workspaces[1].0.is_internal_dependency);
}
#[test]
fn mark_internal_deps_deduplicates_by_path() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let pkg_a = temp_dir.path().join("a");
std::fs::create_dir_all(&pkg_a).unwrap();
let mut workspaces = vec![
(
WorkspaceInfo {
root: pkg_a.clone(),
name: "a".to_string(),
is_internal_dependency: false,
},
vec![],
),
(
WorkspaceInfo {
root: pkg_a,
name: "a".to_string(),
is_internal_dependency: false,
},
vec![],
),
];
mark_internal_dependencies(&mut workspaces);
assert_eq!(
workspaces.len(),
1,
"duplicate paths should be deduplicated"
);
}
#[test]
fn collect_patterns_from_package_json() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(
dir.path().join("package.json"),
r#"{"workspaces": ["packages/*", "apps/*"]}"#,
)
.unwrap();
let patterns = collect_workspace_patterns(dir.path());
assert_eq!(patterns, vec!["packages/*", "apps/*"]);
}
#[test]
fn collect_patterns_from_pnpm_workspace() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(
dir.path().join("pnpm-workspace.yaml"),
"packages:\n - 'packages/*'\n - 'libs/*'\n",
)
.unwrap();
let patterns = collect_workspace_patterns(dir.path());
assert_eq!(patterns, vec!["packages/*", "libs/*"]);
}
#[test]
fn collect_patterns_combines_sources() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(
dir.path().join("package.json"),
r#"{"workspaces": ["packages/*"]}"#,
)
.unwrap();
std::fs::write(
dir.path().join("pnpm-workspace.yaml"),
"packages:\n - 'apps/*'\n",
)
.unwrap();
let patterns = collect_workspace_patterns(dir.path());
assert!(patterns.contains(&"packages/*".to_string()));
assert!(patterns.contains(&"apps/*".to_string()));
}
#[test]
fn collect_patterns_empty_when_no_configs() {
let dir = tempfile::tempdir().expect("create temp dir");
let patterns = collect_workspace_patterns(dir.path());
assert!(patterns.is_empty());
}
#[test]
fn discover_workspaces_from_package_json() {
let dir = tempfile::tempdir().expect("create temp dir");
let pkg_a = dir.path().join("packages").join("a");
let pkg_b = dir.path().join("packages").join("b");
std::fs::create_dir_all(&pkg_a).unwrap();
std::fs::create_dir_all(&pkg_b).unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"workspaces": ["packages/*"]}"#,
)
.unwrap();
std::fs::write(
pkg_a.join("package.json"),
r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
)
.unwrap();
std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
let workspaces = discover_workspaces(dir.path());
assert_eq!(workspaces.len(), 2);
let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
assert!(!ws_a.is_internal_dependency);
let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
assert!(ws_b.is_internal_dependency, "b is depended on by a");
}
#[test]
fn discover_workspaces_empty_project() {
let dir = tempfile::tempdir().expect("create temp dir");
let workspaces = discover_workspaces(dir.path());
assert!(workspaces.is_empty());
}
#[test]
fn discover_workspaces_with_negated_patterns() {
let dir = tempfile::tempdir().expect("create temp dir");
let pkg_a = dir.path().join("packages").join("a");
let pkg_test = dir.path().join("packages").join("test-utils");
std::fs::create_dir_all(&pkg_a).unwrap();
std::fs::create_dir_all(&pkg_test).unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
)
.unwrap();
std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
let workspaces = discover_workspaces(dir.path());
assert_eq!(workspaces.len(), 1);
assert_eq!(workspaces[0].name, "a");
}
#[test]
fn discover_workspaces_skips_root_as_workspace() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(
dir.path().join("pnpm-workspace.yaml"),
"packages:\n - '.'\n",
)
.unwrap();
std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
let workspaces = discover_workspaces(dir.path());
assert!(
workspaces.is_empty(),
"root directory should not be added as workspace"
);
}
#[test]
fn discover_workspaces_name_fallback_to_dir_name() {
let dir = tempfile::tempdir().expect("create temp dir");
let pkg_a = dir.path().join("packages").join("my-app");
std::fs::create_dir_all(&pkg_a).unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"workspaces": ["packages/*"]}"#,
)
.unwrap();
std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
let workspaces = discover_workspaces(dir.path());
assert_eq!(workspaces.len(), 1);
assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
}
#[test]
fn undeclared_workspace_detected() {
let dir = tempfile::tempdir().expect("create temp dir");
let pkg_a = dir.path().join("packages").join("a");
let pkg_b = dir.path().join("packages").join("b");
std::fs::create_dir_all(&pkg_a).unwrap();
std::fs::create_dir_all(&pkg_b).unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"workspaces": ["packages/a"]}"#,
)
.unwrap();
std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
let declared = discover_workspaces(dir.path());
assert_eq!(declared.len(), 1);
let undeclared = find_undeclared_workspaces(dir.path(), &declared);
assert_eq!(undeclared.len(), 1);
assert!(
undeclared[0]
.path
.to_string_lossy()
.replace('\\', "/")
.contains("packages/b"),
"should detect packages/b as undeclared: {:?}",
undeclared[0].path
);
}
#[test]
fn no_undeclared_when_all_covered() {
let dir = tempfile::tempdir().expect("create temp dir");
let pkg_a = dir.path().join("packages").join("a");
std::fs::create_dir_all(&pkg_a).unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"workspaces": ["packages/*"]}"#,
)
.unwrap();
std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
let declared = discover_workspaces(dir.path());
let undeclared = find_undeclared_workspaces(dir.path(), &declared);
assert!(undeclared.is_empty());
}
#[test]
fn no_undeclared_when_no_workspace_patterns() {
let dir = tempfile::tempdir().expect("create temp dir");
let sub = dir.path().join("lib");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(dir.path().join("package.json"), r#"{"name": "app"}"#).unwrap();
std::fs::write(sub.join("package.json"), r#"{"name": "lib"}"#).unwrap();
let undeclared = find_undeclared_workspaces(dir.path(), &[]);
assert!(
undeclared.is_empty(),
"should skip check when no workspace patterns exist"
);
}
#[test]
fn undeclared_skips_node_modules_and_hidden_dirs() {
let dir = tempfile::tempdir().expect("create temp dir");
let nm = dir.path().join("node_modules").join("some-pkg");
let hidden = dir.path().join(".hidden");
std::fs::create_dir_all(&nm).unwrap();
std::fs::create_dir_all(&hidden).unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"workspaces": ["packages/*"]}"#,
)
.unwrap();
std::fs::write(nm.join("package.json"), r#"{"name": "nm-pkg"}"#).unwrap();
std::fs::write(hidden.join("package.json"), r#"{"name": "hidden"}"#).unwrap();
let undeclared = find_undeclared_workspaces(dir.path(), &[]);
assert!(
undeclared.is_empty(),
"should not flag node_modules or hidden directories"
);
}
}