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(())
}