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