Skip to main content

aube_workspace/
selector.rs

1//! Pnpm-style `--filter` selectors for workspace packages.
2//!
3//! Supported forms:
4//! - `name` — exact package-name match
5//! - `@scope/*`, `foo-*`, `*-plugin` — glob-style name match
6//! - `./path` or `path/` — match packages whose directory is at or under the
7//!   given path (relative to the workspace root)
8//! - `foo...` / `foo^...` — include dependencies / only dependencies
9//! - `...foo` / `...^foo` — include dependents / only dependents
10//! - `[origin/main]` — packages touched since a git ref
11//! - `!foo` — exclude a selector from the final set
12//!
13//! `--filter-prod <pattern>` selectors go through the same parser but mark
14//! each selector as `prod_only`. Graph traversal (`foo...`, `...foo`) then
15//! walks only `dependencies` / `optionalDependencies` / `peerDependencies`
16//! edges, skipping `devDependencies` and everything reachable solely
17//! through them — matching pnpm's `--filter-prod` semantics.
18
19use aube_manifest::{PackageJson, WorkspaceConfig};
20use std::collections::{BTreeMap, BTreeSet, VecDeque};
21use std::path::{Path, PathBuf};
22use std::process::Command;
23
24/// The effective `--filter` / `--filter-prod` input for a single command
25/// invocation. Commands receive this after `compute_effective_filter` in
26/// `aube/src/main.rs` has merged the global `-r` wildcard into
27/// `filters` and pulled `filter_prods` from the separate global flag.
28#[derive(Debug, Clone, Default, PartialEq, Eq)]
29pub struct EffectiveFilter {
30    /// Raw `--filter` / `-F` values (plus the `*` wildcard when `-r` was
31    /// passed without an explicit selector).
32    pub filters: Vec<String>,
33    /// Raw `--filter-prod` values. These apply the same selector forms as
34    /// `filters` but restrict graph walks to production edges.
35    pub filter_prods: Vec<String>,
36    /// `--fail-if-no-match` — promote "no projects matched" from a warning
37    /// to a hard error. pnpm's default is to warn and exit 0; this flag
38    /// (mirrored) opts into the strict behavior for CI use.
39    pub fail_if_no_match: bool,
40    /// Include the workspace root alongside the selected workspace packages.
41    pub include_workspace_root: bool,
42}
43
44impl EffectiveFilter {
45    pub fn is_empty(&self) -> bool {
46        self.filters.is_empty() && self.filter_prods.is_empty()
47    }
48
49    /// Build an `EffectiveFilter` from just `--filter` values. Useful for
50    /// tests and for callers that never cared about `--filter-prod`.
51    pub fn from_filters<I>(filters: I) -> Self
52    where
53        I: IntoIterator,
54        I::Item: Into<String>,
55    {
56        Self {
57            filters: filters.into_iter().map(Into::into).collect(),
58            filter_prods: Vec::new(),
59            fail_if_no_match: false,
60            include_workspace_root: false,
61        }
62    }
63}
64
65/// A single parsed selector.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Selector {
68    base: BaseSelector,
69    include_dependencies: bool,
70    include_dependents: bool,
71    exclude_self: bool,
72    exclude: bool,
73    /// Originates from `--filter-prod`: graph walks skip `devDependencies`.
74    prod_only: bool,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
78enum BaseSelector {
79    /// Exact package-name match.
80    Name(String),
81    /// Glob against the package name (e.g. `@scope/*`).
82    NameGlob(String),
83    /// Path selector rooted at the workspace directory. Matches packages
84    /// whose directory equals or is nested under this path.
85    Path(PathBuf),
86    /// Packages with files changed relative to a git ref.
87    ChangedSince(String),
88}
89
90impl Selector {
91    /// Parse a raw `--filter` argument.
92    pub fn parse(raw: &str) -> Result<Self, ParseError> {
93        Self::parse_kind(raw, false)
94    }
95
96    /// Parse a raw `--filter-prod` argument. Same grammar as `parse`, but
97    /// the resulting selector's graph walks skip `devDependencies`.
98    pub fn parse_prod(raw: &str) -> Result<Self, ParseError> {
99        Self::parse_kind(raw, true)
100    }
101
102    fn parse_kind(raw: &str, prod_only: bool) -> Result<Self, ParseError> {
103        if raw.is_empty() {
104            return Err(ParseError::Empty);
105        }
106        let (exclude, raw) = raw
107            .strip_prefix('!')
108            .map(|s| (true, s))
109            .unwrap_or((false, raw));
110        if raw.is_empty() {
111            return Err(ParseError::Empty);
112        }
113
114        let (include_dependents, raw) = raw
115            .strip_prefix("...")
116            .map(|s| (true, s))
117            .unwrap_or((false, raw));
118        let (exclude_self_from_dependents, raw) = raw
119            .strip_prefix('^')
120            .map(|s| (true, s))
121            .unwrap_or((false, raw));
122        let (include_dependencies, raw) = raw
123            .strip_suffix("...")
124            .map(|s| (true, s))
125            .unwrap_or((false, raw));
126        let (exclude_self_from_dependencies, raw) = raw
127            .strip_suffix('^')
128            .map(|s| (true, s))
129            .unwrap_or((false, raw));
130        if raw.is_empty() {
131            return Err(ParseError::Empty);
132        }
133        let exclude_self = exclude_self_from_dependents || exclude_self_from_dependencies;
134
135        let base = if raw.starts_with('[') && raw.ends_with(']') && raw.len() > 2 {
136            BaseSelector::ChangedSince(raw[1..raw.len() - 1].to_string())
137        }
138        // Path-style selectors: leading `./`, `../`, `/`, or a trailing `/`.
139        // The pnpm-style `./packages/**` "directory and all descendants"
140        // form already enters this branch via the `./` prefix; we then
141        // strip the `/**` suffix so it collapses to the same `./packages`
142        // path. We deliberately do NOT also accept a bare `/**` suffix
143        // here — `@scope/**` and `name/**` are name globs and must keep
144        // routing through the `NameGlob` branch below.
145        else if raw.starts_with("./")
146            || raw.starts_with("../")
147            || raw.starts_with('/')
148            || raw.ends_with('/')
149        {
150            let trimmed = raw.strip_suffix("/**").unwrap_or(raw).trim_end_matches('/');
151            // Strip a leading `./` so the stored PathBuf has no CurDir
152            // component. `Path::components` normalizes mid-path `CurDir`
153            // already, but keeping the stored form canonical makes the
154            // matcher's `starts_with` contract obvious at a glance.
155            let normalized = trimmed.strip_prefix("./").unwrap_or(trimmed);
156            BaseSelector::Path(PathBuf::from(normalized))
157        } else
158        // Only `*` and `?` are recognized glob metacharacters. Bracket
159        // expressions (`[ab]-pkg`) are intentionally treated as literal
160        // names — `glob_match` doesn't implement them, so detecting `[`
161        // here would only produce silent mismatches.
162        if raw.contains('*') || raw.contains('?') {
163            BaseSelector::NameGlob(raw.to_string())
164        } else {
165            BaseSelector::Name(raw.to_string())
166        };
167
168        Ok(Selector {
169            base,
170            include_dependencies,
171            include_dependents,
172            exclude_self,
173            exclude,
174            prod_only,
175        })
176    }
177
178    /// Test whether this selector matches a workspace package.
179    pub fn matches(&self, pkg: &WorkspacePkg<'_>) -> bool {
180        match &self.base {
181            BaseSelector::Name(n) => pkg.name == Some(n.as_str()),
182            BaseSelector::NameGlob(pat) => match pkg.name {
183                Some(name) => glob_match(pat, name),
184                None => false,
185            },
186            BaseSelector::Path(p) => {
187                let target = pkg.workspace_root.join(p);
188                // Normalize both sides by stripping trailing slashes; we
189                // don't canonicalize because the directories must exist on
190                // disk for the workspace walk to have found them anyway.
191                pkg.dir.starts_with(&target)
192            }
193            BaseSelector::ChangedSince(_) => false,
194        }
195    }
196}
197
198/// Lightweight view of a workspace package for matching.
199pub struct WorkspacePkg<'a> {
200    pub name: Option<&'a str>,
201    pub dir: &'a Path,
202    pub workspace_root: &'a Path,
203}
204
205#[derive(Debug, thiserror::Error, miette::Diagnostic)]
206pub enum ParseError {
207    #[error("empty --filter selector")]
208    #[diagnostic(code(ERR_AUBE_FILTER_EMPTY))]
209    Empty,
210}
211
212/// Parse the combined `--filter` + `--filter-prod` inputs into a single
213/// ordered selector list where each selector carries its `prod_only`
214/// flag. Exclusions (`!pkg`) from either list apply to the whole set.
215pub fn parse_effective(filter: &EffectiveFilter) -> Result<Vec<Selector>, ParseError> {
216    let mut out = Vec::with_capacity(filter.filters.len() + filter.filter_prods.len());
217    for raw in &filter.filters {
218        out.push(Selector::parse(raw)?);
219    }
220    for raw in &filter.filter_prods {
221        out.push(Selector::parse_prod(raw)?);
222    }
223    Ok(out)
224}
225
226#[derive(Debug, Clone)]
227pub struct SelectedPackage {
228    pub name: Option<String>,
229    pub version: Option<String>,
230    pub private: bool,
231    pub dir: PathBuf,
232    pub manifest: PackageJson,
233}
234
235struct IndexedPackage {
236    selected: SelectedPackage,
237    /// Every declared workspace-sibling-capable dep name (prod + dev +
238    /// optional + peer). Used by default graph walks.
239    all_deps: BTreeSet<String>,
240    /// Production subset: `dependencies` + `optionalDependencies` +
241    /// `peerDependencies`. `--filter-prod` graph walks use this.
242    prod_deps: BTreeSet<String>,
243}
244
245pub fn select_workspace_packages(
246    workspace_root: &Path,
247    workspace_pkgs: &[PathBuf],
248    filter: &EffectiveFilter,
249) -> Result<Vec<SelectedPackage>, SelectError> {
250    let selectors = parse_effective(filter).map_err(SelectError::Parse)?;
251    let packages = index_packages(workspace_pkgs);
252    if selectors.is_empty() {
253        let mut selected: Vec<_> = packages.into_iter().map(|p| p.selected).collect();
254        include_workspace_root(workspace_root, filter, &mut selected)?;
255        return Ok(selected);
256    }
257
258    let has_positive = selectors.iter().any(|s| !s.exclude);
259    let mut included: BTreeSet<usize> = if has_positive {
260        BTreeSet::new()
261    } else {
262        (0..packages.len()).collect()
263    };
264    let mut excluded: BTreeSet<usize> = BTreeSet::new();
265    for selector in &selectors {
266        let matches = expand_selector(workspace_root, &packages, selector)?;
267        if selector.exclude {
268            excluded.extend(matches);
269        } else {
270            included.extend(matches);
271        }
272    }
273    for idx in excluded {
274        included.remove(&idx);
275    }
276
277    let mut selected: Vec<_> = packages
278        .into_iter()
279        .enumerate()
280        .filter_map(|(idx, pkg)| included.contains(&idx).then_some(pkg.selected))
281        .collect();
282    include_workspace_root(workspace_root, filter, &mut selected)?;
283    Ok(selected)
284}
285
286fn index_packages(workspace_pkgs: &[PathBuf]) -> Vec<IndexedPackage> {
287    let mut packages = Vec::new();
288    for dir in workspace_pkgs {
289        let Ok(manifest) = PackageJson::from_path(&dir.join("package.json")) else {
290            continue;
291        };
292        let prod_deps: BTreeSet<String> = manifest
293            .dependencies
294            .keys()
295            .chain(manifest.optional_dependencies.keys())
296            .chain(manifest.peer_dependencies.keys())
297            .cloned()
298            .collect();
299        let all_deps: BTreeSet<String> = prod_deps
300            .iter()
301            .cloned()
302            .chain(manifest.dev_dependencies.keys().cloned())
303            .collect();
304        let private = manifest
305            .extra
306            .get("private")
307            .and_then(|v| v.as_bool())
308            .unwrap_or(false);
309        packages.push(IndexedPackage {
310            selected: SelectedPackage {
311                name: manifest.name.clone(),
312                version: manifest.version.clone(),
313                private,
314                dir: dir.clone(),
315                manifest,
316            },
317            all_deps,
318            prod_deps,
319        });
320    }
321    packages
322}
323
324fn include_workspace_root(
325    workspace_root: &Path,
326    filter: &EffectiveFilter,
327    selected: &mut Vec<SelectedPackage>,
328) -> Result<(), SelectError> {
329    let include_workspace_root = filter.include_workspace_root
330        || WorkspaceConfig::load(workspace_root)
331            .map_err(SelectError::WorkspaceConfig)?
332            .include_workspace_root
333            .unwrap_or(false);
334    if !include_workspace_root || !workspace_root.join("package.json").is_file() {
335        return Ok(());
336    }
337    if selected.iter().any(|pkg| pkg.dir == workspace_root) {
338        return Ok(());
339    }
340
341    let manifest = PackageJson::from_path(&workspace_root.join("package.json"))
342        .map_err(SelectError::Manifest)?;
343    selected.push(SelectedPackage {
344        name: manifest.name.clone(),
345        version: manifest.version.clone(),
346        private: manifest
347            .extra
348            .get("private")
349            .and_then(|v| v.as_bool())
350            .unwrap_or(false),
351        dir: workspace_root.to_path_buf(),
352        manifest,
353    });
354    Ok(())
355}
356
357fn expand_selector(
358    workspace_root: &Path,
359    packages: &[IndexedPackage],
360    selector: &Selector,
361) -> Result<BTreeSet<usize>, SelectError> {
362    let mut seeds = match &selector.base {
363        BaseSelector::ChangedSince(rev) => changed_since(workspace_root, packages, rev)?,
364        _ => packages
365            .iter()
366            .enumerate()
367            .filter_map(|(idx, pkg)| {
368                let view = WorkspacePkg {
369                    name: pkg.selected.name.as_deref(),
370                    dir: &pkg.selected.dir,
371                    workspace_root,
372                };
373                selector.matches(&view).then_some(idx)
374            })
375            .collect(),
376    };
377    let original_seeds = seeds.clone();
378
379    if selector.include_dependencies {
380        seeds.extend(walk_dependencies(
381            packages,
382            &original_seeds,
383            selector.prod_only,
384        ));
385    }
386    if selector.include_dependents {
387        seeds.extend(walk_dependents(
388            packages,
389            &original_seeds,
390            selector.prod_only,
391        ));
392    }
393    if selector.exclude_self {
394        for idx in original_seeds {
395            seeds.remove(&idx);
396        }
397    }
398    Ok(seeds)
399}
400
401fn name_index(packages: &[IndexedPackage]) -> BTreeMap<&str, usize> {
402    packages
403        .iter()
404        .enumerate()
405        .filter_map(|(idx, pkg)| pkg.selected.name.as_deref().map(|n| (n, idx)))
406        .collect()
407}
408
409/// Return the edge set to traverse from a package. `prod_only=true` drops
410/// `devDependencies` edges — matching pnpm's `--filter-prod` semantics.
411fn outgoing_deps(pkg: &IndexedPackage, prod_only: bool) -> &BTreeSet<String> {
412    if prod_only {
413        &pkg.prod_deps
414    } else {
415        &pkg.all_deps
416    }
417}
418
419fn walk_dependencies(
420    packages: &[IndexedPackage],
421    seeds: &BTreeSet<usize>,
422    prod_only: bool,
423) -> BTreeSet<usize> {
424    let names = name_index(packages);
425    let mut out = BTreeSet::new();
426    let mut q: VecDeque<usize> = seeds.iter().copied().collect();
427    while let Some(idx) = q.pop_front() {
428        for dep_name in outgoing_deps(&packages[idx], prod_only) {
429            let Some(dep_idx) = names.get(dep_name.as_str()).copied() else {
430                continue;
431            };
432            if out.insert(dep_idx) {
433                q.push_back(dep_idx);
434            }
435        }
436    }
437    out
438}
439
440fn walk_dependents(
441    packages: &[IndexedPackage],
442    seeds: &BTreeSet<usize>,
443    prod_only: bool,
444) -> BTreeSet<usize> {
445    let mut rev_index: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
446    for (idx, pkg) in packages.iter().enumerate() {
447        for dep_name in outgoing_deps(pkg, prod_only) {
448            rev_index.entry(dep_name.as_str()).or_default().push(idx);
449        }
450    }
451    let mut out = BTreeSet::new();
452    let mut q: VecDeque<usize> = seeds.iter().copied().collect();
453    while let Some(idx) = q.pop_front() {
454        let Some(name) = packages[idx].selected.name.as_deref() else {
455            continue;
456        };
457        if let Some(dependents) = rev_index.get(name) {
458            for &dep_idx in dependents {
459                if out.insert(dep_idx) {
460                    q.push_back(dep_idx);
461                }
462            }
463        }
464    }
465    out
466}
467
468fn changed_since(
469    workspace_root: &Path,
470    packages: &[IndexedPackage],
471    rev: &str,
472) -> Result<BTreeSet<usize>, SelectError> {
473    // `git diff <revspec> -- <paths>` can't accept a `--` terminator
474    // before the revspec, so a rev that begins with `-` would land as
475    // an option. Reject at the boundary as defense against the
476    // CVE-2017-1000117 class of argv injection. NUL is rejected too
477    // because it never appears in a legitimate ref.
478    if rev.starts_with('-') {
479        return Err(SelectError::GitFailed(format!(
480            "refusing to pass revspec starting with `-` to git: {rev:?}"
481        )));
482    }
483    if rev.contains('\0') {
484        return Err(SelectError::GitFailed(
485            "refusing to pass revspec containing NUL byte to git".to_string(),
486        ));
487    }
488    let revspec = format!("{rev}...HEAD");
489    let git_root = git_root(workspace_root)?;
490    let output = Command::new("git")
491        .arg("diff")
492        .arg("--name-only")
493        .arg(&revspec)
494        .arg("--")
495        .current_dir(&git_root)
496        .output()
497        .map_err(SelectError::GitIo)?;
498    if !output.status.success() {
499        return Err(SelectError::GitFailed(
500            String::from_utf8_lossy(&output.stderr).trim().to_string(),
501        ));
502    }
503    let mut out = BTreeSet::new();
504    for line in String::from_utf8_lossy(&output.stdout).lines() {
505        let changed = git_root.join(line);
506        for (idx, pkg) in packages.iter().enumerate() {
507            if changed.starts_with(&pkg.selected.dir) {
508                out.insert(idx);
509            }
510        }
511    }
512    Ok(out)
513}
514
515fn git_root(workspace_root: &Path) -> Result<PathBuf, SelectError> {
516    let output = Command::new("git")
517        .args(["rev-parse", "--show-toplevel"])
518        .current_dir(workspace_root)
519        .output()
520        .map_err(SelectError::GitIo)?;
521    if !output.status.success() {
522        return Err(SelectError::GitFailed(
523            String::from_utf8_lossy(&output.stderr).trim().to_string(),
524        ));
525    }
526    Ok(PathBuf::from(
527        String::from_utf8_lossy(&output.stdout).trim(),
528    ))
529}
530
531#[derive(Debug, thiserror::Error, miette::Diagnostic)]
532pub enum SelectError {
533    #[error("{0}")]
534    #[diagnostic(transparent)]
535    Parse(#[from] ParseError),
536    #[error("failed to load workspace config: {0}")]
537    WorkspaceConfig(#[source] aube_manifest::Error),
538    #[error("failed to read workspace root package.json: {0}")]
539    Manifest(#[source] aube_manifest::Error),
540    #[error("failed to run git for [ref] filter: {0}")]
541    #[diagnostic(code(ERR_AUBE_FILTER_GIT_IO))]
542    GitIo(std::io::Error),
543    #[error("git [ref] filter failed: {0}")]
544    #[diagnostic(code(ERR_AUBE_FILTER_GIT_FAILED))]
545    GitFailed(String),
546}
547
548/// Minimal glob matcher supporting `*` (any run of chars) and `?` (one
549/// char). We deliberately avoid pulling in the `glob` crate's `Pattern`
550/// here because it's tuned for paths, not package names, and chokes on
551/// `/` inside scoped names like `@babel/*`.
552fn glob_match(pattern: &str, s: &str) -> bool {
553    let pat: Vec<char> = pattern.chars().collect();
554    let text: Vec<char> = s.chars().collect();
555    fn inner(pat: &[char], text: &[char]) -> bool {
556        let mut pi = 0;
557        let mut ti = 0;
558        let mut star: Option<(usize, usize)> = None;
559        while ti < text.len() {
560            if pi < pat.len() && (pat[pi] == '?' || pat[pi] == text[ti]) {
561                pi += 1;
562                ti += 1;
563            } else if pi < pat.len() && pat[pi] == '*' {
564                star = Some((pi, ti));
565                pi += 1;
566            } else if let Some((sp, st)) = star {
567                pi = sp + 1;
568                ti = st + 1;
569                star = Some((sp, ti));
570            } else {
571                return false;
572            }
573        }
574        while pi < pat.len() && pat[pi] == '*' {
575            pi += 1;
576        }
577        pi == pat.len()
578    }
579    inner(&pat, &text)
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    #[test]
587    fn parse_forms() {
588        assert_eq!(
589            Selector::parse("foo").unwrap(),
590            Selector {
591                base: BaseSelector::Name("foo".into()),
592                include_dependencies: false,
593                include_dependents: false,
594                exclude_self: false,
595                exclude: false,
596                prod_only: false,
597            }
598        );
599        assert_eq!(
600            Selector::parse("@babel/core").unwrap(),
601            Selector {
602                base: BaseSelector::Name("@babel/core".into()),
603                include_dependencies: false,
604                include_dependents: false,
605                exclude_self: false,
606                exclude: false,
607                prod_only: false,
608            }
609        );
610        assert_eq!(
611            Selector::parse("@babel/*").unwrap(),
612            Selector {
613                base: BaseSelector::NameGlob("@babel/*".into()),
614                include_dependencies: false,
615                include_dependents: false,
616                exclude_self: false,
617                exclude: false,
618                prod_only: false,
619            }
620        );
621        assert_eq!(
622            Selector::parse("./packages/a").unwrap(),
623            Selector {
624                base: BaseSelector::Path(PathBuf::from("packages/a")),
625                include_dependencies: false,
626                include_dependents: false,
627                exclude_self: false,
628                exclude: false,
629                prod_only: false,
630            }
631        );
632        // `./packages/**` is the pnpm "directory and all descendants"
633        // form; aube collapses it to the same path because the matcher
634        // is already "at or under".
635        assert_eq!(
636            Selector::parse("./packages/**").unwrap(),
637            Selector {
638                base: BaseSelector::Path(PathBuf::from("packages")),
639                include_dependencies: false,
640                include_dependents: false,
641                exclude_self: false,
642                exclude: false,
643                prod_only: false,
644            }
645        );
646        // A bare `<name>/**` (no path-y prefix) must keep routing
647        // through `NameGlob` — `@scope/**` is a scoped name glob, not
648        // a path.
649        assert_eq!(
650            Selector::parse("@scope/**").unwrap(),
651            Selector {
652                base: BaseSelector::NameGlob("@scope/**".into()),
653                include_dependencies: false,
654                include_dependents: false,
655                exclude_self: false,
656                exclude: false,
657                prod_only: false,
658            }
659        );
660        assert!(Selector::parse("").is_err());
661    }
662
663    #[test]
664    fn parse_prod_sets_prod_only() {
665        let sel = Selector::parse_prod("foo...").unwrap();
666        assert!(sel.prod_only);
667        assert!(sel.include_dependencies);
668        assert_eq!(sel.base, BaseSelector::Name("foo".into()));
669        assert!(!Selector::parse("foo...").unwrap().prod_only);
670    }
671
672    #[test]
673    fn prod_graph_walk_skips_dev_deps() {
674        // Build three workspace packages:
675        //   api      → depends on lib (prod) and tooling (dev)
676        //   lib      → no deps
677        //   tooling  → no deps
678        // A regular `api...` walk should reach both `lib` and `tooling`;
679        // a `--filter-prod` `api...` walk should reach only `lib`.
680        let mk_pkg = |name: &str, prod: &[&str], dev: &[&str]| -> IndexedPackage {
681            let manifest = aube_manifest::PackageJson {
682                name: Some(name.to_string()),
683                ..aube_manifest::PackageJson::default()
684            };
685            IndexedPackage {
686                selected: SelectedPackage {
687                    name: Some(name.to_string()),
688                    version: None,
689                    private: false,
690                    dir: PathBuf::from(format!("/ws/{name}")),
691                    manifest,
692                },
693                all_deps: prod
694                    .iter()
695                    .chain(dev.iter())
696                    .map(|s| (*s).to_string())
697                    .collect(),
698                prod_deps: prod.iter().map(|s| (*s).to_string()).collect(),
699            }
700        };
701        let packages = vec![
702            mk_pkg("api", &["lib"], &["tooling"]),
703            mk_pkg("lib", &[], &[]),
704            mk_pkg("tooling", &[], &[]),
705        ];
706
707        let mut seeds = BTreeSet::new();
708        seeds.insert(0); // api
709
710        let full = walk_dependencies(&packages, &seeds, false);
711        assert_eq!(full, BTreeSet::from([1, 2]));
712
713        let prod = walk_dependencies(&packages, &seeds, true);
714        assert_eq!(prod, BTreeSet::from([1]));
715
716        // walk_dependents with prod_only should also skip dev edges: the
717        // only dependent of `tooling` is `api` via a dev edge, so prod
718        // mode should return an empty set.
719        let mut tool_seeds = BTreeSet::new();
720        tool_seeds.insert(2);
721        let tool_all = walk_dependents(&packages, &tool_seeds, false);
722        assert_eq!(tool_all, BTreeSet::from([0]));
723        let tool_prod = walk_dependents(&packages, &tool_seeds, true);
724        assert!(tool_prod.is_empty());
725    }
726
727    #[test]
728    fn parse_graph_forms() {
729        let deps = Selector::parse("foo...").unwrap();
730        assert_eq!(deps.base, BaseSelector::Name("foo".into()));
731        assert!(deps.include_dependencies);
732        assert!(!deps.include_dependents);
733        assert!(!deps.exclude_self);
734
735        let only_deps = Selector::parse("foo^...").unwrap();
736        assert!(only_deps.include_dependencies);
737        assert!(only_deps.exclude_self);
738
739        let dependents = Selector::parse("...foo").unwrap();
740        assert!(dependents.include_dependents);
741
742        let only_dependents = Selector::parse("...^foo").unwrap();
743        assert!(only_dependents.include_dependents);
744        assert!(only_dependents.exclude_self);
745    }
746
747    #[test]
748    fn glob_matches_names() {
749        assert!(glob_match("@babel/*", "@babel/core"));
750        assert!(glob_match("@babel/*", "@babel/preset-env"));
751        assert!(!glob_match("@babel/*", "@babel-x/core"));
752        assert!(glob_match("foo-*", "foo-bar"));
753        assert!(glob_match("*-plugin", "a-plugin"));
754        assert!(glob_match("*", "anything"));
755        assert!(!glob_match("foo", "foobar"));
756    }
757
758    #[test]
759    fn path_selector_matches_nested() {
760        let root = Path::new("/ws");
761        let sel = Selector::parse("./packages").unwrap();
762        let dir = PathBuf::from("/ws/packages/a");
763        assert!(sel.matches(&WorkspacePkg {
764            name: Some("a"),
765            dir: &dir,
766            workspace_root: root,
767        }));
768    }
769
770    #[test]
771    fn include_workspace_root_adds_root_even_when_filter_matches_nothing() {
772        let dir = tempfile::tempdir().unwrap();
773        std::fs::write(
774            dir.path().join("package.json"),
775            r#"{"name":"root","version":"0.0.0","private":true}"#,
776        )
777        .unwrap();
778        std::fs::write(
779            dir.path().join("pnpm-workspace.yaml"),
780            "packages:\n  - packages/*\nincludeWorkspaceRoot: true\n",
781        )
782        .unwrap();
783        std::fs::create_dir_all(dir.path().join("packages/app")).unwrap();
784        std::fs::write(
785            dir.path().join("packages/app/package.json"),
786            r#"{"name":"app","version":"1.0.0"}"#,
787        )
788        .unwrap();
789
790        let selected = select_workspace_packages(
791            dir.path(),
792            &[dir.path().join("packages/app")],
793            &EffectiveFilter::from_filters(["missing"]),
794        )
795        .unwrap();
796
797        assert_eq!(selected.len(), 1);
798        assert_eq!(selected[0].name.as_deref(), Some("root"));
799        assert_eq!(selected[0].dir, dir.path());
800    }
801
802    #[test]
803    fn include_workspace_root_yaml_applies_with_no_selectors() {
804        // Regression: when both `filters` and `filter_prods` are empty,
805        // `select_workspace_packages` short-circuits — root inclusion
806        // (CLI flag or YAML) must still apply on that path.
807        let dir = tempfile::tempdir().unwrap();
808        std::fs::write(
809            dir.path().join("package.json"),
810            r#"{"name":"root","version":"0.0.0","private":true}"#,
811        )
812        .unwrap();
813        std::fs::write(
814            dir.path().join("pnpm-workspace.yaml"),
815            "packages:\n  - packages/*\nincludeWorkspaceRoot: true\n",
816        )
817        .unwrap();
818        std::fs::create_dir_all(dir.path().join("packages/app")).unwrap();
819        std::fs::write(
820            dir.path().join("packages/app/package.json"),
821            r#"{"name":"app","version":"1.0.0"}"#,
822        )
823        .unwrap();
824
825        let selected = select_workspace_packages(
826            dir.path(),
827            &[dir.path().join("packages/app")],
828            &EffectiveFilter::default(),
829        )
830        .unwrap();
831
832        let names: Vec<_> = selected
833            .iter()
834            .filter_map(|pkg| pkg.name.as_deref())
835            .collect();
836        assert_eq!(names, vec!["app", "root"]);
837    }
838
839    #[test]
840    fn include_workspace_root_flag_adds_root_to_regular_matches() {
841        let dir = tempfile::tempdir().unwrap();
842        std::fs::write(
843            dir.path().join("package.json"),
844            r#"{"name":"root","version":"0.0.0","private":true}"#,
845        )
846        .unwrap();
847        std::fs::create_dir_all(dir.path().join("packages/app")).unwrap();
848        std::fs::write(
849            dir.path().join("packages/app/package.json"),
850            r#"{"name":"app","version":"1.0.0"}"#,
851        )
852        .unwrap();
853        let mut filter = EffectiveFilter::from_filters(["app"]);
854        filter.include_workspace_root = true;
855
856        let selected =
857            select_workspace_packages(dir.path(), &[dir.path().join("packages/app")], &filter)
858                .unwrap();
859        let names: Vec<_> = selected
860            .iter()
861            .filter_map(|pkg| pkg.name.as_deref())
862            .collect();
863
864        assert_eq!(names, vec!["app", "root"]);
865    }
866
867    #[test]
868    fn changed_since_rejects_dash_prefixed_rev() {
869        // CVE-2017-1000117 class: a rev beginning with `-` would be
870        // interpreted by `git diff` as an option because the
871        // subcommand does not accept a `--` terminator before the
872        // revspec. Reject at the boundary before the format! call.
873        // The validation runs before `git_root`, so `workspace_root`
874        // does not need to exist for this test.
875        let err =
876            changed_since(Path::new("/nonexistent"), &[], "--upload-pack=/tmp/evil").unwrap_err();
877        let msg = match err {
878            SelectError::GitFailed(m) => m,
879            other => panic!("expected GitFailed, got {other:?}"),
880        };
881        assert!(msg.contains("refusing"), "unexpected error: {msg}");
882    }
883
884    #[test]
885    fn changed_since_rejects_nul_in_rev() {
886        let err = changed_since(Path::new("/nonexistent"), &[], "main\0evil").unwrap_err();
887        let msg = match err {
888            SelectError::GitFailed(m) => m,
889            other => panic!("expected GitFailed, got {other:?}"),
890        };
891        assert!(msg.contains("refusing"), "unexpected error: {msg}");
892    }
893}