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 commit(root: &Path, message: &str) {
334 git(
335 root,
336 &[
337 "-c",
338 "user.name=Fallow Test",
339 "-c",
340 "user.email=fallow@example.test",
341 "-c",
342 "commit.gpgsign=false",
343 "commit",
344 "-qm",
345 message,
346 ],
347 );
348 }
349
350 fn workspace(root: &Path, relative: &str) -> WorkspaceInfo {
351 WorkspaceInfo {
352 root: root.join(relative),
353 name: relative.to_owned(),
354 is_internal_dependency: false,
355 }
356 }
357
358 fn nested_repo() -> tempfile::TempDir {
359 let temp = tempfile::tempdir().expect("tempdir");
360 let root = temp.path();
361 let parent = "packages/parent";
362 let child = "packages/parent/packages/child";
363 let other = "packages/other";
364 for package in [parent, child, other] {
365 fs::create_dir_all(root.join(package)).expect("package directory");
366 fs::write(
367 root.join(package).join("index.ts"),
368 "export const value = 1;",
369 )
370 .expect("source");
371 fs::write(root.join(package).join("package.json"), "{}").expect("manifest");
372 }
373 git(root, &["init", "-q"]);
374 git(root, &["add", "."]);
375 commit(root, "base");
376 git(root, &["branch", "base"]);
377 fs::write(root.join(child).join("index.ts"), "export const value = 2;")
378 .expect("child change");
379 git(root, &["add", "."]);
380 commit(root, "child change");
381 fs::write(
382 root.join(parent).join("index.ts"),
383 "export const value = 3;",
384 )
385 .expect("parent change");
386
387 temp
388 }
389
390 #[test]
391 fn nested_refs_scope_source_and_manifest_owners() {
392 let temp = nested_repo();
393 let root = temp.path();
394 let parent = "packages/parent";
395 let child = "packages/parent/packages/child";
396 let other = "packages/other";
397 let workspaces = [
398 workspace(root, parent),
399 workspace(root, child),
400 workspace(root, other),
401 ];
402 let configured = BTreeMap::from([
403 (parent.to_owned(), "base".to_owned()),
404 (child.to_owned(), "HEAD".to_owned()),
405 ]);
406 let scope = PackageChangeScope::resolve(root, &configured, &workspaces)
407 .expect("valid refs")
408 .expect("package scope");
409 assert!(scope.includes(&root.join(parent).join("index.ts")));
410 assert!(!scope.includes(&root.join(child).join("index.ts")));
411 assert!(scope.includes(&root.join(other).join("package.json")));
412 assert!(scope.includes(&root.join("root.ts")));
413 assert_eq!(
414 scope.baseline_for(&root.join(child).join("index.ts")),
415 Some("HEAD")
416 );
417 assert_eq!(scope.baseline_for(&root.join(other).join("index.ts")), None);
418 let parent_root = canonical_root(&root.join(parent)).expect("canonical package");
419 let Some(WorkspaceBaseline::Changed { files, .. }) = scope.workspaces.get(&parent_root)
420 else {
421 panic!("parent baseline missing");
422 };
423 assert_eq!(files.len(), 1, "retain only parent-owned changed paths");
424 assert!(files.contains(&parent_root.join("index.ts")));
425
426 let mut results = AnalysisResults::default();
427 for package in [parent, child] {
428 results
429 .unused_files
430 .push(UnusedFileFinding::with_actions(UnusedFile {
431 path: root.join(package).join("index.ts"),
432 }));
433 }
434 results.unresolved_catalog_references.push(
435 UnresolvedCatalogReferenceFinding::with_actions(UnresolvedCatalogReference {
436 entry_name: "react".to_owned(),
437 catalog_name: "default".to_owned(),
438 path: root.join(other).join("package.json"),
439 line: 1,
440 available_in_catalogs: Vec::new(),
441 }),
442 );
443 crate::changed_files::filter_results_by_path_scope(&mut results, &scope);
444 assert_eq!(results.unused_files.len(), 1);
445 assert_eq!(
446 results.unused_files[0].file.path,
447 root.join(parent).join("index.ts")
448 );
449 assert_eq!(results.unresolved_catalog_references.len(), 1);
450 }
451
452 #[test]
453 fn invalid_ref_and_clean_packages_have_explicit_scope() {
454 let temp = nested_repo();
455 let root = temp.path();
456 let parent = "packages/parent";
457 let child = "packages/parent/packages/child";
458 let other = "packages/other";
459 let workspaces = [
460 workspace(root, parent),
461 workspace(root, child),
462 workspace(root, other),
463 ];
464 let invalid_ref = BTreeMap::from([(parent.to_owned(), "missing-ref".to_owned())]);
465 assert!(matches!(
466 PackageChangeScope::resolve(root, &invalid_ref, &workspaces),
467 Err(PackageBaselineError::Git { .. })
468 ));
469
470 git(root, &["add", "."]);
471 commit(root, "parent change");
472 let all_mapped = BTreeMap::from([
473 (parent.to_owned(), "HEAD".to_owned()),
474 (child.to_owned(), "HEAD".to_owned()),
475 (other.to_owned(), "HEAD".to_owned()),
476 ]);
477 let clean = PackageChangeScope::resolve(root, &all_mapped, &workspaces)
478 .expect("valid HEAD")
479 .expect("package scope");
480 assert!(!clean.includes(&root.join(parent).join("index.ts")));
481 assert!(!clean.includes(&root.join(child).join("index.ts")));
482 assert!(!clean.includes(&root.join(other).join("package.json")));
483 assert!(clean.includes(&root.join("root.ts")));
484 }
485
486 #[test]
487 fn invalid_mapping_fails_before_scope_is_applied() {
488 let temp = tempfile::tempdir().expect("tempdir");
489 let root = temp.path();
490 fs::create_dir_all(root.join("packages/app")).expect("package directory");
491 let workspaces = [workspace(root, "packages/app")];
492 let invalid_key = BTreeMap::from([("packages/../app".to_owned(), "HEAD".to_owned())]);
493 assert!(matches!(
494 PackageChangeScope::resolve(root, &invalid_key, &workspaces),
495 Err(PackageBaselineError::InvalidWorkspaceKey { .. })
496 ));
497 let unknown = BTreeMap::from([("packages/missing".to_owned(), "HEAD".to_owned())]);
498 assert!(matches!(
499 PackageChangeScope::resolve(root, &unknown, &workspaces),
500 Err(PackageBaselineError::UnknownWorkspace { .. })
501 ));
502 }
503
504 #[cfg(unix)]
505 #[test]
506 fn symlinked_alias_is_not_an_exact_workspace_root() {
507 let temp = tempfile::tempdir().expect("tempdir");
508 let root = temp.path();
509 fs::create_dir_all(root.join("packages/app")).expect("package directory");
510 std::os::unix::fs::symlink(root.join("packages/app"), root.join("alias"))
511 .expect("workspace alias");
512 let configured = BTreeMap::from([("alias".to_owned(), "HEAD".to_owned())]);
513 assert!(matches!(
514 PackageChangeScope::resolve(root, &configured, &[workspace(root, "packages/app")]),
515 Err(PackageBaselineError::UnknownWorkspace { .. })
516 ));
517 }
518
519 #[cfg(unix)]
522 #[test]
523 fn a_symlinked_workspace_is_mapped_under_its_discovered_root() {
524 let temp = nested_repo();
525 let root = temp.path();
526 fs::create_dir_all(root.join("external/linked")).expect("link target");
527 fs::write(root.join("external/linked/index.ts"), "export const x = 1;").expect("source");
528 std::os::unix::fs::symlink(root.join("external/linked"), root.join("packages/linked"))
529 .expect("workspace link");
530 let workspaces = [workspace(root, "packages/linked")];
531 let configured = BTreeMap::from([("packages/linked".to_owned(), "HEAD".to_owned())]);
532 let scope = PackageChangeScope::resolve(root, &configured, &workspaces)
533 .expect("the discovered root is a valid key")
534 .expect("package scope");
535 assert_eq!(
536 scope.baseline_for(&root.join("external/linked/index.ts")),
537 Some("HEAD")
538 );
539 assert!(scope.includes(&root.join("external/linked/index.ts")));
540 let rows =
541 crate::change_scope::package_baseline_statuses(std::slice::from_ref(&scope), root);
542 assert_eq!(rows.len(), 1);
543 assert_eq!(rows[0].workspace_root, "packages/linked");
544 }
545
546 #[test]
547 fn an_unknown_key_suggests_the_closest_workspace_root() {
548 let temp = tempfile::tempdir().expect("tempdir");
549 let root = temp.path();
550 fs::create_dir_all(root.join("packages/web")).expect("package directory");
551 let configured = BTreeMap::from([("packages/wbe".to_owned(), "HEAD".to_owned())]);
552 let Err(PackageBaselineError::UnknownWorkspace { suggestion, .. }) =
553 PackageChangeScope::resolve(root, &configured, &[workspace(root, "packages/web")])
554 else {
555 panic!("an unknown key must fail to resolve");
556 };
557 assert_eq!(suggestion.as_deref(), Some("packages/web"));
558 }
559}