use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use cargo_metadata::semver::Version;
use crate::error::{EachError, InvalidPackageFileLineError, PackageFileReadError, PackageFileUtf8Error, UnknownSelectorError};
use crate::workspace::{Member, Workspace};
#[derive(Debug, Default, Clone)]
pub(crate) struct Selection {
pub(crate) packages: Vec<String>,
pub(crate) all: bool,
pub(crate) exclude: Vec<String>,
pub(crate) none: bool,
pub(crate) package_file_supplied: bool,
}
impl Selection {
pub(crate) fn from_sources(
packages: &[String],
package_files: &[PathBuf],
all: bool,
exclude: &[String],
none: bool,
) -> Result<Self, EachError> {
let mut combined = packages.to_vec();
for path in package_files {
combined.extend(read_package_file(path)?);
}
Ok(Self {
packages: combined,
all,
exclude: exclude.to_vec(),
none,
package_file_supplied: !package_files.is_empty(),
})
}
#[must_use]
pub(crate) fn is_whole_workspace(&self) -> bool {
self.all && self.exclude.is_empty() && !self.none
}
pub(crate) fn resolve<'w>(&self, workspace: &'w Workspace) -> Result<Vec<&'w Member>, EachError> {
if self.none {
return Ok(Vec::new());
}
let mut base: Vec<&Member> = if self.all {
workspace.members.iter().collect()
} else if !self.packages.is_empty() {
resolve_selectors(workspace, &self.packages)?
} else if self.package_file_supplied {
Vec::new()
} else {
workspace
.members
.iter()
.filter(|m| workspace.default_member_names.contains(&m.name))
.collect()
};
let excluded: HashSet<&str> = resolve_selectors(workspace, &self.exclude)?
.into_iter()
.map(|m| m.name.as_str())
.collect();
base.retain(|m| !excluded.contains(m.name.as_str()));
Ok(base)
}
}
fn read_package_file(path: &Path) -> Result<Vec<String>, EachError> {
let display = path.display().to_string();
let bytes = fs::read(path).map_err(|error| PackageFileReadError::caused_by(display.clone(), error))?;
let contents = String::from_utf8(bytes).map_err(|error| PackageFileUtf8Error::caused_by(display.clone(), error))?;
contents
.strip_prefix('\u{feff}')
.unwrap_or(&contents)
.lines()
.enumerate()
.filter_map(|(index, line)| {
if line.is_empty() {
None
} else {
Some(validate_package_file_spec(&display, index + 1, line).map(str::to_owned))
}
})
.collect()
}
fn validate_package_file_spec<'a>(path: &str, line: usize, spec: &'a str) -> Result<&'a str, EachError> {
let invalid = |reason: &str| InvalidPackageFileLineError::new(path.to_owned(), line, spec.to_owned(), reason.to_owned()).into();
if spec.bytes().any(|byte| byte.is_ascii_whitespace()) {
return Err(invalid("leading, trailing, and embedded whitespace are not allowed"));
}
if spec.starts_with('#') {
return Err(invalid("comments are not supported"));
}
if spec.starts_with('-') {
return Err(invalid("command-line tokens are not package specs"));
}
let mut pieces = spec.split('@');
let name = pieces.next().expect("split always yields at least one element");
let version = pieces.next();
if pieces.next().is_some() {
return Err(invalid("a package spec may contain at most one `@`"));
}
if name.is_empty() {
return Err(invalid("expected a package name or Unix glob, optionally followed by `@version`"));
}
if !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'*' | b'?'))
{
return Err(invalid("expected a package name or Unix glob, optionally followed by `@version`"));
}
if version.is_some_and(str::is_empty) {
return Err(invalid("the version qualifier after `@` must not be empty"));
}
Ok(spec)
}
fn resolve_selectors<'w>(workspace: &'w Workspace, selectors: &[String]) -> Result<Vec<&'w Member>, EachError> {
let mut matched: HashSet<&str> = HashSet::new();
for selector in selectors {
let (name_pat, version) = selector.split_once('@').map_or((selector.as_str(), None), |(n, v)| (n, Some(v)));
let hits: Vec<&Member> = workspace
.members
.iter()
.filter(|m| glob_matches(name_pat, &m.name) && version.is_none_or(|v| version_matches(v, &member_version(m))))
.collect();
if hits.is_empty() {
return Err(UnknownSelectorError::new(selector.clone()).into());
}
for m in hits {
matched.insert(m.name.as_str());
}
}
Ok(workspace.members.iter().filter(|m| matched.contains(m.name.as_str())).collect())
}
fn member_version(member: &Member) -> Version {
member
.version
.parse()
.expect("member versions come from cargo metadata and are always valid semver")
}
fn version_matches(supplied: &str, actual: &Version) -> bool {
let (rest, build) = supplied.split_once('+').map_or((supplied, None), |(r, b)| (r, Some(b)));
let (release, pre) = rest.split_once('-').map_or((rest, None), |(r, p)| (r, Some(p)));
if pre == Some("") || build == Some("") {
return false;
}
let components: Vec<&str> = release.split('.').collect();
if components.is_empty() || components.len() > 3 {
return false;
}
if (pre.is_some() || build.is_some()) && components.len() < 3 {
return false;
}
let actual_release = [actual.major, actual.minor, actual.patch];
for (i, component) in components.iter().enumerate() {
if component.len() > 1 && component.starts_with('0') {
return false;
}
if component.parse::<u64>() != Ok(actual_release[i]) {
return false;
}
}
let pre_ok = match pre {
Some(pre) => actual.pre.as_str() == pre,
None => actual.pre.is_empty(),
};
if !pre_ok {
return false;
}
match build {
Some(build) => actual.build.as_str() == build,
None => true,
}
}
fn glob_matches(pattern: &str, name: &str) -> bool {
let name: Vec<char> = name.chars().collect();
let mut previous = vec![false; name.len() + 1];
previous[0] = true;
for pattern_char in pattern.chars() {
let mut current = vec![false; name.len() + 1];
if pattern_char == '*' {
current[0] = previous[0];
}
for (index, name_char) in name.iter().enumerate() {
current[index + 1] = if pattern_char == '*' {
previous[index + 1] || current[index]
} else {
previous[index] && (pattern_char == '?' || pattern_char == *name_char)
};
}
previous = current;
}
previous[name.len()]
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use std::collections::BTreeSet;
use serde_json::Value;
use super::*;
fn member(name: &str) -> Member {
Member {
name: name.to_owned(),
version: "0.1.0".to_owned(),
rust_version: Some("1.70.0".parse().expect("valid Rust version")),
manifest_path: PathBuf::from(format!("/ws/{name}/Cargo.toml")),
publishable: true,
features: BTreeSet::new(),
targets: Vec::new(),
dependencies: BTreeSet::new(),
metadata: Value::Null,
}
}
fn workspace(defaults: &[&str]) -> Workspace {
Workspace {
members: vec![member("alpha"), member("beta"), member("gamma")],
default_member_names: defaults.iter().map(|s| (*s).to_owned()).collect(),
root_manifest_path: PathBuf::from("/ws/Cargo.toml"),
}
}
fn names(members: &[&Member]) -> Vec<String> {
members.iter().map(|m| m.name.clone()).collect()
}
#[test]
fn none_resolves_empty() {
let ws = workspace(&["alpha", "beta", "gamma"]);
let sel = Selection {
none: true,
..Selection::default()
};
assert!(sel.resolve(&ws).expect("resolve").is_empty());
}
#[test]
fn all_selects_every_member() {
let ws = workspace(&["alpha"]);
let sel = Selection {
all: true,
..Selection::default()
};
assert_eq!(names(&sel.resolve(&ws).expect("resolve")), ["alpha", "beta", "gamma"]);
}
#[test]
fn empty_selection_uses_default_members() {
let ws = workspace(&["alpha", "gamma"]);
let sel = Selection::default();
assert_eq!(names(&sel.resolve(&ws).expect("resolve")), ["alpha", "gamma"]);
}
#[test]
fn an_empty_package_file_source_selects_nothing() {
let ws = workspace(&["alpha", "gamma"]);
let sel = Selection {
package_file_supplied: true,
..Selection::default()
};
assert!(sel.resolve(&ws).expect("resolve").is_empty());
}
#[test]
fn package_file_lines_reject_comments_tokens_and_whitespace() {
for spec in [
"# alpha",
"--workspace",
" alpha",
"alpha ",
"alpha beta",
"@1",
"alpha!",
"alpha@",
"alpha@1@2",
] {
validate_package_file_spec("packages.txt", 1, spec).expect_err(spec);
}
for spec in ["alpha", "alpha@1.2.3", "cargo-*", "?eta"] {
assert_eq!(validate_package_file_spec("packages.txt", 1, spec).expect(spec), spec);
}
}
#[test]
fn package_glob_and_version_spec_match_on_name() {
let ws = workspace(&["alpha", "beta", "gamma"]);
let sel = Selection {
packages: vec!["beta@0.1.0".to_owned(), "gam*".to_owned()],
..Selection::default()
};
assert_eq!(names(&sel.resolve(&ws).expect("resolve")), ["beta", "gamma"]);
}
#[test]
fn version_spec_must_match_member_version() {
let ws = workspace(&["alpha", "beta", "gamma"]);
let sel = Selection {
packages: vec!["beta@9.9.9".to_owned()],
..Selection::default()
};
let err = sel.resolve(&ws).expect_err("wrong version must error");
assert!(err.to_string().contains("beta@9.9.9"));
let ok = Selection {
packages: vec!["beta@0.1.0".to_owned()],
..Selection::default()
};
assert_eq!(names(&ok.resolve(&ws).expect("resolve")), ["beta"]);
let partial = Selection {
packages: vec!["beta@0.1".to_owned()],
..Selection::default()
};
assert_eq!(names(&partial.resolve(&ws).expect("resolve")), ["beta"]);
}
#[test]
fn version_matches_follows_cargo_partial_version_rule() {
fn ver(s: &str) -> Version {
s.parse().expect("valid semver")
}
assert!(version_matches("0.1.0", &ver("0.1.0"))); assert!(version_matches("0.1", &ver("0.1.0"))); assert!(version_matches("0", &ver("0.1.0"))); assert!(!version_matches("0.30", &ver("0.3.0"))); assert!(!version_matches("0.2", &ver("0.1.0"))); assert!(!version_matches("0.1.0.0", &ver("0.1.0"))); assert!(!version_matches("1.x", &ver("1.0.0"))); assert!(version_matches("1.2.3-beta.1", &ver("1.2.3-beta.1+build"))); assert!(!version_matches("1.2", &ver("1.2.3-beta.1"))); assert!(!version_matches("1.2.3-beta", &ver("1.2.3-beta.1"))); assert!(!version_matches("1.2.3", &ver("1.2.3-beta"))); assert!(version_matches("1.2.3", &ver("1.2.3"))); assert!(version_matches("1.2.3+build", &ver("1.2.3+build"))); assert!(!version_matches("1.2.3+wrong", &ver("1.2.3+build"))); assert!(version_matches("1.2.3", &ver("1.2.3+build"))); assert!(!version_matches("1.2-beta", &ver("1.2.0-beta"))); assert!(!version_matches("1.2+build", &ver("1.2.0+build"))); assert!(!version_matches("1.2.3-", &ver("1.2.3"))); assert!(!version_matches("1.2.3+", &ver("1.2.3"))); assert!(!version_matches("01.2.3", &ver("1.2.3"))); assert!(!version_matches("1.02.3", &ver("1.2.3"))); assert!(version_matches("0.1.0", &ver("0.1.0")));
assert!(version_matches("1.10", &ver("1.10.0")));
}
#[test]
fn exclude_removes_from_workspace() {
let ws = workspace(&["alpha", "beta", "gamma"]);
let sel = Selection {
all: true,
exclude: vec!["beta".to_owned()],
..Selection::default()
};
assert_eq!(names(&sel.resolve(&ws).expect("resolve")), ["alpha", "gamma"]);
}
#[test]
fn unknown_selector_errors() {
let ws = workspace(&["alpha"]);
let sel = Selection {
packages: vec!["nope-*".to_owned()],
..Selection::default()
};
let err = sel.resolve(&ws).expect_err("unknown selector must error");
assert_eq!(err.to_string(), "package selector `nope-*` did not match any workspace member");
}
#[test]
fn is_whole_workspace_detects_pass_through() {
let all = Selection {
all: true,
..Selection::default()
};
assert!(all.is_whole_workspace());
let all_with_pkg = Selection {
all: true,
packages: vec!["alpha".to_owned()],
..Selection::default()
};
assert!(all_with_pkg.is_whole_workspace());
let narrowed = Selection {
all: true,
exclude: vec!["beta".to_owned()],
..Selection::default()
};
assert!(!narrowed.is_whole_workspace());
let none = Selection {
all: true,
none: true,
..Selection::default()
};
assert!(!none.is_whole_workspace());
}
#[test]
fn glob_matcher_handles_wildcards() {
assert!(glob_matches("alpha*", "alpha"));
assert!(glob_matches("*macros", "alpha_macros"));
assert!(glob_matches("a?pha", "alpha"));
assert!(!glob_matches("alpha", "alphax"));
assert!(!glob_matches("a?", "a"));
assert!(!glob_matches("a*b", "ac"));
assert!(glob_matches("*", "anything"));
assert!(glob_matches("**", "anything"));
assert!(glob_matches("a*b*c", "axxbyyc"));
assert!(glob_matches("*a*a*a", "banana_aaa"));
assert!(!glob_matches("a*b*c", "axxbyy"));
assert!(glob_matches("", ""));
assert!(!glob_matches("", "x"));
assert!(glob_matches("*", ""));
}
#[test]
fn selector_include_exclude_deduplicates_in_workspace_order() {
let ws = workspace(&["alpha"]);
let selected = Selection {
packages: vec!["g*".to_owned(), "*a".to_owned(), "alpha".to_owned()],
exclude: vec!["b*".to_owned()],
..Selection::default()
}
.resolve(&ws)
.expect("all include and exclude selectors match");
assert_eq!(names(&selected), ["alpha", "gamma"]);
}
#[test]
fn none_takes_precedence_over_invalid_selectors() {
let ws = workspace(&["alpha"]);
let selected = Selection {
packages: vec!["missing".to_owned()],
exclude: vec!["also-missing".to_owned()],
none: true,
..Selection::default()
}
.resolve(&ws)
.expect("--none short-circuits selector resolution");
assert!(selected.is_empty());
}
#[test]
fn glob_backtracking_and_empty_matches_are_exact() {
let cases = [
("a*b", "ab", true),
("a*b", "axxb", true),
("a*b", "axxbc", false),
("*ab", "aaab", true),
("*ab", "aaaa", false),
("a**b", "axxb", true),
("?*?", "ab", true),
("?*?", "a", false),
("*?", "", false),
("***", "", true),
("***", "abc", true),
("α*?", "αβγ", true),
];
for (pattern, name, expected) in cases {
assert_eq!(glob_matches(pattern, name), expected, "{pattern:?} against {name:?}");
}
}
}