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}
27
28impl<W: Write + Send> TextReporter<W> {
29 pub fn new(writer: W) -> Self {
40 Self::with_color(writer, std::io::stdout().is_terminal())
41 }
42
43 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 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 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 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 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 writeln!(
143 self.writer,
144 " {} {} {}",
145 colorize("│", border_ansi, self.color),
146 dim("Location: ", self.color),
147 location,
148 )?;
149
150 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 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 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 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 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 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!("{}:{}", strip_unc_prefix(path), line),
354 (Some(path), None) => strip_unc_prefix(path).to_string(),
355 _ => location.source.to_string(),
356 }
357}
358
359fn strip_unc_prefix(path: &str) -> &str {
366 path.strip_prefix(r"\\?\").unwrap_or(path)
367}
368
369fn highlight(text: &str, color: bool) -> String {
370 colorize(text, "1", color)
371}
372
373fn dim(text: &str, color: bool) -> String {
374 colorize(text, "90", color)
375}
376
377fn colorize(text: &str, ansi: &str, color: bool) -> String {
378 if color {
379 format!("\x1b[{ansi}m{text}\x1b[0m")
380 } else {
381 text.to_string()
382 }
383}