Skip to main content

backhopper_core/compat/
scope.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 and series-wide tracked-scope filters.
6//!
7//! A call site `mod:fun(...)` only deserves a verdict when `mod` belongs
8//! to a project the operator is actually tracking. Otherwise its
9//! "absence" from the pin's snapshot is meaningless.
10
11use std::collections::{BTreeMap, BTreeSet};
12use std::path::Path;
13use std::str::FromStr;
14
15use crate::model::names::{ModuleName, ProjectName, RecordName};
16use crate::model::snapshot::{Snapshot, state};
17
18/// Decides whether a call site or a touched path is tracked for one pin.
19#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct PinScope {
21    modules: BTreeSet<ModuleName>,
22    records: BTreeSet<RecordName>,
23    path_prefixes: Vec<String>,
24}
25
26impl PinScope {
27    pub fn from_snapshot(
28        project: ProjectName,
29        snapshot: &Snapshot<state::Canonical>,
30        extra_modules: impl IntoIterator<Item = ModuleName>,
31    ) -> Self {
32        let mut modules: BTreeSet<ModuleName> =
33            snapshot.modules().iter().map(|m| m.name.clone()).collect();
34        modules.extend(extra_modules);
35        let records: BTreeSet<RecordName> = snapshot.records().map(|r| r.name.clone()).collect();
36        Self {
37            modules,
38            records,
39            path_prefixes: vec![format!("deps/{project}/")],
40        }
41    }
42
43    pub fn contains_module(&self, module: &ModuleName) -> bool {
44        self.modules.contains(module)
45    }
46
47    pub fn contains_record(&self, record: &RecordName) -> bool {
48        self.records.contains(record)
49    }
50
51    pub fn modules(&self) -> &BTreeSet<ModuleName> {
52        &self.modules
53    }
54
55    pub fn records(&self) -> &BTreeSet<RecordName> {
56        &self.records
57    }
58
59    /// Rewrites a patch path into a project-local path, or returns
60    /// `None` when the path belongs to a different project.
61    pub fn rewrite_path<'p>(&self, path: &'p Path) -> Option<&'p Path> {
62        let s = path.to_str()?;
63        for prefix in &self.path_prefixes {
64            if let Some(rest) = s.strip_prefix(prefix.as_str()) {
65                return Some(Path::new(rest));
66            }
67        }
68        if self.path_prefixes.is_empty() || !s.starts_with("deps/") {
69            return Some(path);
70        }
71        None
72    }
73}
74
75/// Parses `extra_modules` from raw config strings. Invalid entries are
76/// dropped with a `tracing::warn`: the caller treats `parse_module_names`
77/// as best-effort but operators get a diagnostic for the bad string.
78pub fn parse_module_names<'a>(raw: impl IntoIterator<Item = &'a String>) -> Vec<ModuleName> {
79    raw.into_iter()
80        .filter_map(|s| match ModuleName::from_str(s) {
81            Ok(name) => Some(name),
82            Err(e) => {
83                tracing::warn!(value = %s, error = %e, "ignoring invalid module name in config");
84                None
85            }
86        })
87        .collect()
88}
89
90/// Aggregates untracked module calls observed across a series.
91#[derive(Debug, Clone, Default, PartialEq, Eq)]
92pub struct UntrackedTally {
93    counts: BTreeMap<ModuleName, usize>,
94}
95
96impl UntrackedTally {
97    pub fn record(&mut self, module: ModuleName) {
98        *self.counts.entry(module).or_insert(0) += 1;
99    }
100
101    pub fn into_map(self) -> BTreeMap<ModuleName, usize> {
102        self.counts
103    }
104}