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