use std::path::{Path, PathBuf};
use crate::manifest::Manifest;
#[derive(Debug, Clone)]
pub struct Member {
pub name: String,
pub path: PathBuf,
pub manifest: Manifest,
}
pub fn members(dir: &Path, manifest: &Manifest) -> Vec<Member> {
let (includes, excludes) = patterns(dir, manifest);
if includes.is_empty() {
return Vec::new();
}
let root = dir.canonicalize().ok();
let mut members: Vec<Member> = expand(dir, &includes)
.into_iter()
.filter(|path| path.canonicalize().ok() != root)
.filter(|path| !matches_any(dir, path, &excludes))
.filter_map(|path| {
let manifest = Manifest::load(&path).ok()?;
if manifest.scripts.is_empty() {
return None;
}
let name = manifest.name.clone().or_else(|| {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
})?;
Some(Member {
name,
path,
manifest,
})
})
.collect();
members.sort_by(|a, b| a.name.cmp(&b.name));
members.dedup_by(|a, b| a.name == b.name);
members
}
fn patterns(dir: &Path, manifest: &Manifest) -> (Vec<String>, Vec<String>) {
let declared = pnpm_packages(&dir.join("pnpm-workspace.yaml"))
.unwrap_or_else(|| manifest.workspaces.clone());
let (excludes, includes): (Vec<String>, Vec<String>) = declared
.into_iter()
.partition(|pattern| pattern.starts_with('!'));
(
includes,
excludes
.into_iter()
.map(|pattern| pattern[1..].to_owned())
.collect(),
)
}
fn pnpm_packages(path: &Path) -> Option<Vec<String>> {
let contents = std::fs::read_to_string(path).ok()?;
let mut packages = Vec::new();
let mut inside = false;
for line in contents.lines() {
let trimmed = line.trim_end();
if trimmed.trim_start().starts_with('#') || trimmed.trim().is_empty() {
continue;
}
if inside {
if !trimmed.starts_with([' ', '\t']) {
break;
}
if let Some(entry) = trimmed.trim_start().strip_prefix("- ") {
packages.push(unquote(entry));
}
continue;
}
if trimmed.starts_with("packages:") {
inside = true;
}
}
inside.then_some(packages)
}
fn unquote(entry: &str) -> String {
let entry = entry.trim();
let entry = match entry.chars().next() {
Some(quote @ ('\'' | '"')) => entry
.strip_prefix(quote)
.and_then(|rest| rest.split(quote).next())
.unwrap_or(entry),
_ => entry.split('#').next().unwrap_or(entry).trim(),
};
entry.to_owned()
}
fn expand(dir: &Path, patterns: &[String]) -> Vec<PathBuf> {
let mut found = Vec::new();
for pattern in patterns {
let full = dir.join(pattern).join("package.json");
let Some(pattern) = full.to_str() else {
continue;
};
let Ok(paths) = glob::glob(pattern) else {
continue;
};
for path in paths.flatten() {
if path
.components()
.any(|part| part.as_os_str() == "node_modules")
{
continue;
}
if let Some(parent) = path.parent() {
found.push(parent.to_path_buf());
}
}
}
found.sort();
found.dedup();
found
}
fn matches_any(dir: &Path, path: &Path, patterns: &[String]) -> bool {
patterns.iter().any(|pattern| {
let pattern = dir.join(pattern);
pattern
.to_str()
.and_then(|pattern| glob::Pattern::new(pattern).ok())
.is_some_and(|pattern| pattern.matches_path(path))
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn workspace(files: &[(&str, &str)]) -> (tempfile::TempDir, Vec<Member>) {
let dir = tempfile::tempdir().expect("temp dir");
for (path, contents) in files {
let full = dir.path().join(path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).expect("mkdir");
}
fs::write(full, contents).expect("write");
}
let manifest = Manifest::load(dir.path()).expect("root manifest");
let members = members(dir.path(), &manifest);
(dir, members)
}
fn names(members: &[Member]) -> Vec<&str> {
members.iter().map(|member| member.name.as_str()).collect()
}
#[test]
fn a_project_without_a_workspace_has_no_members() {
let (_dir, members) = workspace(&[("package.json", r#"{"name":"solo"}"#)]);
assert!(members.is_empty());
}
#[test]
fn the_workspaces_field_finds_members() {
let (_dir, members) = workspace(&[
("package.json", r#"{"name":"root","workspaces":["apps/*"]}"#),
(
"apps/blog/package.json",
r#"{"name":"blog","scripts":{"dev":"x"}}"#,
),
(
"apps/shop/package.json",
r#"{"name":"shop","scripts":{"dev":"x"}}"#,
),
]);
assert_eq!(names(&members), ["blog", "shop"]);
}
#[test]
fn pnpm_workspace_yaml_wins_over_the_manifest_field() {
let (_dir, members) = workspace(&[
(
"package.json",
r#"{"name":"root","workspaces":["ignored/*"]}"#,
),
("pnpm-workspace.yaml", "packages:\n - 'apps/*'\n"),
(
"apps/blog/package.json",
r#"{"name":"blog","scripts":{"dev":"x"}}"#,
),
(
"ignored/nope/package.json",
r#"{"name":"nope","scripts":{"dev":"x"}}"#,
),
]);
assert_eq!(names(&members), ["blog"]);
}
#[test]
fn only_the_packages_block_of_the_yaml_is_read() {
let yaml = "packages:\n - 'apps/*'\n\nminimumReleaseAgeExclude:\n - '@biomejs/*'\n - 'apps/*'\n\noverrides:\n nanoid: ^3.3.18\n";
let (_dir, members) = workspace(&[
("package.json", r#"{"name":"root"}"#),
("pnpm-workspace.yaml", yaml),
(
"apps/blog/package.json",
r#"{"name":"blog","scripts":{"dev":"x"}}"#,
),
]);
assert_eq!(names(&members), ["blog"]);
}
#[test]
fn negated_patterns_exclude_members() {
let yaml = "packages:\n - 'apps/*'\n - '!apps/private'\n";
let (_dir, members) = workspace(&[
("package.json", r#"{"name":"root"}"#),
("pnpm-workspace.yaml", yaml),
(
"apps/blog/package.json",
r#"{"name":"blog","scripts":{"dev":"x"}}"#,
),
(
"apps/private/package.json",
r#"{"name":"private","scripts":{"dev":"x"}}"#,
),
]);
assert_eq!(names(&members), ["blog"]);
}
#[test]
fn the_root_is_not_a_member_of_itself() {
let (_dir, members) = workspace(&[
(
"package.json",
r#"{"name":"@casoon/thing","scripts":{"build":"x"}}"#,
),
("pnpm-workspace.yaml", "packages:\n - '.'\n"),
]);
assert!(members.is_empty());
}
#[test]
fn members_without_scripts_are_left_out() {
let (_dir, members) = workspace(&[
("package.json", r#"{"name":"root","workspaces":["apps/*"]}"#),
(
"apps/blog/package.json",
r#"{"name":"blog","scripts":{"dev":"x"}}"#,
),
("apps/types/package.json", r#"{"name":"types"}"#),
]);
assert_eq!(
names(&members),
["blog"],
"a member with nothing to run is noise"
);
}
#[test]
fn node_modules_is_never_a_workspace_member() {
let (_dir, members) = workspace(&[
(
"package.json",
r#"{"name":"root","workspaces":["packages/**/*"]}"#,
),
(
"packages/ui/package.json",
r#"{"name":"ui","scripts":{"dev":"x"}}"#,
),
(
"packages/ui/node_modules/dep/package.json",
r#"{"name":"dep","scripts":{"dev":"x"}}"#,
),
]);
assert_eq!(names(&members), ["ui"]);
}
#[test]
fn a_member_without_a_name_falls_back_to_its_directory() {
let (_dir, members) = workspace(&[
("package.json", r#"{"name":"root","workspaces":["apps/*"]}"#),
("apps/blog/package.json", r#"{"scripts":{"dev":"x"}}"#),
]);
assert_eq!(names(&members), ["blog"]);
}
#[test]
fn comments_and_quoting_styles_are_tolerated() {
let yaml =
"# leading comment\npackages:\n # inner comment\n - \"apps/*\"\n - 'shared'\n";
let (_dir, members) = workspace(&[
("package.json", r#"{"name":"root"}"#),
("pnpm-workspace.yaml", yaml),
(
"apps/blog/package.json",
r#"{"name":"blog","scripts":{"dev":"x"}}"#,
),
(
"shared/package.json",
r#"{"name":"shared","scripts":{"build":"x"}}"#,
),
]);
assert_eq!(names(&members), ["blog", "shared"]);
}
}