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,
25 patterns: Vec<String>,
27 git_ref: Option<String>,
29 },
30 InvalidPattern {
32 pattern: String,
34 message: String,
36 },
37 UnmatchedPatterns {
39 patterns: Vec<String>,
41 available: String,
43 },
44 EmptyAfterExclusions {
46 included: String,
49 excluded: String,
51 },
52 ChangedWorkspacesFailed {
54 git_ref: String,
56 message: String,
58 },
59 MutuallyExclusive,
61}
62
63pub 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
87pub 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
103pub 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
117fn 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
161pub 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
174fn 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}