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