use std::error::Error;
use std::io::{self, Write as _};
use std::path::Path;
use crate::config::{DupsConfig, SmellsConfig};
use crate::cycom::markers::markers_for;
use crate::detection::{FunctionDetectionMarkers as _, detect_function_bodies};
use crate::dups;
use crate::loc::counter::LineKind;
use crate::score::{self, ScoringModel, analyzer::Grade};
use crate::util::read_and_classify;
use crate::walk::{ExcludeFilter, WalkConfig};
struct ProjectStats {
avg_function_len: f64,
avg_param_count: f64,
function_count: usize,
dup_ratio: f64,
health_grade: Grade,
}
pub fn run(path: &Path, yes: bool) -> Result<(), Box<dyn Error>> {
let output_path = path.join(".kimun.toml");
if output_path.exists() && !yes {
eprintln!(
"warning: .kimun.toml already exists at {}",
output_path.display()
);
eprint!("Overwrite? [y/N] ");
io::stderr().flush()?;
let mut line = String::new();
io::stdin().read_line(&mut line)?;
if !line.trim().eq_ignore_ascii_case("y") {
println!("Aborted.");
return Ok(());
}
}
let filter = ExcludeFilter::default();
let cfg = WalkConfig::new(path, false, &filter);
print!("Analyzing project...");
io::stdout().flush()?;
let stats = collect_stats(&cfg)?;
println!(" done.\n");
let suggested_max_lines = suggest_max_lines(stats.avg_function_len);
let suggested_max_params = suggest_max_params(stats.avg_param_count);
let suggested_max_dup_ratio = suggest_max_dup_ratio(stats.dup_ratio);
let suggested_fail_below = grade_one_lower(stats.health_grade);
let show_params = stats.function_count > 0 && stats.avg_param_count >= 1.0;
let fn_val = format!("{:.0} lines", stats.avg_function_len);
let param_val = format!("{:.1}", stats.avg_param_count);
let dup_val = format!("{:.1}%", stats.dup_ratio);
let grade_val = stats.health_grade.as_str();
let mut widths = vec![dup_val.len(), grade_val.len()];
if stats.function_count > 0 {
widths.push(fn_val.len());
}
if show_params {
widths.push(param_val.len());
}
let val_width = widths.into_iter().max().unwrap_or(0);
println!("Current state:");
if stats.function_count > 0 {
println!(
" avg function length: {fn_val:<val_width$} → suggested max_lines = {suggested_max_lines}"
);
}
if show_params {
println!(
" avg param count: {param_val:<val_width$} → suggested max_params = {suggested_max_params}"
);
}
println!(
" dup ratio: {dup_val:<val_width$} → suggested max_dup_ratio = {suggested_max_dup_ratio:.1}"
);
println!(
" health score: {grade_val:<val_width$} → suggested fail_below = {}",
suggested_fail_below.as_str()
);
println!();
if !yes {
eprint!("Write .kimun.toml with these values? [Y/n] ");
io::stderr().flush()?;
let mut line = String::new();
io::stdin().read_line(&mut line)?;
if line.trim().eq_ignore_ascii_case("n") {
println!("Aborted.");
return Ok(());
}
}
let toml = build_toml(
suggested_max_lines,
suggested_max_params,
suggested_max_dup_ratio,
suggested_fail_below,
);
std::fs::write(&output_path, toml)?;
println!("Wrote {}", output_path.display());
Ok(())
}
fn collect_stats(cfg: &WalkConfig<'_>) -> Result<ProjectStats, Box<dyn Error>> {
let mut all_lengths: Vec<usize> = Vec::new();
let mut all_params: Vec<usize> = Vec::new();
for (file_path, spec) in cfg.source_files() {
let Some(markers) = markers_for(spec.name) else {
continue;
};
let Ok(Some((lines, kinds))) = read_and_classify(&file_path, spec) else {
continue;
};
let code_lines: Vec<(usize, &str)> = lines
.iter()
.enumerate()
.filter(|(i, _)| kinds.get(*i) == Some(&LineKind::Code))
.map(|(i, l)| (i, l.as_str()))
.collect();
let functions = detect_function_bodies(&lines, &code_lines, markers);
let overhead = if markers.brace_scoped() { 2 } else { 1 };
for func in &functions {
let total = func.code_lines.len();
let body_len = total.saturating_sub(overhead.min(total));
all_lengths.push(body_len);
if let Some(sig_line) = lines.get(func.start_line)
&& let Some(n) = count_params(sig_line)
{
all_params.push(n);
}
}
}
let function_count = all_lengths.len();
let avg_function_len = if function_count == 0 {
0.0
} else {
all_lengths.iter().sum::<usize>() as f64 / function_count as f64
};
let avg_param_count = if all_params.is_empty() {
0.0
} else {
all_params.iter().sum::<usize>() as f64 / all_params.len() as f64
};
let dup_metrics = dups::compute_metrics(cfg, DupsConfig::DEFAULT_MIN_LINES);
let dup_ratio = dup_metrics.percentage();
let score = score::compute_score(cfg, 0, 6, &ScoringModel::Cognitive)?;
let health_grade = score.grade;
Ok(ProjectStats {
avg_function_len,
avg_param_count,
function_count,
dup_ratio,
health_grade,
})
}
fn count_params(sig: &str) -> Option<usize> {
let open = sig.find('(')?;
let after = &sig[open + 1..];
let mut depth = 1usize;
let mut top_level_commas = 0usize;
let mut all_empty = true;
for ch in after.chars() {
match ch {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
break;
}
}
',' if depth == 1 => top_level_commas += 1,
c if depth == 1 && !c.is_whitespace() => all_empty = false,
_ => {}
}
}
if all_empty && top_level_commas == 0 {
Some(0)
} else {
Some(top_level_commas + 1)
}
}
fn suggest_max_lines(avg: f64) -> usize {
if avg < 1.0 {
return SmellsConfig::DEFAULT_MAX_LINES;
}
let next_multiple = (avg as usize).div_ceil(5) * 5;
(next_multiple + 5).max(20)
}
fn suggest_max_params(avg: f64) -> usize {
(avg.ceil() as usize).max(3)
}
fn suggest_max_dup_ratio(ratio: f64) -> f64 {
if ratio < 0.1 {
return 2.0;
}
((ratio * 2.0).ceil() / 2.0).max(2.0)
}
fn grade_one_lower(grade: Grade) -> Grade {
use Grade::*;
match grade {
APlusPlus => APlus,
APlus => A,
A => AMinus,
AMinus => BPlus,
BPlus => B,
B => BMinus,
BMinus => CPlus,
CPlus => C,
C => CMinus,
CMinus => DPlus,
DPlus => D,
D => DMinus,
DMinus => F,
F => FMinus,
FMinus | FMinusMinus => FMinusMinus,
}
}
const TEMPLATE: &str = include_str!("template.toml");
fn build_toml(
max_lines: usize,
max_params: usize,
max_dup_ratio: f64,
fail_below: Grade,
) -> String {
TEMPLATE
.replace("{{max_lines}}", &max_lines.to_string())
.replace("{{max_params}}", &max_params.to_string())
.replace("{{max_dup_ratio}}", &format!("{max_dup_ratio:.1}"))
.replace("{{fail_below}}", fail_below.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn suggest_max_lines_rounds_up_and_adds_headroom() {
assert_eq!(suggest_max_lines(38.0), 45); assert_eq!(suggest_max_lines(40.0), 45); assert_eq!(suggest_max_lines(41.0), 50); assert_eq!(suggest_max_lines(0.0), 50); }
#[test]
fn suggest_max_params_ceilings_avg() {
assert_eq!(suggest_max_params(3.2), 4);
assert_eq!(suggest_max_params(4.0), 4);
assert_eq!(suggest_max_params(1.5), 3); }
#[test]
fn suggest_max_dup_ratio_rounds_to_next_half() {
assert_eq!(suggest_max_dup_ratio(4.1), 4.5); assert_eq!(suggest_max_dup_ratio(8.6), 9.0); assert_eq!(suggest_max_dup_ratio(0.0), 2.0); assert_eq!(suggest_max_dup_ratio(2.0), 2.0); assert_eq!(suggest_max_dup_ratio(4.5), 4.5); }
#[test]
fn grade_one_lower_steps_down() {
assert_eq!(grade_one_lower(Grade::BPlus), Grade::B);
assert_eq!(grade_one_lower(Grade::A), Grade::AMinus);
assert_eq!(grade_one_lower(Grade::FMinusMinus), Grade::FMinusMinus);
}
#[test]
fn count_params_basic() {
assert_eq!(count_params("fn foo(a: i32, b: i32)"), Some(2));
assert_eq!(count_params("fn bar()"), Some(0));
assert_eq!(count_params("fn baz(x: Vec<(i32, i32)>, y: i32)"), Some(2));
}
}