vsec 0.0.1

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

use std::fmt::Write;

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

/// Safely truncate a string at a character boundary
fn truncate_str(s: &str, max_chars: usize) -> String {
    let truncated: String = s.chars().take(max_chars).collect();
    if truncated.len() < s.len() {
        format!("{}...", truncated)
    } else {
        truncated
    }
}

/// Markdown formatter for reports
pub struct MarkdownFormatter {
    /// Whether to show score breakdown
    show_scores: bool,
    /// Whether to show remediation suggestions
    show_remediation: bool,
}

impl MarkdownFormatter {
    pub fn new(config: &Config) -> Self {
        Self {
            show_scores: config.output.show_scores,
            show_remediation: config.output.show_remediation,
        }
    }

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

    /// Format findings to Markdown
    pub fn format(&self, findings: &[Finding]) -> String {
        let mut output = String::new();

        // Title
        writeln!(output, "# Secretrace Report\n").unwrap();

        // Summary
        writeln!(output, "## Summary\n").unwrap();
        writeln!(output, "- **Total findings**: {}", findings.len()).unwrap();

        // Count by severity
        let critical = findings
            .iter()
            .filter(|f| f.score.severity == Severity::Critical)
            .count();
        let high = findings
            .iter()
            .filter(|f| f.score.severity == Severity::High)
            .count();
        let medium = findings
            .iter()
            .filter(|f| f.score.severity == Severity::Medium)
            .count();
        let low = findings
            .iter()
            .filter(|f| f.score.severity == Severity::Low)
            .count();

        if critical > 0 {
            writeln!(output, "- **Critical**: {}", critical).unwrap();
        }
        if high > 0 {
            writeln!(output, "- **High**: {}", high).unwrap();
        }
        if medium > 0 {
            writeln!(output, "- **Medium**: {}", medium).unwrap();
        }
        if low > 0 {
            writeln!(output, "- **Low**: {}", low).unwrap();
        }

        writeln!(output).unwrap();

        if findings.is_empty() {
            writeln!(output, "No findings detected.").unwrap();
            return output;
        }

        // Findings table
        writeln!(output, "## Findings\n").unwrap();
        writeln!(output, "| Severity | File | Line | Name | Description |").unwrap();
        writeln!(output, "|----------|------|------|------|-------------|").unwrap();

        for finding in findings {
            let severity_badge = match finding.score.severity {
                Severity::Critical => "🔴 Critical",
                Severity::High => "🟠 High",
                Severity::Medium => "🟡 Medium",
                Severity::Low => "🟢 Low",
                Severity::Info => "ℹ️ Info",
                Severity::None => "⚪ None",
            };

            let name = match &finding.suspect {
                SuspectValue::Constant { name, .. } => name.clone(),
                SuspectValue::Static { name, .. } => name.clone(),
                SuspectValue::InlineLiteral { value } => format!("\"{}\"", truncate_str(value, 20)),
                SuspectValue::EnvDefault { var_name, .. } => var_name.clone(),
            };

            let description = finding.explanation.replace('|', "\\|");
            let description = truncate_str(&description, 60);

            writeln!(
                output,
                "| {} | `{}` | {} | `{}` | {} |",
                severity_badge,
                finding.location.file.display(),
                finding.location.line,
                name,
                description
            )
            .unwrap();
        }

        // Detailed findings
        writeln!(output, "\n## Details\n").unwrap();

        for (i, finding) in findings.iter().enumerate() {
            self.format_finding_detail(&mut output, finding, i + 1);
        }

        output
    }

    fn format_finding_detail(&self, output: &mut String, finding: &Finding, index: usize) {
        let severity = match finding.score.severity {
            Severity::Critical => "Critical",
            Severity::High => "High",
            Severity::Medium => "Medium",
            Severity::Low => "Low",
            Severity::Info => "Info",
            Severity::None => "None",
        };

        writeln!(output, "### {}. {} ({})\n", index, finding.id.0, severity).unwrap();

        writeln!(
            output,
            "**Location**: `{}:{}:{}`\n",
            finding.location.file.display(),
            finding.location.line,
            finding.location.column
        )
        .unwrap();

        // Suspect value
        match &finding.suspect {
            SuspectValue::Constant { name, value, .. } => {
                writeln!(output, "**Type**: Constant\n").unwrap();
                writeln!(output, "```rust").unwrap();
                writeln!(output, "const {}: &str = \"{}\";", name, truncate(value, 80)).unwrap();
                writeln!(output, "```\n").unwrap();
            }
            SuspectValue::Static { name, value, .. } => {
                writeln!(output, "**Type**: Static\n").unwrap();
                writeln!(output, "```rust").unwrap();
                writeln!(output, "static {}: &str = \"{}\";", name, truncate(value, 80)).unwrap();
                writeln!(output, "```\n").unwrap();
            }
            SuspectValue::InlineLiteral { value } => {
                writeln!(output, "**Type**: Inline Literal\n").unwrap();
                writeln!(output, "```rust").unwrap();
                writeln!(output, "\"{}\"", truncate(value, 80)).unwrap();
                writeln!(output, "```\n").unwrap();
            }
            SuspectValue::EnvDefault {
                var_name,
                default_value,
            } => {
                writeln!(output, "**Type**: Environment Variable Default\n").unwrap();
                writeln!(output, "Variable: `{}`\n", var_name).unwrap();
                writeln!(output, "Default: `\"{}\"`\n", truncate(default_value, 80)).unwrap();
            }
        }

        writeln!(output, "**Explanation**: {}\n", finding.explanation).unwrap();

        if self.show_scores {
            writeln!(output, "**Score**: {}\n", finding.score.total).unwrap();
            if !finding.score.factors.is_empty() {
                writeln!(output, "**Score Factors**:\n").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).unwrap();
            }
        }

        if self.show_remediation {
            if let Some(ref remediation) = finding.remediation {
                writeln!(output, "**Remediation**: {}\n", remediation).unwrap();
            }
        }

        writeln!(output, "---\n").unwrap();
    }
}

fn truncate(s: &str, max_chars: usize) -> String {
    let truncated: String = s.chars().take(max_chars).collect();
    if truncated.len() < s.len() {
        format!("{}...", truncated)
    } else {
        s.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: "Test explanation".to_string(),
            remediation: Some("Test remediation".to_string()),
            metadata: std::collections::HashMap::new(),
        }
    }

    #[test]
    fn test_markdown_format_empty() {
        let formatter = MarkdownFormatter::default_formatter();
        let output = formatter.format(&[]);

        assert!(output.contains("# Secretrace Report"));
        assert!(output.contains("**Total findings**: 0"));
        assert!(output.contains("No findings detected."));
    }

    #[test]
    fn test_markdown_format_single_finding() {
        let formatter = MarkdownFormatter::default_formatter();
        let finding = create_test_finding();
        let output = formatter.format(&[finding]);

        assert!(output.contains("# Secretrace Report"));
        assert!(output.contains("**Total findings**: 1"));
        assert!(output.contains("API_KEY"));
        assert!(output.contains("src/main.rs"));
    }

    #[test]
    fn test_markdown_has_table() {
        let formatter = MarkdownFormatter::default_formatter();
        let finding = create_test_finding();
        let output = formatter.format(&[finding]);

        assert!(output.contains("| Severity |"));
        assert!(output.contains("|----------|"));
    }

    #[test]
    fn test_markdown_has_details() {
        let formatter = MarkdownFormatter::default_formatter();
        let finding = create_test_finding();
        let output = formatter.format(&[finding]);

        assert!(output.contains("## Details"));
        assert!(output.contains("**Location**:"));
        assert!(output.contains("**Explanation**:"));
    }
}