use crate::cli::{FailThreshold, InitArgs, OutputWriter};
use crate::config::{DEFAULT_ABBREVIATIONS, SCHEMA_VERSION};
use crate::rules::{builtin_registry, RuleDefinition, RuleRegistry};
use crate::Severity;
use std::collections::BTreeMap;
use std::fs;
use std::io::{IsTerminal, Write};
use std::path::Path;
use std::process::ExitCode;
const DEFAULT_CONFIG_FILE: &str = ".gruff-rs.yaml";
const HEADER: &str = "\
# gruff-rs default configuration.
#
# Generated by `gruff-rs init` from the built-in rule registry.
# Rule ids and metadata can be discovered with `gruff-rs rules`.
# Per-rule thresholds and severities can be tuned in place; see the
# README for the full configuration shape (paths, allowlists, rules,
# custom_rules, exclude).
#
# For an existing project, run `gruff-rs analyse --generate-baseline`
# to accept today's findings as the starting point.
";
const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
".agents/**",
".claude/**",
".codex/**",
".github/**",
".goat-flow/**",
"target/**",
"node_modules/**",
"**/Cargo.lock",
"**/package-lock.json",
"**/yarn.lock",
"**/pnpm-lock.yaml",
];
pub(crate) fn run_init(args: InitArgs, writer: OutputWriter) -> ExitCode {
let output = &args.output;
let preserved_ignores = read_existing_ignore_patterns(output);
let preserved_min_severity = read_existing_minimum_severity(output);
let body = render_default_config(
&builtin_registry(),
&preserved_ignores,
&preserved_min_severity,
);
if args.stdout {
writer.emit_unconditional(&body);
return ExitCode::SUCCESS;
}
if !args.force && output.exists() {
eprintln!(
"gruff-rs: refusing to overwrite existing {}; pass --force to replace it",
output.display()
);
return ExitCode::from(2);
}
if let Err(error) = fs::write(output, &body) {
eprintln!("gruff-rs: unable to write {}: {error}", output.display());
return ExitCode::from(2);
}
writer.emit_unconditional(&format!(
"Wrote default config to {}\nRun `gruff-rs analyse --generate-baseline` to accept today's findings as the starting point.",
output.display()
));
ExitCode::SUCCESS
}
pub(crate) fn prompt_for_command(
project_root: Option<&Path>,
config_override: Option<&Path>,
no_config: bool,
no_interaction: bool,
) {
if let Some(root) = project_root {
prompt_when_config_missing(root, config_override, no_config, no_interaction);
}
}
pub(crate) fn prompt_when_config_missing(
project_root: &Path,
config_override: Option<&Path>,
no_config: bool,
no_interaction: bool,
) {
let target = project_root.join(DEFAULT_CONFIG_FILE);
if !should_offer_init(&target, config_override, no_config, no_interaction) {
return;
}
if !should_write_default_config(project_root) {
return;
}
write_default_config_at(&target);
}
fn should_offer_init(
target: &Path,
config_override: Option<&Path>,
no_config: bool,
no_interaction: bool,
) -> bool {
if no_config || config_override.is_some() || no_interaction {
return false;
}
if target.exists() {
return false;
}
std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
}
fn should_write_default_config(project_root: &Path) -> bool {
let mut stderr = std::io::stderr();
let _ = write!(
stderr,
"No {DEFAULT_CONFIG_FILE} found in {}. Generate one with `gruff-rs init`? [y/N] ",
project_root.display()
);
let _ = stderr.flush();
let mut response = String::new();
if std::io::stdin().read_line(&mut response).is_err() {
return false;
}
matches!(response.trim().to_ascii_lowercase().as_str(), "y" | "yes")
}
fn write_default_config_at(target: &Path) {
let mut stderr = std::io::stderr();
let preserved_ignores = read_existing_ignore_patterns(target);
let preserved_min_severity = read_existing_minimum_severity(target);
match fs::write(
target,
render_default_config(
&builtin_registry(),
&preserved_ignores,
&preserved_min_severity,
),
) {
Ok(()) => {
let _ = writeln!(stderr, "Wrote {}", target.display());
let _ = writeln!(
stderr,
"Run `gruff-rs analyse --generate-baseline` to accept today's findings as the starting point."
);
}
Err(error) => {
let _ = writeln!(
stderr,
"gruff-rs: unable to write {}: {error}",
target.display()
);
}
}
}
pub(crate) fn render_default_config(
registry: &RuleRegistry,
extra_ignores: &[String],
preserved_min_severity: &BTreeMap<String, FailThreshold>,
) -> String {
let mut out = String::new();
out.push_str(HEADER);
out.push('\n');
append_schema_version_section(&mut out);
append_minimum_severity_section(&mut out, preserved_min_severity);
append_paths_section(&mut out, extra_ignores);
append_allowlists_section(&mut out);
out.push_str("rules:\n");
for definition in registry.definitions() {
append_rule_entry(&mut out, definition);
}
out
}
fn append_schema_version_section(out: &mut String) {
out.push_str(&format!("schemaVersion: {SCHEMA_VERSION}\n\n"));
}
fn append_minimum_severity_section(out: &mut String, preserved: &BTreeMap<String, FailThreshold>) {
out.push_str("# minimumSeverity controls per-subcommand fail thresholds.\n");
out.push_str("# Uncomment and edit to override the binary defaults. Valid values:\n");
out.push_str("# none, advisory, warning, error. CLI --fail-on flag overrides this block.\n");
out.push_str("minimumSeverity:\n");
append_minimum_severity_entry(out, "analyse", preserved.get("analyse"), "advisory");
append_minimum_severity_entry(out, "report", preserved.get("report"), "none");
out.push('\n');
}
fn append_minimum_severity_entry(
out: &mut String,
command: &str,
preserved: Option<&FailThreshold>,
placeholder: &str,
) {
match preserved {
Some(value) => out.push_str(&format!(" {command}: {}\n", value.as_str())),
None => out.push_str(&format!(" # {command}: {placeholder}\n")),
}
}
fn append_paths_section(out: &mut String, extra_ignores: &[String]) {
out.push_str("paths:\n");
out.push_str(" # Discovery-time do-not-read patterns. Use this for generated\n");
out.push_str(" # build/vendor artifacts; use `gruff-rs analyse --generate-baseline`\n");
out.push_str(" # or top-level `exclude` entries for accepted findings.\n");
out.push_str(" ignore:\n");
let mut emitted: Vec<&str> =
Vec::with_capacity(DEFAULT_IGNORE_PATTERNS.len() + extra_ignores.len());
for pattern in DEFAULT_IGNORE_PATTERNS {
if !emitted.contains(pattern) {
emitted.push(pattern);
out.push_str(&format!(" - {}\n", yaml_string(pattern)));
}
}
for pattern in extra_ignores {
let entry = pattern.as_str();
if !emitted.contains(&entry) {
emitted.push(entry);
out.push_str(&format!(" - {}\n", yaml_string(entry)));
}
}
out.push('\n');
}
fn append_allowlists_section(out: &mut String) {
out.push_str("allowlists:\n");
out.push_str(" acceptedAbbreviations:\n");
for abbreviation in DEFAULT_ABBREVIATIONS {
out.push_str(&format!(" - {abbreviation}\n"));
}
out.push_str(" secretPreviews: []\n");
out.push('\n');
}
fn append_rule_entry(out: &mut String, definition: &RuleDefinition) {
out.push_str(&format!(" # {}\n", definition.description));
out.push_str(&format!(" {}:\n", definition.id));
out.push_str(&format!(
" enabled: {}\n",
if definition.default_enabled {
"true"
} else {
"false"
}
));
if let Some(threshold) = definition.threshold {
out.push_str(&format!(
" threshold: {}\n",
format_threshold(threshold.default)
));
out.push_str(&format!(
" severity: {}\n",
severity_name(definition.default_severity)
));
}
}
fn severity_name(severity: Severity) -> &'static str {
match severity {
Severity::Advisory => "advisory",
Severity::Warning => "warning",
Severity::Error => "error",
}
}
fn format_threshold(value: f64) -> String {
if value.fract() == 0.0 && value.is_finite() {
format!("{}", value as i64)
} else {
format!("{value}")
}
}
fn yaml_string(value: &str) -> String {
if needs_quoting(value) {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
} else {
value.to_string()
}
}
const YAML_LEADING_INDICATORS: &[char] = &['*', '&', '?', '|', '>', '!', '%', '@', '`'];
fn needs_quoting(value: &str) -> bool {
let starts_with_indicator = value
.chars()
.next()
.is_some_and(|first| YAML_LEADING_INDICATORS.contains(&first));
starts_with_indicator || value.contains(": ") || value.contains(" #")
}
pub(crate) fn read_existing_ignore_patterns(path: &Path) -> Vec<String> {
let Some(value) = read_existing_yaml(path, "ignore preservation") else {
return Vec::new();
};
value
.get("paths")
.and_then(|paths| paths.get("ignore"))
.and_then(|ignore| ignore.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|entry| entry.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
pub(crate) fn read_existing_minimum_severity(path: &Path) -> BTreeMap<String, FailThreshold> {
let Some(value) = read_existing_yaml(path, "minimumSeverity preservation") else {
return BTreeMap::new();
};
value
.get("minimumSeverity")
.and_then(|v| v.as_mapping())
.map(|mapping| mapping.iter().filter_map(parse_preserved_entry).collect())
.unwrap_or_default()
}
fn parse_preserved_entry(
(key, value): (&serde_yaml::Value, &serde_yaml::Value),
) -> Option<(String, FailThreshold)> {
let command = key.as_str()?;
if !matches!(command, "analyse" | "report") {
return None;
}
let threshold: FailThreshold = value.as_str()?.parse().ok()?;
Some((command.to_string(), threshold))
}
fn read_existing_yaml(path: &Path, purpose: &str) -> Option<serde_yaml::Value> {
if !path.exists() {
return None;
}
let content = fs::read_to_string(path).ok()?;
match serde_yaml::from_str(&content) {
Ok(value) => Some(value),
Err(error) => {
let _ = writeln!(
std::io::stderr(),
"gruff-rs: could not parse existing {} for {purpose} ({error}); proceeding with defaults only",
path.display()
);
None
}
}
}