1#[derive(Debug, Clone, serde::Serialize)]
8#[allow(
9 clippy::struct_field_names,
10 reason = "triggered in bin but not lib — #[expect] would be unfulfilled in lib"
11)]
12pub struct TargetThresholds {
13 pub fan_in_p95: f64,
15 pub fan_in_p75: f64,
17 pub fan_out_p95: f64,
19 pub fan_out_p90: usize,
21}
22
23#[derive(Debug, Clone, serde::Serialize)]
25#[serde(rename_all = "snake_case")]
26pub enum RecommendationCategory {
27 UrgentChurnComplexity,
29 BreakCircularDependency,
31 SplitHighImpact,
33 RemoveDeadCode,
35 ExtractComplexFunctions,
37 ExtractDependencies,
39}
40
41impl RecommendationCategory {
42 #[must_use]
44 pub const fn label(&self) -> &'static str {
45 match self {
46 Self::UrgentChurnComplexity => "churn+complexity",
47 Self::BreakCircularDependency => "circular dep",
48 Self::SplitHighImpact => "high impact",
49 Self::RemoveDeadCode => "dead code",
50 Self::ExtractComplexFunctions => "complexity",
51 Self::ExtractDependencies => "coupling",
52 }
53 }
54
55 #[must_use]
57 pub const fn compact_label(&self) -> &'static str {
58 match self {
59 Self::UrgentChurnComplexity => "churn_complexity",
60 Self::BreakCircularDependency => "circular_dep",
61 Self::SplitHighImpact => "high_impact",
62 Self::RemoveDeadCode => "dead_code",
63 Self::ExtractComplexFunctions => "complexity",
64 Self::ExtractDependencies => "coupling",
65 }
66 }
67}
68
69#[derive(Debug, Clone, serde::Serialize)]
71pub struct ContributingFactor {
72 pub metric: &'static str,
74 pub value: f64,
76 pub threshold: f64,
78 pub detail: String,
80}
81
82#[derive(Debug, Clone, serde::Serialize)]
102#[serde(rename_all = "snake_case")]
103pub enum EffortEstimate {
104 Low,
106 Medium,
108 High,
110}
111
112impl EffortEstimate {
113 #[must_use]
115 pub const fn label(&self) -> &'static str {
116 match self {
117 Self::Low => "low",
118 Self::Medium => "medium",
119 Self::High => "high",
120 }
121 }
122
123 #[must_use]
125 pub const fn numeric(&self) -> f64 {
126 match self {
127 Self::Low => 1.0,
128 Self::Medium => 2.0,
129 Self::High => 3.0,
130 }
131 }
132}
133
134#[derive(Debug, Clone, serde::Serialize)]
141#[serde(rename_all = "snake_case")]
142pub enum Confidence {
143 High,
145 Medium,
147 Low,
149}
150
151impl Confidence {
152 #[must_use]
154 pub const fn label(&self) -> &'static str {
155 match self {
156 Self::High => "high",
157 Self::Medium => "medium",
158 Self::Low => "low",
159 }
160 }
161}
162
163#[derive(Debug, Clone, serde::Serialize)]
168pub struct TargetEvidence {
169 #[serde(skip_serializing_if = "Vec::is_empty")]
171 pub unused_exports: Vec<String>,
172 #[serde(skip_serializing_if = "Vec::is_empty")]
174 pub complex_functions: Vec<EvidenceFunction>,
175 #[serde(skip_serializing_if = "Vec::is_empty")]
177 pub cycle_path: Vec<String>,
178}
179
180#[derive(Debug, Clone, serde::Serialize)]
182pub struct EvidenceFunction {
183 pub name: String,
185 pub line: u32,
187 pub cognitive: u16,
189}
190
191#[derive(Debug, Clone, serde::Serialize)]
192pub struct RefactoringTarget {
193 pub path: std::path::PathBuf,
195 pub priority: f64,
197 pub efficiency: f64,
200 pub recommendation: String,
202 pub category: RecommendationCategory,
204 pub effort: EffortEstimate,
206 pub confidence: Confidence,
208 #[serde(skip_serializing_if = "Vec::is_empty")]
210 pub factors: Vec<ContributingFactor>,
211 #[serde(skip_serializing_if = "Option::is_none")]
213 pub evidence: Option<TargetEvidence>,
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
223 fn category_labels_are_non_empty() {
224 let categories = [
225 RecommendationCategory::UrgentChurnComplexity,
226 RecommendationCategory::BreakCircularDependency,
227 RecommendationCategory::SplitHighImpact,
228 RecommendationCategory::RemoveDeadCode,
229 RecommendationCategory::ExtractComplexFunctions,
230 RecommendationCategory::ExtractDependencies,
231 ];
232 for cat in &categories {
233 assert!(!cat.label().is_empty(), "{cat:?} should have a label");
234 }
235 }
236
237 #[test]
238 fn category_labels_are_unique() {
239 let categories = [
240 RecommendationCategory::UrgentChurnComplexity,
241 RecommendationCategory::BreakCircularDependency,
242 RecommendationCategory::SplitHighImpact,
243 RecommendationCategory::RemoveDeadCode,
244 RecommendationCategory::ExtractComplexFunctions,
245 RecommendationCategory::ExtractDependencies,
246 ];
247 let labels: Vec<&str> = categories
248 .iter()
249 .map(RecommendationCategory::label)
250 .collect();
251 let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
252 assert_eq!(labels.len(), unique.len(), "category labels must be unique");
253 }
254
255 #[test]
258 fn category_serializes_as_snake_case() {
259 let json = serde_json::to_string(&RecommendationCategory::UrgentChurnComplexity).unwrap();
260 assert_eq!(json, r#""urgent_churn_complexity""#);
261
262 let json = serde_json::to_string(&RecommendationCategory::BreakCircularDependency).unwrap();
263 assert_eq!(json, r#""break_circular_dependency""#);
264 }
265
266 #[test]
267 fn refactoring_target_skips_empty_factors() {
268 let target = RefactoringTarget {
269 path: std::path::PathBuf::from("/src/foo.ts"),
270 priority: 75.0,
271 efficiency: 75.0,
272 recommendation: "Test recommendation".into(),
273 category: RecommendationCategory::RemoveDeadCode,
274 effort: EffortEstimate::Low,
275 confidence: Confidence::High,
276 factors: vec![],
277 evidence: None,
278 };
279 let json = serde_json::to_string(&target).unwrap();
280 assert!(!json.contains("factors"));
281 assert!(!json.contains("evidence"));
282 }
283
284 #[test]
285 fn effort_numeric_values() {
286 assert!((EffortEstimate::Low.numeric() - 1.0).abs() < f64::EPSILON);
287 assert!((EffortEstimate::Medium.numeric() - 2.0).abs() < f64::EPSILON);
288 assert!((EffortEstimate::High.numeric() - 3.0).abs() < f64::EPSILON);
289 }
290
291 #[test]
292 fn confidence_labels_are_non_empty() {
293 let levels = [Confidence::High, Confidence::Medium, Confidence::Low];
294 for level in &levels {
295 assert!(!level.label().is_empty(), "{level:?} should have a label");
296 }
297 }
298
299 #[test]
300 fn confidence_serializes_as_snake_case() {
301 let json = serde_json::to_string(&Confidence::High).unwrap();
302 assert_eq!(json, r#""high""#);
303 let json = serde_json::to_string(&Confidence::Medium).unwrap();
304 assert_eq!(json, r#""medium""#);
305 let json = serde_json::to_string(&Confidence::Low).unwrap();
306 assert_eq!(json, r#""low""#);
307 }
308
309 #[test]
310 fn contributing_factor_serializes_correctly() {
311 let factor = ContributingFactor {
312 metric: "fan_in",
313 value: 15.0,
314 threshold: 10.0,
315 detail: "15 files depend on this".into(),
316 };
317 let json = serde_json::to_string(&factor).unwrap();
318 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
319 assert_eq!(parsed["metric"], "fan_in");
320 assert_eq!(parsed["value"], 15.0);
321 assert_eq!(parsed["threshold"], 10.0);
322 }
323
324 #[test]
327 fn category_compact_labels_are_non_empty() {
328 let categories = [
329 RecommendationCategory::UrgentChurnComplexity,
330 RecommendationCategory::BreakCircularDependency,
331 RecommendationCategory::SplitHighImpact,
332 RecommendationCategory::RemoveDeadCode,
333 RecommendationCategory::ExtractComplexFunctions,
334 RecommendationCategory::ExtractDependencies,
335 ];
336 for cat in &categories {
337 assert!(
338 !cat.compact_label().is_empty(),
339 "{cat:?} should have a compact_label"
340 );
341 }
342 }
343
344 #[test]
345 fn category_compact_labels_are_unique() {
346 let categories = [
347 RecommendationCategory::UrgentChurnComplexity,
348 RecommendationCategory::BreakCircularDependency,
349 RecommendationCategory::SplitHighImpact,
350 RecommendationCategory::RemoveDeadCode,
351 RecommendationCategory::ExtractComplexFunctions,
352 RecommendationCategory::ExtractDependencies,
353 ];
354 let labels: Vec<&str> = categories
355 .iter()
356 .map(RecommendationCategory::compact_label)
357 .collect();
358 let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
359 assert_eq!(labels.len(), unique.len(), "compact labels must be unique");
360 }
361
362 #[test]
363 fn category_compact_labels_have_no_spaces() {
364 let categories = [
365 RecommendationCategory::UrgentChurnComplexity,
366 RecommendationCategory::BreakCircularDependency,
367 RecommendationCategory::SplitHighImpact,
368 RecommendationCategory::RemoveDeadCode,
369 RecommendationCategory::ExtractComplexFunctions,
370 RecommendationCategory::ExtractDependencies,
371 ];
372 for cat in &categories {
373 assert!(
374 !cat.compact_label().contains(' '),
375 "compact_label for {:?} should not contain spaces: '{}'",
376 cat,
377 cat.compact_label()
378 );
379 }
380 }
381
382 #[test]
385 fn effort_labels_are_non_empty() {
386 let efforts = [
387 EffortEstimate::Low,
388 EffortEstimate::Medium,
389 EffortEstimate::High,
390 ];
391 for effort in &efforts {
392 assert!(!effort.label().is_empty(), "{effort:?} should have a label");
393 }
394 }
395
396 #[test]
397 fn effort_serializes_as_snake_case() {
398 assert_eq!(
399 serde_json::to_string(&EffortEstimate::Low).unwrap(),
400 r#""low""#
401 );
402 assert_eq!(
403 serde_json::to_string(&EffortEstimate::Medium).unwrap(),
404 r#""medium""#
405 );
406 assert_eq!(
407 serde_json::to_string(&EffortEstimate::High).unwrap(),
408 r#""high""#
409 );
410 }
411
412 #[test]
415 fn target_evidence_skips_empty_fields() {
416 let evidence = TargetEvidence {
417 unused_exports: vec![],
418 complex_functions: vec![],
419 cycle_path: vec![],
420 };
421 let json = serde_json::to_string(&evidence).unwrap();
422 assert!(!json.contains("unused_exports"));
423 assert!(!json.contains("complex_functions"));
424 assert!(!json.contains("cycle_path"));
425 }
426
427 #[test]
428 fn target_evidence_with_data() {
429 let evidence = TargetEvidence {
430 unused_exports: vec!["foo".to_string(), "bar".to_string()],
431 complex_functions: vec![EvidenceFunction {
432 name: "processData".into(),
433 line: 42,
434 cognitive: 30,
435 }],
436 cycle_path: vec![],
437 };
438 let json = serde_json::to_string(&evidence).unwrap();
439 assert!(json.contains("unused_exports"));
440 assert!(json.contains("complex_functions"));
441 assert!(json.contains("processData"));
442 assert!(!json.contains("cycle_path"));
443 }
444}