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::io::Write;
18use std::path::{Path, PathBuf};
19
20use anyhow::Result;
21use clap::Args;
22
23use crate::Exit;
24use crate::config;
25use crate::files;
26use crate::languages;
27use toml::Value;
28
29/// The header underline, exactly 60 characters wide. `write!` cannot express
30/// the count cleanly, and the spec pins the exact width: a `=`-string of any
31/// other length fails A2.
32const HEADER_RULE: &str = "============================================================";
33
34#[derive(Debug, Args)]
35pub struct DoctorArgs {
36 /// Repository or directory to report on.
37 #[arg(value_name = "PATH", default_value = ".")]
38 pub path: PathBuf,
39
40 /// Config file to report on. Defaults to `drep.toml` under PATH.
41 #[arg(long, value_name = "FILE")]
42 pub config: Option<PathBuf>,
43}
44
45/// Run the command, writing to stdout. Diagnostic findings return
46/// `Ok(Exit::Clean)`; failures to produce the report remain ordinary errors.
47pub fn run(args: &DoctorArgs) -> Result<Exit> {
48 let mut out = std::io::stdout().lock();
49 match run_to(&mut out, args) {
50 Ok(exit) => Ok(exit),
51 // `drep doctor | head -5` closes the pipe under us. That is the
52 // reader's choice, not a diagnostic failure, and turning it into exit 2
53 // would contradict this command's one contract.
54 Err(err) if is_broken_pipe(&err) => Ok(Exit::Clean),
55 Err(err) => Err(err),
56 }
57}
58
59/// Whether `err` is the reader having closed the pipe.
60pub(crate) fn is_broken_pipe(err: &anyhow::Error) -> bool {
61 err.downcast_ref::<std::io::Error>()
62 .is_some_and(|io| io.kind() == std::io::ErrorKind::BrokenPipe)
63}
64
65/// `run`, writing to an arbitrary sink so tests can capture the report.
66pub fn run_to<W: Write>(out: &mut W, args: &DoctorArgs) -> Result<Exit> {
67 run_at(out, args, &crate::auth::default_path()?)
68}
69
70/// `run_to`, against a named auth store.
71///
72/// A parameter for the same reason `check`, `init` and `auth` take one: the
73/// store is user-level state, and a test reading the real one reports whatever
74/// the developer happens to have stored.
75pub fn run_at<W: Write>(out: &mut W, args: &DoctorArgs, auth_path: &Path) -> Result<Exit> {
76 run_at_with_codex(out, args, auth_path, &crate::llm::codex::current_status)
77}
78
79/// [`run_at`] with the Codex readiness diagnostic injected for tests.
80pub(crate) fn run_at_with_codex<W: Write>(
81 out: &mut W,
82 args: &DoctorArgs,
83 auth_path: &Path,
84 codex_probe: &dyn Fn() -> Result<crate::llm::codex::CodexStatus, String>,
85) -> Result<Exit> {
86 // `canonicalize` can fail (the path does not exist, or a parent is
87 // unreadable). An unreadable path is still worth reporting on - the user
88 // has typed something and wants to know what drep sees - so fall back to
89 // the path as given rather than erroring out.
90 let root = args
91 .path
92 .canonicalize()
93 .unwrap_or_else(|_| args.path.clone());
94
95 writeln!(out, "drep in {}", root.display())?;
96 writeln!(out, "{HEADER_RULE}")?;
97
98 let files = files::expand_paths(std::slice::from_ref(&root), files::is_scan_target);
99 let file_refs: Vec<&Path> = files.iter().map(PathBuf::as_path).collect();
100 let buckets = languages::group_by_language(&file_refs);
101
102 if buckets.is_empty() {
103 writeln!(out)?;
104 writeln!(out, "No source files drep recognises were found here.")?;
105 // The LLM section still prints. "Is my model configured?" is the
106 // question a new user most needs answered, and a docs-only repo - or
107 // one whose languages drep does not register - is exactly where they
108 // are most likely to be asking it. Returning here answered it with
109 // silence.
110 write_llm_section(out, args, &root, auth_path, codex_probe)?;
111 return Ok(Exit::Clean);
112 }
113
114 write_languages_section(out, &buckets)?;
115 // The missing list falls out of the same pass that printed the tool
116 // lines. Recomputing it afterwards meant asking `tool_status` twice per
117 // tool - each call stats the config files and walks PATH - and, worse,
118 // left room for the summary to disagree with the lines above it.
119 let missing = write_tools_section(out, &buckets, &root)?;
120 write_llm_section(out, args, &root, auth_path, codex_probe)?;
121
122 // Deliberately last, after the LLM block: the user reads their coverage
123 // report before being told what is wrong with it.
124 if let Some(line) = missing_tools_line(&missing) {
125 writeln!(out)?;
126 writeln!(out, "{line}")?;
127 }
128
129 Ok(Exit::Clean)
130}
131
132/// Build the trailing "configured tool(s) are missing" line, or `None` when
133/// there is nothing to report.
134///
135/// Extracted so A4 can pin the rendering independently of the runner's
136/// availability on a particular developer machine.
137fn missing_tools_line(missing: &[&str]) -> Option<String> {
138 if missing.is_empty() {
139 return None;
140 }
141 Some(format!(
142 "{} configured tool(s) are missing: {}. drep exits 2 rather than reporting those files clean.",
143 missing.len(),
144 missing.join(", "),
145 ))
146}
147
148/// `Languages found:` block, one line per detected language.
149fn write_languages_section<W: Write>(
150 out: &mut W,
151 buckets: &[(&'static languages::spec::LanguageSupport, Vec<&Path>)],
152) -> Result<()> {
153 writeln!(out)?;
154 writeln!(out, "Languages found:")?;
155 for (language, paths) in buckets {
156 writeln!(out, " {}: {} file(s)", language.display_name, paths.len())?;
157 }
158 Ok(())
159}
160
161/// `Deterministic checks (these gate):` block. Tool status comes from
162/// `runner::tool_status` so `doctor` cannot disagree with `check` about
163/// whether a tool will run.
164///
165/// Returns the names of the tools that were `Unavailable`, so the trailing
166/// summary is built from the same statuses that were printed rather than from
167/// a second round of `tool_status` calls.
168fn write_tools_section<W: Write>(
169 out: &mut W,
170 buckets: &[(&'static languages::spec::LanguageSupport, Vec<&Path>)],
171 root: &Path,
172) -> Result<Vec<&'static str>> {
173 writeln!(out)?;
174 writeln!(out, "Deterministic checks (these gate):")?;
175 let mut missing: Vec<&'static str> = Vec::new();
176 for (language, _) in buckets {
177 if language.tools.is_empty() {
178 writeln!(out, " {}: no tools wired up yet", language.display_name)?;
179 continue;
180 }
181 for spec in language.tools {
182 let outcome = languages::runner::tool_status(spec, root);
183 writeln!(out, " {}: {}", spec.name, outcome.detail)?;
184 // `Skipped` is the project exercising a choice, not a problem.
185 // Rendering it as one trains users to ignore the report.
186 if matches!(outcome.status, languages::runner::ToolStatus::Unavailable)
187 && !missing.contains(&spec.name)
188 {
189 // Deduplicated: `eslint` belongs to both JavaScript and
190 // TypeScript, so a repo with both and no eslint binary
191 // otherwise reported "2 configured tool(s) are missing:
192 // eslint, eslint" - a count that overstates the problem and a
193 // list that reads like a bug.
194 missing.push(spec.name);
195 }
196 }
197 }
198 Ok(missing)
199}
200
201/// `LLM analysis (required):` block.
202///
203/// Display path is the *raw* file, not `config::load`: a fresh clone is
204/// exactly when the report is most useful, and `load` fails on an unset
205/// referenced variable. `load` is consulted only to surface problems that are
206/// not the unset variable.
207fn write_llm_section<W: Write>(
208 out: &mut W,
209 args: &DoctorArgs,
210 root: &Path,
211 auth_path: &Path,
212 codex_probe: &dyn Fn() -> Result<crate::llm::codex::CodexStatus, String>,
213) -> Result<()> {
214 writeln!(out)?;
215 writeln!(out, "LLM analysis (required):")?;
216
217 let config_path: PathBuf = match &args.config {
218 Some(p) => p.clone(),
219 None => root.join(config::default_config_path()),
220 };
221
222 let raw = match std::fs::read_to_string(&config_path) {
223 Ok(raw) => raw,
224 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
225 writeln!(
226 out,
227 " No config file at {} - `drep check` cannot run. Run `drep init`.",
228 config_path.display()
229 )?;
230 return Ok(());
231 }
232 Err(err) => {
233 writeln!(out, " {} could not be read: {err}", config_path.display())?;
234 return Ok(());
235 }
236 };
237
238 let value: toml::Value = match toml::from_str(&raw) {
239 Ok(v) => v,
240 Err(err) => {
241 writeln!(
242 out,
243 " {} could not be parsed: {}",
244 config_path.display(),
245 err.message()
246 )?;
247 return Ok(());
248 }
249 };
250
251 // The three cases are distinguished, not collapsed. Folding "not an array"
252 // into "absent" made `[llm]` (the pre-2.0 single-table shape) report as
253 // "declares no `[[llm]]` provider. Run `drep init`." and return early - so
254 // the `config::load` check below, which would have named the actual type
255 // mismatch, was never reached, and the user was pointed at a command that
256 // would refuse to overwrite their file.
257 let providers = match value.get("llm") {
258 None => {
259 writeln!(
260 out,
261 " {} declares no `[[llm]]` provider. Run `drep init`.",
262 config_path.display()
263 )?;
264 return Ok(());
265 }
266 Some(Value::Array(entries)) if entries.is_empty() => {
267 writeln!(
268 out,
269 " {} declares no `[[llm]]` provider. Run `drep init`.",
270 config_path.display()
271 )?;
272 return Ok(());
273 }
274 Some(Value::Array(entries)) => entries,
275 Some(_) => {
276 writeln!(
277 out,
278 " {} has an `llm` key that is not a `[[llm]]` array of tables. \
279 Providers are declared as `[[llm]]`, one block per provider.",
280 config_path.display()
281 )?;
282 // Fall through to `config::load` below, which names the parse
283 // error precisely.
284 report_load_failure(out, &config_path)?;
285 return Ok(());
286 }
287 };
288
289 // Print providers verbatim from the raw file: model and endpoint come out
290 // unexpanded, so `${VAR}` shows as `${VAR}` rather than being swallowed by
291 // the variable-not-set error.
292 //
293 // A disabled entry is called out rather than listed as if it were in play.
294 // Before failover existed this section listed every `[[llm]]` block without
295 // noting that only one was ever consulted; now that the list is a real
296 // failover chain, an inert entry is the one thing the listing can still get
297 // wrong - a user who parks their local model wants to see that the cloud
298 // entry below it is what will run, and a user who copied a block without
299 // its `enabled` line wants to see that it is not.
300 //
301 // The numbering is the **chain position**, not the position in the file, so
302 // a disabled entry gets a bullet rather than a number and the entries after
303 // it shift up. That is what makes it agree with `drep check`: a failure
304 // line reading "[1] cloud-model" has to name the same provider this listing
305 // calls 1, and numbering the file would make the two disagree the moment
306 // anything above was parked.
307 // Read once for the whole listing. A store that cannot be read is reported
308 // rather than fatal: `doctor` exists to describe a broken setup, so failing
309 // out here would suppress everything else it had to say.
310 let needs_auth_store = providers.iter().any(|entry| {
311 entry_is_enabled(entry) && entry.get("backend").and_then(Value::as_str) != Some("codex")
312 });
313 let store = match needs_auth_store
314 .then(|| crate::auth::AuthStore::load(auth_path))
315 .transpose()
316 {
317 Ok(Some(store)) => store,
318 Ok(None) => crate::auth::AuthStore::new(),
319 Err(err) => {
320 writeln!(out, " The auth store could not be read: {err}")?;
321 crate::auth::AuthStore::new()
322 }
323 };
324
325 let mut enabled_count = 0usize;
326 let mut codex_status: Option<Result<crate::llm::codex::CodexStatus, String>> = None;
327 for entry in providers {
328 let model = entry
329 .get("model")
330 .and_then(|v| v.as_str())
331 .unwrap_or("(no model set)");
332 let endpoint = entry
333 .get("endpoint")
334 .and_then(|v| v.as_str())
335 .unwrap_or("(no endpoint set)");
336 // Shown only when it is not the default, so an OpenAI-compatible listing
337 // keeps the line it has always had. It is worth showing at all because
338 // the protocol decides the path a request is posted to, and a wrong one
339 // reports as the endpoint being down.
340 let protocol = match entry.get("protocol").and_then(|v| v.as_str()) {
341 None | Some("openai") => String::new(),
342 Some(other) => format!(" [{other}]"),
343 };
344 let is_codex = entry.get("backend").and_then(Value::as_str) == Some("codex");
345 let description = if is_codex {
346 format!("{model} via ChatGPT/Codex subscription")
347 } else {
348 format!("{model} at {endpoint}{protocol}")
349 };
350 if entry_is_enabled(entry) {
351 enabled_count += 1;
352 writeln!(out, " {enabled_count}. {description}")?;
353 if is_codex {
354 let status = codex_status.get_or_insert_with(codex_probe);
355 match status {
356 Ok(status) => {
357 writeln!(out, " Codex CLI: {}", status.cli_version())?;
358 writeln!(out, " authentication: ChatGPT-managed")?;
359 writeln!(out, " isolation: ephemeral, read-only, tools disabled")?;
360 }
361 Err(err) => writeln!(out, " unavailable: {err}")?,
362 }
363 } else {
364 writeln!(out, " key: {}", key_source_line(entry, &store))?;
365 }
366 } else {
367 writeln!(out, " - {description} (disabled - skipped)")?;
368 }
369 }
370 writeln!(out, " {}", failover_line(enabled_count))?;
371
372 // Unset environment variables, deduped in first-seen order.
373 //
374 // Over the *parsed* tree, using `config`'s own scanner. Doctor had its own
375 // regex - `\$\{([A-Z_][A-Z0-9_]*)\}` - which is narrower than what
376 // `config::load` actually substitutes, so `${openrouter_key}` produced no
377 // warning here while `load` still failed on it. And since the branch below
378 // suppresses `EnvVarUnset` on the grounds it was "already reported", the
379 // user got a clean-looking report for a config `drep check` refuses to
380 // load. Scanning the parsed tree rather than the file text also stops a
381 // `${VAR}` inside a comment raising a false alarm.
382 for name in unset_env_vars(&value) {
383 writeln!(
384 out,
385 " {name} is NOT set - LLM analysis will fail until you export it."
386 )?;
387 }
388
389 // Surface other load failures. `EnvVarUnset` is already reported above;
390 // repeating it reads as two separate problems.
391 match config::load(&config_path) {
392 Err(config::ConfigError::EnvVarUnset(_, _)) => Ok(()),
393 other => report_load_result(out, &config_path, other),
394 }
395}
396
397/// Where this provider's key will come from, as `doctor` phrases it.
398///
399/// Read from the *raw* tree for the same reason the model and endpoint are: a
400/// `${VAR}` shows as itself rather than being swallowed by the
401/// variable-not-set error, so the report describes the file the user wrote.
402///
403/// The distinction is worth a line because "works on my machine" and "works in
404/// CI" are different configurations, and once a stored key exists the
405/// difference is invisible in `drep.toml`.
406fn key_source_line(entry: &Value, store: &crate::auth::AuthStore) -> String {
407 let api_key = entry.get("api_key").and_then(|v| v.as_str());
408 let endpoint = entry.get("endpoint").and_then(|v| v.as_str());
409
410 // `enabled` is passed as true because this line is only printed for entries
411 // the listing has already established are in the chain.
412 let source = crate::auth::source_of(api_key, endpoint, true, store);
413
414 match (source, api_key) {
415 // The reference is shown verbatim - that is the whole reason doctor
416 // reads the raw tree rather than the loaded config.
417 // Only a `${VAR}` reference is echoed. `api_key` may hold a literal
418 // secret - `config::load` accepts one - and doctor's output is what
419 // people paste into bug reports and CI logs.
420 (crate::auth::KeySource::Config, Some(reference))
421 if !crate::config::env_var_refs_in(&Value::String(reference.to_string()))
422 .is_empty() =>
423 {
424 format!("{reference} ({})", crate::auth::KeySource::Config.label())
425 }
426 (crate::auth::KeySource::Config, _) => format!(
427 "a literal value ({}) - prefer `${{VAR}}` so the file can be committed",
428 crate::auth::KeySource::Config.label()
429 ),
430 (source, _) => source.label().to_string(),
431 }
432}
433
434/// Report why the config will not load, if it will not.
435fn report_load_failure<W: Write>(out: &mut W, config_path: &Path) -> Result<()> {
436 let loaded = config::load(config_path);
437 report_load_result(out, config_path, loaded)
438}
439
440/// Shared tail of the two load-reporting paths.
441fn report_load_result<W: Write>(
442 out: &mut W,
443 config_path: &Path,
444 loaded: Result<config::Config, config::ConfigError>,
445) -> Result<()> {
446 if let Err(err) = loaded {
447 writeln!(out, " {} will not load: {err}", config_path.display())?;
448 }
449 Ok(())
450}
451
452/// Whether a raw `[[llm]]` table is in the failover chain.
453///
454/// The default comes from `LlmConfig::default()` rather than a literal `true`,
455/// so this cannot disagree with what `config::load` will actually decide. The
456/// raw table is read instead of the loaded config because `load` fails on an
457/// unset `${VAR}` - and a fresh clone with no key exported is exactly when this
458/// report is most useful.
459fn entry_is_enabled(entry: &toml::Value) -> bool {
460 entry
461 .get("enabled")
462 .and_then(toml::Value::as_bool)
463 .unwrap_or_else(|| config::LlmConfig::default().enabled)
464}
465
466/// What the chain will actually do, given how many providers are in it.
467///
468/// Three genuinely different situations, and saying "providers are tried in
469/// order" for a one-provider config would be true but useless - the thing that
470/// user needs to know is that there is no fallback at all.
471fn failover_line(enabled: usize) -> String {
472 match enabled {
473 0 => "Every provider is disabled - `drep check` cannot run. Re-enable one.".to_owned(),
474 1 => "One provider, so there is no fallback: if it is unreachable, `drep check` exits 2."
475 .to_owned(),
476 n => format!(
477 "{n} providers, tried in order: a transport failure falls through to the \
478 next. A 401 or 403 does not - that is misconfiguration, and failing \
479 over would hide it."
480 ),
481 }
482}
483
484/// Every variable the config references that is not set, in first-seen order.
485///
486/// The *reference* grammar is `config::required_env_var_refs`, shared with the
487/// substituter so the two cannot disagree; all this adds is the "and it is not
488/// set" filter. It excludes disabled providers for the same reason `load` does
489/// not expand them: a variable only a parked provider names is not required,
490/// and warning about it reports a problem `drep check` does not have.
491fn unset_env_vars(value: &toml::Value) -> Vec<String> {
492 config::required_env_var_refs(value)
493 .into_iter()
494 .filter(|name| std::env::var_os(name).is_none())
495 .collect()
496}
497
498#[cfg(test)]
499mod unit_tests;
500
501/// Acceptance tests live in their own directory under `tests/`, declared
502/// from this module. The directory has its own `mod.rs` so the files there
503/// are reachable by name - a Rust file no `mod` declaration reaches is never
504/// compiled, and a test file that is never compiled looks exactly like a
505/// passing one.
506#[cfg(test)]
507mod tests;