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::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;