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            (Some(_), _) => Row {
224                label: "bynkc (override)".into(),
225                level: Level::Ok,
226                detail: format!("{ver} (override)"),
227                remedy: None,
228            },
229        };
230        rows.push(row);
231    }
232
233    let level = rows.iter().map(|r| r.level).max().unwrap_or(Level::Ok);
234    CapabilityReport {
235        capability: Capability::Compile,
236        optional: false,
237        rows,
238        level,
239    }
240}
241
242/// Aggregate a capability from its rows (worst row wins).
243fn capability(cap: Capability, rows: Vec<Row>) -> CapabilityReport {
244    let level = rows.iter().map(|r| r.level).max().unwrap_or(Level::Ok);
245    CapabilityReport {
246        capability: cap,
247        optional: cap.is_optional(),
248        rows,
249        level,
250    }
251}
252
253fn detect_node(tb: &dyn Toolbox, root: Option<&std::path::Path>, floor: u32) -> Row {
254    // A runtime is never npx-provisionable.
255    let probe = probe::detect(
256        tb,
257        "node",
258        DetectOpts {
259            project_root: root,
260            allow_npx: false,
261        },
262    );
263    let remedy = format!("install Node.js ≥ {floor} from https://nodejs.org");
264    if probe.is_missing() {
265        return Row {
266            label: "node".into(),
267            level: Level::Fail,
268            detail: "missing".into(),
269            remedy: Some(remedy),
270        };
271    }
272    let below = probe.version.map(|v| v.major < floor).unwrap_or(false);
273    if below {
274        let v = probe.version.unwrap();
275        return Row {
276            label: "node".into(),
277            level: Level::Warn,
278            detail: format!("v{v} below floor (≥ {floor})"),
279            remedy: Some(remedy),
280        };
281    }
282    Row {
283        label: "node".into(),
284        level: Level::Ok,
285        detail: present_detail(&probe),
286        remedy: None,
287    }
288}
289
290/// The `tsc | tsx` runner requirement — satisfied by the *better* of the two.
291fn detect_runner(tb: &dyn Toolbox, root: Option<&std::path::Path>) -> Row {
292    let tsc = probe::detect(
293        tb,
294        "tsc",
295        DetectOpts {
296            project_root: root,
297            allow_npx: true,
298        },
299    );
300    let tsx = probe::detect(
301        tb,
302        "tsx",
303        DetectOpts {
304            project_root: root,
305            allow_npx: true,
306        },
307    );
308    let best = pick_better(&tsc, &tsx);
309    let remedy = "npm install -g tsx (or: npm install -g typescript)".to_string();
310    match best {
311        Some(p) if p.is_present() => Row {
312            label: "tsc | tsx".into(),
313            level: Level::Ok,
314            detail: format!("{} {}", p.tool, present_detail(p)),
315            remedy: None,
316        },
317        Some(p) => Row {
318            // provisionable via npx
319            label: "tsc | tsx".into(),
320            level: Level::Warn,
321            detail: format!("{} provisionable via npx (not installed)", p.tool),
322            remedy: Some(remedy),
323        },
324        None => Row {
325            label: "tsc | tsx".into(),
326            level: Level::Fail,
327            detail: "missing".into(),
328            remedy: Some(remedy),
329        },
330    }
331}
332
333fn detect_npm_tool(
334    tb: &dyn Toolbox,
335    root: Option<&std::path::Path>,
336    tool: &str,
337    remedy: &str,
338) -> Row {
339    let probe = probe::detect(
340        tb,
341        tool,
342        DetectOpts {
343            project_root: root,
344            allow_npx: true,
345        },
346    );
347    npm_row(tool, &probe, remedy)
348}
349
350fn detect_plain(tb: &dyn Toolbox, tool: &str, remedy: &str) -> Row {
351    let probe = probe::detect(
352        tb,
353        tool,
354        DetectOpts {
355            project_root: None,
356            allow_npx: false,
357        },
358    );
359    if probe.is_present() {
360        Row {
361            label: tool.into(),
362            level: Level::Ok,
363            detail: present_detail(&probe),
364            remedy: None,
365        }
366    } else {
367        Row {
368            label: tool.into(),
369            level: Level::Fail,
370            detail: "missing".into(),
371            remedy: Some(remedy.into()),
372        }
373    }
374}
375
376fn npm_row(tool: &str, probe: &Probe, remedy: &str) -> Row {
377    if probe.is_present() {
378        Row {
379            label: tool.into(),
380            level: Level::Ok,
381            detail: present_detail(probe),
382            remedy: None,
383        }
384    } else if probe.is_provisionable() {
385        Row {
386            label: tool.into(),
387            level: Level::Warn,
388            detail: "provisionable via npx (not installed)".into(),
389            remedy: Some(remedy.into()),
390        }
391    } else {
392        Row {
393            label: tool.into(),
394            level: Level::Fail,
395            detail: "missing".into(),
396            remedy: Some(remedy.into()),
397        }
398    }
399}
400
401/// `path`/`project-local` beats `npx` beats `missing`; among installed, prefer
402/// the first argument (caller order).
403fn pick_better<'a>(a: &'a Probe, b: &'a Probe) -> Option<&'a Probe> {
404    fn rank(p: &Probe) -> u8 {
405        if p.is_present() {
406            2
407        } else if p.is_provisionable() {
408            1
409        } else {
410            0
411        }
412    }
413    let (ra, rb) = (rank(a), rank(b));
414    if ra == 0 && rb == 0 {
415        None
416    } else if ra >= rb {
417        Some(a)
418    } else {
419        Some(b)
420    }
421}
422
423fn present_detail(probe: &Probe) -> String {
424    let ver = probe
425        .version
426        .map(|v| format!("v{v}"))
427        .unwrap_or_else(|| "installed".into());
428    format!("{ver} ({})", probe.provenance.token())
429}