1use std::io::IsTerminal;
4use std::io::Write;
5
6use crate::{MatchLocation, Severity, VerificationResult, VerifiedFinding};
7
8use super::banner;
9use super::{ReportError, Reporter};
10
11pub 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 pub fn new(writer: W) -> Self {
41 Self::with_color(writer, std::io::stdout().is_terminal())
42 }
43
44 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 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 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 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 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 writeln!(
122 self.writer,
123 " {} {} {}",
124 colorize("│", border_ansi, self.color),
125 dim("Location: ", self.color),
126 location,
127 )?;
128
129 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 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 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 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 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 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}