gruff-rs 0.4.0

Rust static analyzer and quality linter for CI: dead-code, complexity, security, secrets, and architecture rules with deterministic SARIF/JSON output and baseline support.
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
}

/// Per-command entry point used by `analyse`, `summary`, `report`, and
/// `dashboard`. Returns immediately if the project root could not be resolved;
/// otherwise delegates to [`prompt_when_config_missing`].
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);
    }
}

/// Offer to generate `.gruff-rs.yaml` when an analysis command runs in a project
/// that does not yet have one. Silent when `--config` is set, `--no-config` is
/// set, `--no-interaction`/`--silent` is set, stdin or stderr is not a TTY, or
/// a config file already exists at the default path.
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"
        }
    ));
    // Severity is only configurable for rules that expose a numeric threshold;
    // the config loader rejects `severity` without a paired `threshold`.
    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()
    }
}

// YAML reserves several leading indicators ("*&?|>!%@`") and treats unquoted
// sequences containing ": " or " #" as maps or comments; quote when either
// pattern is present.
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(" #")
}

// Preserve user-customized `paths.ignore` entries when `gruff-rs init` regenerates
// an existing config. Returns the existing entries as-is so the renderer can union
// them with `DEFAULT_IGNORE_PATTERNS`. Missing file, unreadable file, or a missing
// `paths.ignore` key all degrade to an empty list. Malformed YAML degrades to an
// empty list too but emits a stderr warning - `--force` must still be able to
// repair a broken config.
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()
}

/// Read any hand-edited `minimumSeverity:` entries from an existing
/// config file so `gruff-rs init --force` does not wipe them. Mirrors
/// `read_existing_ignore_patterns`. Unknown commands and invalid
/// thresholds are silently skipped - the user re-runs init expecting a
/// repair, so partial preservation is preferable to refusing to write.
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
        }
    }
}