slopguard-cli 0.1.17

CLI tool to catch AI-generated code patterns via static analysis
use std::collections::BTreeMap;
use std::fmt;
use std::fs::read_to_string;
use std::io::{self, Write};
use std::path::Path;

use slopguard_core::finding::{CacheStats, Finding, ScanResult};
use slopguard_core::rule::{Category, Rule, Severity};

use crate::output::json::rule_kind;
use crate::output::plural;

const RED: &str = "\x1b[1;31m";
const YELLOW: &str = "\x1b[1;33m";
const CYAN: &str = "\x1b[36m";
const CYAN_BOLD: &str = "\x1b[1;36m";
const BOLD: &str = "\x1b[1m";
const RESET: &str = "\x1b[0m";

pub(crate) struct Colors {
    pub(crate) red: &'static str,
    pub(crate) yellow: &'static str,
    pub(crate) cyan: &'static str,
    pub(crate) cyan_bold: &'static str,
    pub(crate) bold: &'static str,
    pub(crate) reset: &'static str,
}

impl Colors {
    pub(crate) fn new(enabled: bool) -> Self {
        if enabled {
            Self {
                red: RED,
                yellow: YELLOW,
                cyan: CYAN,
                cyan_bold: CYAN_BOLD,
                bold: BOLD,
                reset: RESET,
            }
        } else {
            Self {
                red: "",
                yellow: "",
                cyan: "",
                cyan_bold: "",
                bold: "",
                reset: "",
            }
        }
    }

    fn severity(&self, severity: &Severity) -> &'static str {
        match severity {
            Severity::Error => self.red,
            Severity::Warning => self.yellow,
        }
    }
}

fn gutter_width(line: usize) -> usize {
    fmt::format(format_args!("{line}")).len()
}

fn write_finding(
    w: &mut impl Write,
    finding: &Finding,
    source_lines: &[&str],
    c: &Colors,
) -> io::Result<()> {
    let sev_color = c.severity(&finding.severity);
    let sev_label = &finding.severity;
    let escalated = if finding.escalated { "[escalated]" } else { "" };

    writeln!(
        w,
        "{sev_color}{sev_label}{escalated}{reset}[{id}]: {msg}",
        reset = c.reset,
        id = finding.rule_id,
        msg = finding.message
    )?;

    let gw = gutter_width(finding.line);
    let padding = " ".repeat(gw);

    writeln!(
        w,
        "{padding} {cy}-->{r} {}:{}:{}",
        finding.file.display(),
        finding.line,
        finding.column,
        cy = c.cyan,
        r = c.reset
    )?;
    writeln!(w, "{padding} {cy}|{r}", cy = c.cyan, r = c.reset)?;

    if finding.line > 0 && finding.line <= source_lines.len() {
        let line_content = source_lines[finding.line - 1];
        writeln!(
            w,
            "{cb}{ln}{r} {cy}|{r} {line_content}",
            cb = c.cyan_bold,
            ln = finding.line,
            cy = c.cyan,
            r = c.reset
        )?;

        let col_start = finding.column.saturating_sub(1);
        let col_end = if finding.end_line == finding.line {
            finding.end_column.saturating_sub(1)
        } else {
            line_content.len()
        };
        let underline_len = col_end.saturating_sub(col_start).max(1);
        let spaces = " ".repeat(col_start);
        let carets = "^".repeat(underline_len);
        writeln!(
            w,
            "{padding} {cy}|{r} {spaces}{sc}{carets}{r}",
            cy = c.cyan,
            r = c.reset,
            sc = sev_color
        )?;
    }

    writeln!(w, "{padding} {cy}|{r}", cy = c.cyan, r = c.reset)?;

    if let Some(note) = &finding.note {
        writeln!(w, "{padding} {cy}={r} {note}", cy = c.cyan, r = c.reset)?;
    }

    writeln!(w)?;
    Ok(())
}

pub fn format_explain(rule: &Rule, w: &mut impl Write) -> io::Result<()> {
    let category = rule
        .category
        .as_ref()
        .unwrap_or(&Category::Correctness)
        .to_string();

    writeln!(w, "id:        {}", rule.id)?;
    writeln!(w, "language:  {}", rule.language)?;
    writeln!(w, "severity:  {}", rule.severity)?;
    writeln!(w, "category:  {category}")?;
    writeln!(w, "message:   {}", rule.message)?;
    if let Some(note) = &rule.note {
        writeln!(w, "note:      {note}")?;
    }
    if let Some(fix) = &rule.fix {
        writeln!(w, "fix:       {fix}")?;
    }
    writeln!(w, "type:      {}", rule_kind(rule))?;
    if let Some(metric) = &rule.metric {
        writeln!(w, "metric:    {metric}")?;
    }
    if let Some(threshold) = rule.threshold {
        writeln!(w, "threshold: {threshold}")?;
    }
    if let Some(ai_check) = &rule.ai_check {
        if let Some(model) = &ai_check.model {
            writeln!(w, "model:     {model}")?;
        }
        writeln!(w)?;
        writeln!(w, "prompt:")?;
        for line in ai_check.prompt.lines() {
            writeln!(w, "  {line}")?;
        }
    }

    if let Some(tests) = &rule.tests {
        if !tests.should_match.is_empty() {
            writeln!(w)?;
            writeln!(w, "should_match:")?;
            for snippet in &tests.should_match {
                for line in snippet.lines() {
                    writeln!(w, "  {line}")?;
                }
            }
        }
        if !tests.should_not_match.is_empty() {
            writeln!(w)?;
            writeln!(w, "should_not_match:")?;
            for snippet in &tests.should_not_match {
                for line in snippet.lines() {
                    writeln!(w, "  {line}")?;
                }
            }
        }
    }

    Ok(())
}

fn write_cache_line(w: &mut impl Write, files_scanned: usize, cs: &CacheStats) -> io::Result<()> {
    writeln!(
        w,
        "Scanned {files_scanned} files ({cached} cached, {changed} changed)",
        cached = cs.cached,
        changed = cs.changed,
    )
}

pub fn format_text(result: &ScanResult, w: &mut impl Write, use_colors: bool) -> io::Result<()> {
    let c = Colors::new(use_colors);

    if let (Some(base), Some(changed)) = (&result.stats.diff_base, result.stats.files_changed) {
        writeln!(w, "Scanning {changed} changed files (base: {base})")?;
        writeln!(w)?;
    }

    let mut files_cache: BTreeMap<&Path, Vec<String>> = BTreeMap::new();

    for finding in &result.findings {
        let source = files_cache.entry(&finding.file).or_insert_with(|| {
            read_to_string(&finding.file)
                .unwrap_or_default()
                .lines()
                .map(String::from)
                .collect()
        });
        let source_lines: Vec<&str> = source.iter().map(String::as_str).collect();
        write_finding(w, finding, &source_lines, &c)?;
    }

    let errors_label = plural(result.stats.errors, "error", "errors");
    let warnings_label = plural(result.stats.warnings, "warning", "warnings");
    let files_label = plural(result.stats.files_scanned, "file", "files");

    if let Some(ref cs) = result.cache_stats {
        write_cache_line(w, result.stats.files_scanned, cs)?;
    }

    if result.stats.baseline_filtered > 0 {
        let label = plural(result.stats.baseline_filtered, "finding", "findings");
        writeln!(
            w,
            "{n} {label} filtered by baseline",
            n = result.stats.baseline_filtered
        )?;
    }

    let escalated = result.findings.iter().filter(|f| f.escalated).count();
    if escalated > 0 {
        let label = plural(escalated, "finding", "findings");
        writeln!(w, "{escalated} {label} escalated to error")?;
    }

    if result.stats.total > 0 {
        writeln!(
            w,
            "{b}summary{r}: {red}{errors}{r} {errors_label}, {yel}{warnings}{r} {warnings_label} in {files} {files_label}",
            b = c.bold, r = c.reset,
            red = c.red, yel = c.yellow,
            errors = result.stats.errors,
            warnings = result.stats.warnings,
            files = result.stats.files_scanned,
        )?;
    } else {
        writeln!(
            w,
            "0 errors, 0 warnings in {} {files_label}",
            result.stats.files_scanned,
        )?;
    }

    Ok(())
}