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