1use std::path::PathBuf;
17
18use crate::compiler::{Compiler, Origin, Skew};
19use crate::probe::{self, DetectOpts, Probe, Provenance, Toolbox};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Capability {
24 Compile,
27 Test,
29 Deploy,
31 Editor,
33 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 pub fn is_optional(self) -> bool {
52 matches!(self, Capability::Editor | Capability::BuildFromSource)
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
58pub enum Level {
59 Ok,
60 Warn,
61 Fail,
62}
63
64#[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#[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#[derive(Debug, Clone)]
85pub struct Report {
86 pub driver_version: String,
87 pub compiler: Compiler,
88 pub capabilities: Vec<CapabilityReport>,
89}
90
91#[derive(Debug, Clone, Default)]
93pub struct DoctorOptions {
94 pub only: Option<Capability>,
96 pub strict: bool,
98}
99
100#[derive(Debug, Clone)]
103pub struct Context {
104 pub project_root: Option<PathBuf>,
106 pub in_repo: bool,
108 pub node_floor: u32,
110}
111
112impl Report {
113 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 pub fn is_all_ok(&self) -> bool {
134 self.capabilities.iter().all(|c| c.level == Level::Ok)
135 }
136}
137
138pub 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 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_wrangler(tb, root);
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
183fn 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 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(_), 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
250fn 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 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
298fn 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 floor = bynk_emit::TYPESCRIPT_MAJOR_FLOOR;
318 let tested = bynk_emit::TYPESCRIPT_MAJOR_TESTED;
319 let remedy = format!(
322 "npm install -g typescript@{tested} (or `npm install -g tsx`, which runs tests without type-checking)"
323 );
324 match best {
325 Some(p) if p.is_present() && p.tool == "tsc" => {
333 let detail = format!("{} {}", p.tool, present_detail(p));
334 match p.version.map(|v| v.major) {
335 Some(major) if major < floor => Row {
336 label: "tsc | tsx".into(),
337 level: Level::Warn,
338 detail: format!("{detail}, below floor (≥ {floor})"),
339 remedy: Some(remedy),
340 },
341 Some(major) if major > tested => Row {
342 label: "tsc | tsx".into(),
343 level: Level::Ok,
344 detail: format!("{detail}, untested (verified up to {tested})"),
345 remedy: None,
346 },
347 _ => Row {
348 label: "tsc | tsx".into(),
349 level: Level::Ok,
350 detail,
351 remedy: None,
352 },
353 }
354 }
355 Some(p) if p.is_present() => Row {
356 label: "tsc | tsx".into(),
357 level: Level::Ok,
358 detail: format!("{} {}", p.tool, present_detail(p)),
359 remedy: None,
360 },
361 Some(p) => Row {
362 label: "tsc | tsx".into(),
364 level: Level::Warn,
365 detail: format!("{} provisionable via npx (not installed)", p.tool),
366 remedy: Some(remedy),
367 },
368 None => Row {
369 label: "tsc | tsx".into(),
370 level: Level::Fail,
371 detail: "missing".into(),
372 remedy: Some(remedy),
373 },
374 }
375}
376
377pub fn wrangler_below_min(probe: &Probe) -> bool {
382 let min = probe::Version::parse(bynk_emit::WRANGLER_MIN).expect("WRANGLER_MIN is a version");
383 probe.is_present()
384 && probe
385 .version
386 .is_some_and(|v| (v.major, v.minor, v.patch) < (min.major, min.minor, min.patch))
387}
388
389pub fn wrangler_upgrade_remedy(probe: &Probe) -> &'static str {
395 match probe.provenance {
396 Provenance::ProjectLocal(_) => "npm install --save-dev wrangler@4 (in the project)",
397 _ => "npm install -g wrangler@4",
398 }
399}
400
401fn detect_wrangler(tb: &dyn Toolbox, root: Option<&std::path::Path>) -> Row {
416 let min = bynk_emit::WRANGLER_MIN;
417 let date = bynk_emit::COMPATIBILITY_DATE;
418 let install = "npm install -g wrangler@4";
424 let probe = probe::detect(
425 tb,
426 "wrangler",
427 DetectOpts {
428 project_root: root,
429 allow_npx: true,
430 },
431 );
432 if wrangler_below_min(&probe) {
433 let remedy = wrangler_upgrade_remedy(&probe);
434 return Row {
435 label: "wrangler".into(),
436 level: Level::Warn,
437 detail: format!(
438 "{}, below {min}: `bynk dev` can't serve compatibility date {date}, and agents can't deploy",
439 present_detail(&probe)
440 ),
441 remedy: Some(remedy.into()),
442 };
443 }
444 let mut row = npm_row("wrangler", &probe, install);
445 if probe.is_provisionable() {
446 row.remedy = Some(format!(
447 "{install}, or clear a stale npx cache (~/.npm/_npx)"
448 ));
449 }
450 row
451}
452
453fn detect_plain(tb: &dyn Toolbox, tool: &str, remedy: &str) -> Row {
454 let probe = probe::detect(
455 tb,
456 tool,
457 DetectOpts {
458 project_root: None,
459 allow_npx: false,
460 },
461 );
462 if probe.is_present() {
463 Row {
464 label: tool.into(),
465 level: Level::Ok,
466 detail: present_detail(&probe),
467 remedy: None,
468 }
469 } else {
470 Row {
471 label: tool.into(),
472 level: Level::Fail,
473 detail: "missing".into(),
474 remedy: Some(remedy.into()),
475 }
476 }
477}
478
479fn npm_row(tool: &str, probe: &Probe, remedy: &str) -> Row {
480 if probe.is_present() {
481 Row {
482 label: tool.into(),
483 level: Level::Ok,
484 detail: present_detail(probe),
485 remedy: None,
486 }
487 } else if probe.is_provisionable() {
488 Row {
489 label: tool.into(),
490 level: Level::Warn,
491 detail: "provisionable via npx (not installed)".into(),
492 remedy: Some(remedy.into()),
493 }
494 } else {
495 Row {
496 label: tool.into(),
497 level: Level::Fail,
498 detail: "missing".into(),
499 remedy: Some(remedy.into()),
500 }
501 }
502}
503
504fn pick_better<'a>(a: &'a Probe, b: &'a Probe) -> Option<&'a Probe> {
507 fn rank(p: &Probe) -> u8 {
508 if p.is_present() {
509 2
510 } else if p.is_provisionable() {
511 1
512 } else {
513 0
514 }
515 }
516 let (ra, rb) = (rank(a), rank(b));
517 if ra == 0 && rb == 0 {
518 None
519 } else if ra >= rb {
520 Some(a)
521 } else {
522 Some(b)
523 }
524}
525
526fn present_detail(probe: &Probe) -> String {
527 let ver = probe
528 .version
529 .map(|v| format!("v{v}"))
530 .unwrap_or_else(|| "installed".into());
531 format!("{ver} ({})", probe.provenance.token())
532}