Skip to main content

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}