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 AddTestCoverage,
41}
42
43impl RecommendationCategory {
44 #[must_use]
46 pub const fn label(&self) -> &'static str {
47 match self {
48 Self::UrgentChurnComplexity => "churn+complexity",
49 Self::BreakCircularDependency => "circular dependency",
50 Self::SplitHighImpact => "high impact",
51 Self::RemoveDeadCode => "dead code",
52 Self::ExtractComplexFunctions => "complexity",
53 Self::ExtractDependencies => "coupling",
54 Self::AddTestCoverage => "untested risk",
55 }
56 }
57
58 #[must_use]
60 pub const fn compact_label(&self) -> &'static str {
61 match self {
62 Self::UrgentChurnComplexity => "churn_complexity",
63 Self::BreakCircularDependency => "circular_dep",
64 Self::SplitHighImpact => "high_impact",
65 Self::RemoveDeadCode => "dead_code",
66 Self::ExtractComplexFunctions => "complexity",
67 Self::ExtractDependencies => "coupling",
68 Self::AddTestCoverage => "untested_risk",
69 }
70 }
71}
72
73#[derive(Debug, Clone, serde::Serialize)]
75pub struct ContributingFactor {
76 pub metric: &'static str,
78 pub value: f64,
80 pub threshold: f64,
82 pub detail: String,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
106#[serde(rename_all = "snake_case")]
107pub enum EffortEstimate {
108 Low,
110 Medium,
112 High,
114}
115
116impl EffortEstimate {
117 #[must_use]
119 pub const fn label(&self) -> &'static str {
120 match self {
121 Self::Low => "low",
122 Self::Medium => "medium",
123 Self::High => "high",
124 }
125 }
126
127 #[must_use]
129 pub const fn numeric(&self) -> f64 {
130 match self {
131 Self::Low => 1.0,
132 Self::Medium => 2.0,
133 Self::High => 3.0,
134 }
135 }
136}
137
138#[derive(Debug, Clone, serde::Serialize)]
145#[serde(rename_all = "snake_case")]
146pub enum Confidence {
147 High,
149 Medium,
151 Low,
153}
154
155impl Confidence {
156 #[must_use]
158 pub const fn label(&self) -> &'static str {
159 match self {
160 Self::High => "high",
161 Self::Medium => "medium",
162 Self::Low => "low",
163 }
164 }
165}
166
167#[derive(Debug, Clone, serde::Serialize)]
172pub struct TargetEvidence {
173 #[serde(skip_serializing_if = "Vec::is_empty")]
175 pub unused_exports: Vec<String>,
176 #[serde(skip_serializing_if = "Vec::is_empty")]
178 pub complex_functions: Vec<EvidenceFunction>,
179 #[serde(skip_serializing_if = "Vec::is_empty")]
181 pub cycle_path: Vec<String>,
182}
183
184#[derive(Debug, Clone, serde::Serialize)]
186pub struct EvidenceFunction {
187 pub name: String,
189 pub line: u32,
191 pub cognitive: u16,
193}
194
195#[derive(Debug, Clone, serde::Serialize)]
196pub struct RefactoringTarget {
197 pub path: std::path::PathBuf,
199 pub priority: f64,
201 pub efficiency: f64,
204 pub recommendation: String,
206 pub category: RecommendationCategory,
208 pub effort: EffortEstimate,
210 pub confidence: Confidence,
212 #[serde(skip_serializing_if = "Vec::is_empty")]
214 pub factors: Vec<ContributingFactor>,
215 #[serde(skip_serializing_if = "Option::is_none")]
217 pub evidence: Option<TargetEvidence>,
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
227 fn category_labels_are_non_empty() {
228 let categories = [
229 RecommendationCategory::UrgentChurnComplexity,
230 RecommendationCategory::BreakCircularDependency,
231 RecommendationCategory::SplitHighImpact,
232 RecommendationCategory::RemoveDeadCode,
233 RecommendationCategory::ExtractComplexFunctions,
234 RecommendationCategory::ExtractDependencies,
235 RecommendationCategory::AddTestCoverage,
236 ];
237 for cat in &categories {
238 assert!(!cat.label().is_empty(), "{cat:?} should have a label");
239 }
240 }
241
242 #[test]
243 fn category_labels_are_unique() {
244 let categories = [
245 RecommendationCategory::UrgentChurnComplexity,
246 RecommendationCategory::BreakCircularDependency,
247 RecommendationCategory::SplitHighImpact,
248 RecommendationCategory::RemoveDeadCode,
249 RecommendationCategory::ExtractComplexFunctions,
250 RecommendationCategory::ExtractDependencies,
251 RecommendationCategory::AddTestCoverage,
252 ];
253 let labels: Vec<&str> = categories
254 .iter()
255 .map(RecommendationCategory::label)
256 .collect();
257 let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
258 assert_eq!(labels.len(), unique.len(), "category labels must be unique");
259 }
260
261 #[test]
264 fn category_serializes_as_snake_case() {
265 let json = serde_json::to_string(&RecommendationCategory::UrgentChurnComplexity).unwrap();
266 assert_eq!(json, r#""urgent_churn_complexity""#);
267
268 let json = serde_json::to_string(&RecommendationCategory::BreakCircularDependency).unwrap();
269 assert_eq!(json, r#""break_circular_dependency""#);
270 }
271
272 #[test]
273 fn refactoring_target_skips_empty_factors() {
274 let target = RefactoringTarget {
275 path: std::path::PathBuf::from("/src/foo.ts"),
276 priority: 75.0,
277 efficiency: 75.0,
278 recommendation: "Test recommendation".into(),
279 category: RecommendationCategory::RemoveDeadCode,
280 effort: EffortEstimate::Low,
281 confidence: Confidence::High,
282 factors: vec![],
283 evidence: None,
284 };
285 let json = serde_json::to_string(&target).unwrap();
286 assert!(!json.contains("factors"));
287 assert!(!json.contains("evidence"));
288 }
289
290 #[test]
291 fn effort_numeric_values() {
292 assert!((EffortEstimate::Low.numeric() - 1.0).abs() < f64::EPSILON);
293 assert!((EffortEstimate::Medium.numeric() - 2.0).abs() < f64::EPSILON);
294 assert!((EffortEstimate::High.numeric() - 3.0).abs() < f64::EPSILON);
295 }
296
297 #[test]
298 fn confidence_labels_are_non_empty() {
299 let levels = [Confidence::High, Confidence::Medium, Confidence::Low];
300 for level in &levels {
301 assert!(!level.label().is_empty(), "{level:?} should have a label");
302 }
303 }
304
305 #[test]
306 fn confidence_serializes_as_snake_case() {
307 let json = serde_json::to_string(&Confidence::High).unwrap();
308 assert_eq!(json, r#""high""#);
309 let json = serde_json::to_string(&Confidence::Medium).unwrap();
310 assert_eq!(json, r#""medium""#);
311 let json = serde_json::to_string(&Confidence::Low).unwrap();
312 assert_eq!(json, r#""low""#);
313 }
314
315 #[test]
316 fn contributing_factor_serializes_correctly() {
317 let factor = ContributingFactor {
318 metric: "fan_in",
319 value: 15.0,
320 threshold: 10.0,
321 detail: "15 files depend on this".into(),
322 };
323 let json = serde_json::to_string(&factor).unwrap();
324 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
325 assert_eq!(parsed["metric"], "fan_in");
326 assert_eq!(parsed["value"], 15.0);
327 assert_eq!(parsed["threshold"], 10.0);
328 }
329
330 #[test]
333 fn category_compact_labels_are_non_empty() {
334 let categories = [
335 RecommendationCategory::UrgentChurnComplexity,
336 RecommendationCategory::BreakCircularDependency,
337 RecommendationCategory::SplitHighImpact,
338 RecommendationCategory::RemoveDeadCode,
339 RecommendationCategory::ExtractComplexFunctions,
340 RecommendationCategory::ExtractDependencies,
341 RecommendationCategory::AddTestCoverage,
342 ];
343 for cat in &categories {
344 assert!(
345 !cat.compact_label().is_empty(),
346 "{cat:?} should have a compact_label"
347 );
348 }
349 }
350
351 #[test]
352 fn category_compact_labels_are_unique() {
353 let categories = [
354 RecommendationCategory::UrgentChurnComplexity,
355 RecommendationCategory::BreakCircularDependency,
356 RecommendationCategory::SplitHighImpact,
357 RecommendationCategory::RemoveDeadCode,
358 RecommendationCategory::ExtractComplexFunctions,
359 RecommendationCategory::ExtractDependencies,
360 RecommendationCategory::AddTestCoverage,
361 ];
362 let labels: Vec<&str> = categories
363 .iter()
364 .map(RecommendationCategory::compact_label)
365 .collect();
366 let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
367 assert_eq!(labels.len(), unique.len(), "compact labels must be unique");
368 }
369
370 #[test]
371 fn category_compact_labels_have_no_spaces() {
372 let categories = [
373 RecommendationCategory::UrgentChurnComplexity,
374 RecommendationCategory::BreakCircularDependency,
375 RecommendationCategory::SplitHighImpact,
376 RecommendationCategory::RemoveDeadCode,
377 RecommendationCategory::ExtractComplexFunctions,
378 RecommendationCategory::ExtractDependencies,
379 RecommendationCategory::AddTestCoverage,
380 ];
381 for cat in &categories {
382 assert!(
383 !cat.compact_label().contains(' '),
384 "compact_label for {:?} should not contain spaces: '{}'",
385 cat,
386 cat.compact_label()
387 );
388 }
389 }
390
391 #[test]
394 fn effort_labels_are_non_empty() {
395 let efforts = [
396 EffortEstimate::Low,
397 EffortEstimate::Medium,
398 EffortEstimate::High,
399 ];
400 for effort in &efforts {
401 assert!(!effort.label().is_empty(), "{effort:?} should have a label");
402 }
403 }
404
405 #[test]
406 fn effort_serializes_as_snake_case() {
407 assert_eq!(
408 serde_json::to_string(&EffortEstimate::Low).unwrap(),
409 r#""low""#
410 );
411 assert_eq!(
412 serde_json::to_string(&EffortEstimate::Medium).unwrap(),
413 r#""medium""#
414 );
415 assert_eq!(
416 serde_json::to_string(&EffortEstimate::High).unwrap(),
417 r#""high""#
418 );
419 }
420
421 #[test]
424 fn target_evidence_skips_empty_fields() {
425 let evidence = TargetEvidence {
426 unused_exports: vec![],
427 complex_functions: vec![],
428 cycle_path: vec![],
429 };
430 let json = serde_json::to_string(&evidence).unwrap();
431 assert!(!json.contains("unused_exports"));
432 assert!(!json.contains("complex_functions"));
433 assert!(!json.contains("cycle_path"));
434 }
435
436 #[test]
437 fn target_evidence_with_data() {
438 let evidence = TargetEvidence {
439 unused_exports: vec!["foo".to_string(), "bar".to_string()],
440 complex_functions: vec![EvidenceFunction {
441 name: "processData".into(),
442 line: 42,
443 cognitive: 30,
444 }],
445 cycle_path: vec![],
446 };
447 let json = serde_json::to_string(&evidence).unwrap();
448 assert!(json.contains("unused_exports"));
449 assert!(json.contains("complex_functions"));
450 assert!(json.contains("processData"));
451 assert!(!json.contains("cycle_path"));
452 }
453}