Skip to main content

fallow_engine/
package_baselines.rs

1//! Per-workspace Git baselines for changed-file result scoping.
2
3use 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/// Failure to resolve an authored workspace baseline.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum PackageBaselineError {
16    /// The project or a discovered workspace root could not be resolved.
17    UnavailableRoot {
18        /// Root that could not be resolved.
19        path: PathBuf,
20        /// Filesystem error detail.
21        message: String,
22    },
23    /// A key is not an exact, relative, slash-separated workspace root.
24    InvalidWorkspaceKey {
25        /// Authored key.
26        key: String,
27    },
28    /// No discovered workspace has this root.
29    UnknownWorkspace {
30        /// Authored key.
31        key: String,
32        /// The closest discovered workspace root, when one is close.
33        suggestion: Option<String>,
34    },
35    /// Git could not resolve a package's baseline ref.
36    Git {
37        /// Authored workspace key.
38        key: String,
39        /// Authored Git ref.
40        reference: String,
41        /// Underlying Git error.
42        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        /// The authored key, as discovery reports the workspace root.
90        key: String,
91        reference: String,
92        files: Arc<FxHashSet<PathBuf>>,
93    },
94}
95
96/// Resolved package baselines for one analysis project.
97///
98/// A file belongs to its nearest discovered workspace root. Workspaces with
99/// no authored baseline and files outside workspace roots remain in full scope.
100#[derive(Debug, Clone)]
101pub struct PackageChangeScope {
102    root: PathBuf,
103    authored_root: PathBuf,
104    workspaces: BTreeMap<PathBuf, WorkspaceBaseline>,
105}
106
107impl PackageChangeScope {
108    /// Canonical analysis root used by this package scope.
109    #[must_use]
110    pub fn project_root(&self) -> &Path {
111        &self.root
112    }
113
114    /// Whether this scope owns a project path, including root-level files.
115    #[must_use]
116    pub fn covers(&self, path: &Path) -> bool {
117        self.absolute_path(path).starts_with(&self.root)
118    }
119
120    /// Authored workspace keys and their refs. A key is project-relative and
121    /// uses the path that discovery reports, also for a symlinked workspace.
122    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    /// Resolve configured refs and discovered workspace ownership atomically.
133    /// An empty map means no package scope was requested.
134    ///
135    /// # Errors
136    ///
137    /// Returns an error for an invalid workspace key, an undiscovered package,
138    /// an unavailable root, or any Git ref that cannot be resolved.
139    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        // A key names a workspace root exactly as discovery reports it, which
152        // is also what `fallow list --workspaces` prints. A symlinked
153        // workspace is therefore mapped under its link path, and a symlink
154        // that discovery does not report is not an alias for a workspace.
155        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    /// Effective Git ref for a file, or `None` in a full-scope workspace.
244    #[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    /// Whether a finding owner path belongs to this mixed scope.
253    #[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
294/// The project-relative, slash-separated root of a discovered workspace.
295fn 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    /// Discovery reports a symlinked workspace under its link path, and
520    /// `fallow list --workspaces` prints that path. The map accepts it.
521    #[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}