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, and it is not concurrency: `--staged` asks
115/// git which documents this commit touches. Every other mode reads files and
116/// runs 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, through [`crate::diff`], which is the single place in the
127/// binary that invokes git. Everything else here is synchronous file reading
128/// 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_files(root, files::is_markdown).await?;
141 Ok(analyze_files(
142 staged.into_iter().map(|p| root.join(p)).collect(),
143 BTreeMap::new(),
144 args.threshold(),
145 ))
146}
147
148/// Resolve the paths, read each file, run the checks, and gate.
149///
150/// Split from [`run`] so a test asserts on the outcome without capturing
151/// stdout, and so the rendering has nothing to decide.
152fn analyze(args: &LintDocsArgs, root: &Path) -> LintOutcome {
153 let mut failures: BTreeMap<PathBuf, FailureReason> = BTreeMap::new();
154 let targets = resolve(&args.paths, root, &mut failures);
155 analyze_files(targets, failures, args.threshold())
156}
157
158/// Read each target, run the checks, and gate.
159///
160/// Takes the failures already collected rather than starting empty: the path
161/// expansion rejects some of what the user named, and those rejections outrank
162/// every finding this function can produce.
163fn analyze_files(
164 targets: Vec<PathBuf>,
165 mut failures: BTreeMap<PathBuf, FailureReason>,
166 threshold: Option<Severity>,
167) -> LintOutcome {
168 let mut findings = Vec::new();
169 for path in targets {
170 match std::fs::read_to_string(&path) {
171 Ok(content) => findings.extend(crate::docs::analyze(&path, &content)),
172 Err(err) => {
173 failures.insert(path, FailureReason::Unreadable(err.to_string()));
174 }
175 }
176 }
177
178 // The output contract is "reads top to bottom, file by file". Sorting by
179 // file here rather than trusting the expander's order keeps that contract
180 // owned by this function; `docs::analyze` has already ordered each file's
181 // own findings, and a stable sort preserves that, so the position key is
182 // not restated.
183 findings.sort_by(|a, b| a.file_path.cmp(&b.file_path));
184
185 let gating = gating(&findings, threshold);
186 let exit = gate(&failures, gating);
187 LintOutcome {
188 findings,
189 failures,
190 exit,
191 gating,
192 }
193}
194
195/// Expand the arguments to markdown files, recording what the user named and
196/// drep will not analyze.
197///
198/// The policy lives in [`files::expand_named`], shared with `check`, so the two
199/// commands cannot disagree about what a named path that resolves to nothing
200/// means.
201fn resolve(
202 paths: &[PathBuf],
203 root: &Path,
204 failures: &mut BTreeMap<PathBuf, FailureReason>,
205) -> Vec<PathBuf> {
206 let files::Expansion { targets, rejected } =
207 files::expand_named(paths, root, files::is_markdown);
208 for (path, why) in rejected {
209 let reason = match why {
210 files::Rejected::Missing => {
211 FailureReason::Unreadable("no such file or directory".to_owned())
212 }
213 files::Rejected::Unanalyzable => {
214 FailureReason::unsupported(&path, files::redirect_hint(&path))
215 }
216 };
217 failures.insert(path, reason);
218 }
219 targets
220}
221
222/// What the threshold did to these findings.
223///
224/// The comparison is `findings::any_at_or_above`, shared with `check`: two
225/// commands in one binary that disagree about which findings a severity
226/// threshold covers are two contracts a hook author has to learn.
227fn gating(findings: &[Finding], threshold: Option<Severity>) -> Gating {
228 match threshold {
229 None => Gating::ReportOnly,
230 Some(threshold) if findings::any_at_or_above(findings, threshold) => Gating::Blocked,
231 Some(threshold) => Gating::NoneReached(threshold),
232 }
233}
234
235/// Failures outrank findings.
236///
237/// The precedence matches `check`'s, deliberately: a run that did not read a
238/// file the user named has to say so even when it also found issues, or they
239/// fix the issues and never learn about the file.
240fn gate(failures: &BTreeMap<PathBuf, FailureReason>, gating: Gating) -> Exit {
241 if !failures.is_empty() {
242 return Exit::Unanalyzed;
243 }
244 match gating {
245 Gating::Blocked => Exit::FoundIssues,
246 Gating::ReportOnly | Gating::NoneReached(_) => Exit::Clean,
247 }
248}
249
250#[cfg(test)]
251mod tests;