drep/cli/check/mod.rs
1//! `drep check` - the commit gate, end to end.
2//!
3//! Three concerns, three sibling modules, one orchestrator here:
4//!
5//! - [`input`] resolves the three input modes (paths, `--staged`, `--diff`)
6//! into a uniform `[Hunk]` list. Whatever the caller passed, the rest of
7//! the pipeline sees the same shape.
8//! - [`deterministic`] runs the configured per-language tools, collecting
9//! their findings AND marking every file in a batch failed when a tool was
10//! `Unavailable` — the per-tool/per-file join the exit-2 contract rests on.
11//! - [`render`] turns the two layers' findings plus the failure map into the
12//! text or JSON output the user sees.
13//!
14//! The split is by topic, not by file size, because the smallest meaningful
15//! unit of `check` is "one input mode" or "one output format" and the
16//! dependencies between those are weak. The orchestrator ([`run`]) is the
17//! only place where the layers meet, which is what the loading-order
18//! invariants and the exit-code precedence are pinned against.
19
20mod deterministic;
21// `pub(crate)` for `READ_MAX_BYTES` alone: the guard's relationship to
22// `analysis::payload::PAYLOAD_MAX_BYTES` is load-bearing (it must never sit
23// below it), and the assertion pinning that has to see both constants.
24pub(crate) mod input;
25mod render;
26
27use std::collections::BTreeMap;
28use std::path::{Path, PathBuf};
29
30use anyhow::{Context, Result, anyhow};
31use clap::{ArgGroup, Args};
32
33use crate::analysis::findings::{self, Finding, Severity};
34use crate::analysis::result::{AnalysisResult, FailureReason, union_failures};
35use crate::auth;
36use crate::cli::{OutputFormat, severity_parser};
37use crate::config::{self, LlmConfig};
38use crate::llm::cache::Cache;
39use crate::llm::chain::ProviderChain;
40
41#[derive(Debug, Args)]
42// One rule, stated once. Paired `conflicts_with_all` attributes say the same
43// thing from each side and have to be kept in agreement; a fourth input mode
44// would mean editing every existing one, and missing a single edit silently
45// permits an illegal combination.
46// Deliberately NOT `.required(true)`. Bare `drep check` is a supported
47// invocation meaning "the whole tree": `input::resolve` expands `root` through
48// `files::expand_paths`, exactly as an explicit `.` would. Requiring one of the
49// three would turn the plainest invocation into a usage error. Pinned by
50// `bare_check_with_no_paths_expands_the_root_instead_of_reading_a_directory`,
51// which exists because an earlier version passed the root through as a *file*
52// and exited 2 without analyzing anything.
53#[command(group(ArgGroup::new("input").args(["paths", "staged", "diff"]).multiple(false)))]
54pub struct CheckArgs {
55 /// Files or directories to check. Duplicates and overlaps are collapsed,
56 /// so `drep check a.rs .` analyzes `a.rs` once.
57 #[arg(value_name = "PATH")]
58 pub paths: Vec<PathBuf>,
59
60 /// Check the files staged for commit. For a pre-commit hook.
61 #[arg(long)]
62 pub staged: bool,
63
64 /// Check the files changed since REF, e.g. `origin/main`. For pre-push.
65 #[arg(long, value_name = "REF")]
66 pub diff: Option<String>,
67
68 /// The commit to diff *to*. Defaults to `HEAD`. Only valid with `--diff`.
69 ///
70 /// A pre-push hook needs this: git can push a ref that is not the
71 /// checked-out one (`git push origin feature:feature` from another branch,
72 /// or `git push --all`), and diffing to `HEAD` there reviews a different
73 /// branch and lets the pushed code through unseen.
74 #[arg(long, value_name = "REF", requires = "diff")]
75 pub tip: Option<String>,
76
77 #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
78 pub format: OutputFormat,
79
80 /// Also block on LLM findings at or above this severity.
81 ///
82 /// Deterministic tool findings always block; this opts the LLM's findings
83 /// into gating too. Left unset, they inform without blocking - which is
84 /// the useful default, because the model emits style suggestions on
85 /// nearly every file.
86 #[arg(long, value_name = "SEVERITY", value_parser = severity_parser())]
87 pub fail_on: Option<Severity>,
88}
89
90/// Everything one `check` run produced.
91///
92/// The two layers stay in separate fields all the way to rendering. Their
93/// findings are gated differently - deterministic ones always block, LLM ones
94/// only under `--fail-on` - and keeping them apart makes that structural
95/// rather than a tag on `Finding` that a caller could read wrong.
96pub struct CheckOutcome {
97 /// Findings from the configured deterministic tools. These always block.
98 pub tool_findings: Vec<Finding>,
99 /// Findings from the LLM. These only block under `--fail-on`.
100 pub llm_findings: Vec<Finding>,
101 /// Files that went unanalyzed for any reason. The two layers cover the
102 /// same files, so the CLI unions them; on a key collision the first
103 /// reason wins, matching `AnalysisResult::merge`.
104 pub failures: BTreeMap<PathBuf, FailureReason>,
105 /// Which providers answered, and for how many files.
106 ///
107 /// Empty when the LLM layer produced nothing at all. Only providers that
108 /// served at least one file appear, in chain order, so a run that never
109 /// left the head is one entry - and a run that did fall through says so
110 /// rather than leaving a silent switch from a local model to a paid one.
111 pub provider_uses: Vec<ProviderUse>,
112 /// The gate's verdict for this run.
113 ///
114 /// On the outcome rather than passed alongside it: `render` used to take
115 /// an `Exit` as a separate argument, which let the two disagree - and they
116 /// did, because `render` computed its own and ignored `--fail-on`. A field
117 /// set once by `run` makes the mismatch unrepresentable.
118 pub exit: Exit,
119}
120
121/// One provider's share of a run.
122///
123/// The backend location is carried, not just the model, because "gpt-5.6-sol" does not
124/// tell a user whether they paid for it - two entries can name the same model
125/// at a local proxy and at the vendor.
126pub struct ProviderUse {
127 /// Zero-based position in the chain. Rendered one-based.
128 pub index: usize,
129 pub model: String,
130 pub location: String,
131 pub files: usize,
132}
133
134/// One `check` invocation: resolve input, run both layers, gate, render, return
135/// `Exit`.
136///
137/// `root` is the working directory the input resolution runs against. The CLI
138/// passes `Path::new(".")`, the tests pass a `TempDir` so each one stands
139/// alone. Failure to load the config is a hard error: the LLM is mandatory in
140/// 2.x and there is no deterministic-only mode.
141pub async fn run(args: &CheckArgs, root: &Path) -> Result<Exit> {
142 run_with(
143 args,
144 root,
145 Cache::new(Cache::default_root(), CACHE_TTL_DAYS, CACHE_MAX_BYTES),
146 )
147 .await
148}
149
150/// How long a cached LLM response stays valid, and how large the store may
151/// grow. Documented tunables rather than magic numbers at the call site.
152pub const CACHE_TTL_DAYS: u64 = 30;
153/// See [`CACHE_TTL_DAYS`].
154pub const CACHE_MAX_BYTES: u64 = 256 * 1024 * 1024;
155
156/// `run`, with the response cache supplied.
157///
158/// Split out so a test can point the cache at its own `TempDir`. The cache
159/// was built inside the run from `Cache::default_root()`, which is the user's
160/// real cache directory - so every test run wrote to it, and a stale entry
161/// from one test satisfied another: an unreachable-endpoint test once exited 0
162/// because a previous test had cached a clean response for the same payload.
163pub(crate) async fn run_with(args: &CheckArgs, root: &Path, cache: Cache) -> Result<Exit> {
164 run_against(args, root, cache, &auth::default_path()?).await
165}
166
167/// `run_with`, against a named auth store.
168///
169/// The store is user-level state outside the repository, so it is a parameter
170/// for the same reason `root` is one: a test using the real one reads whatever
171/// the developer has stored, and a config that omits `api_key` would then
172/// behave differently on their machine than in CI. `init::run_with` and
173/// `auth::run_at` already thread it for that reason; this was the one command
174/// that did not.
175pub(crate) async fn run_against(
176 args: &CheckArgs,
177 root: &Path,
178 cache: Cache,
179 auth_path: &Path,
180) -> Result<Exit> {
181 // Anchored on `root`, not the process cwd. `config::default_config_path()`
182 // resolves against the cwd, which would make `root` a half-truth: input
183 // resolution would read one directory and configuration another. The CLI
184 // passes ".", so production behaviour is unchanged, and a test can point
185 // the whole run at a `TempDir` without chdir-ing a shared process.
186 let config_path = root.join(config::default_config_path());
187 let mut config = config::load(&config_path)
188 .with_context(|| format!("could not load {}", config_path.display()))?;
189
190 // Fill in the keys the file left unset from the user-level store. An
191 // explicit `api_key` in `drep.toml` always wins, so this cannot change what
192 // an existing config does; it only supplies what `drep init` stopped writing
193 // into the repository. A store that cannot be read is fatal rather than
194 // treated as empty - running the gate unauthenticated would surface as a 401
195 // per file, which reads as a broken key rather than a broken store.
196 let store = auth::AuthStore::load(auth_path)
197 .with_context(|| format!("could not read the auth store at {}", auth_path.display()))?;
198 auth::resolve(&mut config, &store);
199 // The whole enabled chain, not just its head: `providers()` is the
200 // failover order, and `load` has already rejected a config with none.
201 // The `is_empty` guard stays because `Config` is constructible without
202 // going through `load`, and a panic inside the commit gate is a worse
203 // failure than a message naming the file. The error is `load`'s own rather
204 // than a second sentence written here: two hand-written copies of "no
205 // provider configured" drift, and the copy that lived here had already
206 // lost the actionable "run `drep init`" half.
207 let providers = config.providers();
208 if providers.is_empty() {
209 return Err(config::ConfigError::NoProviders(config_path.clone()).into());
210 }
211
212 // Resolved *after* the config, because a missing config is fatal and
213 // resolution is not free: in `--staged` mode it spawns git, and in paths
214 // mode it reads every target file into memory. Discovering "no drep.toml"
215 // afterwards means paying for all of it and throwing it away.
216 let work = input::resolve(args, root)
217 .await
218 .with_context(|| format!("could not resolve input under {}", root.display()))?;
219
220 // The two layers share no data - they only both report failures - so they
221 // run concurrently. The deterministic leg is pure added latency otherwise:
222 // a warm `cargo clippy` on this repo is ~3.5s, and the LLM leg is
223 // multi-second regardless, so joining hides essentially all of it.
224 let (deterministic_result, llm_result) = tokio::join!(
225 deterministic::run(&work, root),
226 run_llm(&work, &providers, cache.clone()),
227 );
228 let (tool_findings, tool_failures) = deterministic_result;
229 let (llm_result, provider_uses) = llm_result?;
230
231 // All concurrent writers have finished, so one oldest-first pass can
232 // enforce the configured disk ceiling without racing another put. Cache
233 // maintenance is best-effort: an unreadable cache must never turn an
234 // otherwise valid review into an unanalyzed file.
235 let _ = cache.evict_if_needed();
236
237 let mut failures: BTreeMap<PathBuf, FailureReason> = BTreeMap::new();
238 // Union order is the reporting priority: a file that could not be read
239 // keeps that reason over a later layer's view of the same path.
240 union_failures(&mut failures, work.read_failures);
241 union_failures(&mut failures, tool_failures);
242 union_failures(&mut failures, llm_result.failed_files);
243
244 let mut outcome = CheckOutcome {
245 tool_findings,
246 llm_findings: llm_result.findings,
247 failures,
248 provider_uses,
249 exit: Exit::Clean,
250 };
251 outcome.exit = gate(&outcome, args.fail_on);
252
253 render::render(&outcome, args.format)?;
254 Ok(outcome.exit)
255}
256
257/// What the process should exit with.
258///
259/// Two precedence rules, in order:
260/// 1. Any failure → `Unanalyzed` (exit 2). A failure outranks a finding,
261/// because the file went unanalyzed whether or not the LLM also produced
262/// findings on a partial result.
263/// 2. Any blocking finding → `FoundIssues` (exit 1). Tool findings always
264/// block; LLM findings block when `fail_on` admits their severity and
265/// `fail_on` is set.
266fn gate(outcome: &CheckOutcome, fail_on: Option<Severity>) -> Exit {
267 if !outcome.failures.is_empty() {
268 return Exit::Unanalyzed;
269 }
270 if any_blocking_tool_finding(&outcome.tool_findings) {
271 return Exit::FoundIssues;
272 }
273 if let Some(threshold) = fail_on
274 && findings::any_at_or_above(&outcome.llm_findings, threshold)
275 {
276 return Exit::FoundIssues;
277 }
278 Exit::Clean
279}
280
281/// A tool finding always blocks. A deterministic tool's verdict is the
282/// project's own choice, so the gate honors it without an allow-list.
283fn any_blocking_tool_finding(findings: &[Finding]) -> bool {
284 !findings.is_empty()
285}
286
287/// Run the LLM layer.
288///
289/// Returns the LLM findings, its failures, and which providers served. The
290/// analyzer is built from the whole enabled provider chain, so a transport
291/// failure at the head falls through to the next entry; each provider carries
292/// its own concurrency limiter, sized by its own `max_concurrent`. A failure
293/// to build the chain (a misconfigured `[[llm]]` entry) is propagated as
294/// `Err` - the gate is LLM-only and there is no deterministic-only fallback.
295async fn run_llm(
296 work: &input::Work,
297 cfgs: &[&LlmConfig],
298 cache: Cache,
299) -> Result<(AnalysisResult, Vec<ProviderUse>)> {
300 let chain =
301 ProviderChain::new(cfgs).map_err(|e| anyhow!("could not build LLM analyzer: {e}"))?;
302 let analyzer = crate::analysis::code_quality::CodeQualityAnalyzer::new(chain, cache);
303
304 let result = analyzer.analyze_files(&work.by_file).await;
305 let uses = provider_uses(analyzer.chain());
306 Ok((result, uses))
307}
308
309/// Who answered, and for how many files.
310///
311/// Read off the chain, which counted as it went - the counts never leave the
312/// object that produced them, so they cannot drift from the demotion state
313/// sitting beside them, and `AnalysisResult` needs no per-provider field whose
314/// merge rule would be the one exception to its union-not-sum invariant.
315/// Providers that served nothing are omitted: the report answers "who reviewed
316/// this code", and an untouched fallback did not.
317fn provider_uses(chain: &ProviderChain) -> Vec<ProviderUse> {
318 chain
319 .providers()
320 .iter()
321 .enumerate()
322 .filter(|(_, provider)| provider.served() > 0)
323 .map(|(index, provider)| ProviderUse {
324 index,
325 model: provider.model().to_owned(),
326 location: provider.location().to_owned(),
327 files: provider.served(),
328 })
329 .collect()
330}
331
332/// The crate's `Exit` re-exported so `cli::check::run` returns the same
333/// type the upper layer expects, without leaking the orchestrator's
334/// dependency path.
335pub use crate::Exit;
336
337#[cfg(test)]
338mod tests;