1use std::collections::BTreeMap;
4use std::fmt;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use fallow_config::WorkspaceInfo;
9use rustc_hash::FxHashSet;
10
11use crate::changed_files::{ChangedFilesBatch, ChangedFilesError, ChangedPathScope};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum PackageBaselineError {
16 UnavailableRoot {
18 path: PathBuf,
20 message: String,
22 },
23 InvalidWorkspaceKey {
25 key: String,
27 },
28 UnknownWorkspace {
30 key: String,
32 suggestion: Option<String>,
34 },
35 Git {
37 key: String,
39 reference: String,
41 source: ChangedFilesError,
43 },
44}
45
46impl fmt::Display for PackageBaselineError {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 match self {
49 Self::UnavailableRoot { path, message } => {
50 write!(
51 f,
52 "cannot resolve workspace root '{}': {message}",
53 path.display()
54 )
55 }
56 Self::InvalidWorkspaceKey { key } => write!(
57 f,
58 "workspace baseline key '{key}' must be an exact project-relative workspace root"
59 ),
60 Self::UnknownWorkspace { key, suggestion } => {
61 write!(
62 f,
63 "workspace baseline key '{key}' names no discovered workspace"
64 )?;
65 match suggestion {
66 Some(suggestion) => write!(f, "; did you mean '{suggestion}'?"),
67 None => Ok(()),
68 }
69 }
70 Self::Git {
71 key,
72 reference,
73 source,
74 } => write!(
75 f,
76 "workspace baseline '{reference}' for '{key}' failed: {}",
77 source.describe()
78 ),
79 }
80 }
81}
82
83impl std::error::Error for PackageBaselineError {}
84
85#[derive(Debug, Clone)]
86enum WorkspaceBaseline {
87 Full,
88 Changed {
89 key: String,
91 reference: String,
92 files: Arc<FxHashSet<PathBuf>>,
93 },
94}
95
96#[derive(Debug, Clone)]
101pub struct PackageChangeScope {
102 root: PathBuf,
103 authored_root: PathBuf,
104 workspaces: BTreeMap<PathBuf, WorkspaceBaseline>,
105}
106
107impl PackageChangeScope {
108 #[must_use]
110 pub fn project_root(&self) -> &Path {
111 &self.root
112 }
113
114 #[must_use]
116 pub fn covers(&self, path: &Path) -> bool {
117 self.absolute_path(path).starts_with(&self.root)
118 }
119
120 pub fn configured_baselines(&self) -> impl Iterator<Item = (&str, &str)> {
123 self.workspaces.values().filter_map(|baseline| {
124 if let WorkspaceBaseline::Changed { key, reference, .. } = baseline {
125 Some((key.as_str(), reference.as_str()))
126 } else {
127 None
128 }
129 })
130 }
131
132 pub(crate) fn resolve(
140 root: &Path,
141 configured: &BTreeMap<String, String>,
142 workspaces: &[WorkspaceInfo],
143 ) -> Result<Option<Self>, PackageBaselineError> {
144 if configured.is_empty() {
145 return Ok(None);
146 }
147
148 let authored_root = dunce::simplified(root).to_path_buf();
149 let root = canonical_root(root)?;
150 let mut packages = BTreeMap::new();
151 let mut roots_by_key = BTreeMap::new();
156 for workspace in workspaces {
157 let canonical = canonical_root(&workspace.root)?;
158 if let Some(key) = workspace_key(&workspace.root, &authored_root, &root) {
159 roots_by_key.insert(key, canonical.clone());
160 }
161 packages.insert(canonical, WorkspaceBaseline::Full);
162 }
163
164 let mut validated = Vec::with_capacity(configured.len());
165 for (key, reference) in configured {
166 if !fallow_config::glob_validation::is_exact_workspace_root(key) {
167 return Err(PackageBaselineError::InvalidWorkspaceKey { key: key.clone() });
168 }
169 let Some(path) = roots_by_key.get(key) else {
170 return Err(PackageBaselineError::UnknownWorkspace {
171 key: key.clone(),
172 suggestion: fallow_config::levenshtein::closest_match(
173 key,
174 roots_by_key.keys().map(String::as_str),
175 )
176 .map(str::to_owned),
177 });
178 };
179 validated.push((key, reference, path.clone()));
180 }
181
182 let configured_owners: BTreeMap<PathBuf, &str> = validated
183 .iter()
184 .map(|(_, reference, path)| (path.clone(), reference.as_str()))
185 .collect();
186 let Some((first_key, first_ref, _)) = validated.first() else {
187 return Ok(None);
188 };
189 let mut batch = ChangedFilesBatch::new(&root, first_ref).map_err(|source| {
190 PackageBaselineError::Git {
191 key: (*first_key).clone(),
192 reference: (*first_ref).clone(),
193 source,
194 }
195 })?;
196 let mut refs = BTreeMap::<String, Arc<FxHashSet<PathBuf>>>::new();
197 for (key, reference, path) in validated {
198 let files = if let Some(files) = refs.get(reference) {
199 Arc::clone(files)
200 } else {
201 let files =
202 batch
203 .changed_files(reference)
204 .map_err(|source| PackageBaselineError::Git {
205 key: key.clone(),
206 reference: reference.clone(),
207 source,
208 })?;
209 let files = Arc::new(
210 files
211 .into_iter()
212 .map(|path| dunce::simplified(&path).to_path_buf())
213 .filter(|file| {
214 let owner = file
215 .ancestors()
216 .find(|ancestor| packages.contains_key(*ancestor));
217 owner
218 .and_then(|owner| configured_owners.get(owner))
219 .is_some_and(|owner_ref| *owner_ref == reference.as_str())
220 })
221 .collect(),
222 );
223 refs.insert(reference.clone(), Arc::clone(&files));
224 files
225 };
226 packages.insert(
227 path,
228 WorkspaceBaseline::Changed {
229 key: key.clone(),
230 reference: reference.clone(),
231 files,
232 },
233 );
234 }
235
236 Ok(Some(Self {
237 root,
238 authored_root,
239 workspaces: packages,
240 }))
241 }
242
243 #[must_use]
245 pub fn baseline_for(&self, path: &Path) -> Option<&str> {
246 match self.owner_absolute(&self.absolute_path(path)) {
247 Some(WorkspaceBaseline::Changed { reference, .. }) => Some(reference),
248 Some(WorkspaceBaseline::Full) | None => None,
249 }
250 }
251
252 #[must_use]
254 pub(crate) fn includes(&self, path: &Path) -> bool {
255 let absolute = self.absolute_path(path);
256 match self.owner_absolute(&absolute) {
257 Some(WorkspaceBaseline::Changed { files, .. }) => files.contains(&absolute),
258 Some(WorkspaceBaseline::Full) | None => true,
259 }
260 }
261
262 fn owner_absolute(&self, path: &Path) -> Option<&WorkspaceBaseline> {
263 path.ancestors()
264 .find_map(|ancestor| self.workspaces.get(ancestor))
265 }
266
267 fn absolute_path(&self, path: &Path) -> PathBuf {
268 let absolute = if path.is_absolute() {
269 if let Ok(relative) = path.strip_prefix(&self.authored_root) {
270 self.root.join(relative)
271 } else {
272 path.to_path_buf()
273 }
274 } else {
275 self.root.join(path)
276 };
277 dunce::simplified(&absolute).to_path_buf()
278 }
279}
280
281impl ChangedPathScope for PackageChangeScope {
282 fn contains(&self, path: &Path) -> bool {
283 self.includes(path)
284 }
285}
286
287fn canonical_root(path: &Path) -> Result<PathBuf, PackageBaselineError> {
288 dunce::canonicalize(path).map_err(|err| PackageBaselineError::UnavailableRoot {
289 path: path.to_path_buf(),
290 message: err.to_string(),
291 })
292}
293
294fn workspace_key(workspace_root: &Path, authored_root: &Path, root: &Path) -> Option<String> {
296 let workspace_root = dunce::simplified(workspace_root);
297 let relative = workspace_root
298 .strip_prefix(authored_root)
299 .or_else(|_| workspace_root.strip_prefix(root))
300 .ok()?;
301 let key = relative
302 .components()
303 .map(|component| component.as_os_str().to_string_lossy())
304 .collect::<Vec<_>>()
305 .join("/");
306 (!key.is_empty()).then_some(key)
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use std::fs;
313 use std::process::Command;
314
315 use fallow_types::output_dead_code::{UnresolvedCatalogReferenceFinding, UnusedFileFinding};
316 use fallow_types::results::{AnalysisResults, UnresolvedCatalogReference, UnusedFile};
317
318 fn git(root: &Path, args: &[&str]) {
319 let mut command = Command::new("git");
320 crate::changed_files::clear_ambient_git_env(&mut command);
321 let output = command
322 .args(args)
323 .current_dir(root)
324 .output()
325 .expect("run git");
326 assert!(
327 output.status.success(),
328 "git {args:?}: {}",
329 String::from_utf8_lossy(&output.stderr)
330 );
331 }
332
333 fn workspace(root: &Path, relative: &str) -> WorkspaceInfo {
334 WorkspaceInfo {
335 root: root.join(relative),
336 name: relative.to_owned(),
337 is_internal_dependency: false,
338 }
339 }
340
341 fn nested_repo() -> tempfile::TempDir {
342 let temp = tempfile::tempdir().expect("tempdir");
343 let root = temp.path();
344 let parent = "packages/parent";
345 let child = "packages/parent/packages/child";
346 let other = "packages/other";
347 for package in [parent, child, other] {
348 fs::create_dir_all(root.join(package)).expect("package directory");
349 fs::write(
350 root.join(package).join("index.ts"),
351 "export const value = 1;",
352 )
353 .expect("source");
354 fs::write(root.join(package).join("package.json"), "{}").expect("manifest");
355 }
356 git(root, &["init", "-q"]);
357 git(root, &["add", "."]);
358 git(
359 root,
360 &[
361 "-c",
362 "user.name=Fallow Test",
363 "-c",
364 "user.email=fallow@example.test",
365 "commit",
366 "-qm",
367 "base",
368 ],
369 );
370 git(root, &["branch", "base"]);
371 fs::write(root.join(child).join("index.ts"), "export const value = 2;")
372 .expect("child change");
373 git(root, &["add", "."]);
374 git(
375 root,
376 &[
377 "-c",
378 "user.name=Fallow Test",
379 "-c",
380 "user.email=fallow@example.test",
381 "commit",
382 "-qm",
383 "child change",
384 ],
385 );
386 fs::write(
387 root.join(parent).join("index.ts"),
388 "export const value = 3;",
389 )
390 .expect("parent change");
391
392 temp
393 }
394
395 #[test]
396 fn nested_refs_scope_source_and_manifest_owners() {
397 let temp = nested_repo();
398 let root = temp.path();
399 let parent = "packages/parent";
400 let child = "packages/parent/packages/child";
401 let other = "packages/other";
402 let workspaces = [
403 workspace(root, parent),
404 workspace(root, child),
405 workspace(root, other),
406 ];
407 let configured = BTreeMap::from([
408 (parent.to_owned(), "base".to_owned()),
409 (child.to_owned(), "HEAD".to_owned()),
410 ]);
411 let scope = PackageChangeScope::resolve(root, &configured, &workspaces)
412 .expect("valid refs")
413 .expect("package scope");
414 assert!(scope.includes(&root.join(parent).join("index.ts")));
415 assert!(!scope.includes(&root.join(child).join("index.ts")));
416 assert!(scope.includes(&root.join(other).join("package.json")));
417 assert!(scope.includes(&root.join("root.ts")));
418 assert_eq!(
419 scope.baseline_for(&root.join(child).join("index.ts")),
420 Some("HEAD")
421 );
422 assert_eq!(scope.baseline_for(&root.join(other).join("index.ts")), None);
423 let parent_root = canonical_root(&root.join(parent)).expect("canonical package");
424 let Some(WorkspaceBaseline::Changed { files, .. }) = scope.workspaces.get(&parent_root)
425 else {
426 panic!("parent baseline missing");
427 };
428 assert_eq!(files.len(), 1, "retain only parent-owned changed paths");
429 assert!(files.contains(&parent_root.join("index.ts")));
430
431 let mut results = AnalysisResults::default();
432 for package in [parent, child] {
433 results
434 .unused_files
435 .push(UnusedFileFinding::with_actions(UnusedFile {
436 path: root.join(package).join("index.ts"),
437 }));
438 }
439 results.unresolved_catalog_references.push(
440 UnresolvedCatalogReferenceFinding::with_actions(UnresolvedCatalogReference {
441 entry_name: "react".to_owned(),
442 catalog_name: "default".to_owned(),
443 path: root.join(other).join("package.json"),
444 line: 1,
445 available_in_catalogs: Vec::new(),
446 }),
447 );
448 crate::changed_files::filter_results_by_path_scope(&mut results, &scope);
449 assert_eq!(results.unused_files.len(), 1);
450 assert_eq!(
451 results.unused_files[0].file.path,
452 root.join(parent).join("index.ts")
453 );
454 assert_eq!(results.unresolved_catalog_references.len(), 1);
455 }
456
457 #[test]
458 fn invalid_ref_and_clean_packages_have_explicit_scope() {
459 let temp = nested_repo();
460 let root = temp.path();
461 let parent = "packages/parent";
462 let child = "packages/parent/packages/child";
463 let other = "packages/other";
464 let workspaces = [
465 workspace(root, parent),
466 workspace(root, child),
467 workspace(root, other),
468 ];
469 let invalid_ref = BTreeMap::from([(parent.to_owned(), "missing-ref".to_owned())]);
470 assert!(matches!(
471 PackageChangeScope::resolve(root, &invalid_ref, &workspaces),
472 Err(PackageBaselineError::Git { .. })
473 ));
474
475 git(root, &["add", "."]);
476 git(
477 root,
478 &[
479 "-c",
480 "user.name=Fallow Test",
481 "-c",
482 "user.email=fallow@example.test",
483 "commit",
484 "-qm",
485 "parent change",
486 ],
487 );
488 let all_mapped = BTreeMap::from([
489 (parent.to_owned(), "HEAD".to_owned()),
490 (child.to_owned(), "HEAD".to_owned()),
491 (other.to_owned(), "HEAD".to_owned()),
492 ]);
493 let clean = PackageChangeScope::resolve(root, &all_mapped, &workspaces)
494 .expect("valid HEAD")
495 .expect("package scope");
496 assert!(!clean.includes(&root.join(parent).join("index.ts")));
497 assert!(!clean.includes(&root.join(child).join("index.ts")));
498 assert!(!clean.includes(&root.join(other).join("package.json")));
499 assert!(clean.includes(&root.join("root.ts")));
500 }
501
502 #[test]
503 fn invalid_mapping_fails_before_scope_is_applied() {
504 let temp = tempfile::tempdir().expect("tempdir");
505 let root = temp.path();
506 fs::create_dir_all(root.join("packages/app")).expect("package directory");
507 let workspaces = [workspace(root, "packages/app")];
508 let invalid_key = BTreeMap::from([("packages/../app".to_owned(), "HEAD".to_owned())]);
509 assert!(matches!(
510 PackageChangeScope::resolve(root, &invalid_key, &workspaces),
511 Err(PackageBaselineError::InvalidWorkspaceKey { .. })
512 ));
513 let unknown = BTreeMap::from([("packages/missing".to_owned(), "HEAD".to_owned())]);
514 assert!(matches!(
515 PackageChangeScope::resolve(root, &unknown, &workspaces),
516 Err(PackageBaselineError::UnknownWorkspace { .. })
517 ));
518 }
519
520 #[cfg(unix)]
521 #[test]
522 fn symlinked_alias_is_not_an_exact_workspace_root() {
523 let temp = tempfile::tempdir().expect("tempdir");
524 let root = temp.path();
525 fs::create_dir_all(root.join("packages/app")).expect("package directory");
526 std::os::unix::fs::symlink(root.join("packages/app"), root.join("alias"))
527 .expect("workspace alias");
528 let configured = BTreeMap::from([("alias".to_owned(), "HEAD".to_owned())]);
529 assert!(matches!(
530 PackageChangeScope::resolve(root, &configured, &[workspace(root, "packages/app")]),
531 Err(PackageBaselineError::UnknownWorkspace { .. })
532 ));
533 }
534
535 #[cfg(unix)]
538 #[test]
539 fn a_symlinked_workspace_is_mapped_under_its_discovered_root() {
540 let temp = nested_repo();
541 let root = temp.path();
542 fs::create_dir_all(root.join("external/linked")).expect("link target");
543 fs::write(root.join("external/linked/index.ts"), "export const x = 1;").expect("source");
544 std::os::unix::fs::symlink(root.join("external/linked"), root.join("packages/linked"))
545 .expect("workspace link");
546 let workspaces = [workspace(root, "packages/linked")];
547 let configured = BTreeMap::from([("packages/linked".to_owned(), "HEAD".to_owned())]);
548 let scope = PackageChangeScope::resolve(root, &configured, &workspaces)
549 .expect("the discovered root is a valid key")
550 .expect("package scope");
551 assert_eq!(
552 scope.baseline_for(&root.join("external/linked/index.ts")),
553 Some("HEAD")
554 );
555 assert!(scope.includes(&root.join("external/linked/index.ts")));
556 let rows =
557 crate::change_scope::package_baseline_statuses(std::slice::from_ref(&scope), root);
558 assert_eq!(rows.len(), 1);
559 assert_eq!(rows[0].workspace_root, "packages/linked");
560 }
561
562 #[test]
563 fn an_unknown_key_suggests_the_closest_workspace_root() {
564 let temp = tempfile::tempdir().expect("tempdir");
565 let root = temp.path();
566 fs::create_dir_all(root.join("packages/web")).expect("package directory");
567 let configured = BTreeMap::from([("packages/wbe".to_owned(), "HEAD".to_owned())]);
568 let Err(PackageBaselineError::UnknownWorkspace { suggestion, .. }) =
569 PackageChangeScope::resolve(root, &configured, &[workspace(root, "packages/web")])
570 else {
571 panic!("an unknown key must fail to resolve");
572 };
573 assert_eq!(suggestion.as_deref(), Some("packages/web"));
574 }
575}