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 explicit workspace filters against known workspace metadata.
88///
89/// # Errors
90///
91/// Returns a typed scope error when no workspaces are available, a pattern is
92/// invalid, a positive pattern is unmatched, or negation excludes everything.
93fn resolve_workspace_filter_roots(
94    root: &Path,
95    patterns: &[String],
96    workspaces: &[WorkspaceInfo],
97) -> Result<Vec<PathBuf>, WorkspaceScopeError> {
98    if workspaces.is_empty() {
99        return Err(WorkspaceScopeError::NoWorkspaces {
100            mode: WorkspaceScopeMode::Workspace,
101            patterns: patterns.to_vec(),
102            git_ref: None,
103        });
104    }
105
106    let rel_paths = workspace_relative_paths(root, workspaces);
107    let (positive, negative) = split_workspace_patterns(patterns);
108    let mut matched = match_positive_workspace_patterns(&positive, workspaces, &rel_paths)?;
109
110    for pattern in &negative {
111        for index in find_workspace_matches(pattern, workspaces, &rel_paths)? {
112            matched.remove(&index);
113        }
114    }
115
116    if matched.is_empty() {
117        return Err(WorkspaceScopeError::EmptyAfterExclusions {
118            included: describe_included_patterns(&positive),
119            excluded: describe_excluded_patterns(&negative),
120        });
121    }
122
123    let mut roots = matched
124        .into_iter()
125        .map(|index| workspaces[index].root.clone())
126        .collect::<Vec<_>>();
127    roots.sort();
128    Ok(roots)
129}
130
131/// Resolve workspace roots that contain files changed since `git_ref`.
132///
133/// # Errors
134///
135/// Returns a typed scope error when no workspaces are available or git fails.
136fn resolve_changed_workspace_roots(
137    root: &Path,
138    git_ref: &str,
139    workspaces: &[WorkspaceInfo],
140) -> Result<Vec<PathBuf>, WorkspaceScopeError> {
141    if workspaces.is_empty() {
142        return Err(WorkspaceScopeError::NoWorkspaces {
143            mode: WorkspaceScopeMode::ChangedWorkspaces,
144            patterns: Vec::new(),
145            git_ref: Some(git_ref.to_owned()),
146        });
147    }
148
149    let changed_files = crate::changed_files::changed_files(root, git_ref).map_err(|err| {
150        WorkspaceScopeError::ChangedWorkspacesFailed {
151            git_ref: git_ref.to_owned(),
152            message: err.describe(),
153        }
154    })?;
155    let mut roots = workspaces
156        .iter()
157        .filter(|workspace| {
158            changed_files
159                .iter()
160                .any(|file| file.starts_with(&workspace.root))
161        })
162        .map(|workspace| workspace.root.clone())
163        .collect::<Vec<_>>();
164    roots.sort();
165    Ok(roots)
166}
167
168fn match_positive_workspace_patterns(
169    positive: &[&str],
170    workspaces: &[WorkspaceInfo],
171    rel_paths: &[String],
172) -> Result<FxHashSet<usize>, WorkspaceScopeError> {
173    let mut matched = FxHashSet::default();
174    let mut unmatched = Vec::new();
175
176    if positive.is_empty() {
177        matched.extend(0..workspaces.len());
178    } else {
179        for pattern in positive {
180            let hits = find_workspace_matches(pattern, workspaces, rel_paths)?;
181            if hits.is_empty() {
182                unmatched.push((*pattern).to_owned());
183            }
184            matched.extend(hits);
185        }
186    }
187
188    if !unmatched.is_empty() {
189        return Err(WorkspaceScopeError::UnmatchedPatterns {
190            patterns: unmatched,
191            available: format_available_workspaces(workspaces),
192        });
193    }
194
195    Ok(matched)
196}
197
198fn find_workspace_matches(
199    pattern: &str,
200    workspaces: &[WorkspaceInfo],
201    rel_paths: &[String],
202) -> Result<Vec<usize>, WorkspaceScopeError> {
203    if let Some(index) = workspaces
204        .iter()
205        .position(|workspace| workspace.name == pattern)
206    {
207        return Ok(vec![index]);
208    }
209    if let Some(index) = rel_paths.iter().position(|path| path == pattern) {
210        return Ok(vec![index]);
211    }
212
213    let glob = Glob::new(pattern).map_err(|err| WorkspaceScopeError::InvalidPattern {
214        pattern: pattern.to_owned(),
215        message: err.to_string(),
216    })?;
217    let matcher = glob.compile_matcher();
218    Ok(workspaces
219        .iter()
220        .enumerate()
221        .filter_map(|(index, workspace)| {
222            (matcher.is_match(&workspace.name) || matcher.is_match(&rel_paths[index]))
223                .then_some(index)
224        })
225        .collect())
226}
227
228fn split_workspace_patterns(patterns: &[String]) -> (Vec<&str>, Vec<&str>) {
229    let mut positive = Vec::new();
230    let mut negative = Vec::new();
231    for pattern in patterns {
232        let trimmed = pattern.trim();
233        if trimmed.is_empty() {
234            continue;
235        }
236        if let Some(negative_pattern) = trimmed.strip_prefix('!') {
237            let negative_pattern = negative_pattern.trim();
238            if !negative_pattern.is_empty() {
239                negative.push(negative_pattern);
240            }
241        } else {
242            positive.push(trimmed);
243        }
244    }
245    (positive, negative)
246}
247
248fn workspace_relative_paths(root: &Path, workspaces: &[WorkspaceInfo]) -> Vec<String> {
249    workspaces
250        .iter()
251        .map(|workspace| relative_workspace_path(&workspace.root, root))
252        .collect()
253}
254
255fn relative_workspace_path(workspace_root: &Path, root: &Path) -> String {
256    workspace_root
257        .strip_prefix(root)
258        .unwrap_or(workspace_root)
259        .to_string_lossy()
260        .replace('\\', "/")
261}
262
263fn describe_included_patterns(positive: &[&str]) -> String {
264    if positive.is_empty() {
265        "<all>".to_owned()
266    } else {
267        quote_patterns(positive)
268    }
269}
270
271fn describe_excluded_patterns(negative: &[&str]) -> String {
272    quote_patterns(negative)
273}
274
275fn quote_patterns(patterns: &[&str]) -> String {
276    patterns
277        .iter()
278        .map(|pattern| format!("'{pattern}'"))
279        .collect::<Vec<_>>()
280        .join(", ")
281}
282
283fn format_available_workspaces(workspaces: &[WorkspaceInfo]) -> String {
284    const MAX_SHOWN: usize = 10;
285    let total = workspaces.len();
286    if total <= MAX_SHOWN {
287        return workspaces
288            .iter()
289            .map(|workspace| workspace.name.as_str())
290            .collect::<Vec<_>>()
291            .join(", ");
292    }
293    let shown = workspaces
294        .iter()
295        .take(MAX_SHOWN)
296        .map(|workspace| workspace.name.as_str())
297        .collect::<Vec<_>>()
298        .join(", ");
299    format!(
300        "{shown}, ... and {} more ({total} total)",
301        total - MAX_SHOWN
302    )
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    fn ws(name: &str, rel: &str) -> WorkspaceInfo {
310        WorkspaceInfo {
311            root: PathBuf::from("/project").join(rel),
312            name: name.to_owned(),
313            is_internal_dependency: false,
314        }
315    }
316
317    #[test]
318    fn workspace_filter_exact_name_short_circuits_glob_metachars() {
319        let workspaces = vec![ws("web-[staging]", "apps/web-staging")];
320        let roots = resolve_workspace_filter_roots(
321            Path::new("/project"),
322            &["web-[staging]".to_owned()],
323            &workspaces,
324        )
325        .expect("resolve workspace");
326
327        assert_eq!(roots, vec![PathBuf::from("/project/apps/web-staging")]);
328    }
329
330    #[test]
331    fn workspace_filter_globs_against_name_and_path() {
332        let workspaces = vec![
333            ws("@scope/ui", "packages/ui"),
334            ws("admin", "apps/admin"),
335            ws("web", "apps/web"),
336        ];
337        let roots = resolve_workspace_filter_roots(
338            Path::new("/project"),
339            &["apps/*".to_owned()],
340            &workspaces,
341        )
342        .expect("resolve workspace");
343
344        assert_eq!(
345            roots,
346            vec![
347                PathBuf::from("/project/apps/admin"),
348                PathBuf::from("/project/apps/web")
349            ]
350        );
351
352        let roots = resolve_workspace_filter_roots(
353            Path::new("/project"),
354            &["@scope/*".to_owned()],
355            &workspaces,
356        )
357        .expect("resolve workspace");
358        assert_eq!(roots, vec![PathBuf::from("/project/packages/ui")]);
359    }
360
361    #[test]
362    fn workspace_filter_reports_invalid_glob_after_no_literal_match() {
363        let workspaces = vec![ws("web", "apps/web")];
364        let err = resolve_workspace_filter_roots(
365            Path::new("/project"),
366            &["web-[bad".to_owned()],
367            &workspaces,
368        )
369        .expect_err("invalid glob");
370
371        assert!(matches!(err, WorkspaceScopeError::InvalidPattern { .. }));
372    }
373
374    #[test]
375    fn workspace_filter_negation_can_exclude_selected_workspaces() {
376        let workspaces = vec![
377            ws("web", "apps/web"),
378            ws("docs", "apps/docs"),
379            ws("legacy", "apps/legacy"),
380        ];
381        let roots = resolve_workspace_filter_roots(
382            Path::new("/project"),
383            &["apps/*".to_owned(), "!apps/legacy".to_owned()],
384            &workspaces,
385        )
386        .expect("resolve workspace");
387
388        assert_eq!(
389            roots,
390            vec![
391                PathBuf::from("/project/apps/docs"),
392                PathBuf::from("/project/apps/web")
393            ]
394        );
395    }
396
397    #[test]
398    fn workspace_filter_only_negation_starts_from_all_workspaces() {
399        let workspaces = vec![ws("web", "apps/web"), ws("legacy", "apps/legacy")];
400        let roots = resolve_workspace_filter_roots(
401            Path::new("/project"),
402            &["!apps/legacy".to_owned()],
403            &workspaces,
404        )
405        .expect("resolve workspace");
406
407        assert_eq!(roots, vec![PathBuf::from("/project/apps/web")]);
408    }
409
410    #[test]
411    fn workspace_filter_reports_unmatched_patterns_with_available_list() {
412        let workspaces = vec![ws("web", "apps/web"), ws("docs", "apps/docs")];
413        let err = resolve_workspace_filter_roots(
414            Path::new("/project"),
415            &["missing".to_owned()],
416            &workspaces,
417        )
418        .expect_err("unmatched pattern");
419
420        assert_eq!(
421            err,
422            WorkspaceScopeError::UnmatchedPatterns {
423                patterns: vec!["missing".to_owned()],
424                available: "web, docs".to_owned(),
425            }
426        );
427    }
428
429    #[test]
430    fn workspace_filter_reports_empty_after_exclusions() {
431        let workspaces = vec![ws("web", "apps/web")];
432        let err = resolve_workspace_filter_roots(
433            Path::new("/project"),
434            &["!apps/web".to_owned()],
435            &workspaces,
436        )
437        .expect_err("empty selection");
438
439        assert_eq!(
440            err,
441            WorkspaceScopeError::EmptyAfterExclusions {
442                included: "<all>".to_owned(),
443                excluded: "'apps/web'".to_owned(),
444            }
445        );
446    }
447
448    #[test]
449    fn workspace_available_list_truncates_when_above_cap() {
450        let workspaces = (0..15)
451            .map(|index| ws(&format!("pkg-{index}"), &format!("packages/pkg-{index}")))
452            .collect::<Vec<_>>();
453
454        let rendered = format_available_workspaces(&workspaces);
455
456        assert!(rendered.starts_with("pkg-0, pkg-1,"));
457        assert!(rendered.contains("and 5 more"));
458        assert!(rendered.contains("15 total"));
459    }
460
461    #[test]
462    fn changed_workspace_scope_ignores_root_only_changes() {
463        let workspaces = vec![ws("ui", "packages/ui"), ws("api", "packages/api")];
464        let mut changed = FxHashSet::default();
465        changed.insert(PathBuf::from("/project/package.json"));
466        changed.insert(PathBuf::from("/project/pnpm-lock.yaml"));
467
468        let roots = roots_for_changed_files(&workspaces, &changed);
469
470        assert!(roots.is_empty());
471    }
472
473    #[test]
474    fn changed_workspace_scope_maps_files_to_workspace_roots() {
475        let workspaces = vec![
476            ws("ui", "packages/ui"),
477            ws("api", "packages/api"),
478            ws("cli", "packages/cli"),
479        ];
480        let mut changed = FxHashSet::default();
481        changed.insert(PathBuf::from("/project/packages/api/src/b.ts"));
482        changed.insert(PathBuf::from("/project/packages/ui/src/a.ts"));
483
484        let roots = roots_for_changed_files(&workspaces, &changed);
485
486        assert_eq!(
487            roots,
488            vec![
489                PathBuf::from("/project/packages/api"),
490                PathBuf::from("/project/packages/ui")
491            ]
492        );
493    }
494
495    fn roots_for_changed_files(
496        workspaces: &[WorkspaceInfo],
497        changed_files: &FxHashSet<PathBuf>,
498    ) -> Vec<PathBuf> {
499        let mut roots = workspaces
500            .iter()
501            .filter(|workspace| {
502                changed_files
503                    .iter()
504                    .any(|file| file.starts_with(&workspace.root))
505            })
506            .map(|workspace| workspace.root.clone())
507            .collect::<Vec<_>>();
508        roots.sort();
509        roots
510    }
511}