Skip to main content

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;