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 Graph,
105}
106
107#[derive(Debug, Clone, Serialize)]
108#[serde(tag = "kind", rename_all = "snake_case")]
109pub enum AnalysisTarget {
110 Export {
111 file: PathBuf,
112 export_name: String,
113 },
114 File {
115 file: PathBuf,
116 },
117 Files {
118 files: Vec<PathBuf>,
119 },
120 Graph,
123}
124
125#[derive(Debug, Clone, Serialize, Default)]
126pub struct Summary {
127 pub directly_affected_files: usize,
128 pub transitively_affected_files: usize,
129 pub total_affected_files: usize,
130 pub unresolved_imports: usize,
131 pub ambiguous_edges: usize,
132 pub parse_failures: usize,
133 pub skipped_inputs: usize,
136 pub risk_tier: RiskTier,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, ValueEnum)]
143#[serde(rename_all = "snake_case")]
144pub enum RiskTier {
145 #[default]
146 Minor,
147 Moderate,
148 Risky,
149 High,
150}
151
152pub fn compute_tier(affected: usize, packages: usize) -> RiskTier {
157 if affected == 0 {
158 RiskTier::Minor
159 } else if affected > 25 || packages >= 3 {
160 RiskTier::High
161 } else if affected <= 3 && packages <= 1 {
162 RiskTier::Minor
163 } else if affected <= 10 && packages <= 2 {
164 RiskTier::Moderate
165 } else {
166 RiskTier::Risky
167 }
168}
169
170#[derive(Debug, Clone, Serialize)]
171pub struct GraphNode {
172 pub id: String,
173 pub label: String,
174 pub file: PathBuf,
175 pub symbol: Option<String>,
176 pub kind: NodeKind,
177 pub depth: usize,
178}
179
180#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
181#[serde(rename_all = "snake_case")]
182pub enum NodeKind {
183 File,
184 Export,
185}
186
187#[derive(Debug, Clone, Serialize)]
188pub struct GraphEdge {
189 pub from: String,
190 pub to: String,
191 pub kind: EdgeKind,
192 pub is_ambiguous: bool,
193}
194
195#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, PartialOrd, Ord)]
196#[serde(rename_all = "snake_case")]
197pub enum EdgeKind {
198 ImportsNamed,
199 ImportsDefault,
200 ImportsNamespace,
201 ImportsDynamic,
202 ReexportsNamed,
203 ReexportsStar,
204 UsesJsxComponent,
205 RequiresModule,
206 CommonJsExport,
207 MocksModule,
209}
210
211#[derive(Debug, Clone)]
212pub struct ModuleState {
213 pub file: PathBuf,
214 pub public_exports: BTreeSet<String>,
215 pub export_to_locals: BTreeMap<String, BTreeSet<String>>,
216 pub local_to_exports: BTreeMap<String, BTreeSet<String>>,
217}
218
219impl ModuleState {
220 pub fn new(file: PathBuf) -> Self {
221 Self {
222 file,
223 public_exports: BTreeSet::new(),
224 export_to_locals: BTreeMap::new(),
225 local_to_exports: BTreeMap::new(),
226 }
227 }
228
229 pub fn add_export(&mut self, exported: impl Into<String>, local: Option<String>) {
230 let exported = exported.into();
231 self.public_exports.insert(exported.clone());
232 if let Some(local) = local {
233 self.export_to_locals
234 .entry(exported.clone())
235 .or_default()
236 .insert(local.clone());
237 self.local_to_exports
238 .entry(local)
239 .or_default()
240 .insert(exported);
241 }
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::{Workspace, normalize_separators, package_key};
248
249 fn workspace(name: &str, root: &str) -> Workspace {
250 Workspace {
251 name: name.to_string(),
252 root: root.to_string(),
253 }
254 }
255
256 #[test]
257 fn normalize_separators_converts_backslashes() {
258 assert_eq!(
259 normalize_separators("src\\report\\tree.rs"),
260 "src/report/tree.rs"
261 );
262 assert_eq!(
263 normalize_separators("src/report/tree.rs"),
264 "src/report/tree.rs"
265 );
266 assert_eq!(normalize_separators(""), "");
267 }
268
269 #[test]
270 fn package_key_matches_workspace_with_backslash_path() {
271 let workspaces = vec![workspace("ui", "packages/ui"), workspace("repo", "")];
272 assert_eq!(
273 package_key("packages\\ui\\src\\button.tsx", &workspaces),
274 "packages/ui"
275 );
276 assert_eq!(package_key("packages\\ui", &workspaces), "packages/ui");
278 }
279
280 #[test]
281 fn package_key_handles_backslash_workspace_roots() {
282 let workspaces = vec![workspace("ui", "packages\\ui")];
284 assert_eq!(
285 package_key("packages/ui/src/button.tsx", &workspaces),
286 "packages/ui"
287 );
288 assert_eq!(
289 package_key("packages\\ui\\src\\button.tsx", &workspaces),
290 "packages/ui"
291 );
292 }
293
294 #[test]
295 fn package_key_falls_back_to_top_level_directory() {
296 assert_eq!(package_key("src\\lib\\util.rs", &[]), "src");
297 assert_eq!(package_key("src/lib/util.rs", &[]), "src");
298 assert_eq!(package_key("main.rs", &[]), ".");
299 }
300
301 #[test]
302 fn package_key_does_not_match_sibling_prefix() {
303 let workspaces = vec![workspace("ui", "packages/ui")];
305 assert_eq!(
306 package_key("packages\\ui-kit\\src\\index.ts", &workspaces),
307 "packages"
308 );
309 }
310}