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