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