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 compile = compile_report(compiler);
147 if root.is_some_and(has_git_dependencies) {
149 compile.rows.push(detect_plain(
150 tb,
151 "git",
152 "install git — this project has git dependencies, which `bynk fetch` clones",
153 ));
154 compile.level = compile
155 .rows
156 .iter()
157 .map(|r| r.level)
158 .max()
159 .unwrap_or(Level::Ok);
160 }
161 let mut capabilities = vec![compile];
162
163 let want = |cap: Capability| opts.only.is_none() || opts.only == Some(cap);
167
168 if want(Capability::Test) {
169 let node = detect_node(tb, root, ctx.node_floor);
170 let runner = detect_runner(tb, root);
171 capabilities.push(capability(Capability::Test, vec![node, runner]));
172 }
173 if want(Capability::Deploy) {
174 let node = detect_node(tb, root, ctx.node_floor);
175 let wrangler = detect_wrangler(tb, root);
176 capabilities.push(capability(Capability::Deploy, vec![node, wrangler]));
177 }
178 if want(Capability::Editor) {
179 let lsp = detect_plain(
180 tb,
181 "bynkc-lsp",
182 "install bynkc-lsp (or download from releases)",
183 );
184 capabilities.push(capability(Capability::Editor, vec![lsp]));
185 }
186 if ctx.in_repo && want(Capability::BuildFromSource) {
187 let cargo = detect_plain(tb, "cargo", "install Rust via https://rustup.rs");
188 capabilities.push(capability(Capability::BuildFromSource, vec![cargo]));
189 }
190
191 Report {
192 driver_version: crate::DRIVER_VERSION.to_string(),
193 compiler: compiler.clone(),
194 capabilities,
195 }
196}
197
198fn compile_report(compiler: &Compiler) -> CapabilityReport {
205 let mut rows = vec![Row {
206 label: "compiler".into(),
207 level: Level::Ok,
208 detail: "in-process".into(),
209 remedy: None,
210 }];
211
212 if matches!(compiler.origin, Some(Origin::Override)) {
215 let ver = compiler
216 .version
217 .map(|v| v.to_string())
218 .unwrap_or_else(|| "unknown".into());
219 let row = match (&compiler.path, compiler.skew) {
220 (None, _) => Row {
221 label: "bynkc (override)".into(),
222 level: Level::Fail,
223 detail: "$BYNK_BYNKC set but not found".into(),
224 remedy: Some("fix BYNK_BYNKC, or unset it to use the in-process compiler".into()),
225 },
226 (Some(_), Some(Skew::Major)) => Row {
227 label: "bynkc (override)".into(),
228 level: Level::Fail,
229 detail: format!("{ver} — major skew vs driver"),
230 remedy: Some("align the override bynkc with bynk, or unset BYNK_BYNKC".into()),
231 },
232 (Some(_), Some(Skew::Minor)) => Row {
233 label: "bynkc (override)".into(),
234 level: Level::Warn,
235 detail: format!("{ver} — minor skew vs driver"),
236 remedy: Some("align the override bynkc with bynk, or unset BYNK_BYNKC".into()),
237 },
238 (Some(_), None) => Row {
241 label: "bynkc (override)".into(),
242 level: Level::Warn,
243 detail: "resolved, but its version could not be read".into(),
244 remedy: Some("check that BYNK_BYNKC points at a bynkc binary".into()),
245 },
246 (Some(_), _) => Row {
247 label: "bynkc (override)".into(),
248 level: Level::Ok,
249 detail: format!("{ver} (override)"),
250 remedy: None,
251 },
252 };
253 rows.push(row);
254 }
255
256 let level = rows.iter().map(|r| r.level).max().unwrap_or(Level::Ok);
257 CapabilityReport {
258 capability: Capability::Compile,
259 optional: false,
260 rows,
261 level,
262 }
263}
264
265fn capability(cap: Capability, rows: Vec<Row>) -> CapabilityReport {
267 let level = rows.iter().map(|r| r.level).max().unwrap_or(Level::Ok);
268 CapabilityReport {
269 capability: cap,
270 optional: cap.is_optional(),
271 rows,
272 level,
273 }
274}
275
276fn detect_node(tb: &dyn Toolbox, root: Option<&std::path::Path>, floor: u32) -> Row {
277 let probe = probe::detect(
279 tb,
280 "node",
281 DetectOpts {
282 project_root: root,
283 allow_npx: false,
284 },
285 );
286 let remedy = format!("install Node.js ≥ {floor} from https://nodejs.org");
287 if probe.is_missing() {
288 return Row {
289 label: "node".into(),
290 level: Level::Fail,
291 detail: "missing".into(),
292 remedy: Some(remedy),
293 };
294 }
295 let below = probe.version.map(|v| v.major < floor).unwrap_or(false);
296 if below {
297 let v = probe.version.unwrap();
298 return Row {
299 label: "node".into(),
300 level: Level::Warn,
301 detail: format!("v{v} below floor (≥ {floor})"),
302 remedy: Some(remedy),
303 };
304 }
305 Row {
306 label: "node".into(),
307 level: Level::Ok,
308 detail: present_detail(&probe),
309 remedy: None,
310 }
311}
312
313fn detect_runner(tb: &dyn Toolbox, root: Option<&std::path::Path>) -> Row {
315 let tsc = probe::detect(
316 tb,
317 "tsc",
318 DetectOpts {
319 project_root: root,
320 allow_npx: true,
321 },
322 );
323 let tsx = probe::detect(
324 tb,
325 "tsx",
326 DetectOpts {
327 project_root: root,
328 allow_npx: true,
329 },
330 );
331 let best = pick_better(&tsc, &tsx);
332 let floor = bynk_emit::TYPESCRIPT_MAJOR_FLOOR;
333 let tested = bynk_emit::TYPESCRIPT_MAJOR_TESTED;
334 let remedy = format!(
337 "npm install -g typescript@{tested} (or `npm install -g tsx`, which runs tests without type-checking)"
338 );
339 match best {
340 Some(p) if p.is_present() && p.tool == "tsc" => {
348 let detail = format!("{} {}", p.tool, present_detail(p));
349 match p.version.map(|v| v.major) {
350 Some(major) if major < floor => Row {
351 label: "tsc | tsx".into(),
352 level: Level::Warn,
353 detail: format!("{detail}, below floor (≥ {floor})"),
354 remedy: Some(remedy),
355 },
356 Some(major) if major > tested => Row {
357 label: "tsc | tsx".into(),
358 level: Level::Ok,
359 detail: format!("{detail}, untested (verified up to {tested})"),
360 remedy: None,
361 },
362 _ => Row {
363 label: "tsc | tsx".into(),
364 level: Level::Ok,
365 detail,
366 remedy: None,
367 },
368 }
369 }
370 Some(p) if p.is_present() => Row {
371 label: "tsc | tsx".into(),
372 level: Level::Ok,
373 detail: format!("{} {}", p.tool, present_detail(p)),
374 remedy: None,
375 },
376 Some(p) => Row {
377 label: "tsc | tsx".into(),
379 level: Level::Warn,
380 detail: format!("{} provisionable via npx (not installed)", p.tool),
381 remedy: Some(remedy),
382 },
383 None => Row {
384 label: "tsc | tsx".into(),
385 level: Level::Fail,
386 detail: "missing".into(),
387 remedy: Some(remedy),
388 },
389 }
390}
391
392pub fn wrangler_below_min(probe: &Probe) -> bool {
397 let min = probe::Version::parse(bynk_emit::WRANGLER_MIN).expect("WRANGLER_MIN is a version");
398 probe.is_present()
399 && probe
400 .version
401 .is_some_and(|v| (v.major, v.minor, v.patch) < (min.major, min.minor, min.patch))
402}
403
404pub fn wrangler_upgrade_remedy(probe: &Probe) -> &'static str {
410 match probe.provenance {
411 Provenance::ProjectLocal(_) => "npm install --save-dev wrangler@4 (in the project)",
412 _ => "npm install -g wrangler@4",
413 }
414}
415
416fn detect_wrangler(tb: &dyn Toolbox, root: Option<&std::path::Path>) -> Row {
431 let min = bynk_emit::WRANGLER_MIN;
432 let date = bynk_emit::COMPATIBILITY_DATE;
433 let install = "npm install -g wrangler@4";
439 let probe = probe::detect(
440 tb,
441 "wrangler",
442 DetectOpts {
443 project_root: root,
444 allow_npx: true,
445 },
446 );
447 if wrangler_below_min(&probe) {
448 let remedy = wrangler_upgrade_remedy(&probe);
449 return Row {
450 label: "wrangler".into(),
451 level: Level::Warn,
452 detail: format!(
453 "{}, below {min}: `bynk dev` can't serve compatibility date {date}, and agents can't deploy",
454 present_detail(&probe)
455 ),
456 remedy: Some(remedy.into()),
457 };
458 }
459 let mut row = npm_row("wrangler", &probe, install);
460 if probe.is_provisionable() {
461 row.remedy = Some(format!(
462 "{install}, or clear a stale npx cache (~/.npm/_npx)"
463 ));
464 }
465 row
466}
467
468fn has_git_dependencies(root: &std::path::Path) -> bool {
471 let Ok(text) = std::fs::read_to_string(root.join("bynk.toml")) else {
472 return false;
473 };
474 let Ok(doc) = text.parse::<toml::Table>() else {
475 return false;
476 };
477 ["dependencies", "dev-dependencies"].iter().any(|table| {
478 doc.get(*table).and_then(|v| v.as_table()).is_some_and(|t| {
479 t.values()
480 .any(|e| e.as_table().is_some_and(|e| e.contains_key("git")))
481 })
482 })
483}
484
485fn detect_plain(tb: &dyn Toolbox, tool: &str, remedy: &str) -> Row {
486 let probe = probe::detect(
487 tb,
488 tool,
489 DetectOpts {
490 project_root: None,
491 allow_npx: false,
492 },
493 );
494 if probe.is_present() {
495 Row {
496 label: tool.into(),
497 level: Level::Ok,
498 detail: present_detail(&probe),
499 remedy: None,
500 }
501 } else {
502 Row {
503 label: tool.into(),
504 level: Level::Fail,
505 detail: "missing".into(),
506 remedy: Some(remedy.into()),
507 }
508 }
509}
510
511fn npm_row(tool: &str, probe: &Probe, remedy: &str) -> Row {
512 if probe.is_present() {
513 Row {
514 label: tool.into(),
515 level: Level::Ok,
516 detail: present_detail(probe),
517 remedy: None,
518 }
519 } else if probe.is_provisionable() {
520 Row {
521 label: tool.into(),
522 level: Level::Warn,
523 detail: "provisionable via npx (not installed)".into(),
524 remedy: Some(remedy.into()),
525 }
526 } else {
527 Row {
528 label: tool.into(),
529 level: Level::Fail,
530 detail: "missing".into(),
531 remedy: Some(remedy.into()),
532 }
533 }
534}
535
536fn pick_better<'a>(a: &'a Probe, b: &'a Probe) -> Option<&'a Probe> {
539 fn rank(p: &Probe) -> u8 {
540 if p.is_present() {
541 2
542 } else if p.is_provisionable() {
543 1
544 } else {
545 0
546 }
547 }
548 let (ra, rb) = (rank(a), rank(b));
549 if ra == 0 && rb == 0 {
550 None
551 } else if ra >= rb {
552 Some(a)
553 } else {
554 Some(b)
555 }
556}
557
558fn present_detail(probe: &Probe) -> String {
559 let ver = probe
560 .version
561 .map(|v| format!("v{v}"))
562 .unwrap_or_else(|| "installed".into());
563 format!("{ver} ({})", probe.provenance.token())
564}