Skip to main content

rust_doctor/
render.rs

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
17/// The linear report never renders narrower than this, and every entry point
18/// normalizes to it. That is what makes the score block total: at this width
19/// the block always fits, so neither it nor this file carries a
20/// narrow-terminal fallback with a second rounding of its own.
21const MIN_WIDTH: usize = 80;
22const _: () = assert!(MIN_WIDTH >= crate::score_block::MIN_BLOCK_COLUMNS);
23/// Narrowest gutter a code frame is drawn with, so a report reads the same
24/// whether its frames sit at line 7 or line 700.
25const 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    /// Allows the score block to animate. Reserved for a real interactive
74    /// terminal: any captured output must stay deterministic.
75    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    // The report is its sections, in order. Nothing is composed inline here:
136    // a line written straight into the entry point is a section nobody named,
137    // and eight of them are what made this function the report's own worst
138    // complexity hotspot.
139    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
153/// What a failed scan may say: the scope it was attempted under, and the stage
154/// that failed.
155///
156/// It measured nothing, so none of the sections below it apply: each of them
157/// counts, tallies, ranks or scores a population no producer ever produced.
158/// `No issues found.` over a scan that never ran is the same false claim as the
159/// `100 / 100` face that used to close such a report, under the line naming the
160/// failure.
161fn 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
170/// What the scan covered and how long it took.
171fn 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
188/// The findings themselves: all of them under `--verbose`, the worst one
189/// otherwise, and a sentence when there are none.
190fn 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
210/// The rule below the findings, the two totals, and the hint that the rest is
211/// one flag away.
212fn 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
234/// A rule firing across enough files that fixing it one site at a time is the
235/// wrong plan.
236fn 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
263/// Where to take the report next. A failed scan reaches none of this: it is
264/// rendered by `render_failure`, having no score to share and nothing the docs
265/// would explain.
266fn 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/// How much of a group the report is drawing.
310///
311/// The two used to be two positional booleans, `top` and `all_locations`,
312/// naming four combinations for the two that exist: the summary shows the
313/// worst group with its first location, the verbose run shows every group with
314/// every location. A call site read `(.., true, false)`, which said nothing.
315#[derive(Clone, Copy, PartialEq, Eq)]
316enum GroupView {
317    /// The single worst group, heading it as the top finding.
318    Top,
319    /// One group among all of them, with every location it carries.
320    Full,
321}
322
323impl GroupView {
324    /// How many of the group's diagnostics get a location and a code frame.
325    /// The summary shows one; the verbose run shows them all, and `usize::MAX`
326    /// is what `take` reads as all of them.
327    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
387/// The source window around one finding, or the reason there is none.
388fn 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    // The gutter comes from the frame rather than from a constant, so a source
404    // row and the caret row under it agree on where the text begins whatever
405    // line numbers the frame carries. The four columns are the floor the
406    // report has always drawn at, not a ceiling on what a line number may
407    // need.
408    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
432/// Other sites a structural finding spans.
433///
434/// The list is bounded here and complete in `--json`: a function cloned two
435/// hundred times is one finding, and printing two hundred references would bury
436/// every other finding under it.
437fn 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
496/// How the run was configured, when a policy reached the report.
497fn 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
525/// What a baseline comparison found, and every finding the branch fixed.
526///
527/// The two used to sit at opposite ends of one grab-bag function with three
528/// unrelated sections between them, each testing `report.delta` again.
529fn 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
580/// The gate's verdict, evaluated or not.
581fn 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
596/// Every stage that failed, on a scan that did not complete.
597fn 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
621/// Rules of the worst tier present in the score scope, bounded to fit on one
622/// line. A cap with no named cause cannot be explained.
623fn 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
706/// How a rule's rate is qualified wherever the ranking names the rule.
707///
708/// A rate published alone cannot be weighed. Thirty-three percent measured on
709/// one adjudicated site and thirty-three measured on forty are the same number
710/// and not the same claim, and the difference is exactly what the ranking's
711/// smoothing acts on, invisibly, in the rate it produces. So the sample is
712/// printed beside the rate wherever a rule is named.
713///
714/// A rule the corpus never adjudicated is named as unmeasured rather than shown
715/// as a number. It is ranked at the middle of the interval, and printing that
716/// middle as a measurement would publish an assumption as an observation.
717fn 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
736/// Why the loudest rule is missing from what to fix.
737///
738/// The rule with the most findings is often the one the corpus found most often
739/// wrong, so a list that drops it without a word reads as a defect of the tool.
740/// Two names carry the point; past that a count does, because the sentence is
741/// there to explain an absence, not to enumerate one.
742///
743/// The sentence says the discount emptied the value rather than that the corpus
744/// adjudicated no true positive, which is what it used to say and what the
745/// smoothed rate can no longer support: no smoothed rate reaches ten thousand
746/// basis points, so a rule reaches this list by having little enough left after
747/// the discount rather than by having been adjudicated wrong everywhere.
748fn 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
789/// One line of prose: sanitized, then wrapped onto as many rows as it needs.
790fn 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
802/// One row of a code frame: sanitized, then cut rather than wrapped.
803///
804/// A source line and the caret row under it are aligned by column, so wrapping
805/// either of them would put the caret under the wrong text. This is the only
806/// thing that separates it from [`line`].
807fn 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;