use std::collections::HashSet;
use cargo_metadata::semver::Version;
use crate::error::{EachError, 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,
}
impl Selection {
#[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 {
workspace
.members
.iter()
.filter(|m| workspace.default_member_names.contains(&m.name))
.collect()
};
if !self.exclude.is_empty() {
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 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,
}
}
#[mutants::skip] fn glob_matches(pattern: &str, name: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let n: Vec<char> = name.chars().collect();
let (mut pi, mut ni) = (0usize, 0usize);
let mut star: Option<usize> = None;
let mut star_ni = 0usize;
while ni < n.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == n[ni]) {
pi += 1;
ni += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some(pi);
star_ni = ni;
pi += 1;
} else if let Some(sp) = star {
pi = sp + 1;
star_ni += 1;
ni = star_ni;
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use std::collections::BTreeSet;
use std::path::PathBuf;
use serde_json::Value;
use super::*;
fn member(name: &str) -> Member {
Member {
name: name.to_owned(),
version: "0.1.0".to_owned(),
manifest_path: PathBuf::from(format!("/ws/{name}/Cargo.toml")),
has_lib: true,
has_bin: false,
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(),
}
}
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 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!(err.to_string().contains("nope-*"));
}
#[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("*", ""));
}
}