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