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