use std::fs;
use std::io::{self, BufRead, Write};
use std::path::Path;
use anyhow::{Context, Result, bail};
use crate::cli::AdapterMeta;
use crate::domain::threshold::ThresholdPreset;
use crate::domain::types::ComplexityMetric;
pub fn handle_init(force: bool, non_interactive: bool, meta: &AdapterMeta) -> Result<()> {
handle_init_with_io(
force,
non_interactive,
meta,
Path::new(meta.config_file_name),
&mut io::stdin().lock(),
&mut io::stderr(),
)
}
pub(crate) fn handle_init_with_io<R: BufRead, W: Write>(
force: bool,
non_interactive: bool,
meta: &AdapterMeta,
config_path: &Path,
stdin: &mut R,
stderr: &mut W,
) -> Result<()> {
if config_path.exists() && !force {
bail!(
"{name} already exists in this directory.\n hint: pass `--force` to overwrite, or edit the existing file directly",
name = meta.config_file_name,
);
}
let preset = if non_interactive {
ThresholdPreset::Default
} else {
prompt_threshold_preset(meta.default_metric, stdin, stderr)?
};
let detection = detect_src_layout();
let content = render_config(meta, preset, &detection);
fs::write(config_path, &content)
.with_context(|| format!("failed to write {}", config_path.display()))?;
writeln!(
stderr,
"✓ wrote {name} (preset = \"{preset_str}\", src = \"{src}\")",
name = meta.config_file_name,
preset_str = preset_str(preset),
src = detection.src_path,
)
.ok();
writeln!(
stderr,
" next: ensure your coverage tool is installed, then run `{name} --help` to see analysis flags.",
name = meta.tool_name,
)
.ok();
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SrcDetection {
pub src_path: String,
pub is_fallback: bool,
}
pub(crate) fn detect_src_layout() -> SrcDetection {
if Path::new("src").is_dir() {
SrcDetection {
src_path: "src".to_string(),
is_fallback: false,
}
} else if Path::new("crates").is_dir() {
SrcDetection {
src_path: "crates".to_string(),
is_fallback: false,
}
} else {
SrcDetection {
src_path: "src".to_string(),
is_fallback: true,
}
}
}
fn preset_str(preset: ThresholdPreset) -> &'static str {
match preset {
ThresholdPreset::Strict => "strict",
ThresholdPreset::Default => "default",
ThresholdPreset::Lenient => "lenient",
}
}
pub(crate) fn render_config(
meta: &AdapterMeta,
preset: ThresholdPreset,
detection: &SrcDetection,
) -> String {
let mut out = String::with_capacity(1024);
out.push_str("# ");
out.push_str(meta.config_file_name);
out.push_str(" — generated by `");
out.push_str(meta.tool_name);
out.push_str(" init`\n");
out.push_str("# Edit freely; the analyzer re-reads this file on every run.\n\n");
let (strict, default, lenient, metric_name) = preset_display(meta.default_metric);
out.push_str("# Threshold preset (cutoffs are for the ");
out.push_str(metric_name);
out.push_str(" metric):\n");
out.push_str(&format!(
"# strict ({strict}) — high-quality libraries, safety-critical code\n"
));
out.push_str(&format!(
"# default ({default}) — typical projects (balanced)\n"
));
out.push_str(&format!(
"# lenient ({lenient}) — legacy or transitional code\n"
));
out.push_str("# Use `threshold = N` instead to set a custom numeric cutoff.\n");
out.push_str("preset = \"");
out.push_str(preset_str(preset));
out.push_str("\"\n\n");
out.push_str("# Source root the analyzer walks.\n");
if detection.is_fallback {
out.push_str("# (auto-detect found no `src/` or `crates/` directory — adjust if your sources live elsewhere)\n");
}
out.push_str("src = \"");
out.push_str(&detection.src_path);
out.push_str("\"\n\n");
out.push_str("# Glob patterns matched against project-relative file paths.\n");
out.push_str("# Uncomment to ignore these directories (one common starting set):\n");
out.push_str("# exclude = [\n");
for pattern in meta.default_excludes {
out.push_str("# \"");
out.push_str(pattern);
out.push_str("\",\n");
}
out.push_str("# ]\n");
out
}
fn preset_display(metric: ComplexityMetric) -> (f64, f64, f64, &'static str) {
(
ThresholdPreset::Strict.threshold(metric),
ThresholdPreset::Default.threshold(metric),
ThresholdPreset::Lenient.threshold(metric),
match metric {
ComplexityMetric::Cyclomatic => "cyclomatic",
ComplexityMetric::Cognitive => "cognitive",
},
)
}
fn prompt_threshold_preset<R: BufRead, W: Write>(
metric: ComplexityMetric,
stdin: &mut R,
stderr: &mut W,
) -> Result<ThresholdPreset> {
let (strict, default, lenient, metric_name) = preset_display(metric);
write!(
stderr,
"Threshold preset ({metric_name} metric)?\n (s)trict = {strict} high-quality libs\n (d)efault = {default} typical projects\n (l)enient = {lenient} legacy code\n[d]: "
)
.ok();
stderr.flush().ok();
let mut buf = String::new();
stdin
.read_line(&mut buf)
.context("failed to read threshold preset from stdin")?;
Ok(parse_preset_input(&buf))
}
pub(crate) fn parse_preset_input(input: &str) -> ThresholdPreset {
match input.trim().chars().next() {
Some('s' | 'S') => ThresholdPreset::Strict,
Some('l' | 'L') => ThresholdPreset::Lenient,
_ => ThresholdPreset::Default,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn fake_meta() -> AdapterMeta {
AdapterMeta {
tool_name: "fake-adapter",
tool_version: "0.5.0",
long_version: "0.5.0",
about: "test",
long_about: "test",
after_help: "",
coverage_hint: "test",
extensions: &["rs"],
tool_info_uri: "https://example.invalid",
rule_help_uri: "https://example.invalid",
config_file_name: "fake-adapter.toml",
default_excludes: &["tests/**", "benches/**", "examples/**"],
forced_excludes: &[],
default_metric: crate::domain::types::ComplexityMetric::Cognitive,
}
}
#[test]
fn parse_preset_input_strict_variants() {
assert_eq!(parse_preset_input("s"), ThresholdPreset::Strict);
assert_eq!(parse_preset_input("S"), ThresholdPreset::Strict);
assert_eq!(parse_preset_input("s\n"), ThresholdPreset::Strict);
assert_eq!(parse_preset_input(" s "), ThresholdPreset::Strict);
assert_eq!(parse_preset_input("strict"), ThresholdPreset::Strict);
}
#[test]
fn parse_preset_input_lenient_variants() {
assert_eq!(parse_preset_input("l"), ThresholdPreset::Lenient);
assert_eq!(parse_preset_input("L"), ThresholdPreset::Lenient);
assert_eq!(parse_preset_input("lenient"), ThresholdPreset::Lenient);
}
#[test]
fn parse_preset_input_defaults_on_empty_or_garbage() {
assert_eq!(parse_preset_input(""), ThresholdPreset::Default);
assert_eq!(parse_preset_input("\n"), ThresholdPreset::Default);
assert_eq!(parse_preset_input(" "), ThresholdPreset::Default);
assert_eq!(parse_preset_input("d"), ThresholdPreset::Default);
assert_eq!(parse_preset_input("D"), ThresholdPreset::Default);
assert_eq!(parse_preset_input("xyz"), ThresholdPreset::Default);
assert_eq!(parse_preset_input("42"), ThresholdPreset::Default);
}
#[test]
fn render_config_includes_preset_and_src() {
let meta = fake_meta();
let detection = SrcDetection {
src_path: "src".to_string(),
is_fallback: false,
};
let out = render_config(&meta, ThresholdPreset::Strict, &detection);
assert!(out.contains("preset = \"strict\""), "preset line missing");
assert!(out.contains("src = \"src\""), "src line missing");
}
#[test]
fn render_config_emits_fallback_hint_only_when_detection_failed() {
let meta = fake_meta();
let detected = SrcDetection {
src_path: "src".to_string(),
is_fallback: false,
};
let fallback = SrcDetection {
src_path: "src".to_string(),
is_fallback: true,
};
let with_detect = render_config(&meta, ThresholdPreset::Default, &detected);
let with_fallback = render_config(&meta, ThresholdPreset::Default, &fallback);
assert!(!with_detect.contains("adjust if your sources live elsewhere"));
assert!(with_fallback.contains("adjust if your sources live elsewhere"));
}
#[test]
fn render_config_emits_commented_excludes_from_meta() {
let meta = fake_meta();
let detection = SrcDetection {
src_path: "src".to_string(),
is_fallback: false,
};
let out = render_config(&meta, ThresholdPreset::Default, &detection);
assert!(out.contains("# exclude = ["));
assert!(out.contains("tests/**"));
assert!(out.contains("benches/**"));
assert!(out.contains("examples/**"));
}
#[test]
fn render_config_includes_header_and_threshold_descriptions() {
let meta = fake_meta();
let detection = SrcDetection {
src_path: "src".to_string(),
is_fallback: false,
};
let out = render_config(&meta, ThresholdPreset::Default, &detection);
let expected_header = format!("# {}", meta.config_file_name);
assert!(
out.contains(&expected_header),
"header line missing; got:\n{out}",
);
assert!(out.contains("Threshold preset"));
assert!(out.contains("strict (8)"));
assert!(out.contains("default (15)"));
assert!(out.contains("lenient (25)"));
}
#[test]
fn prompt_reads_strict_from_piped_stdin() {
let mut stdin = Cursor::new(b"s\n");
let mut stderr: Vec<u8> = Vec::new();
let preset =
prompt_threshold_preset(ComplexityMetric::Cognitive, &mut stdin, &mut stderr).unwrap();
assert_eq!(preset, ThresholdPreset::Strict);
}
#[test]
fn prompt_defaults_when_stdin_is_empty() {
let mut stdin = Cursor::new(b"");
let mut stderr: Vec<u8> = Vec::new();
let preset =
prompt_threshold_preset(ComplexityMetric::Cognitive, &mut stdin, &mut stderr).unwrap();
assert_eq!(preset, ThresholdPreset::Default);
}
#[test]
fn prompt_numbers_track_the_metric() {
let mut stdin = Cursor::new(b"\n");
let mut stderr: Vec<u8> = Vec::new();
prompt_threshold_preset(ComplexityMetric::Cyclomatic, &mut stdin, &mut stderr).unwrap();
let shown = String::from_utf8(stderr).unwrap();
assert!(shown.contains("cyclomatic metric"), "prompt: {shown}");
assert!(shown.contains("(s)trict = 8"), "prompt: {shown}");
assert!(shown.contains("(d)efault = 15"), "prompt: {shown}");
assert!(shown.contains("(l)enient = 25"), "prompt: {shown}");
}
#[test]
fn handle_init_writes_default_config_in_empty_dir() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("crap4rs.toml");
let meta = fake_meta();
let mut stdin = Cursor::new(b"");
let mut stderr: Vec<u8> = Vec::new();
handle_init_with_io(false, true, &meta, &path, &mut stdin, &mut stderr).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("preset = \"default\""));
assert!(content.contains("src = \"src\""));
}
#[test]
fn handle_init_bails_when_file_exists_without_force() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("crap4rs.toml");
fs::write(&path, "preset = \"lenient\"\n").unwrap();
let meta = fake_meta();
let mut stdin = Cursor::new(b"");
let mut stderr: Vec<u8> = Vec::new();
let err = handle_init_with_io(false, true, &meta, &path, &mut stdin, &mut stderr)
.expect_err("init should bail when file exists without --force");
let msg = format!("{err:#}");
let expected = format!("{} already exists", meta.config_file_name);
assert!(msg.contains(&expected), "got: {msg}");
assert!(msg.contains("--force"), "got: {msg}");
let content = fs::read_to_string(&path).unwrap();
assert_eq!(content, "preset = \"lenient\"\n");
}
#[test]
fn handle_init_overwrites_with_force() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("crap4rs.toml");
fs::write(&path, "preset = \"lenient\"\n").unwrap();
let meta = fake_meta();
let mut stdin = Cursor::new(b"");
let mut stderr: Vec<u8> = Vec::new();
handle_init_with_io(true, true, &meta, &path, &mut stdin, &mut stderr).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("preset = \"default\""));
assert!(!content.contains("preset = \"lenient\""));
}
#[test]
fn handle_init_interactive_reads_preset_from_stdin() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("crap4rs.toml");
let meta = fake_meta();
let mut stdin = Cursor::new(b"s\n");
let mut stderr: Vec<u8> = Vec::new();
handle_init_with_io(false, false, &meta, &path, &mut stdin, &mut stderr).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("preset = \"strict\""), "got: {content}");
}
#[test]
fn generated_config_round_trips_through_loader() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("crap4rs.toml");
let meta = fake_meta();
let mut stdin = Cursor::new(b"");
let mut stderr: Vec<u8> = Vec::new();
handle_init_with_io(false, true, &meta, &path, &mut stdin, &mut stderr).unwrap();
let config =
crate::adapters::config::load_config(&path).expect("init's generated TOML must load");
assert_eq!(
config.preset,
Some(ThresholdPreset::Default),
"loaded preset should match"
);
assert_eq!(config.src.as_deref(), Some(Path::new("src")));
}
}