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 pub roots: Vec<RootImpact>,
17 pub nodes: Vec<GraphNode>,
18 pub edges: Vec<GraphEdge>,
19 pub warnings: Vec<String>,
20}
21
22#[derive(Debug, Clone, Serialize)]
25pub struct Workspace {
26 pub name: String,
27 pub root: String,
29}
30
31#[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#[derive(Debug, Clone, Serialize)]
46pub struct RootImpactFile {
47 pub path: String,
48 pub endpoint: bool,
49 pub depth: usize,
51}
52
53pub 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
66pub 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 pub skipped_inputs: usize,
118 pub risk_tier: RiskTier,
120}
121
122#[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
134pub 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 assert_eq!(package_key("packages\\ui", &workspaces), "packages/ui");
258 }
259
260 #[test]
261 fn package_key_handles_backslash_workspace_roots() {
262 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 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}