vsec 0.0.1

Detect secrets and in Rust codebases
Documentation
// src/output/text.rs

use std::fmt::Write;

use crate::config::Config;
use crate::models::{Finding, Severity, SuspectValue};

/// Text formatter for human-readable output
pub struct TextFormatter {
    /// Whether to show colors (not implemented yet, for future use)
    color: bool,
    /// Whether to show score breakdown
    show_scores: bool,
    /// Whether to show remediation suggestions
    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,
        }
    }

    /// Create with default settings
    pub fn default_formatter() -> Self {
        Self {
            color: true,
            show_scores: false,
            show_remediation: true,
        }
    }

    /// Format findings to text
    pub fn format(&self, findings: &[Finding]) -> String {
        if findings.is_empty() {
            return "No findings detected.".to_string();
        }

        let mut output = String::new();

        // Summary header
        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) {
        // Header line with severity icon
        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();

        // Location
        writeln!(
            output,
            "   File: {}:{}:{}",
            finding.location.file.display(),
            finding.location.line,
            finding.location.column
        )
        .unwrap();

        // Suspect info
        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();
            }
        }

        // Score (if enabled)
        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();
                }
            }
        }

        // Explanation
        writeln!(output, "   Reason: {}", finding.explanation).unwrap();

        // Remediation (if enabled)
        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); // 50 + "..."
        assert!(truncated.ends_with("..."));
    }
}