Skip to main content

canonic/
doctor.rs

1//! Health check for external and in-process tooling.
2
3use crate::convert;
4use crate::lint::{binary_available, harper_binary_name, harper_core_available};
5use serde::Serialize;
6
7/// One line of doctor status for a tool.
8#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
9pub struct ToolStatus {
10    pub name: String,
11    pub present: bool,
12    pub detail: String,
13}
14
15impl ToolStatus {
16    pub fn line(&self) -> String {
17        let mark = if self.present { "ok" } else { "MISSING" };
18        format!("{}: {} — {}", self.name, mark, self.detail)
19    }
20}
21
22/// Probe pandoc, vale, Harper CLI, in-process harper-core, and optional free Jira.
23pub fn collect_statuses() -> Vec<ToolStatus> {
24    let mut out = Vec::new();
25
26    let pandoc = convert::tool_available();
27    out.push(ToolStatus {
28        name: "pandoc".into(),
29        present: pandoc,
30        detail: if pandoc {
31            convert::pandoc_version_line().unwrap_or_else(|| "present on PATH".into())
32        } else {
33            "not installed or not on PATH (required for `canonic convert`)".into()
34        },
35    });
36
37    let vale = binary_available("vale");
38    out.push(ToolStatus {
39        name: "vale".into(),
40        present: vale,
41        detail: if vale {
42            "present on PATH (used by `canonic lint --engine vale|all`)".into()
43        } else {
44            "not installed or not on PATH (optional; style lint skipped when missing)".into()
45        },
46    });
47
48    match harper_binary_name() {
49        Some(bin) => out.push(ToolStatus {
50            name: "harper-cli".into(),
51            present: true,
52            detail: format!("{bin} present on PATH (optional CLI fallback)"),
53        }),
54        None => out.push(ToolStatus {
55            name: "harper-cli".into(),
56            present: false,
57            detail: "no harper-cli/harper/harperls on PATH (optional; in-process harper-core still used)"
58                .into(),
59        }),
60    }
61
62    let hc = harper_core_available();
63    out.push(ToolStatus {
64        name: "harper-core".into(),
65        present: hc,
66        detail: if hc {
67            format!(
68                "in-process grammar engine linked (v{})",
69                harper_core::core_version()
70            )
71        } else {
72            "in-process harper-core not linked in this build".into()
73        },
74    });
75
76    // Optional free-tier Jira: only when env is configured (never fails doctor critical).
77    out.push(jira_env_status());
78
79    out
80}
81
82/// Report free Jira env + quick probe when `JIRA_BASE_URL` is set.
83fn jira_env_status() -> ToolStatus {
84    if std::env::var_os("JIRA_BASE_URL").is_none() {
85        return ToolStatus {
86            name: "jira".into(),
87            present: false,
88            detail: "JIRA_BASE_URL unset (optional; set for jira-probe / import-jira / jira-comment)"
89                .into(),
90        };
91    }
92    match crate::jira_import::JiraConfig::from_env() {
93        Ok(cfg) => match crate::jira_import::probe_jira(&cfg) {
94            Ok(probe) => {
95                let fmt = match probe.comment_format {
96                    crate::jira_import::CommentBodyFormat::Adf => "ADF/v3",
97                    crate::jira_import::CommentBodyFormat::Wiki => "wiki/v2",
98                    crate::jira_import::CommentBodyFormat::Auto => "auto",
99                };
100                ToolStatus {
101                    name: "jira".into(),
102                    present: true,
103                    detail: format!(
104                        "ok free platform REST — {} ({fmt}; no Marketplace apps)",
105                        probe.display_name
106                    ),
107                }
108            }
109            Err(e) => ToolStatus {
110                name: "jira".into(),
111                present: false,
112                detail: format!("JIRA_BASE_URL set but probe failed: {e:#}"),
113            },
114        },
115        Err(e) => ToolStatus {
116            name: "jira".into(),
117            present: false,
118            detail: format!("Jira env incomplete: {e:#}"),
119        },
120    }
121}
122
123/// Human-readable multi-line doctor report (never empty of named tools).
124pub fn format_doctor(statuses: &[ToolStatus]) -> String {
125    let mut s = String::from("canonic doctor\n");
126    for st in statuses {
127        s.push_str(&st.line());
128        s.push('\n');
129    }
130    s
131}
132
133/// Critical tools for the convert workflow: currently pandoc.
134pub fn critical_missing(statuses: &[ToolStatus]) -> Vec<&ToolStatus> {
135    statuses
136        .iter()
137        .filter(|s| s.name == "pandoc" && !s.present)
138        .collect()
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn doctor_names_pandoc_vale_and_harper() {
147        // Isolate optional Jira probe from ambient builder env.
148        let prev = std::env::var_os("JIRA_BASE_URL");
149        std::env::remove_var("JIRA_BASE_URL");
150        let statuses = collect_statuses();
151        if let Some(v) = prev {
152            std::env::set_var("JIRA_BASE_URL", v);
153        }
154        let names: Vec<_> = statuses.iter().map(|s| s.name.as_str()).collect();
155        assert!(names.contains(&"pandoc"), "{names:?}");
156        assert!(names.contains(&"vale"), "{names:?}");
157        assert!(names.contains(&"jira"), "{names:?}");
158        assert!(
159            names.iter().any(|n| n.contains("harper")),
160            "expected harper status, got {names:?}"
161        );
162        let text = format_doctor(&statuses);
163        assert!(text.contains("pandoc:"));
164        assert!(text.contains("vale:"));
165        assert!(text.contains("harper"));
166        // Explicit ok or MISSING, never silent blank report
167        assert!(
168            text.contains("ok") || text.contains("MISSING"),
169            "expected explicit status markers: {text}"
170        );
171    }
172
173    #[test]
174    fn format_doctor_marks_missing_tools() {
175        let statuses = vec![
176            ToolStatus {
177                name: "pandoc".into(),
178                present: false,
179                detail: "not on PATH".into(),
180            },
181            ToolStatus {
182                name: "vale".into(),
183                present: true,
184                detail: "present".into(),
185            },
186        ];
187        let text = format_doctor(&statuses);
188        assert!(text.contains("pandoc: MISSING"));
189        assert!(text.contains("vale: ok"));
190        assert_eq!(critical_missing(&statuses).len(), 1);
191    }
192}