1use std::io::IsTerminal;
4use std::io::Write;
5
6use crate::{MatchLocation, Severity, VerificationResult, VerifiedFinding};
7
8use super::{ReportError, Reporter, WriterBackedReporter};
9
10pub struct TextReporter<W: Write + Send> {
21 writer: W,
22 count: usize,
23 color: bool,
24 live_count: usize,
25 dead_count: usize,
26 example_suppressions: usize,
32 dogfood_active: bool,
38}
39
40impl<W: Write + Send> TextReporter<W> {
41 pub fn new(writer: W) -> Self {
52 Self::with_color(writer, std::io::stdout().is_terminal())
53 }
54
55 pub fn with_color(writer: W, color: bool) -> Self {
66 Self {
67 writer,
68 count: 0,
69 color,
70 live_count: 0,
71 dead_count: 0,
72 example_suppressions: 0,
73 dogfood_active: false,
74 }
75 }
76
77 pub fn set_example_suppressions(&mut self, n: usize) {
83 self.example_suppressions = n;
84 }
85
86 pub fn set_dogfood_active(&mut self, active: bool) {
91 self.dogfood_active = active;
92 }
93
94 fn print_header(&mut self) -> Result<(), ReportError> {
95 Ok(())
96 }
97}
98
99impl<W: Write + Send> Reporter for TextReporter<W> {
100 fn report(&mut self, finding: &VerifiedFinding) -> Result<(), ReportError> {
101 if self.count == 0 {
102 self.print_header()?;
103 }
104 self.count += 1;
105
106 match &finding.verification {
108 VerificationResult::Live => self.live_count += 1,
109 VerificationResult::Dead => self.dead_count += 1,
110 _ => {}
111 }
112
113 let severity_str = format_severity(finding.severity, self.color);
114 let verified = format_verification(&finding.verification, self.color);
115 let location = format_location(&finding.location);
116 let confidence_value = finding.confidence.unwrap_or(0.0);
117 const BAR_WIDTH: usize = 6;
118 let filled = (confidence_value * BAR_WIDTH as f64) as usize;
119 let bar = format!(
120 "{}{}",
121 "■".repeat(filled.min(BAR_WIDTH)),
122 "□".repeat(BAR_WIDTH.saturating_sub(filled.min(BAR_WIDTH)))
123 );
124 let confidence_tone = if confidence_value >= 0.8 {
125 "31"
126 } else if confidence_value >= 0.5 {
127 "33"
128 } else {
129 "90"
130 };
131 let confidence = format!(
132 "{} {}",
133 colorize(&bar, confidence_tone, self.color),
134 colorize(
135 &format!("{:>3}%", (confidence_value * 100.0) as u32),
136 "90",
137 self.color,
138 )
139 );
140
141 let border_ansi = match finding.severity {
143 Severity::Critical => "1;31",
144 Severity::High => "31",
145 Severity::Medium => "33",
146 Severity::Low => "36",
147 Severity::ClientSafe => "2;36",
148 Severity::Info => "90",
149 };
150
151 writeln!(
153 self.writer,
154 " {} {} {}",
155 colorize("┌", border_ansi, self.color),
156 severity_str,
157 colorize(
158 &format!("─── {}", finding.detector_name),
159 border_ansi,
160 self.color,
161 ),
162 )?;
163
164 writeln!(
166 self.writer,
167 " {} {} {}",
168 colorize("│", border_ansi, self.color),
169 dim("Secret: ", self.color),
170 highlight(&finding.credential_redacted, self.color),
171 )?;
172
173 writeln!(
175 self.writer,
176 " {} {} {}",
177 colorize("│", border_ansi, self.color),
178 dim("Location: ", self.color),
179 location,
180 )?;
181
182 let verify_suffix = if verified.is_empty() {
184 String::new()
185 } else {
186 format!(" ({})", verified)
187 };
188 writeln!(
189 self.writer,
190 " {} {} {}{}",
191 colorize("│", border_ansi, self.color),
192 dim("Confidence:", self.color),
193 confidence,
194 verify_suffix,
195 )?;
196
197 if let Some(commit) = &finding.location.commit {
199 writeln!(
200 self.writer,
201 " {} {} {}",
202 colorize("│", border_ansi, self.color),
203 dim("Commit: ", self.color),
204 commit,
205 )?;
206 }
207
208 if let Some(author) = &finding.location.author {
209 writeln!(
210 self.writer,
211 " {} {} {}",
212 colorize("│", border_ansi, self.color),
213 dim("Author: ", self.color),
214 author,
215 )?;
216 }
217
218 if let Some(date) = &finding.location.date {
219 writeln!(
220 self.writer,
221 " {} {} {}",
222 colorize("│", border_ansi, self.color),
223 dim("Date: ", self.color),
224 date,
225 )?;
226 }
227
228 for (key, value) in &finding.metadata {
230 writeln!(
231 self.writer,
232 " {} {} {}",
233 colorize("│", border_ansi, self.color),
234 dim(&format!("{:<11}", format!("{}:", key)), self.color),
235 value,
236 )?;
237 }
238
239 if !finding.additional_locations.is_empty() {
240 writeln!(
241 self.writer,
242 " {} {} (+{} more locations)",
243 colorize("│", border_ansi, self.color),
244 dim("Extra: ", self.color),
245 finding.additional_locations.len(),
246 )?;
247 }
248
249 let remediation = match finding.severity {
251 Severity::Critical | Severity::High => "Revoke immediately and rotate.",
252 Severity::Medium => "Review usage and rotate if active.",
253 Severity::ClientSafe => {
254 "Public by design (client bundle key); verify scope restrictions."
255 }
256 _ => "Remove from codebase.",
257 };
258 writeln!(
259 self.writer,
260 " {} {} {}",
261 colorize("│", border_ansi, self.color),
262 dim("Action: ", self.color),
263 colorize(remediation, "3;32", self.color),
264 )?;
265
266 writeln!(
268 self.writer,
269 " {}\n",
270 colorize(
271 "└─────────────────────────────────────────────",
272 border_ansi,
273 self.color,
274 ),
275 )?;
276
277 Ok(())
278 }
279
280 fn finish(&mut self) -> Result<(), ReportError> {
281 if self.count == 0 {
282 self.print_header()?;
283 if self.example_suppressions > 0 {
284 let plural = if self.example_suppressions == 1 {
285 ""
286 } else {
287 "s"
288 };
289 let msg = if self.dogfood_active {
290 format!(
291 "No real secrets, but {} example/test key{} suppressed (see --dogfood output above for the full list).",
292 self.example_suppressions, plural
293 )
294 } else {
295 format!(
296 "No real secrets, but {} example/test key{} suppressed. Pass --dogfood to see them.",
297 self.example_suppressions, plural
298 )
299 };
300 writeln!(self.writer, " {}\n", colorize(&msg, "1;33", self.color))?;
301 } else {
302 writeln!(
303 self.writer,
304 " {}\n",
305 colorize("No secrets found. Your code is clean.", "1;32", self.color),
306 )?;
307 }
308 } else {
309 let summary_border = colorize(
310 "━━━ Results ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
311 "90",
312 self.color,
313 );
314 writeln!(self.writer, " {}", summary_border)?;
315
316 let plural = if self.count == 1 { "" } else { "s" };
317
318 let mut parts = vec![highlight(
319 &format!("{} secret{plural} found", self.count),
320 self.color,
321 )];
322 if self.live_count > 0 {
323 parts.push(colorize(
324 &format!("{} live", self.live_count),
325 "1;31",
326 self.color,
327 ));
328 }
329 if self.dead_count > 0 {
330 parts.push(colorize(
331 &format!("{} dead", self.dead_count),
332 "32",
333 self.color,
334 ));
335 }
336 let unverified = self.count - self.live_count - self.dead_count;
337 if unverified > 0 {
338 parts.push(colorize(
339 &format!("{unverified} unverified"),
340 "33",
341 self.color,
342 ));
343 }
344
345 writeln!(self.writer, " {}", parts.join(" · "))?;
346
347 writeln!(self.writer)?;
349 writeln!(
350 self.writer,
351 " {} Revoke active secrets in the provider's dashboard.",
352 colorize("1.", "1;31", self.color),
353 )?;
354 writeln!(
355 self.writer,
356 " {} Remove credentials from codebase and git history.",
357 colorize("2.", "1;33", self.color),
358 )?;
359 writeln!(
360 self.writer,
361 " {} Use a secure secret manager or environment variables.",
362 colorize("3.", "1;32", self.color),
363 )?;
364
365 let end_border = colorize(
366 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
367 "90",
368 self.color,
369 );
370 writeln!(self.writer, "\n {}\n", end_border)?;
371 }
372 self.flush_writer()
373 }
374}
375
376impl<W: Write + Send> WriterBackedReporter for TextReporter<W> {
377 type Writer = W;
378
379 fn writer_mut(&mut self) -> &mut Self::Writer {
380 &mut self.writer
381 }
382}
383
384fn format_severity(severity: Severity, color: bool) -> String {
385 let (label, style) = match severity {
386 Severity::Critical => ("CRITICAL", "1;31"),
387 Severity::High => ("HIGH", "31"),
388 Severity::Medium => ("MEDIUM", "33"),
389 Severity::Low => ("LOW", "36"),
390 Severity::ClientSafe => ("CLIENT-SAFE", "2;36"),
396 Severity::Info => ("INFO", "90"),
397 };
398 colorize(&format!("{:>11}", label), style, color)
399}
400
401fn format_verification(result: &VerificationResult, color: bool) -> String {
402 match result {
403 VerificationResult::Live => colorize("LIVE", "1;31;43", color),
404 VerificationResult::Revoked => colorize("revoked", "1;33", color),
405 VerificationResult::Dead => colorize("dead", "32", color),
406 VerificationResult::RateLimited => colorize("limited", "33", color),
407 VerificationResult::Error(_) => colorize("error", "33", color),
408 VerificationResult::Unverifiable | VerificationResult::Skipped => String::new(),
409 }
410}
411
412fn format_location(location: &MatchLocation) -> String {
413 match (&location.file_path, location.line) {
414 (Some(path), Some(line)) => format!("{}:{}", strip_unc_prefix(path), line),
415 (Some(path), None) => strip_unc_prefix(path).to_string(),
416 _ => location.source.to_string(),
417 }
418}
419
420fn strip_unc_prefix(path: &str) -> &str {
427 path.strip_prefix(r"\\?\").unwrap_or(path)
428}
429
430fn highlight(text: &str, color: bool) -> String {
431 colorize(text, "1", color)
432}
433
434fn dim(text: &str, color: bool) -> String {
435 colorize(text, "90", color)
436}
437
438fn colorize(text: &str, ansi: &str, color: bool) -> String {
439 if color {
440 format!("\x1b[{ansi}m{text}\x1b[0m")
441 } else {
442 text.to_string()
443 }
444}