callisto_graph/config/pattern.rs
1use callisto_model::{Ecosystem, PackageId};
2use globset::{Glob, GlobMatcher};
3
4/// A glob pattern for matching package names in `[[package-set]]` blocks.
5///
6/// Unlike `[[package]]` which uses an exact `PackageId` match,
7/// `[[package-set]]` allows a single rule to target many packages at once
8/// via a glob (e.g. `"pkg-*"` matches `pkg-a`, `pkg-b`, etc.).
9///
10/// Ecosystem prefixes in the pattern are respected: `"cargo:pkg-*"` only
11/// matches Cargo packages; a bare `"pkg-*"` matches packages in any ecosystem
12/// that have a matching name.
13#[derive(Clone, Debug)]
14pub struct PackagePattern {
15 raw: String,
16 /// The ecosystem parsed off the front of the pattern (e.g. `cargo` in
17 /// `"cargo:pkg-*"`), or `None` for a bare pattern like `"pkg-*"`.
18 ecosystem: Option<Ecosystem>,
19 /// Compiled from the pattern with any `ecosystem:` prefix stripped.
20 matcher: GlobMatcher,
21}
22
23impl PackagePattern {
24 pub fn parse(s: &str) -> Result<Self, globset::Error> {
25 let (ecosystem, rest) = match s.split_once(':') {
26 Some((prefix, rest)) => match Ecosystem::from_prefix(prefix) {
27 Some(eco) => (Some(eco), rest),
28 None => (None, s),
29 },
30 None => (None, s),
31 };
32 let glob = Glob::new(rest)?;
33 Ok(Self {
34 raw: s.to_string(),
35 ecosystem,
36 matcher: glob.compile_matcher(),
37 })
38 }
39
40 /// Returns the ecosystem this pattern was scoped to via an
41 /// `ecosystem:` prefix, or `None` for a bare pattern.
42 pub fn ecosystem(&self) -> Option<Ecosystem> {
43 self.ecosystem
44 }
45
46 /// Returns true when the given `PackageId` matches this pattern.
47 ///
48 /// A bare pattern (no `ecosystem:` prefix) matches any ecosystem, so
49 /// `"pkg-*"` matches both `cargo:pkg-a` and `npm:pkg-b`. An
50 /// ecosystem-prefixed pattern like `"cargo:pkg-*"` matches only packages
51 /// in that ecosystem — `id.ecosystem()` must equal the parsed prefix
52 /// before the glob remainder is compared against `id.name()`.
53 pub fn matches(&self, id: &PackageId) -> bool {
54 self.matches_in_ecosystems(id.name(), id.ecosystem().as_slice())
55 }
56
57 /// Like [`Self::matches`], but checks a name against an explicit set of
58 /// candidate ecosystems rather than a single `PackageId`.
59 ///
60 /// This is needed at walk time: discovered package ids are
61 /// [`PackageId::Bare`] (no ecosystem attached to the id itself), so the
62 /// real ecosystem(s) of the package — sourced from its manifests — must
63 /// be supplied explicitly for an ecosystem-prefixed pattern to ever be
64 /// able to match.
65 pub fn matches_in_ecosystems(&self, name: &str, ecosystems: &[Ecosystem]) -> bool {
66 if let Some(want) = self.ecosystem {
67 if !ecosystems.contains(&want) {
68 return false;
69 }
70 }
71 self.matcher.is_match(name)
72 }
73
74 pub fn as_str(&self) -> &str {
75 &self.raw
76 }
77}
78
79impl std::fmt::Display for PackagePattern {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 f.write_str(&self.raw)
82 }
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 /// An ecosystem-prefixed pattern like `"cargo:internal-*"` must match only
90 /// packages in that ecosystem, not any package with a matching name
91 /// regardless of ecosystem. Before the fix, `matches()` glob-matched
92 /// against `id.name()` alone (which never contains the `cargo:` prefix
93 /// baked into the compiled glob), so an ecosystem-prefixed pattern matched
94 /// zero packages of any ecosystem.
95 #[test]
96 fn ecosystem_prefixed_pattern_matches_only_that_ecosystem() {
97 let pattern = PackagePattern::parse("cargo:internal-*").expect("valid glob");
98
99 let cargo_pkg = PackageId::parse("cargo:internal-foo").expect("valid package id");
100 let npm_pkg = PackageId::parse("npm:internal-foo").expect("valid package id");
101
102 assert!(
103 pattern.matches(&cargo_pkg),
104 "'cargo:internal-*' must match the Cargo package 'internal-foo'"
105 );
106 assert!(
107 !pattern.matches(&npm_pkg),
108 "'cargo:internal-*' must NOT match the npm package 'internal-foo'"
109 );
110 }
111}