use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MetricKind {
Complexity,
FunctionLength,
NestingDepth,
ParameterCount,
FileLength,
ClassMethodCount,
FanIn,
FanOut,
}
impl MetricKind {
pub fn all() -> &'static [MetricKind] {
&[
MetricKind::Complexity,
MetricKind::FunctionLength,
MetricKind::NestingDepth,
MetricKind::ParameterCount,
MetricKind::FileLength,
MetricKind::ClassMethodCount,
MetricKind::FanIn,
MetricKind::FanOut,
]
}
pub fn name(&self) -> &'static str {
match self {
MetricKind::Complexity => "complexity",
MetricKind::FunctionLength => "function_length",
MetricKind::NestingDepth => "nesting_depth",
MetricKind::ParameterCount => "parameter_count",
MetricKind::FileLength => "file_length",
MetricKind::ClassMethodCount => "class_method_count",
MetricKind::FanIn => "fan_in",
MetricKind::FanOut => "fan_out",
}
}
}
impl std::fmt::Display for MetricKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricDistribution {
pub count: usize,
pub mean: f64,
pub stddev: f64,
pub p50: f64,
pub p75: f64,
pub p90: f64,
pub p95: f64,
pub max: f64,
pub confident: bool,
}
impl MetricDistribution {
pub fn from_values(values: &mut [f64]) -> Self {
if values.is_empty() {
return Self {
count: 0,
mean: 0.0,
stddev: 0.0,
p50: 0.0,
p75: 0.0,
p90: 0.0,
p95: 0.0,
max: 0.0,
confident: false,
};
}
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = values.len();
let mean = values.iter().sum::<f64>() / n as f64;
let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64;
let stddev = variance.sqrt();
Self {
count: n,
mean,
stddev,
p50: percentile(values, 50.0),
p75: percentile(values, 75.0),
p90: percentile(values, 90.0),
p95: percentile(values, 95.0),
max: *values.last().unwrap_or(&0.0),
confident: n >= 40,
}
}
pub fn adaptive_warn(&self, default: f64) -> f64 {
if !self.confident {
return default;
}
default.max(self.p90)
}
pub fn adaptive_high(&self, default: f64) -> f64 {
if !self.confident {
return default;
}
default.max(self.p95)
}
}
fn percentile(sorted: &[f64], pct: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
let idx = (pct / 100.0 * (sorted.len() - 1) as f64).round() as usize;
sorted[idx.min(sorted.len() - 1)]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StyleProfile {
pub version: u32,
pub generated_at: String,
pub commit_sha: Option<String>,
pub total_files: usize,
pub total_functions: usize,
pub metrics: HashMap<MetricKind, MetricDistribution>,
}
impl StyleProfile {
pub const VERSION: u32 = 1;
pub const FILENAME: &'static str = "style-profile.json";
pub fn load(repo_path: &Path) -> Option<Self> {
let profile_path = repo_path.join(".repotoire").join(Self::FILENAME);
let data = std::fs::read_to_string(&profile_path).ok()?;
let profile: Self = serde_json::from_str(&data).ok()?;
if profile.version != Self::VERSION {
tracing::warn!(
"Style profile version mismatch ({} vs {}), ignoring",
profile.version,
Self::VERSION
);
return None;
}
Some(profile)
}
pub fn save(&self, repo_path: &Path) -> anyhow::Result<()> {
let dir = repo_path.join(".repotoire");
std::fs::create_dir_all(&dir)?;
let path = dir.join(Self::FILENAME);
let json = serde_json::to_string_pretty(self)?;
std::fs::write(&path, json)?;
Ok(())
}
pub fn function_count(&self) -> usize {
self.total_functions
}
pub fn write_table(&self, w: &mut impl std::io::Write) -> std::io::Result<()> {
use console::style;
writeln!(w, "\n📊 Style Profile\n")?;
writeln!(
w,
" Functions: {} Files: {}\n",
self.total_functions, self.total_files
)?;
for kind in MetricKind::all() {
let Some(dist) = self.get(*kind) else {
continue;
};
if dist.count == 0 {
continue;
}
let confidence = if dist.confident {
style("✓").green().to_string()
} else {
style("⚠ low sample").yellow().to_string()
};
writeln!(
w,
" {:<20} mean={:>6.1} p50={:>5.0} p90={:>5.0} p95={:>5.0} max={:>5.0} n={:<5} {}",
kind.name(),
dist.mean,
dist.p50,
dist.p90,
dist.p95,
dist.max,
dist.count,
confidence
)?;
}
Ok(())
}
pub fn print_table(&self) {
let _ = self.write_table(&mut std::io::stdout().lock());
}
pub fn get(&self, kind: MetricKind) -> Option<&MetricDistribution> {
self.metrics.get(&kind)
}
pub fn threshold_warn(&self, kind: MetricKind, default: f64) -> f64 {
self.metrics
.get(&kind)
.map(|d| d.adaptive_warn(default))
.unwrap_or(default)
}
pub fn threshold_high(&self, kind: MetricKind, default: f64) -> f64 {
self.metrics
.get(&kind)
.map(|d| d.adaptive_high(default))
.unwrap_or(default)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_distribution_from_values() {
let mut values: Vec<f64> = (1..=100).map(|i| i as f64).collect();
let dist = MetricDistribution::from_values(&mut values);
assert_eq!(dist.count, 100);
assert!((dist.mean - 50.5).abs() < 0.1);
assert!((dist.p50 - 50.5).abs() < 2.0);
assert!((dist.p90 - 90.0).abs() < 2.0);
assert!((dist.p95 - 95.0).abs() < 2.0);
assert!((dist.max - 100.0).abs() < 0.1);
assert!(dist.confident);
}
#[test]
fn test_small_sample_not_confident() {
let mut values = vec![1.0, 2.0, 3.0];
let dist = MetricDistribution::from_values(&mut values);
assert!(!dist.confident);
}
#[test]
fn test_adaptive_threshold_uses_default_when_not_confident() {
let mut values = vec![1.0, 2.0, 3.0];
let dist = MetricDistribution::from_values(&mut values);
assert_eq!(dist.adaptive_warn(10.0), 10.0); }
#[test]
fn test_adaptive_threshold_uses_p90_when_higher() {
let mut values: Vec<f64> = (1..=100).map(|i| i as f64).collect();
let dist = MetricDistribution::from_values(&mut values);
assert!(dist.adaptive_warn(5.0) >= 89.0);
assert_eq!(dist.adaptive_warn(100.0), 100.0);
}
#[test]
fn test_empty_values() {
let mut values: Vec<f64> = vec![];
let dist = MetricDistribution::from_values(&mut values);
assert_eq!(dist.count, 0);
assert!(!dist.confident);
}
}
#[cfg(test)]
mod tests_function_count {
use super::*;
#[test]
fn function_count_matches_total_functions_field() {
let mut profile = StyleProfile {
version: StyleProfile::VERSION,
generated_at: String::new(),
commit_sha: None,
total_files: 5,
total_functions: 42,
metrics: HashMap::new(),
};
assert_eq!(profile.function_count(), 42);
profile.total_functions = 0;
assert_eq!(profile.function_count(), 0);
}
}