1use std::collections::BTreeSet;
2use std::error::Error;
3use std::fmt;
4use std::io::{self, Write};
5use std::path::Path;
6use std::time::Duration;
7
8use crate::git_scope::ResolvedScope;
9use crate::presentation::{DiagnosticGroup, GroupDiagnostic, ReportPresentation, code_frame};
10use crate::terminal_text::{sanitize, truncate, wrap};
11use crate::{GateStatus, InspectReport, Status};
12
13mod score_header;
14
15const DEFAULT_WIDTH: usize = 80;
16
17const MIN_WIDTH: usize = 80;
22const _: () = assert!(MIN_WIDTH >= crate::score_block::MIN_BLOCK_COLUMNS);
23const FRAME_GUTTER_COLUMNS: usize = 4;
26const DOCS_URL: &str = "https://rust-doctor.com/docs";
27const GITHUB_URL: &str = "https://github.com/arthjean/rust-doctor";
28
29#[derive(Debug)]
30pub enum RenderError {
31 InvalidReport,
32 Json(serde_json::Error),
33 Write(io::Error),
34}
35
36impl RenderError {
37 pub fn is_broken_pipe(&self) -> bool {
38 match self {
39 Self::InvalidReport => false,
40 Self::Json(error) => error.io_error_kind() == Some(io::ErrorKind::BrokenPipe),
41 Self::Write(error) => error.kind() == io::ErrorKind::BrokenPipe,
42 }
43 }
44}
45
46impl fmt::Display for RenderError {
47 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48 match self {
49 Self::InvalidReport => formatter.write_str("refusing to render an invalid report"),
50 Self::Json(error) => write!(formatter, "could not serialize report: {error}"),
51 Self::Write(error) => write!(formatter, "could not write report: {error}"),
52 }
53 }
54}
55
56impl Error for RenderError {
57 fn source(&self) -> Option<&(dyn Error + 'static)> {
58 match self {
59 Self::InvalidReport => None,
60 Self::Json(error) => Some(error),
61 Self::Write(error) => Some(error),
62 }
63 }
64}
65
66#[derive(Debug, Clone, Copy)]
67pub struct TerminalOptions<'a> {
68 pub workspace_root: &'a Path,
69 pub elapsed: Duration,
70 pub verbose: bool,
71 pub width: usize,
72 pub color: bool,
73 pub animate: bool,
76}
77
78impl<'a> TerminalOptions<'a> {
79 pub const fn new(workspace_root: &'a Path) -> Self {
80 Self {
81 workspace_root,
82 elapsed: Duration::ZERO,
83 verbose: false,
84 width: DEFAULT_WIDTH,
85 color: false,
86 animate: false,
87 }
88 }
89
90 fn normalized(self) -> Self {
91 Self {
92 width: self.width.max(MIN_WIDTH),
93 ..self
94 }
95 }
96}
97
98pub fn render_json<W: Write>(report: &InspectReport, mut writer: W) -> Result<(), RenderError> {
99 if !report.is_valid() {
100 return Err(RenderError::InvalidReport);
101 }
102 serde_json::to_writer(&mut writer, report).map_err(RenderError::Json)?;
103 writer.write_all(b"\n").map_err(RenderError::Write)
104}
105
106pub fn render_terminal<W: Write>(report: &InspectReport, writer: W) -> Result<(), RenderError> {
107 render_terminal_with_options(report, writer, TerminalOptions::new(Path::new(".")))
108}
109
110pub fn render_terminal_with_options<W: Write>(
111 report: &InspectReport,
112 writer: W,
113 options: TerminalOptions<'_>,
114) -> Result<(), RenderError> {
115 let presentation = ReportPresentation::derive_terminal(report);
116 render_terminal_with_presentation(report, &presentation, writer, options)
117}
118
119pub fn render_terminal_with_presentation<W: Write>(
120 report: &InspectReport,
121 presentation: &ReportPresentation,
122 mut writer: W,
123 options: TerminalOptions<'_>,
124) -> Result<(), RenderError> {
125 if !report.is_valid() {
126 return Err(RenderError::InvalidReport);
127 }
128 let options = options.normalized();
129 let writer = &mut writer;
130
131 if report.status == Status::Failed {
132 return render_failure(writer, report, options);
133 }
134
135 render_scope(writer, report, options)?;
140 render_scanned(writer, report, options)?;
141 render_findings(writer, presentation, options)?;
142 render_totals(writer, presentation, options)?;
143 render_categories(writer, report, options)?;
144 render_configuration(writer, report, options)?;
145 render_delta(writer, report, options)?;
146 render_gate(writer, report, options)?;
147 render_scan_errors(writer, report, options)?;
148 render_advisories(writer, presentation, options)?;
149 render_score(writer, report, options)?;
150 render_links(writer, report, options)
151}
152
153fn render_failure<W: Write>(
162 writer: &mut W,
163 report: &InspectReport,
164 options: TerminalOptions<'_>,
165) -> Result<(), RenderError> {
166 render_scope(writer, report, options)?;
167 render_scan_errors(writer, report, options)
168}
169
170fn render_scanned<W: Write>(
172 writer: &mut W,
173 report: &InspectReport,
174 options: TerminalOptions<'_>,
175) -> Result<(), RenderError> {
176 line(
177 writer,
178 &format!(
179 "Scanned {} files in {:.1}s",
180 report.audit.source_files,
181 options.elapsed.as_secs_f64()
182 ),
183 options,
184 Style::Accent,
185 )
186}
187
188fn render_findings<W: Write>(
191 writer: &mut W,
192 presentation: &ReportPresentation,
193 options: TerminalOptions<'_>,
194) -> Result<(), RenderError> {
195 if presentation.issue_count == 0 {
196 return line(writer, "No issues found.", options, Style::Success);
197 }
198 if options.verbose {
199 for group in &presentation.groups {
200 render_group(writer, group, options, GroupView::Full)?;
201 }
202 return Ok(());
203 }
204 let Some(group) = presentation.groups.first() else {
205 return Ok(());
206 };
207 render_group(writer, group, options, GroupView::Top)
208}
209
210fn render_totals<W: Write>(
213 writer: &mut W,
214 presentation: &ReportPresentation,
215 options: TerminalOptions<'_>,
216) -> Result<(), RenderError> {
217 line(
218 writer,
219 &"─".repeat(options.width.min(48)),
220 options,
221 Style::Muted,
222 )?;
223 line(
224 writer,
225 &format!(
226 "All {} occurrences across {} findings",
227 presentation.issue_count, presentation.finding_count
228 ),
229 options,
230 Style::Heading,
231 )
232}
233
234fn render_advisories<W: Write>(
237 writer: &mut W,
238 presentation: &ReportPresentation,
239 options: TerminalOptions<'_>,
240) -> Result<(), RenderError> {
241 if !options.verbose && !presentation.groups.is_empty() {
242 line(
243 writer,
244 "Run with --verbose to see every issue.",
245 options,
246 Style::Muted,
247 )?;
248 }
249 for advisory in &presentation.migration_advisories {
250 line(
251 writer,
252 &format!(
253 "Migration advisory: {} appears {} times across {} files.",
254 advisory.rule_id, advisory.occurrences, advisory.files
255 ),
256 options,
257 Style::Warning,
258 )?;
259 }
260 Ok(())
261}
262
263fn render_links<W: Write>(
267 writer: &mut W,
268 report: &InspectReport,
269 options: TerminalOptions<'_>,
270) -> Result<(), RenderError> {
271 if let Ok(url) = report.audit.share_url() {
272 line(writer, &format!("Share: {url}"), options, Style::Accent)?;
273 }
274 line(writer, &format!("Docs: {DOCS_URL}"), options, Style::Muted)?;
275 line(
276 writer,
277 &format!("GitHub: {GITHUB_URL}"),
278 options,
279 Style::Muted,
280 )
281}
282
283fn render_scope<W: Write>(
284 writer: &mut W,
285 report: &InspectReport,
286 options: TerminalOptions<'_>,
287) -> Result<(), RenderError> {
288 let description = report.scope.as_ref().map_or_else(
289 || "Scope: full codebase".to_owned(),
290 |scope| match scope.kind() {
291 ResolvedScope::Full => "Scope: full codebase".to_owned(),
292 ResolvedScope::Files {
293 comparison_base,
294 files,
295 } => format!(
296 "Scope: changed files ({} selected, base {})",
297 files.len(),
298 short_revision(comparison_base)
299 ),
300 ResolvedScope::Baseline { comparison_base } => format!(
301 "Scope: baseline comparison (base {})",
302 short_revision(comparison_base)
303 ),
304 },
305 );
306 line(writer, &description, options, Style::Heading)
307}
308
309#[derive(Clone, Copy, PartialEq, Eq)]
316enum GroupView {
317 Top,
319 Full,
321}
322
323impl GroupView {
324 const fn location_limit(self) -> usize {
328 match self {
329 Self::Top => 1,
330 Self::Full => usize::MAX,
331 }
332 }
333}
334
335fn render_group<W: Write>(
336 writer: &mut W,
337 group: &DiagnosticGroup,
338 options: TerminalOptions<'_>,
339 view: GroupView,
340) -> Result<(), RenderError> {
341 let heading = match view {
342 GroupView::Top => format!("Top {}: {}", group.severity, group.title),
343 GroupView::Full => format!(
344 "{}: {} ({} occurrences)",
345 capitalize(group.severity.to_string()),
346 group.title,
347 group.occurrences
348 ),
349 };
350 line(writer, &heading, options, severity_style(group.severity))?;
351 line(
352 writer,
353 &format!("Rule ID: {}", group.rule_id),
354 options,
355 Style::Accent,
356 )?;
357
358 for diagnostic in group.diagnostics.iter().take(view.location_limit()) {
359 line(writer, &diagnostic.message, options, Style::Plain)?;
360 if let Some(help) = &diagnostic.help {
361 line(writer, &format!("Help: {help}"), options, Style::Muted)?;
362 }
363 if diagnostic.base_severity != diagnostic.severity {
364 line(
365 writer,
366 &format!(
367 "Policy: base severity {}, effective severity {}",
368 diagnostic.base_severity, diagnostic.severity
369 ),
370 options,
371 Style::Muted,
372 )?;
373 }
374 render_related(writer, diagnostic, options, view)?;
375 if let Some(location) = diagnostic.location() {
376 render_code_frame(writer, &location, options)?;
377 }
378 }
379 line(
380 writer,
381 &format!("Rule: {}", group.rule_url),
382 options,
383 Style::Muted,
384 )
385}
386
387fn render_code_frame<W: Write>(
389 writer: &mut W,
390 location: &crate::presentation::GroupLocation,
391 options: TerminalOptions<'_>,
392) -> Result<(), RenderError> {
393 let frame = match code_frame(options.workspace_root, location) {
394 Ok(frame) => frame,
395 Err(unavailable) => {
396 if let Some(location) = unavailable.location {
397 line(writer, &location, options, Style::Accent)?;
398 }
399 return line(writer, &unavailable.message, options, Style::Muted);
400 }
401 };
402 line(writer, &frame.location, options, Style::Accent)?;
403 let gutter = frame.gutter_width().max(FRAME_GUTTER_COLUMNS);
409 let indent = " ".repeat(gutter.saturating_add(3));
410 for source in frame.lines {
411 let prefix = if source.primary { ">" } else { " " };
412 frame_line(
413 writer,
414 &format!("{prefix} {:>gutter$} | {}", source.number, source.text),
415 options,
416 Style::Plain,
417 )?;
418 if let Some(marker) = source.marker {
419 let spaces = marker.column_start.saturating_sub(1);
420 let carets = marker.column_end.saturating_sub(marker.column_start).max(1);
421 frame_line(
422 writer,
423 &format!("{indent}| {}{}", " ".repeat(spaces), "^".repeat(carets)),
424 options,
425 Style::Warning,
426 )?;
427 }
428 }
429 Ok(())
430}
431
432fn render_related<W: Write>(
438 writer: &mut W,
439 diagnostic: &GroupDiagnostic,
440 options: TerminalOptions<'_>,
441 view: GroupView,
442) -> Result<(), RenderError> {
443 const MAX_RELATED: usize = 3;
444 if view != GroupView::Full || diagnostic.related.is_empty() {
445 return Ok(());
446 }
447 for location in diagnostic.related.iter().take(MAX_RELATED) {
448 line(
449 writer,
450 &format!(
451 "Also at: {}:{}:{}",
452 location.path, location.span.line_start, location.span.column_start
453 ),
454 options,
455 Style::Accent,
456 )?;
457 }
458 let remaining = diagnostic.related.len().saturating_sub(MAX_RELATED);
459 if remaining > 0 {
460 line(
461 writer,
462 &format!("and {remaining} more locations"),
463 options,
464 Style::Muted,
465 )?;
466 }
467 Ok(())
468}
469
470fn render_categories<W: Write>(
471 writer: &mut W,
472 report: &InspectReport,
473 options: TerminalOptions<'_>,
474) -> Result<(), RenderError> {
475 for category in &report.audit.categories {
476 line(
477 writer,
478 &format!(
479 "{}: {} errors, {} warnings, {} info, {} unknown (occurrences)",
480 category.name,
481 category.occurrences.errors,
482 category.occurrences.warnings,
483 category.occurrences.info,
484 category.occurrences.unknown
485 ),
486 options,
487 Style::Plain,
488 )?;
489 }
490 if report.audit.categories.is_empty() {
491 line(writer, "Categories: none", options, Style::Plain)?;
492 }
493 Ok(())
494}
495
496fn render_configuration<W: Write>(
498 writer: &mut W,
499 report: &InspectReport,
500 options: TerminalOptions<'_>,
501) -> Result<(), RenderError> {
502 let Some(policy) = &report.policy else {
503 return Ok(());
504 };
505 let source = match policy.blocking.source {
506 crate::BlockingLevelSource::Default => "default",
507 crate::BlockingLevelSource::Config => "config",
508 crate::BlockingLevelSource::Request => "request",
509 };
510 let configuration = policy
511 .config_file
512 .as_deref()
513 .map_or_else(|| "none loaded".to_owned(), |file| format!("{file} loaded"));
514 line(
515 writer,
516 &format!(
517 "Configuration: {configuration}; blocking {} ({source})",
518 policy.blocking.level
519 ),
520 options,
521 Style::Muted,
522 )
523}
524
525fn render_delta<W: Write>(
530 writer: &mut W,
531 report: &InspectReport,
532 options: TerminalOptions<'_>,
533) -> Result<(), RenderError> {
534 let Some(delta) = &report.delta else {
535 return Ok(());
536 };
537 line(
538 writer,
539 &format!(
540 "Delta: +{} introduced; ={} pre-existing; -{} fixed; {} cross-file matches.",
541 delta.summary.introduced,
542 delta.summary.pre_existing,
543 delta.summary.fixed,
544 delta.summary.cross_file_matches
545 ),
546 options,
547 Style::Muted,
548 )?;
549 for diagnostic in &delta.fixed {
550 let path = diagnostic.path.as_deref().unwrap_or("<unknown>");
551 let (line_number, column) = diagnostic
552 .span
553 .as_ref()
554 .map_or((0, 0), |span| (span.line_start, span.column_start));
555 let code = diagnostic
556 .code
557 .as_deref()
558 .map_or_else(String::new, |code| format!(" [{code}]"));
559 line(
560 writer,
561 &format!(
562 "Fixed: {path}:{line_number}:{column} {}{code} {}",
563 diagnostic.severity, diagnostic.message
564 ),
565 options,
566 Style::Success,
567 )?;
568 }
569 if delta.introduced.is_empty() && delta.fixed.is_empty() {
570 return Ok(());
571 }
572 line(
573 writer,
574 "Baseline details remain available in the JSON report.",
575 options,
576 Style::Muted,
577 )
578}
579
580fn render_gate<W: Write>(
582 writer: &mut W,
583 report: &InspectReport,
584 options: TerminalOptions<'_>,
585) -> Result<(), RenderError> {
586 let description = match (report.gate.status, report.gate.blocking_diagnostics) {
587 (GateStatus::Passed | GateStatus::Failed, Some(count)) => format!(
588 "Gate {}: blocking {}, {count} blocking diagnostic(s)",
589 report.gate.status, report.gate.blocking
590 ),
591 _ => format!("Gate not evaluated: blocking {}", report.gate.blocking),
592 };
593 line(writer, &description, options, Style::Muted)
594}
595
596fn render_scan_errors<W: Write>(
598 writer: &mut W,
599 report: &InspectReport,
600 options: TerminalOptions<'_>,
601) -> Result<(), RenderError> {
602 let heading = match report.status {
603 Status::Complete => return Ok(()),
604 Status::Incomplete => "Scan incomplete",
605 Status::Failed => "Scan failed",
606 };
607 for error in &report.errors {
608 line(
609 writer,
610 &format!(
611 "{heading}: {} ({}/{})",
612 error.message, error.stage, error.code
613 ),
614 options,
615 Style::Warning,
616 )?;
617 }
618 Ok(())
619}
620
621fn capping_rule_ids(report: &InspectReport, tier: crate::RuleTier) -> Vec<String> {
624 const MAX_NAMED_RULES: usize = 3;
625 let scoped: Option<BTreeSet<_>> = report.delta.as_ref().map(|delta| {
626 delta
627 .introduced
628 .iter()
629 .map(String::as_str)
630 .collect::<BTreeSet<_>>()
631 });
632 let mut ids: Vec<_> = report
633 .diagnostics
634 .iter()
635 .filter(|diagnostic| {
636 scoped
637 .as_ref()
638 .is_none_or(|scoped| scoped.contains(diagnostic.id.as_str()))
639 })
640 .filter_map(|diagnostic| diagnostic.code.as_deref())
641 .filter(|code| crate::policy::find(code).is_some_and(|definition| definition.tier == tier))
642 .collect::<BTreeSet<_>>()
643 .into_iter()
644 .map(str::to_owned)
645 .collect();
646 ids.truncate(MAX_NAMED_RULES);
647 ids
648}
649
650fn render_score<W: Write>(
651 writer: &mut W,
652 report: &InspectReport,
653 options: TerminalOptions<'_>,
654) -> Result<(), RenderError> {
655 let Some(score) = &report.audit.score else {
656 return line(
657 writer,
658 "Score unavailable: no Rust files were analyzed.",
659 options,
660 Style::Warning,
661 );
662 };
663 if let Some((tier, ceiling)) = score.worst_tier.zip(score.applied_ceiling) {
664 let blocking = capping_rule_ids(report, tier);
665 line(
666 writer,
667 &format!(
668 "Capped at {ceiling}/100 by a {} finding: {}",
669 tier.as_str(),
670 blocking.join(", ")
671 ),
672 options,
673 Style::Warning,
674 )?;
675 }
676 score_header::render(writer, score, options, score_header::Cadence::DEFAULT)?;
677 if !score.authoritative {
678 line(
679 writer,
680 "Score is partial because the scan did not complete or contains unscored findings.",
681 options,
682 Style::Warning,
683 )?;
684 }
685 if let Some(projected) = score
686 .projected_after_top_three
687 .filter(|projected| *projected > score.value)
688 {
689 line(
690 writer,
691 &format!(
692 "Fix the top {} rules to reach a projected {projected}/100: {}",
693 score.projected_rule_ids.len(),
694 named_with_measurement(&score.projected_rule_ids)
695 ),
696 options,
697 Style::Accent,
698 )?;
699 }
700 if let Some(withheld) = withheld_sentence(&score.withheld_rule_ids) {
701 line(writer, &withheld, options, Style::Plain)?;
702 }
703 Ok(())
704}
705
706fn measurement_note(id: &str) -> String {
718 match crate::policy::corpus_measurement(id) {
719 Some(measurement) => {
720 let percent = (u32::from(measurement.noise_basis_points()) + 50) / 100;
721 let sites = measurement.reviewed();
722 let unit = if sites == 1 { "site" } else { "sites" };
723 format!(" ({percent}% noise on {sites} {unit})")
724 }
725 None => " (unmeasured)".to_owned(),
726 }
727}
728
729fn named_with_measurement(ids: &[String]) -> String {
730 ids.iter()
731 .map(|id| format!("{id}{}", measurement_note(id)))
732 .collect::<Vec<_>>()
733 .join(", ")
734}
735
736fn withheld_sentence(withheld: &[String]) -> Option<String> {
749 let named: Vec<String> = withheld
750 .iter()
751 .take(2)
752 .map(|id| format!("{id}{}", measurement_note(id)))
753 .collect();
754 let subject = match (named.as_slice(), withheld.len()) {
755 ([], _) => return None,
756 ([only], _) => format!("{only} reports here but is"),
757 ([first, second], 2) => format!("{first} and {second} report here but are"),
758 ([first, second], total) => format!(
759 "{first}, {second} and {} more report here but are",
760 total - 2
761 ),
762 _ => return None,
763 };
764 Some(format!(
765 "{subject} left out: once the rate the corpus adjudicated is applied, nothing \
766 worth repairing is left."
767 ))
768}
769
770#[derive(Clone, Copy)]
771enum Style {
772 Plain,
773 Heading,
774 Accent,
775 Success,
776 Warning,
777 Muted,
778}
779
780fn severity_style(severity: crate::Severity) -> Style {
781 match severity {
782 crate::Severity::Error => Style::Warning,
783 crate::Severity::Warning => Style::Warning,
784 crate::Severity::Info => Style::Accent,
785 crate::Severity::Unknown => Style::Muted,
786 }
787}
788
789fn line<W: Write>(
791 writer: &mut W,
792 content: &str,
793 options: TerminalOptions<'_>,
794 style: Style,
795) -> Result<(), RenderError> {
796 for bounded in wrap(&sanitize(content), options.width) {
797 write_styled(writer, &bounded, options.color, style)?;
798 }
799 Ok(())
800}
801
802fn frame_line<W: Write>(
808 writer: &mut W,
809 content: &str,
810 options: TerminalOptions<'_>,
811 style: Style,
812) -> Result<(), RenderError> {
813 let bounded = truncate(&sanitize(content), options.width);
814 write_styled(writer, &bounded, options.color, style)
815}
816
817fn write_styled<W: Write>(
818 writer: &mut W,
819 content: &str,
820 color: bool,
821 style: Style,
822) -> Result<(), RenderError> {
823 if color && !matches!(style, Style::Plain) {
824 let code = match style {
825 Style::Heading => "1",
826 Style::Accent => "36",
827 Style::Success => "32",
828 Style::Warning => "33",
829 Style::Muted => "2",
830 Style::Plain => "0",
831 };
832 writeln!(writer, "\u{1b}[{code}m{content}\u{1b}[0m").map_err(RenderError::Write)
833 } else {
834 writeln!(writer, "{content}").map_err(RenderError::Write)
835 }
836}
837
838fn short_revision(revision: &str) -> &str {
839 revision.get(..12).unwrap_or(revision)
840}
841
842fn capitalize(mut value: String) -> String {
843 if let Some(first) = value.get_mut(..1) {
844 first.make_ascii_uppercase();
845 }
846 value
847}
848
849#[cfg(test)]
850mod tests;