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