Skip to main content

keyhog_core/report/
text.rs

1//! Human-readable terminal reporter with severity coloring and rich finding details.
2
3use std::io::IsTerminal;
4use std::io::Write;
5
6use crate::{MatchLocation, Severity, VerificationResult, VerifiedFinding};
7
8use super::banner;
9use super::{ReportError, Reporter};
10
11/// Human-readable text output with gradient banner and styled findings.
12///
13/// # Examples
14///
15/// ```rust
16/// use keyhog_core::TextReporter;
17///
18/// let reporter = TextReporter::with_color(Vec::new(), false);
19/// let _ = reporter;
20/// ```
21pub struct TextReporter<W: Write> {
22    writer: W,
23    count: usize,
24    color: bool,
25    live_count: usize,
26    dead_count: usize,
27}
28
29impl<W: Write> TextReporter<W> {
30    /// Create a text reporter with ANSI colors enabled when stdout is a TTY.
31    ///
32    /// # Examples
33    ///
34    /// ```rust
35    /// use keyhog_core::TextReporter;
36    ///
37    /// let reporter = TextReporter::new(Vec::new());
38    /// let _ = reporter;
39    /// ```
40    pub fn new(writer: W) -> Self {
41        Self::with_color(writer, std::io::stdout().is_terminal())
42    }
43
44    /// Create a text reporter with explicit ANSI color control.
45    ///
46    /// # Examples
47    ///
48    /// ```rust
49    /// use keyhog_core::TextReporter;
50    ///
51    /// let reporter = TextReporter::with_color(Vec::new(), false);
52    /// let _ = reporter;
53    /// ```
54    pub fn with_color(writer: W, color: bool) -> Self {
55        Self {
56            writer,
57            count: 0,
58            color,
59            live_count: 0,
60            dead_count: 0,
61        }
62    }
63
64    fn print_header(&mut self) -> Result<(), ReportError> {
65        banner::print_banner(&mut self.writer, self.color, false)?;
66        Ok(())
67    }
68}
69
70impl<W: Write> Reporter for TextReporter<W> {
71    fn report(&mut self, finding: &VerifiedFinding) -> Result<(), ReportError> {
72        if self.count == 0 {
73            self.print_header()?;
74        }
75        self.count += 1;
76
77        // Track verification stats
78        match &finding.verification {
79            VerificationResult::Live => self.live_count += 1,
80            VerificationResult::Dead => self.dead_count += 1,
81            _ => {}
82        }
83
84        let severity_str = format_severity(finding.severity, self.color);
85        let verified = format_verification(&finding.verification, self.color);
86        let location = format_location(&finding.location);
87        let confidence = format_confidence(finding.confidence.unwrap_or(0.0), self.color);
88
89        // Severity color for the box border
90        let border_ansi = match finding.severity {
91            Severity::Critical => "1;31",
92            Severity::High => "31",
93            Severity::Medium => "33",
94            Severity::Low => "36",
95            Severity::Info => "90",
96        };
97
98        // Top border with severity and detector name
99        writeln!(
100            self.writer,
101            "  {} {} {}",
102            colorize("┌", border_ansi, self.color),
103            severity_str,
104            colorize(
105                &format!("─── {}", finding.detector_name),
106                border_ansi,
107                self.color,
108            ),
109        )?;
110
111        // Secret
112        writeln!(
113            self.writer,
114            "  {} {} {}",
115            colorize("│", border_ansi, self.color),
116            dim("Secret:    ", self.color),
117            highlight(&finding.credential_redacted, self.color),
118        )?;
119
120        // Location
121        writeln!(
122            self.writer,
123            "  {} {} {}",
124            colorize("│", border_ansi, self.color),
125            dim("Location:  ", self.color),
126            location,
127        )?;
128
129        // Confidence + verification
130        let verify_suffix = if verified.is_empty() {
131            String::new()
132        } else {
133            format!("  ({})", verified)
134        };
135        writeln!(
136            self.writer,
137            "  {} {} {}{}",
138            colorize("│", border_ansi, self.color),
139            dim("Confidence:", self.color),
140            confidence,
141            verify_suffix,
142        )?;
143
144        // Commit info
145        if let Some(commit) = &finding.location.commit {
146            writeln!(
147                self.writer,
148                "  {} {} {}",
149                colorize("│", border_ansi, self.color),
150                dim("Commit:    ", self.color),
151                commit,
152            )?;
153        }
154
155        if let Some(author) = &finding.location.author {
156            writeln!(
157                self.writer,
158                "  {} {} {}",
159                colorize("│", border_ansi, self.color),
160                dim("Author:    ", self.color),
161                author,
162            )?;
163        }
164
165        if let Some(date) = &finding.location.date {
166            writeln!(
167                self.writer,
168                "  {} {} {}",
169                colorize("│", border_ansi, self.color),
170                dim("Date:      ", self.color),
171                date,
172            )?;
173        }
174
175        // Extra metadata
176        for (key, value) in &finding.metadata {
177            writeln!(
178                self.writer,
179                "  {} {} {}",
180                colorize("│", border_ansi, self.color),
181                dim(&format!("{:<11}", format!("{}:", key)), self.color),
182                value,
183            )?;
184        }
185
186        if !finding.additional_locations.is_empty() {
187            writeln!(
188                self.writer,
189                "  {} {} (+{} more locations)",
190                colorize("│", border_ansi, self.color),
191                dim("Extra:     ", self.color),
192                finding.additional_locations.len(),
193            )?;
194        }
195
196        // Remediation
197        let remediation = match finding.severity {
198            Severity::Critical | Severity::High => "Revoke immediately and rotate.",
199            Severity::Medium => "Review usage and rotate if active.",
200            _ => "Remove from codebase.",
201        };
202        writeln!(
203            self.writer,
204            "  {} {} {}",
205            colorize("│", border_ansi, self.color),
206            dim("Action:    ", self.color),
207            colorize(remediation, "3;32", self.color),
208        )?;
209
210        // Bottom border
211        writeln!(
212            self.writer,
213            "  {}\n",
214            colorize(
215                "└─────────────────────────────────────────────",
216                border_ansi,
217                self.color,
218            ),
219        )?;
220
221        Ok(())
222    }
223
224    fn finish(&mut self) -> Result<(), ReportError> {
225        if self.count == 0 {
226            self.print_header()?;
227            writeln!(
228                self.writer,
229                "  {}\n",
230                colorize("No secrets found. Your code is clean.", "1;32", self.color),
231            )?;
232        } else {
233            let summary_border = colorize(
234                "━━━ Results ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
235                "90",
236                self.color,
237            );
238            writeln!(self.writer, "  {}", summary_border)?;
239
240            let plural = if self.count == 1 { "" } else { "s" };
241
242            let mut parts = vec![highlight(
243                &format!("{} secret{plural} found", self.count),
244                self.color,
245            )];
246            if self.live_count > 0 {
247                parts.push(colorize(
248                    &format!("{} live", self.live_count),
249                    "1;31",
250                    self.color,
251                ));
252            }
253            if self.dead_count > 0 {
254                parts.push(colorize(
255                    &format!("{} dead", self.dead_count),
256                    "32",
257                    self.color,
258                ));
259            }
260            let unverified = self.count - self.live_count - self.dead_count;
261            if unverified > 0 {
262                parts.push(colorize(
263                    &format!("{unverified} unverified"),
264                    "33",
265                    self.color,
266                ));
267            }
268
269            writeln!(self.writer, "  {}", parts.join(" · "))?;
270
271            // Next steps
272            writeln!(self.writer)?;
273            writeln!(
274                self.writer,
275                "  {} Revoke active secrets in the provider's dashboard.",
276                colorize("1.", "1;31", self.color),
277            )?;
278            writeln!(
279                self.writer,
280                "  {} Remove credentials from codebase and git history.",
281                colorize("2.", "1;33", self.color),
282            )?;
283            writeln!(
284                self.writer,
285                "  {} Use a secure secret manager or environment variables.",
286                colorize("3.", "1;32", self.color),
287            )?;
288
289            let end_border = colorize(
290                "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
291                "90",
292                self.color,
293            );
294            writeln!(self.writer, "\n  {}\n", end_border)?;
295        }
296        Ok(())
297    }
298}
299
300fn format_severity(severity: Severity, color: bool) -> String {
301    let (label, style) = match severity {
302        Severity::Critical => ("CRITICAL", "1;31"),
303        Severity::High => ("HIGH", "31"),
304        Severity::Medium => ("MEDIUM", "33"),
305        Severity::Low => ("LOW", "36"),
306        Severity::Info => ("INFO", "90"),
307    };
308    colorize(&format!("{:>8}", label), style, color)
309}
310
311fn format_verification(result: &VerificationResult, color: bool) -> String {
312    match result {
313        VerificationResult::Live => colorize("LIVE", "1;31;43", color),
314        VerificationResult::Dead => colorize("dead", "32", color),
315        VerificationResult::RateLimited => colorize("limited", "33", color),
316        VerificationResult::Error(_) => colorize("error", "33", color),
317        VerificationResult::Unverifiable | VerificationResult::Skipped => String::new(),
318    }
319}
320
321fn format_location(location: &MatchLocation) -> String {
322    match (&location.file_path, location.line) {
323        (Some(path), Some(line)) => format!("{}:{}", path, line),
324        (Some(path), None) => path.clone(),
325        _ => location.source.clone(),
326    }
327}
328
329fn format_confidence(confidence: f64, color: bool) -> String {
330    const BAR_WIDTH: usize = 6;
331    let filled = (confidence * BAR_WIDTH as f64) as usize;
332    let bar = format!(
333        "{}{}",
334        "■".repeat(filled.min(BAR_WIDTH)),
335        "□".repeat(BAR_WIDTH.saturating_sub(filled.min(BAR_WIDTH)))
336    );
337    let tone = if confidence >= 0.8 {
338        "31"
339    } else if confidence >= 0.5 {
340        "33"
341    } else {
342        "90"
343    };
344    format!(
345        "{} {}",
346        colorize(&bar, tone, color),
347        colorize(&format!("{:>3}%", (confidence * 100.0) as u32), "90", color,)
348    )
349}
350
351fn highlight(text: &str, color: bool) -> String {
352    colorize(text, "1", color)
353}
354
355fn dim(text: &str, color: bool) -> String {
356    colorize(text, "90", color)
357}
358
359fn colorize(text: &str, ansi: &str, color: bool) -> String {
360    if color {
361        format!("\x1b[{ansi}m{text}\x1b[0m")
362    } else {
363        text.to_string()
364    }
365}