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