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.
31pub fn finding_line(source: Option<&str>, f: &Finding) -> String {
32 let column = f.column.map(|c| format!(":{c}")).unwrap_or_default();
33 let tag = match source {
34 Some(source) => format!("{source}/{}", f.kind),
35 None => f.kind.clone(),
36 };
37 format!(
38 "{}:{}{}: {} [{}] {}",
39 f.file_path,
40 f.line,
41 column,
42 f.severity.as_str(),
43 tag,
44 f.message
45 )
46}
47
48/// The suggestion line that follows a finding, when it has one.
49///
50/// Written immediately after the finding it belongs to. Printing every finding
51/// first and every suggestion afterwards detaches them: with two findings, the
52/// first suggestion appears below the second finding and reads as if it
53/// belonged to it.
54pub fn write_suggestion<W: Write>(out: &mut W, f: &Finding) -> Result<()> {
55 if let Some(suggestion) = &f.suggestion {
56 writeln!(out, " suggestion: {suggestion}")?;
57 }
58 Ok(())
59}
60
61/// The "N file(s) could not be analyzed" block.
62///
63/// `findings_above` decides the separating blank line - see the module doc.
64pub fn write_failures<W: Write>(
65 out: &mut W,
66 failures: &BTreeMap<PathBuf, FailureReason>,
67 findings_above: bool,
68) -> Result<()> {
69 if failures.is_empty() {
70 return Ok(());
71 }
72 if findings_above {
73 writeln!(out)?;
74 }
75 writeln!(out, "{} file(s) could not be analyzed:", failures.len())?;
76 for (path, reason) in failures {
77 writeln!(out, " {}: {}", path.display(), reason.one_line())?;
78 }
79 Ok(())
80}