Skip to main content

bynk/
doctor.rs

1//! `bynk doctor` — the capability model, the checks, and the exit-code
2//! contract.
3//!
4//! Probes are **grouped by the capability they unlock**, not listed flat, so a
5//! compile-only user is never told they are "unhealthy" for lacking `wrangler`.
6//! The exit-code contract turns on *what an invocation asks about* (ADR: the
7//! doctor output / exit-code contract):
8//!
9//! - **Bare `bynk doctor`** is informational. It surveys everything but treats
10//!   only the *compile floor* (`bynkc` resolvable and not majorly skewed) as
11//!   required, so it exits `0` even with `test`/`dev` unavailable.
12//! - **`--only <capability>`** promotes that capability's tools to required.
13//! - **`--strict`** promotes *all* warnings (optional gaps, `npx`
14//!   provisionability, minor skew) to failures, for an all-green CI gate.
15
16use std::path::PathBuf;
17
18use crate::compiler::{Compiler, Origin, Skew};
19use crate::probe::{self, DetectOpts, Probe, Toolbox};
20
21/// A unit of work a user might want to do, and the tools it needs.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Capability {
24    /// `bynkc` compile / check / fmt. Always satisfiable if `bynkc` resolved;
25    /// also the home of the driver↔compiler skew check.
26    Compile,
27    /// `bynk test` — Node and one of `tsc`/`tsx` (the runner ladder).
28    Test,
29    /// `dev` / deploy to Cloudflare — Node and `wrangler`.
30    Deploy,
31    /// Editor support — `bynkc-lsp`. Optional; never a failure (except strict).
32    Editor,
33    /// Build Bynk from source — a Rust toolchain. Contributor-only; reported
34    /// only inside the Bynk repo.
35    BuildFromSource,
36}
37
38impl Capability {
39    pub fn token(self) -> &'static str {
40        match self {
41            Capability::Compile => "compile",
42            Capability::Test => "test",
43            Capability::Deploy => "deploy",
44            Capability::Editor => "editor",
45            Capability::BuildFromSource => "build",
46        }
47    }
48
49    /// Optional capabilities never fail a run on their own — they note, and
50    /// `--strict` escalates.
51    pub fn is_optional(self) -> bool {
52        matches!(self, Capability::Editor | Capability::BuildFromSource)
53    }
54}
55
56/// Health of a single row or a whole capability. Ordered: `Ok < Warn < Fail`.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
58pub enum Level {
59    Ok,
60    Warn,
61    Fail,
62}
63
64/// One rendered line under a capability: a tool (or an any-of group like
65/// `tsc | tsx`), its health, a human detail, and a remedy when it is not `Ok`.
66#[derive(Debug, Clone)]
67pub struct Row {
68    pub label: String,
69    pub level: Level,
70    pub detail: String,
71    pub remedy: Option<String>,
72}
73
74/// A capability and its rows, with the aggregated health.
75#[derive(Debug, Clone)]
76pub struct CapabilityReport {
77    pub capability: Capability,
78    pub optional: bool,
79    pub rows: Vec<Row>,
80    pub level: Level,
81}
82
83/// The whole `doctor` result.
84#[derive(Debug, Clone)]
85pub struct Report {
86    pub driver_version: String,
87    pub compiler: Compiler,
88    pub capabilities: Vec<CapabilityReport>,
89}
90
91/// User-facing knobs.
92#[derive(Debug, Clone, Default)]
93pub struct DoctorOptions {
94    /// Scope the gate to one capability (promotes its tools to required).
95    pub only: Option<Capability>,
96    /// Escalate every warning to a failure.
97    pub strict: bool,
98}
99
100/// Environment facts the caller supplies (real values in `main`, fixed values
101/// in tests).
102#[derive(Debug, Clone)]
103pub struct Context {
104    /// Discovered project root (`bynk.toml`), for project-local resolution.
105    pub project_root: Option<PathBuf>,
106    /// Whether to include the contributor `build` capability.
107    pub in_repo: bool,
108    /// Minimum supported Node major (single-sourced from `bynkc`).
109    pub node_floor: u32,
110}
111
112impl Report {
113    /// Should the process exit non-zero, given the options?
114    ///
115    /// Non-zero iff a *required* capability has a hard failure, or — under
116    /// `--strict` — any capability is less than `Ok`. The compile floor is
117    /// always required; `--only <cap>` adds that capability.
118    pub fn exit_nonzero(&self, opts: &DoctorOptions) -> bool {
119        for cap in &self.capabilities {
120            let required =
121                cap.capability == Capability::Compile || opts.only == Some(cap.capability);
122            if required && cap.level == Level::Fail {
123                return true;
124            }
125        }
126        if opts.strict && self.capabilities.iter().any(|c| c.level != Level::Ok) {
127            return true;
128        }
129        false
130    }
131
132    /// One-word overall summary for the human header.
133    pub fn is_all_ok(&self) -> bool {
134        self.capabilities.iter().all(|c| c.level == Level::Ok)
135    }
136}
137
138/// Run the checks against a toolbox and a resolved compiler.
139pub fn diagnose(
140    tb: &dyn Toolbox,
141    compiler: &Compiler,
142    ctx: &Context,
143    opts: &DoctorOptions,
144) -> Report {
145    let root = ctx.project_root.as_deref();
146    let mut capabilities = vec![compile_report(compiler)];
147
148    // Only build the capability the user scoped to (plus the always-on compile
149    // floor), so `--only test` doesn't probe Cloudflare. With no filter, build
150    // them all.
151    let want = |cap: Capability| opts.only.is_none() || opts.only == Some(cap);
152
153    if want(Capability::Test) {
154        let node = detect_node(tb, root, ctx.node_floor);
155        let runner = detect_runner(tb, root);
156        capabilities.push(capability(Capability::Test, vec![node, runner]));
157    }
158    if want(Capability::Deploy) {
159        let node = detect_node(tb, root, ctx.node_floor);
160        let wrangler = detect_npm_tool(tb, root, "wrangler", "npm install -g wrangler");
161        capabilities.push(capability(Capability::Deploy, vec![node, wrangler]));
162    }
163    if want(Capability::Editor) {
164        let lsp = detect_plain(
165            tb,
166            "bynkc-lsp",
167            "install bynkc-lsp (or download from releases)",
168        );
169        capabilities.push(capability(Capability::Editor, vec![lsp]));
170    }
171    if ctx.in_repo && want(Capability::BuildFromSource) {
172        let cargo = detect_plain(tb, "cargo", "install Rust via https://rustup.rs");
173        capabilities.push(capability(Capability::BuildFromSource, vec![cargo]));
174    }
175
176    Report {
177        driver_version: crate::DRIVER_VERSION.to_string(),
178        compiler: compiler.clone(),
179        capabilities,
180    }
181}
182
183/// Compile/check/fmt. The compiler is **linked in-process** (slice 7 / ADR 0101),
184/// so it is always available and cannot skew against itself — the always-ok row.
185/// The external-`bynkc` resolution + skew check applies **only** under a
186/// `BYNK_BYNKC` override (`Origin::Override`), the one path on which a second,
187/// skewable compiler enters; with no override there is nothing external to check
188/// (amends ADR 0084).
189fn compile_report(compiler: &Compiler) -> CapabilityReport {
190    let mut rows = vec![Row {
191        label: "compiler".into(),
192        level: Level::Ok,
193        detail: "in-process".into(),
194        remedy: None,
195    }];
196
197    // Only when the user explicitly pointed `bynk` at an external compiler does a
198    // second binary — and thus skew — exist. Report it then, and only then.
199    if matches!(compiler.origin, Some(Origin::Override)) {
200        let ver = compiler
201            .version
202            .map(|v| v.to_string())
203            .unwrap_or_else(|| "unknown".into());
204        let row = match (&compiler.path, compiler.skew) {
205            (None, _) => Row {
206                label: "bynkc (override)".into(),
207                level: Level::Fail,
208                detail: "$BYNK_BYNKC set but not found".into(),
209                remedy: Some("fix BYNK_BYNKC, or unset it to use the in-process compiler".into()),
210            },
211            (Some(_), Some(Skew::Major)) => Row {
212                label: "bynkc (override)".into(),
213                level: Level::Fail,
214                detail: format!("{ver} — major skew vs driver"),
215                remedy: Some("align the override bynkc with bynk, or unset BYNK_BYNKC".into()),
216            },
217            (Some(_), Some(Skew::Minor)) => Row {
218                label: "bynkc (override)".into(),
219                level: Level::Warn,
220                detail: format!("{ver} — minor skew vs driver"),
221                remedy: Some("align the override bynkc with bynk, or unset BYNK_BYNKC".into()),
222            },
223            // Resolved but its version could not be read — the binary may not
224            // be a bynkc at all; don't render it as a clean Ok.
225            (Some(_), None) => Row {
226                label: "bynkc (override)".into(),
227                level: Level::Warn,
228                detail: "resolved, but its version could not be read".into(),
229                remedy: Some("check that BYNK_BYNKC points at a bynkc binary".into()),
230            },
231            (Some(_), _) => Row {
232                label: "bynkc (override)".into(),
233                level: Level::Ok,
234                detail: format!("{ver} (override)"),
235                remedy: None,
236            },
237        };
238        rows.push(row);
239    }
240
241    let level = rows.iter().map(|r| r.level).max().unwrap_or(Level::Ok);
242    CapabilityReport {
243        capability: Capability::Compile,
244        optional: false,
245        rows,
246        level,
247    }
248}
249
250/// Aggregate a capability from its rows (worst row wins).
251fn capability(cap: Capability, rows: Vec<Row>) -> CapabilityReport {
252    let level = rows.iter().map(|r| r.level).max().unwrap_or(Level::Ok);
253    CapabilityReport {
254        capability: cap,
255        optional: cap.is_optional(),
256        rows,
257        level,
258    }
259}
260
261fn detect_node(tb: &dyn Toolbox, root: Option<&std::path::Path>, floor: u32) -> Row {
262    // A runtime is never npx-provisionable.
263    let probe = probe::detect(
264        tb,
265        "node",
266        DetectOpts {
267            project_root: root,
268            allow_npx: false,
269        },
270    );
271    let remedy = format!("install Node.js ≥ {floor} from https://nodejs.org");
272    if probe.is_missing() {
273        return Row {
274            label: "node".into(),
275            level: Level::Fail,
276            detail: "missing".into(),
277            remedy: Some(remedy),
278        };
279    }
280    let below = probe.version.map(|v| v.major < floor).unwrap_or(false);
281    if below {
282        let v = probe.version.unwrap();
283        return Row {
284            label: "node".into(),
285            level: Level::Warn,
286            detail: format!("v{v} below floor (≥ {floor})"),
287            remedy: Some(remedy),
288        };
289    }
290    Row {
291        label: "node".into(),
292        level: Level::Ok,
293        detail: present_detail(&probe),
294        remedy: None,
295    }
296}
297
298/// The `tsc | tsx` runner requirement — satisfied by the *better* of the two.
299fn detect_runner(tb: &dyn Toolbox, root: Option<&std::path::Path>) -> Row {
300    let tsc = probe::detect(
301        tb,
302        "tsc",
303        DetectOpts {
304            project_root: root,
305            allow_npx: true,
306        },
307    );
308    let tsx = probe::detect(
309        tb,
310        "tsx",
311        DetectOpts {
312            project_root: root,
313            allow_npx: true,
314        },
315    );
316    let best = pick_better(&tsc, &tsx);
317    let remedy = "npm install -g tsx (or: npm install -g typescript)".to_string();
318    match best {
319        Some(p) if p.is_present() => Row {
320            label: "tsc | tsx".into(),
321            level: Level::Ok,
322            detail: format!("{} {}", p.tool, present_detail(p)),
323            remedy: None,
324        },
325        Some(p) => Row {
326            // provisionable via npx
327            label: "tsc | tsx".into(),
328            level: Level::Warn,
329            detail: format!("{} provisionable via npx (not installed)", p.tool),
330            remedy: Some(remedy),
331        },
332        None => Row {
333            label: "tsc | tsx".into(),
334            level: Level::Fail,
335            detail: "missing".into(),
336            remedy: Some(remedy),
337        },
338    }
339}
340
341fn detect_npm_tool(
342    tb: &dyn Toolbox,
343    root: Option<&std::path::Path>,
344    tool: &str,
345    remedy: &str,
346) -> Row {
347    let probe = probe::detect(
348        tb,
349        tool,
350        DetectOpts {
351            project_root: root,
352            allow_npx: true,
353        },
354    );
355    npm_row(tool, &probe, remedy)
356}
357
358fn detect_plain(tb: &dyn Toolbox, tool: &str, remedy: &str) -> Row {
359    let probe = probe::detect(
360        tb,
361        tool,
362        DetectOpts {
363            project_root: None,
364            allow_npx: false,
365        },
366    );
367    if probe.is_present() {
368        Row {
369            label: tool.into(),
370            level: Level::Ok,
371            detail: present_detail(&probe),
372            remedy: None,
373        }
374    } else {
375        Row {
376            label: tool.into(),
377            level: Level::Fail,
378            detail: "missing".into(),
379            remedy: Some(remedy.into()),
380        }
381    }
382}
383
384fn npm_row(tool: &str, probe: &Probe, remedy: &str) -> Row {
385    if probe.is_present() {
386        Row {
387            label: tool.into(),
388            level: Level::Ok,
389            detail: present_detail(probe),
390            remedy: None,
391        }
392    } else if probe.is_provisionable() {
393        Row {
394            label: tool.into(),
395            level: Level::Warn,
396            detail: "provisionable via npx (not installed)".into(),
397            remedy: Some(remedy.into()),
398        }
399    } else {
400        Row {
401            label: tool.into(),
402            level: Level::Fail,
403            detail: "missing".into(),
404            remedy: Some(remedy.into()),
405        }
406    }
407}
408
409/// `path`/`project-local` beats `npx` beats `missing`; among installed, prefer
410/// the first argument (caller order).
411fn pick_better<'a>(a: &'a Probe, b: &'a Probe) -> Option<&'a Probe> {
412    fn rank(p: &Probe) -> u8 {
413        if p.is_present() {
414            2
415        } else if p.is_provisionable() {
416            1
417        } else {
418            0
419        }
420    }
421    let (ra, rb) = (rank(a), rank(b));
422    if ra == 0 && rb == 0 {
423        None
424    } else if ra >= rb {
425        Some(a)
426    } else {
427        Some(b)
428    }
429}
430
431fn present_detail(probe: &Probe) -> String {
432    let ver = probe
433        .version
434        .map(|v| format!("v{v}"))
435        .unwrap_or_else(|| "installed".into());
436    format!("{ver} ({})", probe.provenance.token())
437}