codehelion_core/doctor.rs
1//! Environment diagnostics.
2//!
3//! `doctor` reports which analysis components are usable on the current
4//! machine. It is a diagnostic, never a gate: it inspects the environment and
5//! always succeeds.
6//!
7//! Compiler helpers are separate programs, and this module does not know how to
8//! run one — it is handed what was found out about them. That keeps the crate
9//! that compares programs free of the crate that starts processes, and it is
10//! also what makes the absence of a helper testable: a lookup that finds
11//! nothing is a machine without helpers, which is the case worth being sure
12//! about.
13//!
14//! An absent helper is reported as what is still available rather than as a
15//! problem. Fast and Structural analysis do not need one, so a report that
16//! read as a failure would be telling somebody to fix something that is not
17//! broken.
18//!
19//! # Why being there is not the same as being usable
20//!
21//! A helper that is installed can still be one this build cannot talk to: an
22//! older protocol, a program that dies on startup, a name that resolves to
23//! something else entirely. Reporting that as "available" sends somebody to
24//! debug a scan that was never going to work, and reporting it as "not found"
25//! sends them to install what is already installed. It is its own state, and
26//! what the helper said — or why it said nothing — is the part worth printing.
27//!
28
29use std::io::{self, Write};
30use std::path::PathBuf;
31
32use crate::discovery::Language;
33
34/// Availability of a diagnostic component.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ComponentStatus {
37 /// Present and usable.
38 Available,
39 /// Looked for but not found on this system.
40 NotFound,
41 /// Found, but this build could not use it.
42 Unusable,
43}
44
45impl ComponentStatus {
46 /// Every status a component report can carry.
47 ///
48 /// What the table measures its own column against, so a status added or
49 /// removed moves the column with it rather than leaving it padded for a
50 /// word nothing prints any more.
51 #[must_use]
52 pub const fn all() -> &'static [Self] {
53 &[Self::Available, Self::NotFound, Self::Unusable]
54 }
55
56 /// Short human-readable label for reports.
57 #[must_use]
58 pub const fn label(self) -> &'static str {
59 match self {
60 Self::Available => "available",
61 Self::NotFound => "not found",
62 Self::Unusable => "unusable",
63 }
64 }
65}
66
67/// Whether a component is required for core functionality.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum Requirement {
70 /// Core source auditing depends on this component.
71 Required,
72 /// Enables optional analysis modes only; the tool works without it.
73 Optional,
74}
75
76impl Requirement {
77 /// Both requirements, for the same reason [`ComponentStatus::all`] exists.
78 #[must_use]
79 pub const fn all() -> &'static [Self] {
80 &[Self::Required, Self::Optional]
81 }
82
83 /// Short human-readable label for reports.
84 #[must_use]
85 pub const fn label(self) -> &'static str {
86 match self {
87 Self::Required => "required",
88 Self::Optional => "optional",
89 }
90 }
91}
92
93/// Outcome of inspecting one component.
94#[derive(Debug, Clone)]
95pub struct ComponentReport {
96 /// Component name shown to the user.
97 pub name: &'static str,
98 /// Whether the component is required or optional.
99 pub requirement: Requirement,
100 /// Detected availability.
101 pub status: ComponentStatus,
102 /// Extra detail: a version string, or why the component is unavailable.
103 pub detail: String,
104 /// Lines printed under the component, in the order they were added.
105 ///
106 /// What a helper said about itself does not fit on the line that says
107 /// whether it is there, and squeezing it in would make the common case —
108 /// reading down the status column — harder for the sake of the rare one.
109 pub notes: Vec<String>,
110}
111
112/// What one helper turned out to be, once somebody went and looked.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct HelperFacts {
115 /// Where the program is.
116 pub path: PathBuf,
117 /// Whether it can be talked to, and what it said.
118 pub state: HelperState,
119}
120
121/// Whether a helper that is present can be used.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum HelperState {
124 /// It answered the handshake, and this is what it answered.
125 Answered(Greeting),
126 /// It is there and this build could not talk to it, with the reason.
127 Silent(String),
128}
129
130/// What a helper said about itself at the handshake.
131///
132/// Spelled as text rather than as the protocol's own types: this crate does not
133/// read the protocol, and a diagnostic that made it do so would put the crate
134/// that compares programs downstream of the crate that starts them.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct Greeting {
137 /// The helper's own version.
138 pub version: String,
139 /// The protocol version the two settled on.
140 pub protocol: u32,
141 /// The compilers it analyses with — its own, not the project's.
142 pub toolchains: Vec<String>,
143 /// What it offers to supply, in the spelling the protocol uses.
144 pub capabilities: Vec<String>,
145 /// The classes of execution it acts on when permitted, in the spelling a
146 /// person types to permit them.
147 ///
148 /// Reported because permitting something is a decision, and the person
149 /// making it should be able to find out beforehand whether the program
150 /// they are permitting would do anything with it.
151 pub executes: Vec<String>,
152}
153
154/// An optional out-of-process helper, and what a machine without it loses.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub struct HelperComponent {
157 /// The name reported for it.
158 pub name: &'static str,
159 /// The program to look for.
160 pub binary: &'static str,
161 /// The languages it is the one to ask about.
162 ///
163 /// Beside the sentence that says the same thing to a reader, rather than
164 /// parsed back out of it: a run picking a helper per file and a report
165 /// saying what having it makes possible are then two readings of one
166 /// answer, and neither can drift from the other.
167 pub analyses: &'static [Language],
168 /// What having it makes possible.
169 pub enables: &'static str,
170 /// What to do about not having it.
171 pub advice: &'static str,
172}
173
174/// The helper that answers about Rust.
175///
176/// Named here rather than only inside the list, so that the run which needs it
177/// and the report which says whether it is there name one value: advice that
178/// drifts from the mechanism it describes is advice that points nowhere.
179pub const RUST_HELPER: HelperComponent = HelperComponent {
180 name: "rust-compiler-helper",
181 binary: "codehelion-backend-rust",
182 analyses: &[Language::Rust],
183 enables: "semantic analysis of Rust",
184 advice: "install codehelion-backend-rust beside this binary or on PATH",
185};
186
187/// The helper that answers about C and C++.
188pub const CLANG_HELPER: HelperComponent = HelperComponent {
189 name: "clang-helper",
190 binary: "codehelion-backend-clang",
191 analyses: &[Language::C, Language::Cpp],
192 enables: "semantic analysis of C and C++",
193 advice: "install codehelion-backend-clang beside this binary or on PATH",
194};
195
196/// The helpers a run can use, in a fixed order.
197pub const OPTIONAL_HELPERS: [HelperComponent; 2] = [RUST_HELPER, CLANG_HELPER];
198
199fn inspect_self() -> ComponentReport {
200 ComponentReport {
201 name: "codehelion",
202 requirement: Requirement::Required,
203 status: ComponentStatus::Available,
204 detail: format!("codehelion {}", env!("CARGO_PKG_VERSION")),
205 notes: Vec::new(),
206 }
207}
208
209fn inspect_helper(helper: HelperComponent, found: Option<HelperFacts>) -> ComponentReport {
210 let (status, detail, notes) = match found {
211 // Says what is unaffected before what to do about it: the usual reason
212 // somebody reads this line is to find out whether it matters.
213 None => (
214 ComponentStatus::NotFound,
215 format!(
216 "not needed for fast or structural analysis; enables {}. To add it, {}.",
217 helper.enables, helper.advice
218 ),
219 Vec::new(),
220 ),
221 Some(facts) => {
222 let path = facts.path.display().to_string();
223 match facts.state {
224 HelperState::Answered(greeting) => {
225 (ComponentStatus::Available, path, describe(&greeting))
226 }
227 // The reason goes on its own line rather than beside the path,
228 // because it is the sentence somebody came here for.
229 HelperState::Silent(reason) => (
230 ComponentStatus::Unusable,
231 path,
232 vec![format!("this build could not talk to it: {reason}")],
233 ),
234 }
235 }
236 };
237 ComponentReport {
238 name: helper.name,
239 requirement: Requirement::Optional,
240 status,
241 detail,
242 notes,
243 }
244}
245
246/// What a helper said, as the lines a reader gets.
247///
248/// The toolchain line says whose compiler answered, which is the helper's own
249/// rather than the project's — a scan analysed by a different compiler than the
250/// one that builds the project is a fact worth reading off the diagnostic
251/// instead of discovering in a result.
252fn describe(greeting: &Greeting) -> Vec<String> {
253 let mut notes = vec![format!(
254 "version {}, protocol {}",
255 greeting.version, greeting.protocol
256 )];
257 if !greeting.toolchains.is_empty() {
258 notes.push(format!("analyses with: {}", greeting.toolchains.join(", ")));
259 }
260 // A helper that offers nothing is a helper that will answer every request
261 // with a refusal, so the empty case is stated rather than left off.
262 if greeting.capabilities.is_empty() {
263 notes.push("supplies: nothing this build asked about".to_string());
264 } else {
265 notes.push(format!("supplies: {}", greeting.capabilities.join(", ")));
266 }
267 // Stated either way, because "runs nothing" is the answer somebody
268 // deciding whether to permit something needs just as much as a list is.
269 if greeting.executes.is_empty() {
270 notes.push("runs nothing out of a project, whatever is permitted".to_string());
271 } else {
272 notes.push(format!(
273 "runs when permitted: {}",
274 greeting.executes.join(", ")
275 ));
276 }
277 notes
278}
279
280/// Diagnose the environment, asking `find` what each optional helper turned out
281/// to be.
282///
283/// `find` is given a program name and returns what was found out about it, if
284/// anything. It is a parameter rather than a call because looking for a program
285/// — and starting it — is the business of the layer that runs one, and this
286/// crate does not run anything.
287///
288/// The order is stable so that output is deterministic.
289#[must_use]
290pub fn diagnose_with(find: &dyn Fn(&str) -> Option<HelperFacts>) -> Vec<ComponentReport> {
291 let mut reports = vec![inspect_self()];
292 for helper in OPTIONAL_HELPERS {
293 reports.push(inspect_helper(helper, find(helper.binary)));
294 }
295 reports
296}
297
298/// Diagnose the environment without looking for any helper.
299///
300/// The report a machine with no helpers would get, which is also the report a
301/// caller that cannot look for them should give: claiming a helper is missing
302/// and claiming nobody looked are the same sentence here only because the
303/// outcome is the same either way — nothing semantic is available.
304#[must_use]
305pub fn diagnose() -> Vec<ComponentReport> {
306 diagnose_with(&|_| None)
307}
308
309/// The width of the widest label in a set of them.
310fn label_width<T: Copy>(values: &[T], label: impl Fn(T) -> &'static str) -> usize {
311 values
312 .iter()
313 .copied()
314 .map(|value| label(value).len())
315 .max()
316 .unwrap_or(0)
317}
318
319/// Render `reports` as an aligned plain-text table.
320///
321/// # Errors
322///
323/// Returns an error if writing to `out` fails.
324pub fn render(reports: &[ComponentReport], out: &mut impl Write) -> io::Result<()> {
325 writeln!(out, "codehelion environment diagnostics")?;
326 writeln!(out)?;
327 let name_width = reports.iter().map(|r| r.name.len()).max().unwrap_or(0);
328 // Both label columns are as wide as their widest label and no wider. Read
329 // from the labels rather than written down beside them: a column padded
330 // for a word this build no longer prints is a gap a reader spends time
331 // looking across for something that is not there.
332 let req_width = label_width(Requirement::all(), Requirement::label);
333 let status_width = label_width(ComponentStatus::all(), ComponentStatus::label);
334 for report in reports {
335 writeln!(
336 out,
337 " {name:<name_width$} {req:<req_width$} {status:<status_width$} {detail}",
338 name = report.name,
339 req = report.requirement.label(),
340 status = report.status.label(),
341 detail = report.detail,
342 )?;
343 for note in &report.notes {
344 writeln!(out, " {:<name_width$} {note}", "")?;
345 }
346 }
347 Ok(())
348}
349
350#[cfg(test)]
351#[allow(clippy::unwrap_used, clippy::expect_used)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn diagnose_reports_codehelion_first_and_available() {
357 let reports = diagnose();
358 let first = reports.first().expect("at least one report");
359 assert_eq!(first.name, "codehelion");
360 assert_eq!(first.requirement, Requirement::Required);
361 assert_eq!(first.status, ComponentStatus::Available);
362 assert!(first.detail.contains(env!("CARGO_PKG_VERSION")));
363 }
364
365 /// A machine with no helpers is not a machine with a problem: fast and
366 /// structural analysis need none, and a report that read as a failure
367 /// would send somebody to fix something that is not broken.
368 #[test]
369 fn a_machine_without_helpers_is_told_what_it_still_has() {
370 let reports = diagnose();
371 let helpers: Vec<_> = reports.iter().filter(|r| r.name != "codehelion").collect();
372 assert_eq!(helpers.len(), OPTIONAL_HELPERS.len());
373 for helper in helpers {
374 assert_eq!(helper.requirement, Requirement::Optional);
375 assert_eq!(helper.status, ComponentStatus::NotFound);
376 assert!(
377 helper.detail.contains("not needed for fast or structural"),
378 "{}",
379 helper.detail
380 );
381 }
382 }
383
384 /// And the advice has to name the program that would satisfy the lookup,
385 /// or it is advice for a different tool.
386 #[test]
387 fn the_advice_names_the_program_that_was_looked_for() {
388 for helper in OPTIONAL_HELPERS {
389 let report = inspect_helper(helper, None);
390 assert!(report.detail.contains(helper.binary), "{}", report.detail);
391 }
392 }
393
394 fn greeting() -> Greeting {
395 Greeting {
396 version: "0.1.0".to_string(),
397 protocol: 2,
398 toolchains: vec!["rust-analyzer 0.0.344".to_string()],
399 capabilities: vec!["types".to_string(), "name_resolution".to_string()],
400 executes: vec!["build-script".to_string()],
401 }
402 }
403
404 fn answered(name: &str) -> HelperFacts {
405 HelperFacts {
406 path: PathBuf::from("/opt/bin").join(name),
407 state: HelperState::Answered(greeting()),
408 }
409 }
410
411 #[test]
412 fn a_helper_that_is_there_is_reported_with_where_it_is() {
413 let reports =
414 diagnose_with(&|name| (name == OPTIONAL_HELPERS[0].binary).then(|| answered(name)));
415 let found = &reports[1];
416 assert_eq!(found.name, OPTIONAL_HELPERS[0].name);
417 assert_eq!(found.status, ComponentStatus::Available);
418 assert!(found.detail.contains("/opt/bin"), "{}", found.detail);
419 // And the one that was not found still says so, rather than inheriting
420 // the answer of the helper beside it.
421 assert_eq!(reports[2].status, ComponentStatus::NotFound);
422 }
423
424 /// The point of shaking hands rather than stopping at the path. Which
425 /// compiler will answer, and what it will answer about, decide whether a
426 /// semantic run is worth starting — and neither is knowable from a program
427 /// being on disk.
428 #[test]
429 fn a_helper_that_answered_says_what_it_is_and_what_it_supplies() {
430 let reports =
431 diagnose_with(&|name| (name == OPTIONAL_HELPERS[0].binary).then(|| answered(name)));
432 let notes = reports[1].notes.join("\n");
433 assert!(notes.contains("version 0.1.0"), "{notes}");
434 assert!(notes.contains("protocol 2"), "{notes}");
435 assert!(notes.contains("rust-analyzer 0.0.344"), "{notes}");
436 assert!(notes.contains("types, name_resolution"), "{notes}");
437 // And what permitting something would actually get, which is the fact
438 // a person needs before granting it rather than after.
439 assert!(
440 notes.contains("runs when permitted: build-script"),
441 "{notes}"
442 );
443 }
444
445 /// A helper offering nothing would refuse every request it is sent, which
446 /// is a different situation from one whose capabilities were not printed.
447 #[test]
448 fn a_helper_that_offers_nothing_says_so_rather_than_saying_less() {
449 let report = inspect_helper(
450 OPTIONAL_HELPERS[0],
451 Some(HelperFacts {
452 path: PathBuf::from("/opt/bin/helper"),
453 state: HelperState::Answered(Greeting {
454 capabilities: Vec::new(),
455 executes: Vec::new(),
456 ..greeting()
457 }),
458 }),
459 );
460 assert!(
461 report
462 .notes
463 .iter()
464 .any(|note| note.starts_with("supplies:")),
465 "{:?}",
466 report.notes
467 );
468 // The same for what it runs: "nothing, whatever you permit" is an
469 // answer, and leaving the line off reads as a question nobody asked.
470 assert!(
471 report
472 .notes
473 .iter()
474 .any(|note| note.contains("runs nothing")),
475 "{:?}",
476 report.notes
477 );
478 }
479
480 /// Installed and unusable is its own state. Calling it available sends
481 /// somebody to debug a scan that was never going to work; calling it
482 /// missing sends them to install what is already there.
483 #[test]
484 fn a_helper_that_would_not_answer_is_neither_available_nor_missing() {
485 let report = inspect_helper(
486 OPTIONAL_HELPERS[0],
487 Some(HelperFacts {
488 path: PathBuf::from("/opt/bin/helper"),
489 state: HelperState::Silent("speaks protocol 3, this build speaks 2".to_string()),
490 }),
491 );
492 assert_eq!(report.status, ComponentStatus::Unusable);
493 assert!(
494 report.detail.contains("/opt/bin/helper"),
495 "{}",
496 report.detail
497 );
498 assert!(
499 report.notes.iter().any(|note| note.contains("protocol 3")),
500 "{:?}",
501 report.notes
502 );
503 }
504
505 #[test]
506 fn what_a_helper_said_is_printed_under_it() {
507 let mut buffer = Vec::new();
508 let reports =
509 diagnose_with(&|name| (name == OPTIONAL_HELPERS[0].binary).then(|| answered(name)));
510 render(&reports, &mut buffer).expect("render should succeed");
511 let text = String::from_utf8(buffer).expect("output is utf-8");
512 let lines: Vec<&str> = text.lines().collect();
513 let at = lines
514 .iter()
515 .position(|line| line.contains(OPTIONAL_HELPERS[0].name))
516 .expect("the helper is listed");
517 assert!(lines[at + 1].contains("version 0.1.0"), "{text}");
518 }
519
520 #[test]
521 fn render_lists_every_component_and_the_version() {
522 let mut buffer = Vec::new();
523 render(&diagnose(), &mut buffer).expect("render should succeed");
524 let text = String::from_utf8(buffer).expect("output is utf-8");
525 assert!(text.contains("codehelion"));
526 assert!(text.contains(env!("CARGO_PKG_VERSION")));
527 assert!(text.contains("rust-compiler-helper"));
528 assert!(text.contains("not found"));
529 }
530}