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 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    /// Discovery reports a symlinked workspace under its link path, and
536    /// `fallow list --workspaces` prints that path. The map accepts it.
537    #[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}