use crate::{Finding, Severity};
use colored::Colorize;
use std::path::Path;
const MAX_SNIPPET_WIDTH: usize = 120;
pub fn print_banner() {
eprint!(
"\r {} {} {} ",
"foxguard".truecolor(245, 158, 11).bold(),
format!("v{}", env!("CARGO_PKG_VERSION")).dimmed(),
"\u{00b7} scanning...".dimmed(),
);
}
pub fn clear_banner() {
eprint!("\r{}\r", " ".repeat(60));
}
fn display_path(path: &str) -> String {
if let Ok(cwd) = std::env::current_dir() {
if let Ok(rel) = Path::new(path).strip_prefix(&cwd) {
return rel.display().to_string();
}
}
let parts: Vec<&str> = path.split('/').collect();
if parts.len() > 4 {
format!(".../{}", parts[parts.len() - 3..].join("/"))
} else {
path.to_string()
}
}
pub fn print_findings(findings: &[Finding], files_scanned: usize, duration: std::time::Duration) {
print_findings_with_options(findings, files_scanned, duration, false);
}
pub fn print_findings_with_options(
findings: &[Finding],
files_scanned: usize,
duration: std::time::Duration,
explain: bool,
) {
if findings.is_empty() {
if files_scanned > 0 {
println!(
"\n {} Scanned {} files in {:.2}s.\n",
"\u{2714}".green(),
files_scanned,
duration.as_secs_f64(),
);
} else {
println!("\n {} No security issues found.\n", "\u{2714}".green());
}
return;
}
let mut by_file: Vec<(&str, Vec<&Finding>)> = Vec::new();
let mut current_file = "";
for f in findings {
if f.file != current_file {
current_file = &f.file;
by_file.push((&f.file, Vec::new()));
}
by_file.last_mut().unwrap().1.push(f);
}
println!();
for (file, file_findings) in &by_file {
let count = file_findings.len();
let label = if count == 1 { "issue" } else { "issues" };
let short_path = display_path(file);
println!(
" {} {} {}",
short_path.bold(),
"\u{00b7}".dimmed(),
format!("{count} {label}").dimmed(),
);
println!();
for f in file_findings {
print_finding(f, explain);
}
println!();
}
print_summary(findings, files_scanned, duration);
}
fn severity_badge(severity: Severity) -> colored::ColoredString {
match severity {
Severity::Critical => " CRITICAL ".on_truecolor(130, 50, 180).white().bold(),
Severity::High => " HIGH ".on_red().white().bold(),
Severity::Medium => " MEDIUM ".on_yellow().black().bold(),
Severity::Low => " LOW ".on_blue().white().bold(),
}
}
fn severity_accent(severity: Severity) -> colored::ColoredString {
match severity {
Severity::Critical => "\u{2588}".truecolor(130, 50, 180),
Severity::High => "\u{2588}".red(),
Severity::Medium => "\u{2588}".yellow(),
Severity::Low => "\u{2588}".blue(),
}
}
fn truncate_snippet(line: &str) -> String {
let trimmed = line.trim();
if trimmed.len() <= MAX_SNIPPET_WIDTH {
return trimmed.to_string();
}
let mut end = MAX_SNIPPET_WIDTH;
while end > 0 && !trimmed.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &trimmed[..end])
}
fn print_finding(f: &Finding, explain: bool) {
let accent = severity_accent(f.severity);
let badge = severity_badge(f.severity);
let cwe = f
.cwe
.as_ref()
.map(|c| format!(" ({c})"))
.unwrap_or_default();
println!(" {accent} {badge} {}", f.description,);
println!(
" {accent} {}{} {}",
f.rule_id.cyan().dimmed(),
cwe.dimmed(),
format!("line {}:{}", f.line, f.column).dimmed(),
);
if !f.snippet.is_empty() {
for line in f.snippet.lines() {
let truncated = truncate_snippet(line);
println!(" {accent} {}", truncated.dimmed());
}
}
if explain {
if let (Some(src_line), Some(src_desc)) = (f.source_line, f.source_description.as_ref()) {
let src_path = display_path(&f.file);
println!(
" {accent} {} {}:{} {}",
"source \u{2192}".yellow(),
src_path.dimmed(),
src_line.to_string().dimmed(),
src_desc,
);
}
if let (Some(snk_line), Some(snk_desc)) = (f.sink_line, f.sink_description.as_ref()) {
let snk_path = display_path(&f.file);
println!(
" {accent} {} {}:{} {}",
"sink \u{2192}".red(),
snk_path.dimmed(),
snk_line.to_string().dimmed(),
snk_desc,
);
}
}
if let Some(fix) = f.fix_suggestion.as_ref() {
println!(" {accent} {} {}", "Fix:".green().bold(), fix);
}
println!();
}
fn print_summary(findings: &[Finding], files_scanned: usize, duration: std::time::Duration) {
let critical = findings
.iter()
.filter(|f| f.severity == Severity::Critical)
.count();
let high = findings
.iter()
.filter(|f| f.severity == Severity::High)
.count();
let medium = findings
.iter()
.filter(|f| f.severity == Severity::Medium)
.count();
let low = findings
.iter()
.filter(|f| f.severity == Severity::Low)
.count();
let mut badges = Vec::new();
if critical > 0 {
badges.push(format!(
"{}",
format!(" {critical} critical ")
.on_truecolor(130, 50, 180)
.white()
.bold()
));
}
if high > 0 {
badges.push(format!(
"{}",
format!(" {high} high ").on_red().white().bold()
));
}
if medium > 0 {
badges.push(format!(
"{}",
format!(" {medium} medium ").on_yellow().black().bold()
));
}
if low > 0 {
badges.push(format!(
"{}",
format!(" {low} low ").on_blue().white().bold()
));
}
let total = findings.len();
let secs = duration.as_secs_f64();
println!(" {}", "\u{2500}".repeat(50).dimmed());
println!();
println!(
" {} {} {}",
format!("{total}").bold(),
"issues".dimmed(),
format!("{files_scanned} files \u{00b7} {secs:.2}s").dimmed(),
);
println!();
println!(" {}", badges.join(" "));
println!();
}