Skip to main content

drep/cli/
render.rs

1//! Output shared by the commands that report findings.
2//!
3//! `check` and `lint-docs` print the same two things in the same way: a
4//! finding line, and the "could not be analyzed" block that carries the exit-2
5//! contract. They were transcribed copies, so `lint-docs` inherited a fixed
6//! bug by luck rather than by construction: the blank line above the failure
7//! block is emitted only when a findings block precedes it, because emitting it
8//! unconditionally opened every clean-but-unanalyzed run with a stray empty
9//! line.
10//!
11//! What each command still owns is its own format's shape - `check`'s JSON
12//! wire types, `lint-docs`' report-only footer - and the one deliberate
13//! difference in the finding line, which is passed as an argument rather than
14//! copied.
15
16use std::collections::BTreeMap;
17use std::io::Write;
18use std::path::PathBuf;
19
20use anyhow::Result;
21
22use crate::analysis::findings::Finding;
23use crate::analysis::result::FailureReason;
24
25/// One line of text output for a finding.
26///
27/// `source` is the layer that produced it (`"tool"`, `"llm"`), rendered as a
28/// prefix inside the brackets. `check` passes one because two layers write into
29/// one list and they gate differently; `lint-docs` passes `None`, because it
30/// has a single source and a constant tag on every line says nothing.
31///
32/// The message is excerpted: a tool's diagnostics are text drep did not
33/// write, and the JSON reporters decode `\u001b` escapes faithfully, so a raw
34/// print hands the terminal an escape sequence and a multi-kilobyte message
35/// hands it a flood.
36pub fn finding_line(source: Option<&str>, f: &Finding) -> String {
37    let column = f.column.map(|c| format!(":{c}")).unwrap_or_default();
38    let tag = match source {
39        Some(source) => format!("{source}/{}", f.kind),
40        None => f.kind.clone(),
41    };
42    format!(
43        "{}:{}{}: {} [{}] {}",
44        f.file_path,
45        f.line,
46        column,
47        f.severity.as_str(),
48        tag,
49        crate::text::excerpt(&f.message, 200)
50    )
51}
52
53/// The suggestion line that follows a finding, when it has one.
54///
55/// Written immediately after the finding it belongs to. Printing every finding
56/// first and every suggestion afterwards detaches them: with two findings, the
57/// first suggestion appears below the second finding and reads as if it
58/// belonged to it.
59pub fn write_suggestion<W: Write>(out: &mut W, f: &Finding) -> Result<()> {
60    if let Some(suggestion) = &f.suggestion {
61        writeln!(out, "    suggestion: {suggestion}")?;
62    }
63    Ok(())
64}
65
66/// The "N file(s) could not be analyzed" block.
67///
68/// `findings_above` decides the separating blank line - see the module doc.
69pub fn write_failures<W: Write>(
70    out: &mut W,
71    failures: &BTreeMap<PathBuf, FailureReason>,
72    findings_above: bool,
73) -> Result<()> {
74    if failures.is_empty() {
75        return Ok(());
76    }
77    if findings_above {
78        writeln!(out)?;
79    }
80    writeln!(out, "{} file(s) could not be analyzed:", failures.len())?;
81    for (path, reason) in failures {
82        writeln!(out, "  {}: {}", path.display(), reason.one_line())?;
83    }
84    Ok(())
85}