drep/cli/lint_docs/mod.rs
1//! `drep lint-docs` - rule-based markdown checks. No LLM, no network.
2//!
3//! This command runs on every commit, so its startup path is load-bearing. It
4//! touches [`crate::docs`], [`crate::files`] and - in `--staged` mode only -
5//! [`crate::diff`], and nothing else: no config file is read, no provider
6//! chain is built, and no response cache is opened.
7//!
8//! Two exit rules, and they are independent:
9//!
10//! - A file the user named that drep could not analyze exits **2**, whether or
11//! not `--strict` was passed. "Report-only" governs *findings*; a file that
12//! went unread is not a finding, it is the absence of analysis, and that is
13//! the one thing drep never reports as clean.
14//! - Findings exit **1** only at or above `--fail-on`, which is unset by
15//! default. The default is report-only because the checks include whitespace
16//! and line length, and a gate that blocks a commit over a trailing space is
17//! a gate that gets switched off. `--strict` is the shorthand for
18//! `--fail-on info`: one mechanism, asked at two thresholds.
19
20pub(crate) mod render;
21
22use std::collections::BTreeMap;
23use std::path::{Path, PathBuf};
24
25use anyhow::Result;
26use clap::{ArgGroup, Args};
27
28use crate::Exit;
29use crate::analysis::findings::{self, Finding, Severity};
30use crate::analysis::result::FailureReason;
31use crate::cli::severity_parser;
32use crate::files;
33
34#[derive(Debug, Args)]
35// Same shape as `check`'s input group, and stated once for the same reason:
36// paired `conflicts_with` attributes say it from each side and can drift.
37// Deliberately not `required`: bare `drep lint-docs` means "this tree".
38#[command(group(ArgGroup::new("docs-input").args(["paths", "staged"]).multiple(false)))]
39pub struct LintDocsArgs {
40 /// Markdown files or directories. Defaults to the current directory.
41 #[arg(value_name = "PATH")]
42 pub paths: Vec<PathBuf>,
43
44 /// Lint the markdown staged for commit. For a pre-commit hook.
45 ///
46 /// The hook `drep init` writes uses this. Running the command bare would
47 /// also work - it is ~10 ms over a repository this size - but it reports
48 /// findings in documents the commit never touched, and per-commit noise
49 /// about someone else's file is how a report-only gate gets switched off.
50 #[arg(long)]
51 pub staged: bool,
52
53 /// Exit non-zero when any check fires. Shorthand for `--fail-on info`.
54 #[arg(long, conflicts_with = "fail_on")]
55 pub strict: bool,
56
57 /// Exit non-zero when a check at or above this severity fires.
58 ///
59 /// Severity here answers one question: does the finding change how the
60 /// document renders? An unclosed fence does - everything below it becomes
61 /// code - so it alone is `error`. A malformed heading or link renders
62 /// wrong, so those are `warning`. Whitespace and line length render
63 /// identically, so they are `info`. `--fail-on error` is therefore the
64 /// calibration a hook wants: it blocks on the defect that breaks the
65 /// document and stays quiet about trailing spaces.
66 #[arg(long, value_name = "SEVERITY", value_parser = severity_parser())]
67 pub fail_on: Option<Severity>,
68}
69
70impl LintDocsArgs {
71 /// The severity at or above which a finding fails the run, if any.
72 ///
73 /// One place resolves `--strict` to a threshold, so the gate never has to
74 /// know that two flags exist. `--strict` is `--fail-on info` because
75 /// `Info` is the bottom of the vocabulary: every finding is at or above
76 /// it, which is precisely what "block on anything" means.
77 pub fn threshold(&self) -> Option<Severity> {
78 self.fail_on.or(self.strict.then_some(Severity::Info))
79 }
80}
81
82/// Everything one `lint-docs` run produced.
83pub struct LintOutcome {
84 /// Every finding, sorted by file then by position.
85 pub findings: Vec<Finding>,
86 /// Files the user named that drep could not analyze.
87 pub failures: BTreeMap<PathBuf, FailureReason>,
88 /// The verdict. On the outcome rather than beside it, so the renderer and
89 /// the process cannot disagree - the mistake `check` made and fixed.
90 pub exit: Exit,
91 /// What the threshold did to these findings.
92 ///
93 /// Here for the same reason `exit` is: the footer has to say whether the
94 /// findings on screen blocked the run, and computing that a second time in
95 /// the renderer is the identical mistake one field up. It is not
96 /// derivable from `exit` either - a run with an unreadable file exits
97 /// `Unanalyzed` whether or not its findings also crossed the threshold.
98 pub gating: Gating,
99}
100
101/// What a run's threshold did to its findings.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum Gating {
104 /// No threshold: findings are reported and nothing blocks.
105 ReportOnly,
106 /// A threshold was in force and no finding reached it.
107 NoneReached(Severity),
108 /// A threshold was in force and at least one finding reached it.
109 Blocked,
110}
111
112/// One `lint-docs` invocation.
113///
114/// `async` for exactly one reason: `--staged` asks git which documents this
115/// commit touches and what they contain. Every other mode reads files and runs
116/// pure checks with nothing to await.
117pub async fn run(args: &LintDocsArgs, root: &Path) -> Result<Exit> {
118 let outcome = outcome_for(args, root).await?;
119 render::render(&outcome)?;
120 Ok(outcome.exit)
121}
122
123/// The outcome for one invocation, in whichever input mode it names.
124///
125/// `async` for exactly one reason: `--staged` asks git which documents this
126/// commit touches and what they contain, through [`crate::diff`], which is the
127/// single place in the binary that invokes git. Everything else here is
128/// synchronous file reading and pure checks.
129pub(crate) async fn outcome_for(args: &LintDocsArgs, root: &Path) -> Result<LintOutcome> {
130 if !args.staged {
131 return Ok(analyze(args, root));
132 }
133
134 // Straight to the reader, not through `files::expand_named`: git has
135 // already answered both questions the expander exists to answer - these
136 // paths exist and they are markdown - and the expander resolves an *empty*
137 // list to `root`. That default is what makes bare `drep lint-docs` mean
138 // "this tree", and reusing it here would turn "this commit touches no
139 // markdown" into "lint every document in the repository", on every commit.
140 let staged = crate::diff::staged_contents(root, files::is_markdown).await?;
141 Ok(analyze_files(
142 staged
143 .into_iter()
144 .map(|(path, content)| (root.join(path), content)),
145 BTreeMap::new(),
146 args.threshold(),
147 ))
148}
149
150/// Resolve the paths, read each file, run the checks, and gate.
151///
152/// Split from [`run`] so a test asserts on the outcome without capturing
153/// stdout, and so the rendering has nothing to decide.
154fn analyze(args: &LintDocsArgs, root: &Path) -> LintOutcome {
155 let mut failures: BTreeMap<PathBuf, FailureReason> = BTreeMap::new();
156 let targets = resolve(&args.paths, root, &mut failures);
157 let loaded = targets.into_iter().map(|path| {
158 let content = std::fs::read_to_string(&path);
159 (path, content)
160 });
161 analyze_files(loaded, failures, args.threshold())
162}
163
164/// Run the checks over each document, given with its content or why it could not be read, and gate.
165///
166/// Takes the failures already collected rather than starting empty: the path
167/// expansion rejects some of what the user named, and those rejections outrank
168/// every finding this function can produce.
169fn analyze_files(
170 loaded: impl IntoIterator<Item = (PathBuf, std::io::Result<String>)>,
171 mut failures: BTreeMap<PathBuf, FailureReason>,
172 threshold: Option<Severity>,
173) -> LintOutcome {
174 let mut findings = Vec::new();
175 for (path, content) in loaded {
176 match content {
177 Ok(content) => findings.extend(crate::docs::analyze(&path, &content)),
178 Err(err) => {
179 failures.insert(path, FailureReason::Unreadable(err.to_string()));
180 }
181 }
182 }
183
184 // The output contract is "reads top to bottom, file by file". Sorting by
185 // file here rather than trusting the expander's order keeps that contract
186 // owned by this function; `docs::analyze` has already ordered each file's
187 // own findings, and a stable sort preserves that, so the position key is
188 // not restated.
189 findings.sort_by(|a, b| a.file_path.cmp(&b.file_path));
190
191 let gating = gating(&findings, threshold);
192 let exit = gate(&failures, gating);
193 LintOutcome {
194 findings,
195 failures,
196 exit,
197 gating,
198 }
199}
200
201/// Expand the arguments to markdown files, recording what the user named and
202/// drep will not analyze.
203///
204/// The policy lives in [`files::expand_named`], shared with `check`, so the two
205/// commands cannot disagree about what a named path that resolves to nothing
206/// means.
207fn resolve(
208 paths: &[PathBuf],
209 root: &Path,
210 failures: &mut BTreeMap<PathBuf, FailureReason>,
211) -> Vec<PathBuf> {
212 let files::Expansion { targets, rejected } =
213 files::expand_named(paths, root, files::is_markdown);
214 for (path, why) in rejected {
215 let reason = match why {
216 files::Rejected::Missing => {
217 FailureReason::Unreadable("no such file or directory".to_owned())
218 }
219 files::Rejected::Unanalyzable => {
220 FailureReason::unsupported(&path, files::redirect_hint(&path))
221 }
222 };
223 failures.insert(path, reason);
224 }
225 targets
226}
227
228/// What the threshold did to these findings.
229///
230/// The comparison is `findings::any_at_or_above`, shared with `check`: two
231/// commands in one binary that disagree about which findings a severity
232/// threshold covers are two contracts a hook author has to learn.
233fn gating(findings: &[Finding], threshold: Option<Severity>) -> Gating {
234 match threshold {
235 None => Gating::ReportOnly,
236 Some(threshold) if findings::any_at_or_above(findings, threshold) => Gating::Blocked,
237 Some(threshold) => Gating::NoneReached(threshold),
238 }
239}
240
241/// Failures outrank findings.
242///
243/// The precedence matches `check`'s, deliberately: a run that did not read a
244/// file the user named has to say so even when it also found issues, or they
245/// fix the issues and never learn about the file.
246fn gate(failures: &BTreeMap<PathBuf, FailureReason>, gating: Gating) -> Exit {
247 if !failures.is_empty() {
248 return Exit::Unanalyzed;
249 }
250 match gating {
251 Gating::Blocked => Exit::FoundIssues,
252 Gating::ReportOnly | Gating::NoneReached(_) => Exit::Clean,
253 }
254}
255
256#[cfg(test)]
257mod tests;