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}
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, serde::Deserialize)]
77#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
78pub struct ContributingFactor {
79 #[serde(deserialize_with = "crate::static_str::deserialize")]
81 pub metric: crate::static_str::StaticStr,
82 pub value: f64,
84 pub threshold: f64,
86 pub detail: String,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
111#[serde(rename_all = "snake_case")]
112pub enum EffortEstimate {
113 Low,
115 Medium,
117 High,
119}
120
121impl EffortEstimate {
122 #[must_use]
124 pub const fn label(&self) -> &'static str {
125 match self {
126 Self::Low => "low",
127 Self::Medium => "medium",
128 Self::High => "high",
129 }
130 }
131
132 #[must_use]
134 pub const fn numeric(&self) -> f64 {
135 match self {
136 Self::Low => 1.0,
137 Self::Medium => 2.0,
138 Self::High => 3.0,
139 }
140 }
141}
142
143#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
150#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
151#[serde(rename_all = "snake_case")]
152pub enum Confidence {
153 High,
155 Medium,
157 Low,
159}
160
161impl Confidence {
162 #[must_use]
164 pub const fn label(&self) -> &'static str {
165 match self {
166 Self::High => "high",
167 Self::Medium => "medium",
168 Self::Low => "low",
169 }
170 }
171}
172
173#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
178#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
179pub struct TargetEvidence {
180 #[serde(default, skip_serializing_if = "Vec::is_empty")]
182 pub unused_exports: Vec<String>,
183 #[serde(default, skip_serializing_if = "Vec::is_empty")]
185 pub complex_functions: Vec<EvidenceFunction>,
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
188 pub cycle_path: Vec<String>,
189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
191 pub direct_callers: Vec<DirectCallerEvidence>,
192 #[serde(default, skip_serializing_if = "Vec::is_empty")]
194 pub clone_siblings: Vec<CloneSiblingEvidence>,
195}
196
197#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
199#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
200pub struct DirectCallerEvidence {
201 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
203 pub path: std::path::PathBuf,
204 #[serde(default, skip_serializing_if = "Vec::is_empty")]
206 pub symbols: Vec<DirectCallerSymbolEvidence>,
207}
208
209#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
211#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
212pub struct DirectCallerSymbolEvidence {
213 pub imported: String,
215 pub local: String,
217 pub type_only: bool,
219}
220
221#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
223#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
224pub struct CloneSiblingEvidence {
225 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
227 pub path: std::path::PathBuf,
228 pub start_line: usize,
230 pub end_line: usize,
232 pub fingerprint: String,
234}
235
236#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
238#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
239pub struct EvidenceFunction {
240 pub name: String,
242 pub line: u32,
244 pub cognitive: u16,
246}
247
248#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
251#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
252pub struct RefactoringTarget {
253 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
255 pub path: std::path::PathBuf,
256 pub priority: f64,
258 pub efficiency: f64,
261 pub recommendation: String,
263 pub category: RecommendationCategory,
265 pub effort: EffortEstimate,
267 pub confidence: Confidence,
269 #[serde(default, skip_serializing_if = "Vec::is_empty")]
272 #[cfg_attr(feature = "schema", schemars(default))]
273 pub factors: Vec<ContributingFactor>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub evidence: Option<TargetEvidence>,
277}
278
279#[cfg(test)]
280#[allow(
281 clippy::unwrap_used,
282 reason = "tests use unwrap to keep serialization assertions concise"
283)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn category_labels_are_unique() {
289 let categories = [
290 RecommendationCategory::UrgentChurnComplexity,
291 RecommendationCategory::BreakCircularDependency,
292 RecommendationCategory::SplitHighImpact,
293 RecommendationCategory::RemoveDeadCode,
294 RecommendationCategory::ExtractComplexFunctions,
295 RecommendationCategory::ExtractDependencies,
296 RecommendationCategory::AddTestCoverage,
297 ];
298 let labels: Vec<&str> = categories
299 .iter()
300 .map(RecommendationCategory::label)
301 .collect();
302 let unique: std::collections::BTreeSet<&&str> = labels.iter().collect();
303 assert_eq!(labels.len(), unique.len(), "category labels must be unique");
304 }
305
306 #[test]
307 fn category_serializes_as_snake_case() {
308 let json = serde_json::to_string(&RecommendationCategory::UrgentChurnComplexity).unwrap();
309 assert_eq!(json, r#""urgent_churn_complexity""#);
310
311 let json = serde_json::to_string(&RecommendationCategory::BreakCircularDependency).unwrap();
312 assert_eq!(json, r#""break_circular_dependency""#);
313 }
314
315 #[test]
316 fn refactoring_target_skips_empty_factors() {
317 let target = RefactoringTarget {
318 path: std::path::PathBuf::from("/src/foo.ts"),
319 priority: 75.0,
320 efficiency: 75.0,
321 recommendation: "Test recommendation".into(),
322 category: RecommendationCategory::RemoveDeadCode,
323 effort: EffortEstimate::Low,
324 confidence: Confidence::High,
325 factors: vec![],
326 evidence: None,
327 };
328 let json = serde_json::to_string(&target).unwrap();
329 assert!(!json.contains("factors"));
330 assert!(!json.contains("evidence"));
331 }
332
333 #[test]
334 fn effort_numeric_values() {
335 assert!((EffortEstimate::Low.numeric() - 1.0).abs() < f64::EPSILON);
336 assert!((EffortEstimate::Medium.numeric() - 2.0).abs() < f64::EPSILON);
337 assert!((EffortEstimate::High.numeric() - 3.0).abs() < f64::EPSILON);
338 }
339
340 #[test]
341 fn confidence_serializes_as_snake_case() {
342 let json = serde_json::to_string(&Confidence::High).unwrap();
343 assert_eq!(json, r#""high""#);
344 let json = serde_json::to_string(&Confidence::Medium).unwrap();
345 assert_eq!(json, r#""medium""#);
346 let json = serde_json::to_string(&Confidence::Low).unwrap();
347 assert_eq!(json, r#""low""#);
348 }
349
350 #[test]
351 fn contributing_factor_serializes_correctly() {
352 let factor = ContributingFactor {
353 metric: "fan_in",
354 value: 15.0,
355 threshold: 10.0,
356 detail: "15 files depend on this".into(),
357 };
358 let json = serde_json::to_string(&factor).unwrap();
359 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
360 assert_eq!(parsed["metric"], "fan_in");
361 assert_eq!(parsed["value"], 15.0);
362 assert_eq!(parsed["threshold"], 10.0);
363 }
364
365 #[test]
366 fn category_compact_labels_are_unique() {
367 let categories = [
368 RecommendationCategory::UrgentChurnComplexity,
369 RecommendationCategory::BreakCircularDependency,
370 RecommendationCategory::SplitHighImpact,
371 RecommendationCategory::RemoveDeadCode,
372 RecommendationCategory::ExtractComplexFunctions,
373 RecommendationCategory::ExtractDependencies,
374 RecommendationCategory::AddTestCoverage,
375 ];
376 let labels: Vec<&str> = categories
377 .iter()
378 .map(RecommendationCategory::compact_label)
379 .collect();
380 let unique: std::collections::BTreeSet<&&str> = labels.iter().collect();
381 assert_eq!(labels.len(), unique.len(), "compact labels must be unique");
382 }
383
384 #[test]
385 fn category_compact_labels_have_no_spaces() {
386 let categories = [
387 RecommendationCategory::UrgentChurnComplexity,
388 RecommendationCategory::BreakCircularDependency,
389 RecommendationCategory::SplitHighImpact,
390 RecommendationCategory::RemoveDeadCode,
391 RecommendationCategory::ExtractComplexFunctions,
392 RecommendationCategory::ExtractDependencies,
393 RecommendationCategory::AddTestCoverage,
394 ];
395 for cat in &categories {
396 assert!(
397 !cat.compact_label().contains(' '),
398 "compact_label for {:?} should not contain spaces: '{}'",
399 cat,
400 cat.compact_label()
401 );
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]
422 fn target_evidence_skips_empty_fields() {
423 let evidence = TargetEvidence {
424 unused_exports: vec![],
425 complex_functions: vec![],
426 cycle_path: vec![],
427 direct_callers: vec![],
428 clone_siblings: 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 assert!(!json.contains("direct_callers"));
435 assert!(!json.contains("clone_siblings"));
436 }
437
438 #[test]
439 fn target_evidence_with_data() {
440 let evidence = TargetEvidence {
441 unused_exports: vec!["foo".to_string(), "bar".to_string()],
442 complex_functions: vec![EvidenceFunction {
443 name: "processData".into(),
444 line: 42,
445 cognitive: 30,
446 }],
447 cycle_path: vec![],
448 direct_callers: vec![DirectCallerEvidence {
449 path: "src/consumer.ts".into(),
450 symbols: vec![DirectCallerSymbolEvidence {
451 imported: "processData".into(),
452 local: "processData".into(),
453 type_only: false,
454 }],
455 }],
456 clone_siblings: vec![CloneSiblingEvidence {
457 path: "src/peer.ts".into(),
458 start_line: 12,
459 end_line: 20,
460 fingerprint: "dup:12345678".into(),
461 }],
462 };
463 let json = serde_json::to_string(&evidence).unwrap();
464 assert!(json.contains("unused_exports"));
465 assert!(json.contains("complex_functions"));
466 assert!(json.contains("processData"));
467 assert!(json.contains("direct_callers"));
468 assert!(json.contains("clone_siblings"));
469 assert!(!json.contains("cycle_path"));
470 }
471}