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.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, reason) in [
("#alpha", "comments are not supported"),
("--workspace", "command-line tokens are not package specs"),
(" alpha", "leading, trailing, and embedded whitespace are not allowed"),
("alpha ", "leading, trailing, and embedded whitespace are not allowed"),
("alpha beta", "leading, trailing, and embedded whitespace are not allowed"),
("@1", "expected a package name or Unix glob, optionally followed by `@version`"),
("alpha!", "expected a package name or Unix glob, optionally followed by `@version`"),
("alpha@", "the version qualifier after `@` must not be empty"),
("alpha@1@2", "a package spec may contain at most one `@`"),
] {
assert_eq!(
validate_package_file_spec("packages.txt", 7, spec).expect_err(spec).to_string(),
format!("invalid package spec in `packages.txt` at line 7: `{spec}` ({reason})")
);
}
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() {
assert!(!Selection::default().is_whole_workspace());
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 invalid_exclude_selector_propagates_as_an_error() {
let ws = workspace(&["alpha"]);
let error = Selection {
all: true,
exclude: vec!["missing".to_owned()],
..Selection::default()
}
.resolve(&ws)
.expect_err("an invalid exclusion must not panic or silently pass");
assert_eq!(error.to_string(), "package selector `missing` did not match any workspace member");
}
#[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:?}");
}
}
}