1use std::path::PathBuf;
17
18use crate::compiler::{Compiler, Origin, Skew};
19use crate::probe::{self, DetectOpts, Probe, 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_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
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(_), _) => 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
242fn 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 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
290fn 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 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
401fn 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}