use crate::cli::{InitArgs, OutputWriter};
use crate::rules::{builtin_registry, RuleDefinition, RuleRegistry};
use crate::Severity;
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",
];
const DEFAULT_ABBREVIATIONS: &[&str] = &["id", "db", "io", "ui", "tx", "rx"];
pub(crate) fn run_init(args: InitArgs, writer: OutputWriter) -> ExitCode {
let output = &args.output;
let preserved = read_existing_ignore_patterns(output);
let body = render_default_config(&builtin_registry(), &preserved);
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 = read_existing_ignore_patterns(target);
match fs::write(
target,
render_default_config(&builtin_registry(), &preserved),
) {
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]) -> String {
let mut out = String::new();
out.push_str(HEADER);
out.push('\n');
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_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> {
if !path.exists() {
return Vec::new();
}
let Ok(content) = fs::read_to_string(path) else {
return Vec::new();
};
let value: serde_yaml::Value = match serde_yaml::from_str(&content) {
Ok(value) => value,
Err(error) => {
let _ = writeln!(
std::io::stderr(),
"gruff-rs: could not parse existing {} for ignore preservation ({error}); proceeding with default ignores only",
path.display()
);
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()
}