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, 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)]
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, serde::Deserialize)]
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, serde::Deserialize)]
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, Default, 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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
190 pub direct_callers: Vec<DirectCallerEvidence>,
191 #[serde(default, skip_serializing_if = "Vec::is_empty")]
193 pub clone_siblings: Vec<CloneSiblingEvidence>,
194}
195
196#[derive(Debug, Clone, serde::Serialize)]
198#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
199pub struct DirectCallerEvidence {
200 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
202 pub path: std::path::PathBuf,
203 #[serde(default, skip_serializing_if = "Vec::is_empty")]
205 pub symbols: Vec<DirectCallerSymbolEvidence>,
206}
207
208#[derive(Debug, Clone, serde::Serialize)]
210#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
211pub struct DirectCallerSymbolEvidence {
212 pub imported: String,
214 pub local: String,
216 pub type_only: bool,
218}
219
220#[derive(Debug, Clone, serde::Serialize)]
222#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
223pub struct CloneSiblingEvidence {
224 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
226 pub path: std::path::PathBuf,
227 pub start_line: usize,
229 pub end_line: usize,
231 pub fingerprint: String,
233}
234
235#[derive(Debug, Clone, serde::Serialize)]
237#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
238pub struct EvidenceFunction {
239 pub name: String,
241 pub line: u32,
243 pub cognitive: u16,
245}
246
247#[derive(Debug, Clone, serde::Serialize)]
250#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
251pub struct RefactoringTarget {
252 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
254 pub path: std::path::PathBuf,
255 pub priority: f64,
257 pub efficiency: f64,
260 pub recommendation: String,
262 pub category: RecommendationCategory,
264 pub effort: EffortEstimate,
266 pub confidence: Confidence,
268 #[serde(default, skip_serializing_if = "Vec::is_empty")]
271 #[cfg_attr(feature = "schema", schemars(default))]
272 pub factors: Vec<ContributingFactor>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub evidence: Option<TargetEvidence>,
276}
277
278#[cfg(test)]
279#[allow(
280 clippy::unwrap_used,
281 reason = "tests use unwrap to keep serialization assertions concise"
282)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn category_labels_are_non_empty() {
288 let categories = [
289 RecommendationCategory::UrgentChurnComplexity,
290 RecommendationCategory::BreakCircularDependency,
291 RecommendationCategory::SplitHighImpact,
292 RecommendationCategory::RemoveDeadCode,
293 RecommendationCategory::ExtractComplexFunctions,
294 RecommendationCategory::ExtractDependencies,
295 RecommendationCategory::AddTestCoverage,
296 ];
297 for cat in &categories {
298 assert!(!cat.label().is_empty(), "{cat:?} should have a label");
299 }
300 }
301
302 #[test]
303 fn category_labels_are_unique() {
304 let categories = [
305 RecommendationCategory::UrgentChurnComplexity,
306 RecommendationCategory::BreakCircularDependency,
307 RecommendationCategory::SplitHighImpact,
308 RecommendationCategory::RemoveDeadCode,
309 RecommendationCategory::ExtractComplexFunctions,
310 RecommendationCategory::ExtractDependencies,
311 RecommendationCategory::AddTestCoverage,
312 ];
313 let labels: Vec<&str> = categories
314 .iter()
315 .map(RecommendationCategory::label)
316 .collect();
317 let unique: std::collections::BTreeSet<&&str> = labels.iter().collect();
318 assert_eq!(labels.len(), unique.len(), "category labels must be unique");
319 }
320
321 #[test]
322 fn category_serializes_as_snake_case() {
323 let json = serde_json::to_string(&RecommendationCategory::UrgentChurnComplexity).unwrap();
324 assert_eq!(json, r#""urgent_churn_complexity""#);
325
326 let json = serde_json::to_string(&RecommendationCategory::BreakCircularDependency).unwrap();
327 assert_eq!(json, r#""break_circular_dependency""#);
328 }
329
330 #[test]
331 fn refactoring_target_skips_empty_factors() {
332 let target = RefactoringTarget {
333 path: std::path::PathBuf::from("/src/foo.ts"),
334 priority: 75.0,
335 efficiency: 75.0,
336 recommendation: "Test recommendation".into(),
337 category: RecommendationCategory::RemoveDeadCode,
338 effort: EffortEstimate::Low,
339 confidence: Confidence::High,
340 factors: vec![],
341 evidence: None,
342 };
343 let json = serde_json::to_string(&target).unwrap();
344 assert!(!json.contains("factors"));
345 assert!(!json.contains("evidence"));
346 }
347
348 #[test]
349 fn effort_numeric_values() {
350 assert!((EffortEstimate::Low.numeric() - 1.0).abs() < f64::EPSILON);
351 assert!((EffortEstimate::Medium.numeric() - 2.0).abs() < f64::EPSILON);
352 assert!((EffortEstimate::High.numeric() - 3.0).abs() < f64::EPSILON);
353 }
354
355 #[test]
356 fn confidence_labels_are_non_empty() {
357 let levels = [Confidence::High, Confidence::Medium, Confidence::Low];
358 for level in &levels {
359 assert!(!level.label().is_empty(), "{level:?} should have a label");
360 }
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_non_empty() {
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 ];
399 for cat in &categories {
400 assert!(
401 !cat.compact_label().is_empty(),
402 "{cat:?} should have a compact_label"
403 );
404 }
405 }
406
407 #[test]
408 fn category_compact_labels_are_unique() {
409 let categories = [
410 RecommendationCategory::UrgentChurnComplexity,
411 RecommendationCategory::BreakCircularDependency,
412 RecommendationCategory::SplitHighImpact,
413 RecommendationCategory::RemoveDeadCode,
414 RecommendationCategory::ExtractComplexFunctions,
415 RecommendationCategory::ExtractDependencies,
416 RecommendationCategory::AddTestCoverage,
417 ];
418 let labels: Vec<&str> = categories
419 .iter()
420 .map(RecommendationCategory::compact_label)
421 .collect();
422 let unique: std::collections::BTreeSet<&&str> = labels.iter().collect();
423 assert_eq!(labels.len(), unique.len(), "compact labels must be unique");
424 }
425
426 #[test]
427 fn category_compact_labels_have_no_spaces() {
428 let categories = [
429 RecommendationCategory::UrgentChurnComplexity,
430 RecommendationCategory::BreakCircularDependency,
431 RecommendationCategory::SplitHighImpact,
432 RecommendationCategory::RemoveDeadCode,
433 RecommendationCategory::ExtractComplexFunctions,
434 RecommendationCategory::ExtractDependencies,
435 RecommendationCategory::AddTestCoverage,
436 ];
437 for cat in &categories {
438 assert!(
439 !cat.compact_label().contains(' '),
440 "compact_label for {:?} should not contain spaces: '{}'",
441 cat,
442 cat.compact_label()
443 );
444 }
445 }
446
447 #[test]
448 fn effort_labels_are_non_empty() {
449 let efforts = [
450 EffortEstimate::Low,
451 EffortEstimate::Medium,
452 EffortEstimate::High,
453 ];
454 for effort in &efforts {
455 assert!(!effort.label().is_empty(), "{effort:?} should have a label");
456 }
457 }
458
459 #[test]
460 fn effort_serializes_as_snake_case() {
461 assert_eq!(
462 serde_json::to_string(&EffortEstimate::Low).unwrap(),
463 r#""low""#
464 );
465 assert_eq!(
466 serde_json::to_string(&EffortEstimate::Medium).unwrap(),
467 r#""medium""#
468 );
469 assert_eq!(
470 serde_json::to_string(&EffortEstimate::High).unwrap(),
471 r#""high""#
472 );
473 }
474
475 #[test]
476 fn target_evidence_skips_empty_fields() {
477 let evidence = TargetEvidence {
478 unused_exports: vec![],
479 complex_functions: vec![],
480 cycle_path: vec![],
481 direct_callers: vec![],
482 clone_siblings: vec![],
483 };
484 let json = serde_json::to_string(&evidence).unwrap();
485 assert!(!json.contains("unused_exports"));
486 assert!(!json.contains("complex_functions"));
487 assert!(!json.contains("cycle_path"));
488 assert!(!json.contains("direct_callers"));
489 assert!(!json.contains("clone_siblings"));
490 }
491
492 #[test]
493 fn target_evidence_with_data() {
494 let evidence = TargetEvidence {
495 unused_exports: vec!["foo".to_string(), "bar".to_string()],
496 complex_functions: vec![EvidenceFunction {
497 name: "processData".into(),
498 line: 42,
499 cognitive: 30,
500 }],
501 cycle_path: vec![],
502 direct_callers: vec![DirectCallerEvidence {
503 path: "src/consumer.ts".into(),
504 symbols: vec![DirectCallerSymbolEvidence {
505 imported: "processData".into(),
506 local: "processData".into(),
507 type_only: false,
508 }],
509 }],
510 clone_siblings: vec![CloneSiblingEvidence {
511 path: "src/peer.ts".into(),
512 start_line: 12,
513 end_line: 20,
514 fingerprint: "dup:12345678".into(),
515 }],
516 };
517 let json = serde_json::to_string(&evidence).unwrap();
518 assert!(json.contains("unused_exports"));
519 assert!(json.contains("complex_functions"));
520 assert!(json.contains("processData"));
521 assert!(json.contains("direct_callers"));
522 assert!(json.contains("clone_siblings"));
523 assert!(!json.contains("cycle_path"));
524 }
525}