Skip to main content

fallow_engine/
workspace_scope.rs

1//! Workspace scoping owned by the engine boundary.
2
3use std::path::{Path, PathBuf};
4
5use fallow_config::WorkspaceInfo;
6use globset::Glob;
7use rustc_hash::FxHashSet;
8
9/// User-facing workspace scope mode.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum WorkspaceScopeMode {
12    /// Explicit workspace package names, paths, or globs.
13    Workspace,
14    /// Git-derived changed workspace scope.
15    ChangedWorkspaces,
16}
17
18/// Typed workspace-scope failure. Surfaces decide their own wording.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum WorkspaceScopeError {
21    /// No workspace metadata exists for the requested scope.
22    NoWorkspaces {
23        /// Which scope mode was requested.
24        mode: WorkspaceScopeMode,
25        /// The workspace patterns the request carried.
26        patterns: Vec<String>,
27        /// The git ref for changed-workspace scoping, when that mode was used.
28        git_ref: Option<String>,
29    },
30    /// A pattern was neither an exact name/path nor a valid glob.
31    InvalidPattern {
32        /// The offending pattern as supplied.
33        pattern: String,
34        /// Glob-compilation error detail.
35        message: String,
36    },
37    /// One or more positive patterns matched no workspace.
38    UnmatchedPatterns {
39        /// The patterns that matched nothing.
40        patterns: Vec<String>,
41        /// Preformatted list of available workspace names for the error text.
42        available: String,
43    },
44    /// Negation removed every selected workspace.
45    EmptyAfterExclusions {
46        /// Preformatted description of the positive patterns (`<all>` when
47        /// none were given).
48        included: String,
49        /// Preformatted description of the exclusion patterns.
50        excluded: String,
51    },
52    /// Git failed while resolving changed workspaces.
53    ChangedWorkspacesFailed {
54        /// The git ref the changed-workspace resolution ran against.
55        git_ref: String,
56        /// Underlying git failure detail.
57        message: String,
58    },
59    /// Both workspace scope modes were requested.
60    MutuallyExclusive,
61}
62
63/// Resolve either explicit or changed workspace scope against discovered metadata.
64///
65/// # Errors
66///
67/// Returns a typed scope error when the selection is invalid or git cannot
68/// resolve changed files.
69pub fn resolve_workspace_scope_roots(
70    root: &Path,
71    workspace: Option<&[String]>,
72    changed_workspaces: Option<&str>,
73    workspaces: &[WorkspaceInfo],
74) -> Result<Option<Vec<PathBuf>>, WorkspaceScopeError> {
75    match (workspace, changed_workspaces) {
76        (Some(patterns), None) => {
77            resolve_workspace_filter_roots(root, patterns, workspaces).map(Some)
78        }
79        (None, Some(git_ref)) => {
80            resolve_changed_workspace_roots(root, git_ref, workspaces).map(Some)
81        }
82        (None, None) => Ok(None),
83        (Some(_), Some(_)) => Err(WorkspaceScopeError::MutuallyExclusive),
84    }
85}
86
87/// Resolve either explicit or changed workspace scope by discovering workspace
88/// metadata first.
89///
90/// # Errors
91///
92/// Returns a typed scope error when the selection is invalid, no workspaces are
93/// available, or git cannot resolve changed files.
94pub fn resolve_workspace_scope_roots_for_project(
95    root: &Path,
96    workspace: Option<&[String]>,
97    changed_workspaces: Option<&str>,
98) -> Result<Option<Vec<PathBuf>>, WorkspaceScopeError> {
99    let workspaces = crate::discover::discover_workspace_packages(root);
100    resolve_workspace_scope_roots(root, workspace, changed_workspaces, &workspaces)
101}
102
103/// Resolve explicit workspace filters against known workspace metadata.
104///
105/// # Errors
106///
107/// Returns a typed scope error when no workspaces are available, a pattern is
108/// invalid, a positive pattern is unmatched, or negation excludes everything.
109fn resolve_workspace_filter_roots(
110    root: &Path,
111    patterns: &[String],
112    workspaces: &[WorkspaceInfo],
113) -> Result<Vec<PathBuf>, WorkspaceScopeError> {
114    if workspaces.is_empty() {
115        return Err(WorkspaceScopeError::NoWorkspaces {
116            mode: WorkspaceScopeMode::Workspace,
117            patterns: patterns.to_vec(),
118            git_ref: None,
119        });
120    }
121
122    let rel_paths = workspace_relative_paths(root, workspaces);
123    let (positive, negative) = split_workspace_patterns(patterns);
124    let mut matched = match_positive_workspace_patterns(&positive, workspaces, &rel_paths)?;
125
126    for pattern in &negative {
127        for index in find_workspace_matches(pattern, workspaces, &rel_paths)? {
128            matched.remove(&index);
129        }
130    }
131
132    if matched.is_empty() {
133        return Err(WorkspaceScopeError::EmptyAfterExclusions {
134            included: describe_included_patterns(&positive),
135            excluded: describe_excluded_patterns(&negative),
136        });
137    }
138
139    let mut roots = matched
140        .into_iter()
141        .map(|index| workspaces[index].root.clone())
142        .collect::<Vec<_>>();
143    roots.sort();
144    Ok(roots)
145}
146
147/// Resolve workspace roots that contain files changed since `git_ref`.
148///
149/// # Errors
150///
151/// Returns a typed scope error when no workspaces are available or git fails.
152fn resolve_changed_workspace_roots(
153    root: &Path,
154    git_ref: &str,
155    workspaces: &[WorkspaceInfo],
156) -> Result<Vec<PathBuf>, WorkspaceScopeError> {
157    if workspaces.is_empty() {
158        return Err(WorkspaceScopeError::NoWorkspaces {
159            mode: WorkspaceScopeMode::ChangedWorkspaces,
160            patterns: Vec::new(),
161            git_ref: Some(git_ref.to_owned()),
162        });
163    }
164
165    let changed_files = crate::changed_files::changed_files(root, git_ref).map_err(|err| {
166        WorkspaceScopeError::ChangedWorkspacesFailed {
167            git_ref: git_ref.to_owned(),
168            message: err.describe(),
169        }
170    })?;
171    let mut roots = workspaces
172        .iter()
173        .filter(|workspace| {
174            changed_files
175                .iter()
176                .any(|file| file.starts_with(&workspace.root))
177        })
178        .map(|workspace| workspace.root.clone())
179        .collect::<Vec<_>>();
180    roots.sort();
181    Ok(roots)
182}
183
184fn match_positive_workspace_patterns(
185    positive: &[&str],
186    workspaces: &[WorkspaceInfo],
187    rel_paths: &[String],
188) -> Result<FxHashSet<usize>, WorkspaceScopeError> {
189    let mut matched = FxHashSet::default();
190    let mut unmatched = Vec::new();
191
192    if positive.is_empty() {
193        matched.extend(0..workspaces.len());
194    } else {
195        for pattern in positive {
196            let hits = find_workspace_matches(pattern, workspaces, rel_paths)?;
197            if hits.is_empty() {
198                unmatched.push((*pattern).to_owned());
199            }
200            matched.extend(hits);
201        }
202    }
203
204    if !unmatched.is_empty() {
205        return Err(WorkspaceScopeError::UnmatchedPatterns {
206            patterns: unmatched,
207            available: format_available_workspaces(workspaces),
208        });
209    }
210
211    Ok(matched)
212}
213
214fn find_workspace_matches(
215    pattern: &str,
216    workspaces: &[WorkspaceInfo],
217    rel_paths: &[String],
218) -> Result<Vec<usize>, WorkspaceScopeError> {
219    if let Some(index) = workspaces
220        .iter()
221        .position(|workspace| workspace.name == pattern)
222    {
223        return Ok(vec![index]);
224    }
225    if let Some(index) = rel_paths.iter().position(|path| path == pattern) {
226        return Ok(vec![index]);
227    }
228
229    let glob = Glob::new(pattern).map_err(|err| WorkspaceScopeError::InvalidPattern {
230        pattern: pattern.to_owned(),
231        message: err.to_string(),
232    })?;
233    let matcher = glob.compile_matcher();
234    Ok(workspaces
235        .iter()
236        .enumerate()
237        .filter_map(|(index, workspace)| {
238            (matcher.is_match(&workspace.name) || matcher.is_match(&rel_paths[index]))
239                .then_some(index)
240        })
241        .collect())
242}
243
244fn split_workspace_patterns(patterns: &[String]) -> (Vec<&str>, Vec<&str>) {
245    let mut positive = Vec::new();
246    let mut negative = Vec::new();
247    for pattern in patterns {
248        let trimmed = pattern.trim();
249        if trimmed.is_empty() {
250            continue;
251        }
252        if let Some(negative_pattern) = trimmed.strip_prefix('!') {
253            let negative_pattern = negative_pattern.trim();
254            if !negative_pattern.is_empty() {
255                negative.push(negative_pattern);
256            }
257        } else {
258            positive.push(trimmed);
259        }
260    }
261    (positive, negative)
262}
263
264fn workspace_relative_paths(root: &Path, workspaces: &[WorkspaceInfo]) -> Vec<String> {
265    workspaces
266        .iter()
267        .map(|workspace| relative_workspace_path(&workspace.root, root))
268        .collect()
269}
270
271fn relative_workspace_path(workspace_root: &Path, root: &Path) -> String {
272    workspace_root
273        .strip_prefix(root)
274        .unwrap_or(workspace_root)
275        .to_string_lossy()
276        .replace('\\', "/")
277}
278
279fn describe_included_patterns(positive: &[&str]) -> String {
280    if positive.is_empty() {
281        "<all>".to_owned()
282    } else {
283        quote_patterns(positive)
284    }
285}
286
287fn describe_excluded_patterns(negative: &[&str]) -> String {
288    quote_patterns(negative)
289}
290
291fn quote_patterns(patterns: &[&str]) -> String {
292    patterns
293        .iter()
294        .map(|pattern| format!("'{pattern}'"))
295        .collect::<Vec<_>>()
296        .join(", ")
297}
298
299fn format_available_workspaces(workspaces: &[WorkspaceInfo]) -> String {
300    const MAX_SHOWN: usize = 10;
301    let total = workspaces.len();
302    if total <= MAX_SHOWN {
303        return workspaces
304            .iter()
305            .map(|workspace| workspace.name.as_str())
306            .collect::<Vec<_>>()
307            .join(", ");
308    }
309    let shown = workspaces
310        .iter()
311        .take(MAX_SHOWN)
312        .map(|workspace| workspace.name.as_str())
313        .collect::<Vec<_>>()
314        .join(", ");
315    format!(
316        "{shown}, ... and {} more ({total} total)",
317        total - MAX_SHOWN
318    )
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    fn ws(name: &str, rel: &str) -> WorkspaceInfo {
326        WorkspaceInfo {
327            root: PathBuf::from("/project").join(rel),
328            name: name.to_owned(),
329            is_internal_dependency: false,
330        }
331    }
332
333    #[test]
334    fn workspace_filter_exact_name_short_circuits_glob_metachars() {
335        let workspaces = vec![ws("web-[staging]", "apps/web-staging")];
336        let roots = resolve_workspace_filter_roots(
337            Path::new("/project"),
338            &["web-[staging]".to_owned()],
339            &workspaces,
340        )
341        .expect("resolve workspace");
342
343        assert_eq!(roots, vec![PathBuf::from("/project/apps/web-staging")]);
344    }
345
346    #[test]
347    fn workspace_filter_globs_against_name_and_path() {
348        let workspaces = vec![
349            ws("@scope/ui", "packages/ui"),
350            ws("admin", "apps/admin"),
351            ws("web", "apps/web"),
352        ];
353        let roots = resolve_workspace_filter_roots(
354            Path::new("/project"),
355            &["apps/*".to_owned()],
356            &workspaces,
357        )
358        .expect("resolve workspace");
359
360        assert_eq!(
361            roots,
362            vec![
363                PathBuf::from("/project/apps/admin"),
364                PathBuf::from("/project/apps/web")
365            ]
366        );
367
368        let roots = resolve_workspace_filter_roots(
369            Path::new("/project"),
370            &["@scope/*".to_owned()],
371            &workspaces,
372        )
373        .expect("resolve workspace");
374        assert_eq!(roots, vec![PathBuf::from("/project/packages/ui")]);
375    }
376
377    #[test]
378    fn workspace_filter_reports_invalid_glob_after_no_literal_match() {
379        let workspaces = vec![ws("web", "apps/web")];
380        let err = resolve_workspace_filter_roots(
381            Path::new("/project"),
382            &["web-[bad".to_owned()],
383            &workspaces,
384        )
385        .expect_err("invalid glob");
386
387        assert!(matches!(err, WorkspaceScopeError::InvalidPattern { .. }));
388    }
389
390    #[test]
391    fn workspace_filter_negation_can_exclude_selected_workspaces() {
392        let workspaces = vec![
393            ws("web", "apps/web"),
394            ws("docs", "apps/docs"),
395            ws("legacy", "apps/legacy"),
396        ];
397        let roots = resolve_workspace_filter_roots(
398            Path::new("/project"),
399            &["apps/*".to_owned(), "!apps/legacy".to_owned()],
400            &workspaces,
401        )
402        .expect("resolve workspace");
403
404        assert_eq!(
405            roots,
406            vec![
407                PathBuf::from("/project/apps/docs"),
408                PathBuf::from("/project/apps/web")
409            ]
410        );
411    }
412
413    #[test]
414    fn workspace_filter_only_negation_starts_from_all_workspaces() {
415        let workspaces = vec![ws("web", "apps/web"), ws("legacy", "apps/legacy")];
416        let roots = resolve_workspace_filter_roots(
417            Path::new("/project"),
418            &["!apps/legacy".to_owned()],
419            &workspaces,
420        )
421        .expect("resolve workspace");
422
423        assert_eq!(roots, vec![PathBuf::from("/project/apps/web")]);
424    }
425
426    #[test]
427    fn workspace_filter_reports_unmatched_patterns_with_available_list() {
428        let workspaces = vec![ws("web", "apps/web"), ws("docs", "apps/docs")];
429        let err = resolve_workspace_filter_roots(
430            Path::new("/project"),
431            &["missing".to_owned()],
432            &workspaces,
433        )
434        .expect_err("unmatched pattern");
435
436        assert_eq!(
437            err,
438            WorkspaceScopeError::UnmatchedPatterns {
439                patterns: vec!["missing".to_owned()],
440                available: "web, docs".to_owned(),
441            }
442        );
443    }
444
445    #[test]
446    fn workspace_filter_reports_empty_after_exclusions() {
447        let workspaces = vec![ws("web", "apps/web")];
448        let err = resolve_workspace_filter_roots(
449            Path::new("/project"),
450            &["!apps/web".to_owned()],
451            &workspaces,
452        )
453        .expect_err("empty selection");
454
455        assert_eq!(
456            err,
457            WorkspaceScopeError::EmptyAfterExclusions {
458                included: "<all>".to_owned(),
459                excluded: "'apps/web'".to_owned(),
460            }
461        );
462    }
463
464    #[test]
465    fn workspace_available_list_truncates_when_above_cap() {
466        let workspaces = (0..15)
467            .map(|index| ws(&format!("pkg-{index}"), &format!("packages/pkg-{index}")))
468            .collect::<Vec<_>>();
469
470        let rendered = format_available_workspaces(&workspaces);
471
472        assert!(rendered.starts_with("pkg-0, pkg-1,"));
473        assert!(rendered.contains("and 5 more"));
474        assert!(rendered.contains("15 total"));
475    }
476
477    #[test]
478    fn changed_workspace_scope_ignores_root_only_changes() {
479        let workspaces = vec![ws("ui", "packages/ui"), ws("api", "packages/api")];
480        let mut changed = FxHashSet::default();
481        changed.insert(PathBuf::from("/project/package.json"));
482        changed.insert(PathBuf::from("/project/pnpm-lock.yaml"));
483
484        let roots = roots_for_changed_files(&workspaces, &changed);
485
486        assert!(roots.is_empty());
487    }
488
489    #[test]
490    fn changed_workspace_scope_maps_files_to_workspace_roots() {
491        let workspaces = vec![
492            ws("ui", "packages/ui"),
493            ws("api", "packages/api"),
494            ws("cli", "packages/cli"),
495        ];
496        let mut changed = FxHashSet::default();
497        changed.insert(PathBuf::from("/project/packages/api/src/b.ts"));
498        changed.insert(PathBuf::from("/project/packages/ui/src/a.ts"));
499
500        let roots = roots_for_changed_files(&workspaces, &changed);
501
502        assert_eq!(
503            roots,
504            vec![
505                PathBuf::from("/project/packages/api"),
506                PathBuf::from("/project/packages/ui")
507            ]
508        );
509    }
510
511    fn roots_for_changed_files(
512        workspaces: &[WorkspaceInfo],
513        changed_files: &FxHashSet<PathBuf>,
514    ) -> Vec<PathBuf> {
515        let mut roots = workspaces
516            .iter()
517            .filter(|workspace| {
518                changed_files
519                    .iter()
520                    .any(|file| file.starts_with(&workspace.root))
521            })
522            .map(|workspace| workspace.root.clone())
523            .collect::<Vec<_>>();
524        roots.sort();
525        roots
526    }
527}