1use std::path::{Path, PathBuf};
4
5use fallow_config::WorkspaceInfo;
6use globset::Glob;
7use rustc_hash::FxHashSet;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum WorkspaceScopeMode {
12 Workspace,
14 ChangedWorkspaces,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum WorkspaceScopeError {
21 NoWorkspaces {
23 mode: WorkspaceScopeMode,
24 patterns: Vec<String>,
25 git_ref: Option<String>,
26 },
27 InvalidPattern { pattern: String, message: String },
29 UnmatchedPatterns {
31 patterns: Vec<String>,
32 available: String,
33 },
34 EmptyAfterExclusions { included: String, excluded: String },
36 ChangedWorkspacesFailed { git_ref: String, message: String },
38 MutuallyExclusive,
40}
41
42pub 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
66pub 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
82pub 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
96pub 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
140pub 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
153pub 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}