1use std::collections::{BTreeMap, BTreeSet};
2use std::path::PathBuf;
3
4use clap::ValueEnum;
5use serde::Serialize;
6
7pub const SCHEMA_VERSION: u32 = 1;
11
12#[derive(Debug, Clone, Serialize)]
13pub struct AnalysisResult {
14 pub schema_version: u32,
15 pub mode: AnalysisMode,
16 pub target: AnalysisTarget,
17 pub repo_root: PathBuf,
18 pub source_file_count: usize,
19 pub summary: Summary,
20 pub workspaces: Vec<Workspace>,
21 pub roots: Vec<RootImpact>,
23 pub nodes: Vec<GraphNode>,
24 pub edges: Vec<GraphEdge>,
25 pub warnings: Vec<String>,
26}
27
28#[derive(Debug, Clone, Serialize)]
31pub struct Workspace {
32 pub name: String,
33 pub root: String,
35}
36
37#[derive(Debug, Clone, Serialize)]
40pub struct RootImpact {
41 pub file: String,
42 pub affected: usize,
43 pub direct: usize,
44 pub indirect: usize,
45 pub max_depth: usize,
46 pub packages: usize,
47 pub files: Vec<RootImpactFile>,
48}
49
50#[derive(Debug, Clone, Serialize)]
52pub struct RootImpactFile {
53 pub path: String,
54 pub endpoint: bool,
55 pub depth: usize,
57}
58
59pub fn normalize_separators(label: impl Into<String>) -> String {
64 let label = label.into();
65 if label.contains('\\') {
66 label.replace('\\', "/")
67 } else {
68 label
69 }
70}
71
72pub fn package_key(rel_path: &str, workspaces: &[Workspace]) -> String {
78 let rel_path = normalize_separators(rel_path);
79 if let Some(root) = workspaces.iter().find_map(|workspace| {
80 let root = normalize_separators(workspace.root.as_str());
81 (root.is_empty() || rel_path == root || rel_path.starts_with(&format!("{root}/")))
82 .then_some(root)
83 }) {
84 if root.is_empty() {
85 ".".to_string()
86 } else {
87 root
88 }
89 } else {
90 match rel_path.split_once('/') {
91 Some((head, _)) => head.to_string(),
92 None => ".".to_string(),
93 }
94 }
95}
96
97#[derive(Debug, Clone, Serialize)]
98#[serde(rename_all = "snake_case")]
99pub enum AnalysisMode {
100 Export,
101 File,
102 Files,
103}
104
105#[derive(Debug, Clone, Serialize)]
106#[serde(tag = "kind", rename_all = "snake_case")]
107pub enum AnalysisTarget {
108 Export { file: PathBuf, export_name: String },
109 File { file: PathBuf },
110 Files { files: Vec<PathBuf> },
111}
112
113#[derive(Debug, Clone, Serialize, Default)]
114pub struct Summary {
115 pub directly_affected_files: usize,
116 pub transitively_affected_files: usize,
117 pub total_affected_files: usize,
118 pub unresolved_imports: usize,
119 pub ambiguous_edges: usize,
120 pub parse_failures: usize,
121 pub skipped_inputs: usize,
124 pub risk_tier: RiskTier,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, ValueEnum)]
131#[serde(rename_all = "snake_case")]
132pub enum RiskTier {
133 #[default]
134 Minor,
135 Moderate,
136 Risky,
137 High,
138}
139
140pub fn compute_tier(affected: usize, packages: usize) -> RiskTier {
145 if affected == 0 {
146 RiskTier::Minor
147 } else if affected > 25 || packages >= 3 {
148 RiskTier::High
149 } else if affected <= 3 && packages <= 1 {
150 RiskTier::Minor
151 } else if affected <= 10 && packages <= 2 {
152 RiskTier::Moderate
153 } else {
154 RiskTier::Risky
155 }
156}
157
158#[derive(Debug, Clone, Serialize)]
159pub struct GraphNode {
160 pub id: String,
161 pub label: String,
162 pub file: PathBuf,
163 pub symbol: Option<String>,
164 pub kind: NodeKind,
165 pub depth: usize,
166}
167
168#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
169#[serde(rename_all = "snake_case")]
170pub enum NodeKind {
171 File,
172 Export,
173}
174
175#[derive(Debug, Clone, Serialize)]
176pub struct GraphEdge {
177 pub from: String,
178 pub to: String,
179 pub kind: EdgeKind,
180 pub is_ambiguous: bool,
181}
182
183#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
184#[serde(rename_all = "snake_case")]
185pub enum EdgeKind {
186 ImportsNamed,
187 ImportsDefault,
188 ImportsNamespace,
189 ImportsDynamic,
190 ReexportsNamed,
191 ReexportsStar,
192 UsesJsxComponent,
193 RequiresModule,
194 CommonJsExport,
195}
196
197#[derive(Debug, Clone)]
198pub struct ModuleState {
199 pub file: PathBuf,
200 pub public_exports: BTreeSet<String>,
201 pub export_to_locals: BTreeMap<String, BTreeSet<String>>,
202 pub local_to_exports: BTreeMap<String, BTreeSet<String>>,
203}
204
205impl ModuleState {
206 pub fn new(file: PathBuf) -> Self {
207 Self {
208 file,
209 public_exports: BTreeSet::new(),
210 export_to_locals: BTreeMap::new(),
211 local_to_exports: BTreeMap::new(),
212 }
213 }
214
215 pub fn add_export(&mut self, exported: impl Into<String>, local: Option<String>) {
216 let exported = exported.into();
217 self.public_exports.insert(exported.clone());
218 if let Some(local) = local {
219 self.export_to_locals
220 .entry(exported.clone())
221 .or_default()
222 .insert(local.clone());
223 self.local_to_exports
224 .entry(local)
225 .or_default()
226 .insert(exported);
227 }
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::{Workspace, normalize_separators, package_key};
234
235 fn workspace(name: &str, root: &str) -> Workspace {
236 Workspace {
237 name: name.to_string(),
238 root: root.to_string(),
239 }
240 }
241
242 #[test]
243 fn normalize_separators_converts_backslashes() {
244 assert_eq!(
245 normalize_separators("src\\report\\tree.rs"),
246 "src/report/tree.rs"
247 );
248 assert_eq!(
249 normalize_separators("src/report/tree.rs"),
250 "src/report/tree.rs"
251 );
252 assert_eq!(normalize_separators(""), "");
253 }
254
255 #[test]
256 fn package_key_matches_workspace_with_backslash_path() {
257 let workspaces = vec![workspace("ui", "packages/ui"), workspace("repo", "")];
258 assert_eq!(
259 package_key("packages\\ui\\src\\button.tsx", &workspaces),
260 "packages/ui"
261 );
262 assert_eq!(package_key("packages\\ui", &workspaces), "packages/ui");
264 }
265
266 #[test]
267 fn package_key_handles_backslash_workspace_roots() {
268 let workspaces = vec![workspace("ui", "packages\\ui")];
270 assert_eq!(
271 package_key("packages/ui/src/button.tsx", &workspaces),
272 "packages/ui"
273 );
274 assert_eq!(
275 package_key("packages\\ui\\src\\button.tsx", &workspaces),
276 "packages/ui"
277 );
278 }
279
280 #[test]
281 fn package_key_falls_back_to_top_level_directory() {
282 assert_eq!(package_key("src\\lib\\util.rs", &[]), "src");
283 assert_eq!(package_key("src/lib/util.rs", &[]), "src");
284 assert_eq!(package_key("main.rs", &[]), ".");
285 }
286
287 #[test]
288 fn package_key_does_not_match_sibling_prefix() {
289 let workspaces = vec![workspace("ui", "packages/ui")];
291 assert_eq!(
292 package_key("packages\\ui-kit\\src\\index.ts", &workspaces),
293 "packages"
294 );
295 }
296}