Skip to main content

blast_radius/
graph.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::PathBuf;
3
4use clap::ValueEnum;
5use serde::Serialize;
6
7#[derive(Debug, Clone, Serialize)]
8pub struct AnalysisResult {
9    pub mode: AnalysisMode,
10    pub target: AnalysisTarget,
11    pub repo_root: PathBuf,
12    pub source_file_count: usize,
13    pub summary: Summary,
14    pub workspaces: Vec<Workspace>,
15    /// Per-input-file impact, populated only for multi-file runs.
16    pub roots: Vec<RootImpact>,
17    pub nodes: Vec<GraphNode>,
18    pub edges: Vec<GraphEdge>,
19    pub warnings: Vec<String>,
20}
21
22/// A workspace package discovered in the repo, used to group impacted files by
23/// the package they live in.
24#[derive(Debug, Clone, Serialize)]
25pub struct Workspace {
26    pub name: String,
27    /// Package root, relative to the repo root (empty string == repo root).
28    pub root: String,
29}
30
31/// The blast radius of a single input file, so a multi-file run can show each
32/// file's impact individually alongside the combined total.
33#[derive(Debug, Clone, Serialize)]
34pub struct RootImpact {
35    pub file: String,
36    pub affected: usize,
37    pub direct: usize,
38    pub indirect: usize,
39    pub max_depth: usize,
40    pub packages: usize,
41    pub files: Vec<RootImpactFile>,
42}
43
44/// A single file impacted by a particular input file.
45#[derive(Debug, Clone, Serialize)]
46pub struct RootImpactFile {
47    pub path: String,
48    pub endpoint: bool,
49    /// Hops from the changed file (1 == direct consumer).
50    pub depth: usize,
51}
52
53/// Normalize the separators in a path label to `/`. Labels are built from
54/// `Path::display()`, which uses `\` on Windows, while every grouping rule in
55/// this module and the reports splits on `/` — without this, on Windows every
56/// file collapses into package `.` and the package count degrades to 1.
57pub fn normalize_separators(label: impl Into<String>) -> String {
58    let label = label.into();
59    if label.contains('\\') {
60        label.replace('\\', "/")
61    } else {
62        label
63    }
64}
65
66/// Map a repo-relative path to the package that owns it: the longest matching
67/// workspace root, falling back to the top-level directory. `workspaces` is
68/// expected to be sorted longest-root-first. Both the path and the workspace
69/// roots are separator-normalized defensively, so Windows-style labels still
70/// group correctly.
71pub fn package_key(rel_path: &str, workspaces: &[Workspace]) -> String {
72    let rel_path = normalize_separators(rel_path);
73    if let Some(root) = workspaces.iter().find_map(|workspace| {
74        let root = normalize_separators(workspace.root.as_str());
75        (root.is_empty() || rel_path == root || rel_path.starts_with(&format!("{root}/")))
76            .then_some(root)
77    }) {
78        if root.is_empty() {
79            ".".to_string()
80        } else {
81            root
82        }
83    } else {
84        match rel_path.split_once('/') {
85            Some((head, _)) => head.to_string(),
86            None => ".".to_string(),
87        }
88    }
89}
90
91#[derive(Debug, Clone, Serialize)]
92#[serde(rename_all = "snake_case")]
93pub enum AnalysisMode {
94    Export,
95    File,
96    Files,
97}
98
99#[derive(Debug, Clone, Serialize)]
100#[serde(tag = "kind", rename_all = "snake_case")]
101pub enum AnalysisTarget {
102    Export { file: PathBuf, export_name: String },
103    File { file: PathBuf },
104    Files { files: Vec<PathBuf> },
105}
106
107#[derive(Debug, Clone, Serialize, Default)]
108pub struct Summary {
109    pub directly_affected_files: usize,
110    pub transitively_affected_files: usize,
111    pub total_affected_files: usize,
112    pub unresolved_imports: usize,
113    pub ambiguous_edges: usize,
114    pub parse_failures: usize,
115    /// Input paths passed to `files` mode that were skipped because they were
116    /// missing on disk or not recognized source files.
117    pub skipped_inputs: usize,
118    /// The headline risk verdict for this run, derived from reach and spread.
119    pub risk_tier: RiskTier,
120}
121
122/// The headline blast-radius verdict. Ordered least-to-most severe so callers
123/// can gate on `tier >= threshold` (see `--fail-on-risk`).
124#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, ValueEnum)]
125#[serde(rename_all = "snake_case")]
126pub enum RiskTier {
127    #[default]
128    Minor,
129    Moderate,
130    Risky,
131    High,
132}
133
134/// Reach and spread drive the tier; ambiguity is surfaced as a confidence
135/// caveat elsewhere rather than inflating the score, so the headline stays
136/// trustworthy. `affected` counts downstream files (excludes the target);
137/// `packages` is the number of distinct packages they span.
138pub fn compute_tier(affected: usize, packages: usize) -> RiskTier {
139    if affected == 0 {
140        RiskTier::Minor
141    } else if affected > 25 || packages >= 3 {
142        RiskTier::High
143    } else if affected <= 3 && packages <= 1 {
144        RiskTier::Minor
145    } else if affected <= 10 && packages <= 2 {
146        RiskTier::Moderate
147    } else {
148        RiskTier::Risky
149    }
150}
151
152#[derive(Debug, Clone, Serialize)]
153pub struct GraphNode {
154    pub id: String,
155    pub label: String,
156    pub file: PathBuf,
157    pub symbol: Option<String>,
158    pub kind: NodeKind,
159    pub depth: usize,
160}
161
162#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
163#[serde(rename_all = "snake_case")]
164pub enum NodeKind {
165    File,
166    Export,
167}
168
169#[derive(Debug, Clone, Serialize)]
170pub struct GraphEdge {
171    pub from: String,
172    pub to: String,
173    pub kind: EdgeKind,
174    pub is_ambiguous: bool,
175}
176
177#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
178#[serde(rename_all = "snake_case")]
179pub enum EdgeKind {
180    ImportsNamed,
181    ImportsDefault,
182    ImportsNamespace,
183    ImportsDynamic,
184    ReexportsNamed,
185    ReexportsStar,
186    UsesJsxComponent,
187    RequiresModule,
188    CommonJsExport,
189}
190
191#[derive(Debug, Clone)]
192pub struct ModuleState {
193    pub file: PathBuf,
194    pub public_exports: BTreeSet<String>,
195    pub export_to_locals: BTreeMap<String, BTreeSet<String>>,
196    pub local_to_exports: BTreeMap<String, BTreeSet<String>>,
197}
198
199impl ModuleState {
200    pub fn new(file: PathBuf) -> Self {
201        Self {
202            file,
203            public_exports: BTreeSet::new(),
204            export_to_locals: BTreeMap::new(),
205            local_to_exports: BTreeMap::new(),
206        }
207    }
208
209    pub fn add_export(&mut self, exported: impl Into<String>, local: Option<String>) {
210        let exported = exported.into();
211        self.public_exports.insert(exported.clone());
212        if let Some(local) = local {
213            self.export_to_locals
214                .entry(exported.clone())
215                .or_default()
216                .insert(local.clone());
217            self.local_to_exports
218                .entry(local)
219                .or_default()
220                .insert(exported);
221        }
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::{Workspace, normalize_separators, package_key};
228
229    fn workspace(name: &str, root: &str) -> Workspace {
230        Workspace {
231            name: name.to_string(),
232            root: root.to_string(),
233        }
234    }
235
236    #[test]
237    fn normalize_separators_converts_backslashes() {
238        assert_eq!(
239            normalize_separators("src\\report\\tree.rs"),
240            "src/report/tree.rs"
241        );
242        assert_eq!(
243            normalize_separators("src/report/tree.rs"),
244            "src/report/tree.rs"
245        );
246        assert_eq!(normalize_separators(""), "");
247    }
248
249    #[test]
250    fn package_key_matches_workspace_with_backslash_path() {
251        let workspaces = vec![workspace("ui", "packages/ui"), workspace("repo", "")];
252        assert_eq!(
253            package_key("packages\\ui\\src\\button.tsx", &workspaces),
254            "packages/ui"
255        );
256        // Exact match on the workspace root itself.
257        assert_eq!(package_key("packages\\ui", &workspaces), "packages/ui");
258    }
259
260    #[test]
261    fn package_key_handles_backslash_workspace_roots() {
262        // Workspace roots are labels too, so normalize them defensively as well.
263        let workspaces = vec![workspace("ui", "packages\\ui")];
264        assert_eq!(
265            package_key("packages/ui/src/button.tsx", &workspaces),
266            "packages/ui"
267        );
268        assert_eq!(
269            package_key("packages\\ui\\src\\button.tsx", &workspaces),
270            "packages/ui"
271        );
272    }
273
274    #[test]
275    fn package_key_falls_back_to_top_level_directory() {
276        assert_eq!(package_key("src\\lib\\util.rs", &[]), "src");
277        assert_eq!(package_key("src/lib/util.rs", &[]), "src");
278        assert_eq!(package_key("main.rs", &[]), ".");
279    }
280
281    #[test]
282    fn package_key_does_not_match_sibling_prefix() {
283        // "packages/ui-kit" must not match the "packages/ui" workspace.
284        let workspaces = vec![workspace("ui", "packages/ui")];
285        assert_eq!(
286            package_key("packages\\ui-kit\\src\\index.ts", &workspaces),
287            "packages"
288        );
289    }
290}