1#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
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, serde::Deserialize)]
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 ResolveHiddenCoupling,
46}
47
48impl RecommendationCategory {
49 #[must_use]
51 pub const fn label(&self) -> &'static str {
52 match self {
53 Self::UrgentChurnComplexity => "churn+complexity",
54 Self::BreakCircularDependency => "circular dependency",
55 Self::SplitHighImpact => "high impact",
56 Self::RemoveDeadCode => "dead code",
57 Self::ExtractComplexFunctions => "complexity",
58 Self::ExtractDependencies => "coupling",
59 Self::AddTestCoverage => "untested risk",
60 Self::ResolveHiddenCoupling => "hidden coupling",
61 }
62 }
63
64 #[must_use]
66 pub const fn compact_label(&self) -> &'static str {
67 match self {
68 Self::UrgentChurnComplexity => "churn_complexity",
69 Self::BreakCircularDependency => "circular_dep",
70 Self::SplitHighImpact => "high_impact",
71 Self::RemoveDeadCode => "dead_code",
72 Self::ExtractComplexFunctions => "complexity",
73 Self::ExtractDependencies => "coupling",
74 Self::AddTestCoverage => "untested_risk",
75 Self::ResolveHiddenCoupling => "hidden_coupling",
76 }
77 }
78}
79
80#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
82#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
83pub struct ContributingFactor {
84 #[serde(deserialize_with = "crate::static_str::deserialize")]
86 pub metric: crate::static_str::StaticStr,
87 pub value: f64,
89 pub threshold: f64,
91 pub detail: String,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
115#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
116#[serde(rename_all = "snake_case")]
117pub enum EffortEstimate {
118 Low,
120 Medium,
122 High,
124}
125
126impl EffortEstimate {
127 #[must_use]
129 pub const fn label(&self) -> &'static str {
130 match self {
131 Self::Low => "low",
132 Self::Medium => "medium",
133 Self::High => "high",
134 }
135 }
136
137 #[must_use]
139 pub const fn numeric(&self) -> f64 {
140 match self {
141 Self::Low => 1.0,
142 Self::Medium => 2.0,
143 Self::High => 3.0,
144 }
145 }
146}
147
148#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
155#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
156#[serde(rename_all = "snake_case")]
157pub enum Confidence {
158 High,
160 Medium,
162 Low,
164}
165
166impl Confidence {
167 #[must_use]
169 pub const fn label(&self) -> &'static str {
170 match self {
171 Self::High => "high",
172 Self::Medium => "medium",
173 Self::Low => "low",
174 }
175 }
176}
177
178#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
183#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
184pub struct TargetEvidence {
185 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub unused_exports: Vec<String>,
188 #[serde(default, skip_serializing_if = "Vec::is_empty")]
190 pub complex_functions: Vec<EvidenceFunction>,
191 #[serde(default, skip_serializing_if = "Vec::is_empty")]
193 pub cycle_path: Vec<String>,
194 #[serde(default, skip_serializing_if = "Vec::is_empty")]
196 pub direct_callers: Vec<DirectCallerEvidence>,
197 #[serde(default, skip_serializing_if = "Vec::is_empty")]
199 pub clone_siblings: Vec<CloneSiblingEvidence>,
200 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 pub co_change_partners: Vec<CoChangePartnerEvidence>,
204}
205
206#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
208#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
209pub struct CoChangePartnerEvidence {
210 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
212 pub path: std::path::PathBuf,
213 pub shared_revisions: u32,
215 pub degree: f64,
217}
218
219#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
221#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
222pub struct DirectCallerEvidence {
223 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
225 pub path: std::path::PathBuf,
226 #[serde(default, skip_serializing_if = "Vec::is_empty")]
228 pub symbols: Vec<DirectCallerSymbolEvidence>,
229}
230
231#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
233#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
234pub struct DirectCallerSymbolEvidence {
235 pub imported: String,
237 pub local: String,
239 pub type_only: bool,
241}
242
243#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
245#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
246pub struct CloneSiblingEvidence {
247 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
249 pub path: std::path::PathBuf,
250 pub start_line: usize,
252 pub end_line: usize,
254 pub fingerprint: String,
256}
257
258#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
260#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
261pub struct EvidenceFunction {
262 pub name: String,
264 pub line: u32,
266 pub cognitive: u16,
268}
269
270#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
273#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
274pub struct RefactoringTarget {
275 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
277 pub path: std::path::PathBuf,
278 pub priority: f64,
280 pub efficiency: f64,
283 pub recommendation: String,
285 pub category: RecommendationCategory,
287 pub effort: EffortEstimate,
289 pub confidence: Confidence,
291 #[serde(default, skip_serializing_if = "Vec::is_empty")]
294 #[cfg_attr(feature = "schema", schemars(default))]
295 pub factors: Vec<ContributingFactor>,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub evidence: Option<TargetEvidence>,
299}
300
301#[cfg(test)]
302#[allow(
303 clippy::unwrap_used,
304 reason = "tests use unwrap to keep serialization assertions concise"
305)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn category_labels_are_unique() {
311 let categories = [
312 RecommendationCategory::UrgentChurnComplexity,
313 RecommendationCategory::BreakCircularDependency,
314 RecommendationCategory::SplitHighImpact,
315 RecommendationCategory::RemoveDeadCode,
316 RecommendationCategory::ExtractComplexFunctions,
317 RecommendationCategory::ExtractDependencies,
318 RecommendationCategory::AddTestCoverage,
319 RecommendationCategory::ResolveHiddenCoupling,
320 ];
321 let labels: Vec<&str> = categories
322 .iter()
323 .map(RecommendationCategory::label)
324 .collect();
325 let unique: std::collections::BTreeSet<&&str> = labels.iter().collect();
326 assert_eq!(labels.len(), unique.len(), "category labels must be unique");
327 }
328
329 #[test]
330 fn category_serializes_as_snake_case() {
331 let json = serde_json::to_string(&RecommendationCategory::UrgentChurnComplexity).unwrap();
332 assert_eq!(json, r#""urgent_churn_complexity""#);
333
334 let json = serde_json::to_string(&RecommendationCategory::BreakCircularDependency).unwrap();
335 assert_eq!(json, r#""break_circular_dependency""#);
336 }
337
338 #[test]
339 fn refactoring_target_skips_empty_factors() {
340 let target = RefactoringTarget {
341 path: std::path::PathBuf::from("/src/foo.ts"),
342 priority: 75.0,
343 efficiency: 75.0,
344 recommendation: "Test recommendation".into(),
345 category: RecommendationCategory::RemoveDeadCode,
346 effort: EffortEstimate::Low,
347 confidence: Confidence::High,
348 factors: vec![],
349 evidence: None,
350 };
351 let json = serde_json::to_string(&target).unwrap();
352 assert!(!json.contains("factors"));
353 assert!(!json.contains("evidence"));
354 }
355
356 #[test]
357 fn effort_numeric_values() {
358 assert!((EffortEstimate::Low.numeric() - 1.0).abs() < f64::EPSILON);
359 assert!((EffortEstimate::Medium.numeric() - 2.0).abs() < f64::EPSILON);
360 assert!((EffortEstimate::High.numeric() - 3.0).abs() < f64::EPSILON);
361 }
362
363 #[test]
364 fn confidence_serializes_as_snake_case() {
365 let json = serde_json::to_string(&Confidence::High).unwrap();
366 assert_eq!(json, r#""high""#);
367 let json = serde_json::to_string(&Confidence::Medium).unwrap();
368 assert_eq!(json, r#""medium""#);
369 let json = serde_json::to_string(&Confidence::Low).unwrap();
370 assert_eq!(json, r#""low""#);
371 }
372
373 #[test]
374 fn contributing_factor_serializes_correctly() {
375 let factor = ContributingFactor {
376 metric: "fan_in",
377 value: 15.0,
378 threshold: 10.0,
379 detail: "15 files depend on this".into(),
380 };
381 let json = serde_json::to_string(&factor).unwrap();
382 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
383 assert_eq!(parsed["metric"], "fan_in");
384 assert_eq!(parsed["value"], 15.0);
385 assert_eq!(parsed["threshold"], 10.0);
386 }
387
388 #[test]
389 fn category_compact_labels_are_unique() {
390 let categories = [
391 RecommendationCategory::UrgentChurnComplexity,
392 RecommendationCategory::BreakCircularDependency,
393 RecommendationCategory::SplitHighImpact,
394 RecommendationCategory::RemoveDeadCode,
395 RecommendationCategory::ExtractComplexFunctions,
396 RecommendationCategory::ExtractDependencies,
397 RecommendationCategory::AddTestCoverage,
398 RecommendationCategory::ResolveHiddenCoupling,
399 ];
400 let labels: Vec<&str> = categories
401 .iter()
402 .map(RecommendationCategory::compact_label)
403 .collect();
404 let unique: std::collections::BTreeSet<&&str> = labels.iter().collect();
405 assert_eq!(labels.len(), unique.len(), "compact labels must be unique");
406 }
407
408 #[test]
409 fn category_compact_labels_have_no_spaces() {
410 let categories = [
411 RecommendationCategory::UrgentChurnComplexity,
412 RecommendationCategory::BreakCircularDependency,
413 RecommendationCategory::SplitHighImpact,
414 RecommendationCategory::RemoveDeadCode,
415 RecommendationCategory::ExtractComplexFunctions,
416 RecommendationCategory::ExtractDependencies,
417 RecommendationCategory::AddTestCoverage,
418 RecommendationCategory::ResolveHiddenCoupling,
419 ];
420 for cat in &categories {
421 assert!(
422 !cat.compact_label().contains(' '),
423 "compact_label for {:?} should not contain spaces: '{}'",
424 cat,
425 cat.compact_label()
426 );
427 }
428 }
429
430 #[test]
431 fn effort_serializes_as_snake_case() {
432 assert_eq!(
433 serde_json::to_string(&EffortEstimate::Low).unwrap(),
434 r#""low""#
435 );
436 assert_eq!(
437 serde_json::to_string(&EffortEstimate::Medium).unwrap(),
438 r#""medium""#
439 );
440 assert_eq!(
441 serde_json::to_string(&EffortEstimate::High).unwrap(),
442 r#""high""#
443 );
444 }
445
446 #[test]
447 fn target_evidence_skips_empty_fields() {
448 let evidence = TargetEvidence {
449 unused_exports: vec![],
450 complex_functions: vec![],
451 cycle_path: vec![],
452 direct_callers: vec![],
453 clone_siblings: vec![],
454 co_change_partners: vec![],
455 };
456 let json = serde_json::to_string(&evidence).unwrap();
457 assert!(!json.contains("unused_exports"));
458 assert!(!json.contains("complex_functions"));
459 assert!(!json.contains("cycle_path"));
460 assert!(!json.contains("direct_callers"));
461 assert!(!json.contains("clone_siblings"));
462 }
463
464 #[test]
465 fn target_evidence_with_data() {
466 let evidence = TargetEvidence {
467 unused_exports: vec!["foo".to_string(), "bar".to_string()],
468 complex_functions: vec![EvidenceFunction {
469 name: "processData".into(),
470 line: 42,
471 cognitive: 30,
472 }],
473 cycle_path: vec![],
474 direct_callers: vec![DirectCallerEvidence {
475 path: "src/consumer.ts".into(),
476 symbols: vec![DirectCallerSymbolEvidence {
477 imported: "processData".into(),
478 local: "processData".into(),
479 type_only: false,
480 }],
481 }],
482 clone_siblings: vec![CloneSiblingEvidence {
483 path: "src/peer.ts".into(),
484 start_line: 12,
485 end_line: 20,
486 fingerprint: "dup:12345678".into(),
487 }],
488 co_change_partners: vec![],
489 };
490 let json = serde_json::to_string(&evidence).unwrap();
491 assert!(json.contains("unused_exports"));
492 assert!(json.contains("complex_functions"));
493 assert!(json.contains("processData"));
494 assert!(json.contains("direct_callers"));
495 assert!(json.contains("clone_siblings"));
496 assert!(!json.contains("cycle_path"));
497 }
498}