use std::fmt::Write;
use crate::config::Config;
use crate::models::{Finding, Severity, SuspectValue};
pub struct TextFormatter {
color: bool,
show_scores: bool,
show_remediation: bool,
}
impl TextFormatter {
pub fn new(config: &Config) -> Self {
Self {
color: config.output.color,
show_scores: config.output.show_scores,
show_remediation: config.output.show_remediation,
}
}
pub fn default_formatter() -> Self {
Self {
color: true,
show_scores: false,
show_remediation: true,
}
}
pub fn format(&self, findings: &[Finding]) -> String {
if findings.is_empty() {
return "No findings detected.".to_string();
}
let mut output = String::new();
writeln!(
output,
"Found {} potential secret(s)\n",
findings.len()
)
.unwrap();
for (i, finding) in findings.iter().enumerate() {
if i > 0 {
writeln!(output).unwrap();
}
self.format_finding(&mut output, finding, i + 1);
}
output
}
fn format_finding(&self, output: &mut String, finding: &Finding, index: usize) {
let severity_icon = match finding.score.severity {
Severity::Critical => "[CRITICAL]",
Severity::High => "[HIGH]",
Severity::Medium => "[MEDIUM]",
Severity::Low => "[LOW]",
Severity::Info => "[INFO]",
Severity::None => "[NONE]",
};
writeln!(
output,
"{}. {} {}",
index, severity_icon, finding.id.0
)
.unwrap();
writeln!(
output,
" File: {}:{}:{}",
finding.location.file.display(),
finding.location.line,
finding.location.column
)
.unwrap();
match &finding.suspect {
SuspectValue::Constant { name, value, .. } => {
let truncated = truncate_value(value, 50);
writeln!(output, " Constant: {} = \"{}\"", name, truncated).unwrap();
}
SuspectValue::Static { name, value, .. } => {
let truncated = truncate_value(value, 50);
writeln!(output, " Static: {} = \"{}\"", name, truncated).unwrap();
}
SuspectValue::InlineLiteral { value } => {
let truncated = truncate_value(value, 50);
writeln!(output, " Literal: \"{}\"", truncated).unwrap();
}
SuspectValue::EnvDefault {
var_name,
default_value,
} => {
let truncated = truncate_value(default_value, 50);
writeln!(output, " Env default: {} -> \"{}\"", var_name, truncated).unwrap();
}
}
if self.show_scores {
writeln!(output, " Score: {}", finding.score.total).unwrap();
for factor in &finding.score.factors {
if factor.contribution != 0 {
let sign = if factor.contribution > 0 { "+" } else { "" };
writeln!(
output,
" {}{}: {}",
sign, factor.contribution, factor.reason
)
.unwrap();
}
}
}
writeln!(output, " Reason: {}", finding.explanation).unwrap();
if self.show_remediation {
if let Some(ref remediation) = finding.remediation {
writeln!(output, " Suggestion: {}", remediation).unwrap();
}
}
}
}
fn truncate_value(value: &str, max_chars: usize) -> String {
let truncated: String = value.chars().take(max_chars).collect();
if truncated.len() < value.len() {
format!("{}...", truncated)
} else {
value.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{FindingId, Score, SourceLocation};
use std::path::PathBuf;
fn create_test_finding() -> Finding {
Finding {
id: FindingId::new("test-finding-1"),
suspect: SuspectValue::Constant {
name: "API_KEY".to_string(),
value: "sk_live_12345".to_string(),
type_annotation: Some("&str".to_string()),
},
location: SourceLocation::new(PathBuf::from("src/main.rs"), 10, 5),
usage: None,
context: Default::default(),
score: Score::from_total(85),
explanation: "Constant 'API_KEY' appears to contain a hardcoded secret.".to_string(),
remediation: Some(
"Consider using environment variables or a secrets manager.".to_string(),
),
metadata: std::collections::HashMap::new(),
}
}
#[test]
fn test_format_empty_findings() {
let formatter = TextFormatter::default_formatter();
let output = formatter.format(&[]);
assert_eq!(output, "No findings detected.");
}
#[test]
fn test_format_single_finding() {
let formatter = TextFormatter::default_formatter();
let finding = create_test_finding();
let output = formatter.format(&[finding]);
assert!(output.contains("Found 1 potential secret"));
assert!(output.contains("[HIGH]"));
assert!(output.contains("API_KEY"));
assert!(output.contains("src/main.rs:10:5"));
}
#[test]
fn test_format_with_scores() {
let config = Config {
output: crate::config::OutputConfig {
show_scores: true,
..Default::default()
},
..Default::default()
};
let formatter = TextFormatter::new(&config);
let finding = create_test_finding();
let output = formatter.format(&[finding]);
assert!(output.contains("Score: 85"));
}
#[test]
fn test_truncate_long_value() {
let long_value = "a".repeat(100);
let truncated = truncate_value(&long_value, 50);
assert_eq!(truncated.len(), 53); assert!(truncated.ends_with("..."));
}
}