Skip to main content

backhopper_core/compat/
routing.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! Per-pin path routing: which configured project owns each touched
6//! path, so a pin's verdict can promote to `OutOfScopeFor` or
7//! `Untracked` instead of firing a misleading `FileAbsent` row.
8
9use std::collections::BTreeSet;
10use std::mem;
11use std::path::{Path, PathBuf};
12
13use crate::config::Project;
14use crate::model::names::ProjectName;
15use crate::model::verdict::{InapplicableReason, PinVerdict, SeriesVerdict, Verdict};
16
17impl SeriesVerdict {
18    /// The only way to assemble a series verdict from routed pins:
19    /// an unrouted `PinVerdict` cannot reach the envelope.
20    pub fn from_routed(results: Vec<RoutedPinVerdict>) -> Self {
21        Self::from_results(
22            results
23                .into_iter()
24                .map(RoutedPinVerdict::into_inner)
25                .collect(),
26        )
27    }
28}
29
30/// Path-routing summary for one pin's evaluation.
31#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct PathRouting {
33    pub any_in_scope: bool,
34    pub out_of_scope_owners: BTreeSet<ProjectName>,
35    pub untracked_count: usize,
36}
37
38impl PathRouting {
39    pub fn first_out_of_scope_owner(&self) -> Option<&ProjectName> {
40        self.out_of_scope_owners.first()
41    }
42
43    pub fn has_any_attribution(&self) -> bool {
44        !self.out_of_scope_owners.is_empty() || self.untracked_count > 0
45    }
46}
47
48/// True when path lives under scan_paths, app_roots, or `deps/{name}/`;
49/// projects with no scope configured fall back to "owns everything".
50pub fn project_owns_path(project: &Project, path: &Path) -> bool {
51    project_owns_candidate(project, &path.to_string_lossy())
52}
53
54fn project_owns_candidate(project: &Project, candidate: &str) -> bool {
55    if project.owns_path(Path::new(candidate)) {
56        return true;
57    }
58    if !project.is_self()
59        && let Some(rest) = candidate.strip_prefix("deps/")
60        && let Some(rest) = rest.strip_prefix(project.name.as_str())
61        && rest.starts_with('/')
62    {
63        return true;
64    }
65    project.scan_paths.is_empty() && project.app_roots.is_empty()
66}
67
68pub fn classify_paths_for_pin(
69    touched: &[PathBuf],
70    pin_project: &Project,
71    siblings: &[&Project],
72) -> PathRouting {
73    let mut routing = PathRouting::default();
74    for p in touched {
75        let candidate = p.to_string_lossy();
76        if project_owns_candidate(pin_project, &candidate) {
77            routing.any_in_scope = true;
78            continue;
79        }
80        let owner = siblings
81            .iter()
82            .find(|s| s.name != pin_project.name && project_owns_candidate(s, &candidate))
83            .map(|s| s.name.clone());
84        match owner {
85            Some(name) => {
86                routing.out_of_scope_owners.insert(name);
87            }
88            None => routing.untracked_count += 1,
89        }
90    }
91    routing
92}
93
94/// A pin verdict that has been through path routing exactly once.
95/// `SeriesVerdict::from_routed` is the only consumer, so an unrouted
96/// verdict cannot reach the envelope.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct RoutedPinVerdict(PinVerdict);
99
100impl RoutedPinVerdict {
101    #[must_use]
102    pub fn into_inner(self) -> PinVerdict {
103        self.0
104    }
105}
106
107/// Path routing, reason-granular. File ownership gates only the
108/// path-scoped reason family; reference-level evidence survives no
109/// matter which file the call site lives in. A pin with no owned
110/// paths and no evidence rewrites to `Inapplicable`.
111#[must_use]
112pub fn route_pin_verdict(mut pv: PinVerdict, routing: &PathRouting) -> RoutedPinVerdict {
113    if routing.any_in_scope || !routing.has_any_attribution() {
114        return RoutedPinVerdict(pv);
115    }
116    let inapplicable_reason = |pv: &PinVerdict| {
117        pv.touched.inapplicable_reason().unwrap_or_else(|| {
118            match routing.first_out_of_scope_owner() {
119                Some(name) => InapplicableReason::OutOfScopeFor {
120                    project: name.clone(),
121                },
122                None => InapplicableReason::Untracked,
123            }
124        })
125    };
126    let verdict = mem::replace(&mut pv.verdict, Verdict::Compatible);
127    pv.verdict = match verdict {
128        Verdict::Inapplicable { .. } => verdict,
129        Verdict::Compatible => {
130            if pv.tracked_refs > 0 {
131                Verdict::Compatible
132            } else {
133                Verdict::Inapplicable {
134                    reason: inapplicable_reason(&pv),
135                }
136            }
137        }
138        Verdict::RequiresAdaptation { reasons } | Verdict::Incompatible { reasons } => {
139            let surviving: Vec<_> = reasons
140                .into_iter()
141                .filter(|r| !r.is_path_scoped())
142                .collect();
143            if surviving.is_empty() && pv.tracked_refs == 0 {
144                Verdict::Inapplicable {
145                    reason: inapplicable_reason(&pv),
146                }
147            } else {
148                Verdict::from_reasons(surviving)
149            }
150        }
151    };
152    RoutedPinVerdict(pv)
153}