Skip to main content

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}