1#[derive(Debug, Clone, serde::Serialize)]
8#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
9#[allow(
10 clippy::struct_field_names,
11 reason = "triggered in bin but not lib — #[expect] would be unfulfilled in lib"
12)]
13pub struct TargetThresholds {
14 pub fan_in_p95: f64,
16 pub fan_in_p75: f64,
18 pub fan_out_p95: f64,
20 pub fan_out_p90: usize,
22}
23
24#[derive(Debug, Clone, serde::Serialize)]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27#[serde(rename_all = "snake_case")]
28pub enum RecommendationCategory {
29 UrgentChurnComplexity,
31 BreakCircularDependency,
33 SplitHighImpact,
35 RemoveDeadCode,
37 ExtractComplexFunctions,
39 ExtractDependencies,
41 AddTestCoverage,
43}
44
45impl RecommendationCategory {
46 #[must_use]
48 pub const fn label(&self) -> &'static str {
49 match self {
50 Self::UrgentChurnComplexity => "churn+complexity",
51 Self::BreakCircularDependency => "circular dependency",
52 Self::SplitHighImpact => "high impact",
53 Self::RemoveDeadCode => "dead code",
54 Self::ExtractComplexFunctions => "complexity",
55 Self::ExtractDependencies => "coupling",
56 Self::AddTestCoverage => "untested risk",
57 }
58 }
59
60 #[must_use]
62 pub const fn compact_label(&self) -> &'static str {
63 match self {
64 Self::UrgentChurnComplexity => "churn_complexity",
65 Self::BreakCircularDependency => "circular_dep",
66 Self::SplitHighImpact => "high_impact",
67 Self::RemoveDeadCode => "dead_code",
68 Self::ExtractComplexFunctions => "complexity",
69 Self::ExtractDependencies => "coupling",
70 Self::AddTestCoverage => "untested_risk",
71 }
72 }
73}
74
75#[derive(Debug, Clone, serde::Serialize)]
77#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
78pub struct ContributingFactor {
79 pub metric: &'static str,
81 pub value: f64,
83 pub threshold: f64,
85 pub detail: String,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
109#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
110#[serde(rename_all = "snake_case")]
111pub enum EffortEstimate {
112 Low,
114 Medium,
116 High,
118}
119
120impl EffortEstimate {
121 #[must_use]
123 pub const fn label(&self) -> &'static str {
124 match self {
125 Self::Low => "low",
126 Self::Medium => "medium",
127 Self::High => "high",
128 }
129 }
130
131 #[must_use]
133 pub const fn numeric(&self) -> f64 {
134 match self {
135 Self::Low => 1.0,
136 Self::Medium => 2.0,
137 Self::High => 3.0,
138 }
139 }
140}
141
142#[derive(Debug, Clone, serde::Serialize)]
149#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
150#[serde(rename_all = "snake_case")]
151pub enum Confidence {
152 High,
154 Medium,
156 Low,
158}
159
160impl Confidence {
161 #[must_use]
163 pub const fn label(&self) -> &'static str {
164 match self {
165 Self::High => "high",
166 Self::Medium => "medium",
167 Self::Low => "low",
168 }
169 }
170}
171
172#[derive(Debug, Clone, serde::Serialize)]
177#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
178pub struct TargetEvidence {
179 #[serde(default, skip_serializing_if = "Vec::is_empty")]
181 pub unused_exports: Vec<String>,
182 #[serde(default, skip_serializing_if = "Vec::is_empty")]
184 pub complex_functions: Vec<EvidenceFunction>,
185 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub cycle_path: Vec<String>,
188}
189
190#[derive(Debug, Clone, serde::Serialize)]
192#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
193pub struct EvidenceFunction {
194 pub name: String,
196 pub line: u32,
198 pub cognitive: u16,
200}
201
202#[derive(Debug, Clone, serde::Serialize)]
203#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
204pub struct RefactoringTarget {
205 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
207 pub path: std::path::PathBuf,
208 pub priority: f64,
210 pub efficiency: f64,
213 pub recommendation: String,
215 pub category: RecommendationCategory,
217 pub effort: EffortEstimate,
219 pub confidence: Confidence,
221 #[serde(default, skip_serializing_if = "Vec::is_empty")]
224 #[cfg_attr(feature = "schema", schemars(default))]
225 pub factors: Vec<ContributingFactor>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub evidence: Option<TargetEvidence>,
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
238 fn category_labels_are_non_empty() {
239 let categories = [
240 RecommendationCategory::UrgentChurnComplexity,
241 RecommendationCategory::BreakCircularDependency,
242 RecommendationCategory::SplitHighImpact,
243 RecommendationCategory::RemoveDeadCode,
244 RecommendationCategory::ExtractComplexFunctions,
245 RecommendationCategory::ExtractDependencies,
246 RecommendationCategory::AddTestCoverage,
247 ];
248 for cat in &categories {
249 assert!(!cat.label().is_empty(), "{cat:?} should have a label");
250 }
251 }
252
253 #[test]
254 fn category_labels_are_unique() {
255 let categories = [
256 RecommendationCategory::UrgentChurnComplexity,
257 RecommendationCategory::BreakCircularDependency,
258 RecommendationCategory::SplitHighImpact,
259 RecommendationCategory::RemoveDeadCode,
260 RecommendationCategory::ExtractComplexFunctions,
261 RecommendationCategory::ExtractDependencies,
262 RecommendationCategory::AddTestCoverage,
263 ];
264 let labels: Vec<&str> = categories
265 .iter()
266 .map(RecommendationCategory::label)
267 .collect();
268 let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
269 assert_eq!(labels.len(), unique.len(), "category labels must be unique");
270 }
271
272 #[test]
275 fn category_serializes_as_snake_case() {
276 let json = serde_json::to_string(&RecommendationCategory::UrgentChurnComplexity).unwrap();
277 assert_eq!(json, r#""urgent_churn_complexity""#);
278
279 let json = serde_json::to_string(&RecommendationCategory::BreakCircularDependency).unwrap();
280 assert_eq!(json, r#""break_circular_dependency""#);
281 }
282
283 #[test]
284 fn refactoring_target_skips_empty_factors() {
285 let target = RefactoringTarget {
286 path: std::path::PathBuf::from("/src/foo.ts"),
287 priority: 75.0,
288 efficiency: 75.0,
289 recommendation: "Test recommendation".into(),
290 category: RecommendationCategory::RemoveDeadCode,
291 effort: EffortEstimate::Low,
292 confidence: Confidence::High,
293 factors: vec![],
294 evidence: None,
295 };
296 let json = serde_json::to_string(&target).unwrap();
297 assert!(!json.contains("factors"));
298 assert!(!json.contains("evidence"));
299 }
300
301 #[test]
302 fn effort_numeric_values() {
303 assert!((EffortEstimate::Low.numeric() - 1.0).abs() < f64::EPSILON);
304 assert!((EffortEstimate::Medium.numeric() - 2.0).abs() < f64::EPSILON);
305 assert!((EffortEstimate::High.numeric() - 3.0).abs() < f64::EPSILON);
306 }
307
308 #[test]
309 fn confidence_labels_are_non_empty() {
310 let levels = [Confidence::High, Confidence::Medium, Confidence::Low];
311 for level in &levels {
312 assert!(!level.label().is_empty(), "{level:?} should have a label");
313 }
314 }
315
316 #[test]
317 fn confidence_serializes_as_snake_case() {
318 let json = serde_json::to_string(&Confidence::High).unwrap();
319 assert_eq!(json, r#""high""#);
320 let json = serde_json::to_string(&Confidence::Medium).unwrap();
321 assert_eq!(json, r#""medium""#);
322 let json = serde_json::to_string(&Confidence::Low).unwrap();
323 assert_eq!(json, r#""low""#);
324 }
325
326 #[test]
327 fn contributing_factor_serializes_correctly() {
328 let factor = ContributingFactor {
329 metric: "fan_in",
330 value: 15.0,
331 threshold: 10.0,
332 detail: "15 files depend on this".into(),
333 };
334 let json = serde_json::to_string(&factor).unwrap();
335 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
336 assert_eq!(parsed["metric"], "fan_in");
337 assert_eq!(parsed["value"], 15.0);
338 assert_eq!(parsed["threshold"], 10.0);
339 }
340
341 #[test]
344 fn category_compact_labels_are_non_empty() {
345 let categories = [
346 RecommendationCategory::UrgentChurnComplexity,
347 RecommendationCategory::BreakCircularDependency,
348 RecommendationCategory::SplitHighImpact,
349 RecommendationCategory::RemoveDeadCode,
350 RecommendationCategory::ExtractComplexFunctions,
351 RecommendationCategory::ExtractDependencies,
352 RecommendationCategory::AddTestCoverage,
353 ];
354 for cat in &categories {
355 assert!(
356 !cat.compact_label().is_empty(),
357 "{cat:?} should have a compact_label"
358 );
359 }
360 }
361
362 #[test]
363 fn category_compact_labels_are_unique() {
364 let categories = [
365 RecommendationCategory::UrgentChurnComplexity,
366 RecommendationCategory::BreakCircularDependency,
367 RecommendationCategory::SplitHighImpact,
368 RecommendationCategory::RemoveDeadCode,
369 RecommendationCategory::ExtractComplexFunctions,
370 RecommendationCategory::ExtractDependencies,
371 RecommendationCategory::AddTestCoverage,
372 ];
373 let labels: Vec<&str> = categories
374 .iter()
375 .map(RecommendationCategory::compact_label)
376 .collect();
377 let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
378 assert_eq!(labels.len(), unique.len(), "compact labels must be unique");
379 }
380
381 #[test]
382 fn category_compact_labels_have_no_spaces() {
383 let categories = [
384 RecommendationCategory::UrgentChurnComplexity,
385 RecommendationCategory::BreakCircularDependency,
386 RecommendationCategory::SplitHighImpact,
387 RecommendationCategory::RemoveDeadCode,
388 RecommendationCategory::ExtractComplexFunctions,
389 RecommendationCategory::ExtractDependencies,
390 RecommendationCategory::AddTestCoverage,
391 ];
392 for cat in &categories {
393 assert!(
394 !cat.compact_label().contains(' '),
395 "compact_label for {:?} should not contain spaces: '{}'",
396 cat,
397 cat.compact_label()
398 );
399 }
400 }
401
402 #[test]
405 fn effort_labels_are_non_empty() {
406 let efforts = [
407 EffortEstimate::Low,
408 EffortEstimate::Medium,
409 EffortEstimate::High,
410 ];
411 for effort in &efforts {
412 assert!(!effort.label().is_empty(), "{effort:?} should have a label");
413 }
414 }
415
416 #[test]
417 fn effort_serializes_as_snake_case() {
418 assert_eq!(
419 serde_json::to_string(&EffortEstimate::Low).unwrap(),
420 r#""low""#
421 );
422 assert_eq!(
423 serde_json::to_string(&EffortEstimate::Medium).unwrap(),
424 r#""medium""#
425 );
426 assert_eq!(
427 serde_json::to_string(&EffortEstimate::High).unwrap(),
428 r#""high""#
429 );
430 }
431
432 #[test]
435 fn target_evidence_skips_empty_fields() {
436 let evidence = TargetEvidence {
437 unused_exports: vec![],
438 complex_functions: vec![],
439 cycle_path: vec![],
440 };
441 let json = serde_json::to_string(&evidence).unwrap();
442 assert!(!json.contains("unused_exports"));
443 assert!(!json.contains("complex_functions"));
444 assert!(!json.contains("cycle_path"));
445 }
446
447 #[test]
448 fn target_evidence_with_data() {
449 let evidence = TargetEvidence {
450 unused_exports: vec!["foo".to_string(), "bar".to_string()],
451 complex_functions: vec![EvidenceFunction {
452 name: "processData".into(),
453 line: 42,
454 cognitive: 30,
455 }],
456 cycle_path: vec![],
457 };
458 let json = serde_json::to_string(&evidence).unwrap();
459 assert!(json.contains("unused_exports"));
460 assert!(json.contains("complex_functions"));
461 assert!(json.contains("processData"));
462 assert!(!json.contains("cycle_path"));
463 }
464}