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