drep/cli/doctor.rs
1//! `drep doctor` - report what drep can actually do in this repository.
2//!
3//! Adoption question, not a debugging one. Before trusting drep as a gate, a
4//! user wants to know the real coverage here: which languages are present,
5//! which of their own tools will actually run, and whether the LLM half is
6//! configured.
7//!
8//! **Diagnostic findings never fail `doctor`.** A broken provider or missing
9//! tool still returns `Ok(Exit::Clean)`; it is diagnosis, and `drep check` is
10//! the gate. Ordinary I/O failures can still be returned when the report
11//! itself cannot be written or the platform cannot resolve its user paths.
12//!
13//! All output goes through a `&mut dyn std::io::Write` so the command is
14//! testable without spawning a subprocess. The tests call [`run_to`] directly
15//! against a captured buffer.
16
17use std::collections::BTreeSet;
18use std::io::Write;
19use std::path::{Path, PathBuf};
20
21use anyhow::Result;
22use clap::Args;
23
24use crate::Exit;
25use crate::cli::MachineFiles;
26use crate::files;
27use crate::languages;
28
29mod llm;
30mod site_section;
31
32/// The header underline, exactly 60 characters wide. `write!` cannot express
33/// the count cleanly, and the spec pins the exact width: a `=`-string of any
34/// other length fails A2.
35const HEADER_RULE: &str = "============================================================";
36
37#[derive(Debug, Args)]
38pub struct DoctorArgs {
39 /// Repository or directory to report on.
40 #[arg(value_name = "PATH", default_value = ".")]
41 pub path: PathBuf,
42
43 /// Config file to report on. Defaults to `drep.toml` under PATH.
44 #[arg(long, value_name = "FILE")]
45 pub config: Option<PathBuf>,
46}
47
48/// Run the command, writing to stdout. Diagnostic findings return
49/// `Ok(Exit::Clean)`; failures to produce the report remain ordinary errors.
50pub async fn run(args: &DoctorArgs) -> Result<Exit> {
51 let mut out = std::io::stdout().lock();
52 classify_output(run_to(&mut out, args).await)
53}
54
55/// Treat a reader closing stdout as a clean diagnostic exit while preserving
56/// every other report failure.
57fn classify_output(result: Result<Exit>) -> Result<Exit> {
58 match result {
59 Ok(exit) => Ok(exit),
60 // `drep doctor | head -5` closes the pipe under us. That is the
61 // reader's choice, not a diagnostic failure, and turning it into exit 2
62 // would contradict this command's one contract.
63 Err(err) if is_broken_pipe(&err) => Ok(Exit::Clean),
64 Err(err) => Err(err),
65 }
66}
67
68/// Whether `err` is the reader having closed the pipe.
69fn is_broken_pipe(err: &anyhow::Error) -> bool {
70 err.downcast_ref::<std::io::Error>()
71 .is_some_and(|io| io.kind() == std::io::ErrorKind::BrokenPipe)
72}
73
74/// `run`, writing to an arbitrary sink so tests can capture the report.
75pub async fn run_to<W: Write>(out: &mut W, args: &DoctorArgs) -> Result<Exit> {
76 run_at(
77 out,
78 args,
79 &MachineFiles {
80 auth: &crate::auth::default_path()?,
81 policy: &crate::config::site::default_path(),
82 },
83 )
84 .await
85}
86
87/// `run_to`, against a named auth store and a named site policy file.
88///
89/// Both are parameters for the same reason `check`, `init` and `auth` take the
90/// store: they are machine-level state, and a test reading the real ones reports
91/// whatever the developer happens to have installed. They arrive as one
92/// [`MachineFiles`] because two adjacent `&Path` positionals are a
93/// transposition the compiler cannot catch.
94pub async fn run_at<W: Write>(
95 out: &mut W,
96 args: &DoctorArgs,
97 machine: &MachineFiles<'_>,
98) -> Result<Exit> {
99 run_at_with_codex(out, args, machine, &crate::llm::codex::current_status).await
100}
101
102/// [`run_at`] with the Codex readiness diagnostic injected for tests.
103pub(crate) async fn run_at_with_codex<W: Write>(
104 out: &mut W,
105 args: &DoctorArgs,
106 machine: &MachineFiles<'_>,
107 codex_probe: &dyn Fn() -> Result<crate::llm::codex::CodexStatus, String>,
108) -> Result<Exit> {
109 // `canonicalize` can fail (the path does not exist, or a parent is
110 // unreadable). An unreadable path is still worth reporting on - the user
111 // has typed something and wants to know what drep sees - so fall back to
112 // the path as given rather than erroring out.
113 let root = args
114 .path
115 .canonicalize()
116 .unwrap_or_else(|_| args.path.clone());
117
118 writeln!(out, "drep in {}", root.display())?;
119 writeln!(out, "{HEADER_RULE}")?;
120
121 let files = files::expand_paths(std::slice::from_ref(&root), files::is_scan_target);
122 let file_refs: Vec<&Path> = files.iter().map(PathBuf::as_path).collect();
123 let buckets = languages::group_by_language(&file_refs);
124
125 if buckets.is_empty() {
126 writeln!(out)?;
127 writeln!(out, "No source files drep recognises were found here.")?;
128 // The configuration sections still print. "Is my model configured?" is
129 // the question a new user most needs answered, and a docs-only repo - or
130 // one whose languages drep does not register - is exactly where they
131 // are most likely to be asking it. Returning here answered it with
132 // silence.
133 write_configuration(out, args, &root, &files, machine, codex_probe).await?;
134 return Ok(Exit::Clean);
135 }
136
137 write_languages_section(out, &buckets)?;
138 // The missing list falls out of the same pass that printed the tool
139 // lines. Recomputing it afterwards meant asking `tool_status` twice per
140 // tool - each call stats the config files and walks PATH - and, worse,
141 // left room for the summary to disagree with the lines above it.
142 let missing = write_tools_section(out, &buckets, &root)?;
143 write_configuration(out, args, &root, &files, machine, codex_probe).await?;
144
145 // Deliberately last, after the LLM block: the user reads their coverage
146 // report before being told what is wrong with it.
147 if let Some(line) = missing_tools_line(&missing) {
148 writeln!(out)?;
149 writeln!(out, "{line}")?;
150 }
151
152 Ok(Exit::Clean)
153}
154
155/// The two configuration blocks, in the one order they are ever printed in.
156///
157/// Called from both report shapes so that order is stated once. The policy block
158/// comes first because it governs the chain the block below it describes, and the
159/// policy file is loaded once here rather than in each block: two loads of the
160/// same file could disagree about it within one report.
161///
162/// The marker refusal is evaluated here too, through the same
163/// `SiteConfig::refusal_among` the gate consults and against the directories of
164/// the source files doctor found, so the report cannot describe a policy that
165/// would behave differently at the gate. Its error is carried rather than
166/// propagated: `drep check` fails closed on a policy it cannot evaluate, and this
167/// is the command someone runs to find out why.
168///
169/// The answer is then handed to the LLM block, because a refusal governs it too:
170/// `check` never mints a credential for a refused repository, and a `doctor` that
171/// ran the helper anyway would prompt for an approval - and spend a real
172/// credential call - on behalf of a repository whose review is refused.
173async fn write_configuration<W: Write>(
174 out: &mut W,
175 args: &DoctorArgs,
176 root: &Path,
177 source_files: &[PathBuf],
178 machine: &MachineFiles<'_>,
179 codex_probe: &dyn Fn() -> Result<crate::llm::codex::CodexStatus, String>,
180) -> Result<()> {
181 let site = crate::config::site::load(machine.policy);
182 let in_effect = site.as_ref().ok().and_then(Option::as_ref);
183 let refusal = match in_effect {
184 Some(site) => {
185 let directories = doctor_policy_directories(root, source_files);
186 site.refusal_among(&directories, machine.policy).await
187 }
188 None => Ok(None),
189 };
190 site_section::write_site_section(out, machine.policy, &site, &refusal)?;
191 llm::write_llm_section(
192 out,
193 args,
194 root,
195 machine.auth,
196 in_effect,
197 Semantic::of(&site, &refusal),
198 codex_probe,
199 )
200 .await
201}
202
203/// Repository-discovery starting points for the source doctor found.
204///
205/// `files::expand_paths` walks nested repositories, so asking only `root`
206/// reports permission for a run that `check` refuses on an inner file. When no
207/// recognized source exists, keep the root-level diagnostic: an operator still
208/// needs to see that this checkout is marked even though there is currently no
209/// semantic payload.
210fn doctor_policy_directories(root: &Path, source_files: &[PathBuf]) -> BTreeSet<PathBuf> {
211 if source_files.is_empty() {
212 return BTreeSet::from([root.to_path_buf()]);
213 }
214 source_files
215 .iter()
216 .filter_map(|file| file.parent().map(Path::to_path_buf))
217 .collect()
218}
219
220/// What the policy said about semantic review in this repository.
221///
222/// Three states, because the LLM block used to be told two. It received a
223/// `bool` computed as `matches!(refusal, Ok(Some(_)))`, next to an
224/// `Option<&SiteConfig>` computed as `site.as_ref().ok().flatten()`, and both
225/// flattens sent the same answer for "permitted" and for "could not be
226/// evaluated": a policy file that would not load, and a marker probe whose
227/// repository root would not resolve. `check` exits 2 on either
228/// (`config::site::load`'s `?` in `check::run_against`, and
229/// `SiteConfigError::MarkerRootUnresolved`), so answering "not refused" is how
230/// `doctor` came to run `api_key_command` - spending a real credential call and
231/// triggering whatever approval sits behind it - for a repository whose review
232/// never happens, and then print that the credential works. A check that did not
233/// run, reported as a pass, in the command whose whole contract is what will
234/// actually run here.
235///
236/// The policy itself still travels separately, because the concurrency clamp is
237/// reported from it in every one of these states: a ceiling still applies to a
238/// repository whose semantic review is refused.
239#[derive(Clone, Copy)]
240pub(super) enum Semantic {
241 /// No policy, or a policy that permits this repository. The only state in
242 /// which anything here may spend a credential.
243 Permitted,
244 /// A marker refuses semantic review at this repository's root.
245 Refused,
246 /// The policy could not be evaluated. `check` fails closed; this command
247 /// exists to say why, not to proceed as though it had.
248 Unevaluable,
249}
250
251impl Semantic {
252 /// Why semantic setup is not attempted, when policy did not permit it.
253 pub(super) fn skip_reason(self) -> Option<&'static str> {
254 match self {
255 Self::Permitted => None,
256 Self::Refused => {
257 Some("not attempted, because site policy refuses semantic review here")
258 }
259 Self::Unevaluable => {
260 Some("not attempted, because the site policy above could not be evaluated")
261 }
262 }
263 }
264
265 /// Collapse the two results the report already holds into the one verdict
266 /// that governs whether a credential may be spent.
267 ///
268 /// A load failure is checked before a probe failure only because the probe
269 /// cannot have run without a loaded policy; either one is the same answer.
270 fn of(
271 site: &Result<
272 Option<crate::config::site::SiteConfig>,
273 crate::config::site::SiteConfigError,
274 >,
275 refusal: &Result<
276 Option<crate::config::site::Refusal>,
277 crate::config::site::SiteConfigError,
278 >,
279 ) -> Self {
280 match (site, refusal) {
281 (Err(_), _) | (_, Err(_)) => Self::Unevaluable,
282 (Ok(_), Ok(Some(_))) => Self::Refused,
283 (Ok(_), Ok(None)) => Self::Permitted,
284 }
285 }
286}
287
288/// Build the trailing "configured tool(s) are missing" line, or `None` when
289/// there is nothing to report.
290///
291/// Extracted so A4 can pin the rendering independently of the runner's
292/// availability on a particular developer machine.
293fn missing_tools_line(missing: &[&str]) -> Option<String> {
294 if missing.is_empty() {
295 return None;
296 }
297 Some(format!(
298 "{} configured tool(s) are missing: {}. drep exits 2 rather than reporting those files clean.",
299 missing.len(),
300 missing.join(", "),
301 ))
302}
303
304/// `Languages found:` block, one line per detected language.
305fn write_languages_section<W: Write>(
306 out: &mut W,
307 buckets: &[(&'static languages::spec::LanguageSupport, Vec<&Path>)],
308) -> Result<()> {
309 writeln!(out)?;
310 writeln!(out, "Languages found:")?;
311 for (language, paths) in buckets {
312 writeln!(out, " {}: {} file(s)", language.display_name, paths.len())?;
313 }
314 Ok(())
315}
316
317/// `Deterministic checks (these gate):` block. Tool status comes from
318/// `runner::tool_status` so `doctor` cannot disagree with `check` about
319/// whether a tool will run.
320///
321/// Returns the names of the tools that were `Unavailable`, so the trailing
322/// summary is built from the same statuses that were printed rather than from
323/// a second round of `tool_status` calls.
324fn write_tools_section<W: Write>(
325 out: &mut W,
326 buckets: &[(&'static languages::spec::LanguageSupport, Vec<&Path>)],
327 root: &Path,
328) -> Result<Vec<&'static str>> {
329 writeln!(out)?;
330 writeln!(out, "Deterministic checks (these gate):")?;
331 let mut missing: Vec<&'static str> = Vec::new();
332 for (language, paths) in buckets {
333 if language.tools.is_empty() {
334 writeln!(out, " {}: no tools wired up yet", language.display_name)?;
335 continue;
336 }
337 for spec in language.tools {
338 let roots: BTreeSet<PathBuf> = paths
339 .iter()
340 .filter_map(|path| languages::runner::configuration_root(spec, root, path))
341 .collect();
342 let outcome = if roots.is_empty() {
343 languages::runner::tool_status(spec, root)
344 } else {
345 workspace_tool_status(spec, root, &roots)
346 };
347 writeln!(out, " {}: {}", spec.name, outcome.detail)?;
348 // `Skipped` is the project exercising a choice, not a problem.
349 // Rendering it as one trains users to ignore the report.
350 if matches!(outcome.status, languages::runner::ToolStatus::Unavailable)
351 && !missing.contains(&spec.name)
352 {
353 // Deduplicated: `eslint` belongs to both JavaScript and
354 // TypeScript, so a repo with both and no eslint binary
355 // otherwise reported "2 configured tool(s) are missing:
356 // eslint, eslint" - a count that overstates the problem and a
357 // list that reads like a bug.
358 missing.push(spec.name);
359 }
360 }
361 }
362 Ok(missing)
363}
364
365fn workspace_tool_status(
366 spec: &'static languages::spec::ToolSpec,
367 root: &Path,
368 roots: &BTreeSet<PathBuf>,
369) -> languages::runner::ToolOutcome {
370 let statuses: Vec<_> = roots
371 .iter()
372 .map(|workspace| languages::runner::tool_status_at(spec, root, workspace))
373 .collect();
374 if let Some(unavailable) = statuses
375 .iter()
376 .find(|outcome| matches!(outcome.status, languages::runner::ToolStatus::Unavailable))
377 {
378 return unavailable.clone();
379 }
380 let detail = if roots.len() == 1 && roots.contains(&root.to_path_buf()) {
381 "ready".to_owned()
382 } else {
383 format!("ready in {} workspace(s)", roots.len())
384 };
385 languages::runner::ToolOutcome {
386 tool: spec.name,
387 status: languages::runner::ToolStatus::Ok,
388 findings: Vec::new(),
389 detail,
390 compilation_succeeded: false,
391 }
392}
393
394#[cfg(test)]
395mod unit_tests;
396
397/// Acceptance tests live in their own directory under `tests/`, declared
398/// from this module. The directory has its own `mod.rs` so the files there
399/// are reachable by name - a Rust file no `mod` declaration reaches is never
400/// compiled, and a test file that is never compiled looks exactly like a
401/// passing one.
402#[cfg(test)]
403mod tests;