#[derive(Debug, Clone, serde::Serialize)]
#[allow(
clippy::struct_field_names,
reason = "triggered in bin but not lib — #[expect] would be unfulfilled in lib"
)]
pub struct TargetThresholds {
pub fan_in_p95: f64,
pub fan_in_p75: f64,
pub fan_out_p95: f64,
pub fan_out_p90: usize,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RecommendationCategory {
UrgentChurnComplexity,
BreakCircularDependency,
SplitHighImpact,
RemoveDeadCode,
ExtractComplexFunctions,
ExtractDependencies,
AddTestCoverage,
}
impl RecommendationCategory {
#[must_use]
pub const fn label(&self) -> &'static str {
match self {
Self::UrgentChurnComplexity => "churn+complexity",
Self::BreakCircularDependency => "circular dependency",
Self::SplitHighImpact => "high impact",
Self::RemoveDeadCode => "dead code",
Self::ExtractComplexFunctions => "complexity",
Self::ExtractDependencies => "coupling",
Self::AddTestCoverage => "untested risk",
}
}
#[must_use]
pub const fn compact_label(&self) -> &'static str {
match self {
Self::UrgentChurnComplexity => "churn_complexity",
Self::BreakCircularDependency => "circular_dep",
Self::SplitHighImpact => "high_impact",
Self::RemoveDeadCode => "dead_code",
Self::ExtractComplexFunctions => "complexity",
Self::ExtractDependencies => "coupling",
Self::AddTestCoverage => "untested_risk",
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ContributingFactor {
pub metric: &'static str,
pub value: f64,
pub threshold: f64,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EffortEstimate {
Low,
Medium,
High,
}
impl EffortEstimate {
#[must_use]
pub const fn label(&self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
}
}
#[must_use]
pub const fn numeric(&self) -> f64 {
match self {
Self::Low => 1.0,
Self::Medium => 2.0,
Self::High => 3.0,
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Confidence {
High,
Medium,
Low,
}
impl Confidence {
#[must_use]
pub const fn label(&self) -> &'static str {
match self {
Self::High => "high",
Self::Medium => "medium",
Self::Low => "low",
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct TargetEvidence {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub unused_exports: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub complex_functions: Vec<EvidenceFunction>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub cycle_path: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct EvidenceFunction {
pub name: String,
pub line: u32,
pub cognitive: u16,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct RefactoringTarget {
pub path: std::path::PathBuf,
pub priority: f64,
pub efficiency: f64,
pub recommendation: String,
pub category: RecommendationCategory,
pub effort: EffortEstimate,
pub confidence: Confidence,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub factors: Vec<ContributingFactor>,
#[serde(skip_serializing_if = "Option::is_none")]
pub evidence: Option<TargetEvidence>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn category_labels_are_non_empty() {
let categories = [
RecommendationCategory::UrgentChurnComplexity,
RecommendationCategory::BreakCircularDependency,
RecommendationCategory::SplitHighImpact,
RecommendationCategory::RemoveDeadCode,
RecommendationCategory::ExtractComplexFunctions,
RecommendationCategory::ExtractDependencies,
RecommendationCategory::AddTestCoverage,
];
for cat in &categories {
assert!(!cat.label().is_empty(), "{cat:?} should have a label");
}
}
#[test]
fn category_labels_are_unique() {
let categories = [
RecommendationCategory::UrgentChurnComplexity,
RecommendationCategory::BreakCircularDependency,
RecommendationCategory::SplitHighImpact,
RecommendationCategory::RemoveDeadCode,
RecommendationCategory::ExtractComplexFunctions,
RecommendationCategory::ExtractDependencies,
RecommendationCategory::AddTestCoverage,
];
let labels: Vec<&str> = categories
.iter()
.map(RecommendationCategory::label)
.collect();
let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
assert_eq!(labels.len(), unique.len(), "category labels must be unique");
}
#[test]
fn category_serializes_as_snake_case() {
let json = serde_json::to_string(&RecommendationCategory::UrgentChurnComplexity).unwrap();
assert_eq!(json, r#""urgent_churn_complexity""#);
let json = serde_json::to_string(&RecommendationCategory::BreakCircularDependency).unwrap();
assert_eq!(json, r#""break_circular_dependency""#);
}
#[test]
fn refactoring_target_skips_empty_factors() {
let target = RefactoringTarget {
path: std::path::PathBuf::from("/src/foo.ts"),
priority: 75.0,
efficiency: 75.0,
recommendation: "Test recommendation".into(),
category: RecommendationCategory::RemoveDeadCode,
effort: EffortEstimate::Low,
confidence: Confidence::High,
factors: vec![],
evidence: None,
};
let json = serde_json::to_string(&target).unwrap();
assert!(!json.contains("factors"));
assert!(!json.contains("evidence"));
}
#[test]
fn effort_numeric_values() {
assert!((EffortEstimate::Low.numeric() - 1.0).abs() < f64::EPSILON);
assert!((EffortEstimate::Medium.numeric() - 2.0).abs() < f64::EPSILON);
assert!((EffortEstimate::High.numeric() - 3.0).abs() < f64::EPSILON);
}
#[test]
fn confidence_labels_are_non_empty() {
let levels = [Confidence::High, Confidence::Medium, Confidence::Low];
for level in &levels {
assert!(!level.label().is_empty(), "{level:?} should have a label");
}
}
#[test]
fn confidence_serializes_as_snake_case() {
let json = serde_json::to_string(&Confidence::High).unwrap();
assert_eq!(json, r#""high""#);
let json = serde_json::to_string(&Confidence::Medium).unwrap();
assert_eq!(json, r#""medium""#);
let json = serde_json::to_string(&Confidence::Low).unwrap();
assert_eq!(json, r#""low""#);
}
#[test]
fn contributing_factor_serializes_correctly() {
let factor = ContributingFactor {
metric: "fan_in",
value: 15.0,
threshold: 10.0,
detail: "15 files depend on this".into(),
};
let json = serde_json::to_string(&factor).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["metric"], "fan_in");
assert_eq!(parsed["value"], 15.0);
assert_eq!(parsed["threshold"], 10.0);
}
#[test]
fn category_compact_labels_are_non_empty() {
let categories = [
RecommendationCategory::UrgentChurnComplexity,
RecommendationCategory::BreakCircularDependency,
RecommendationCategory::SplitHighImpact,
RecommendationCategory::RemoveDeadCode,
RecommendationCategory::ExtractComplexFunctions,
RecommendationCategory::ExtractDependencies,
RecommendationCategory::AddTestCoverage,
];
for cat in &categories {
assert!(
!cat.compact_label().is_empty(),
"{cat:?} should have a compact_label"
);
}
}
#[test]
fn category_compact_labels_are_unique() {
let categories = [
RecommendationCategory::UrgentChurnComplexity,
RecommendationCategory::BreakCircularDependency,
RecommendationCategory::SplitHighImpact,
RecommendationCategory::RemoveDeadCode,
RecommendationCategory::ExtractComplexFunctions,
RecommendationCategory::ExtractDependencies,
RecommendationCategory::AddTestCoverage,
];
let labels: Vec<&str> = categories
.iter()
.map(RecommendationCategory::compact_label)
.collect();
let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
assert_eq!(labels.len(), unique.len(), "compact labels must be unique");
}
#[test]
fn category_compact_labels_have_no_spaces() {
let categories = [
RecommendationCategory::UrgentChurnComplexity,
RecommendationCategory::BreakCircularDependency,
RecommendationCategory::SplitHighImpact,
RecommendationCategory::RemoveDeadCode,
RecommendationCategory::ExtractComplexFunctions,
RecommendationCategory::ExtractDependencies,
RecommendationCategory::AddTestCoverage,
];
for cat in &categories {
assert!(
!cat.compact_label().contains(' '),
"compact_label for {:?} should not contain spaces: '{}'",
cat,
cat.compact_label()
);
}
}
#[test]
fn effort_labels_are_non_empty() {
let efforts = [
EffortEstimate::Low,
EffortEstimate::Medium,
EffortEstimate::High,
];
for effort in &efforts {
assert!(!effort.label().is_empty(), "{effort:?} should have a label");
}
}
#[test]
fn effort_serializes_as_snake_case() {
assert_eq!(
serde_json::to_string(&EffortEstimate::Low).unwrap(),
r#""low""#
);
assert_eq!(
serde_json::to_string(&EffortEstimate::Medium).unwrap(),
r#""medium""#
);
assert_eq!(
serde_json::to_string(&EffortEstimate::High).unwrap(),
r#""high""#
);
}
#[test]
fn target_evidence_skips_empty_fields() {
let evidence = TargetEvidence {
unused_exports: vec![],
complex_functions: vec![],
cycle_path: vec![],
};
let json = serde_json::to_string(&evidence).unwrap();
assert!(!json.contains("unused_exports"));
assert!(!json.contains("complex_functions"));
assert!(!json.contains("cycle_path"));
}
#[test]
fn target_evidence_with_data() {
let evidence = TargetEvidence {
unused_exports: vec!["foo".to_string(), "bar".to_string()],
complex_functions: vec![EvidenceFunction {
name: "processData".into(),
line: 42,
cognitive: 30,
}],
cycle_path: vec![],
};
let json = serde_json::to_string(&evidence).unwrap();
assert!(json.contains("unused_exports"));
assert!(json.contains("complex_functions"));
assert!(json.contains("processData"));
assert!(!json.contains("cycle_path"));
}
}