Skip to main content

bynk/
report.rs

1//! Render a [`Report`] in one of three shapes.
2//!
3//! The default human table is for a person at a terminal; `--format short` (one
4//! `capability: level (remedy)` line) and `--format json` are the **pinned
5//! scriptable surface** (golden-tested), siblings to `bynkc check --format
6//! short` (ADR 0071).
7
8use crate::doctor::{Capability, CapabilityReport, Level, Report};
9
10/// Output selector.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum Format {
13    #[default]
14    Human,
15    Short,
16    Json,
17}
18
19/// The displayed status word for a capability. Optional capabilities show
20/// `note` instead of `fail` when their tool is merely absent — a missing editor
21/// is a note, not a failure.
22fn level_word(cap: &CapabilityReport) -> &'static str {
23    match cap.level {
24        Level::Ok => "ok",
25        Level::Warn => "warn",
26        Level::Fail if cap.optional => "note",
27        Level::Fail => "fail",
28    }
29}
30
31pub fn render(report: &Report, format: Format) -> String {
32    match format {
33        Format::Human => human(report),
34        Format::Short => short(report),
35        Format::Json => json(report),
36    }
37}
38
39fn short(report: &Report) -> String {
40    let mut out = String::new();
41    for cap in &report.capabilities {
42        let word = level_word(cap);
43        // The first non-ok row's remedy is the actionable hint for the line.
44        let remedy = cap
45            .rows
46            .iter()
47            .find(|r| r.level != Level::Ok)
48            .and_then(|r| r.remedy.as_deref());
49        match remedy {
50            Some(r) => out.push_str(&format!("{}: {word} ({r})\n", cap.capability.token())),
51            None => out.push_str(&format!("{}: {word}\n", cap.capability.token())),
52        }
53    }
54    out
55}
56
57fn human(report: &Report) -> String {
58    let mut out = String::new();
59    let header = if report.is_all_ok() {
60        "bynk doctor — your environment is ready".to_string()
61    } else {
62        "bynk doctor — environment report".to_string()
63    };
64    out.push_str(&header);
65    out.push('\n');
66    out.push_str(&format!("driver: bynk {}\n", report.driver_version));
67    // Slice 7: the compiler is linked in-process. Only a `BYNK_BYNKC` override
68    // points the driver at an external binary worth naming here.
69    match (report.compiler.origin, report.compiler.path.as_deref()) {
70        (Some(crate::compiler::Origin::Override), Some(path)) => {
71            out.push_str(&format!(
72                "compiler: bynkc at {} (override)\n",
73                path.display()
74            ));
75        }
76        _ => out.push_str("compiler: in-process\n"),
77    }
78    out.push('\n');
79
80    for cap in &report.capabilities {
81        let mark = match cap.level {
82            Level::Ok => "✓",
83            Level::Warn => "!",
84            Level::Fail if cap.optional => "·",
85            Level::Fail => "✗",
86        };
87        out.push_str(&format!(
88            "{mark} {} [{}]{}\n",
89            cap.capability.token(),
90            level_word(cap),
91            if cap.optional { " (optional)" } else { "" }
92        ));
93        for row in &cap.rows {
94            out.push_str(&format!("    {} — {}\n", row.label, row.detail));
95            if let Some(remedy) = &row.remedy {
96                out.push_str(&format!("      ↳ fix: {remedy}\n"));
97            }
98        }
99    }
100    out
101}
102
103// The JSON surface is built from `#[derive(Serialize)]` structs (not the
104// `json!` macro): serde emits struct fields in declaration order regardless of
105// serde_json's `preserve_order` feature, which other workspace crates enable —
106// a map-based value would otherwise reorder under workspace feature unification
107// and break the golden. Field order here *is* the pinned contract.
108#[derive(serde::Serialize)]
109struct JsonReport<'a> {
110    driver: &'a str,
111    compiler: JsonCompiler,
112    all_ok: bool,
113    capabilities: Vec<JsonCap<'a>>,
114}
115
116#[derive(serde::Serialize)]
117struct JsonCompiler {
118    resolved: bool,
119    path: Option<String>,
120    version: Option<String>,
121    origin: Option<&'static str>,
122    skew: Option<&'static str>,
123}
124
125#[derive(serde::Serialize)]
126struct JsonCap<'a> {
127    capability: &'static str,
128    optional: bool,
129    level: &'static str,
130    rows: Vec<JsonRow<'a>>,
131}
132
133#[derive(serde::Serialize)]
134struct JsonRow<'a> {
135    label: &'a str,
136    level: &'static str,
137    detail: &'a str,
138    remedy: Option<&'a str>,
139}
140
141fn json(report: &Report) -> String {
142    let compiler = &report.compiler;
143    let value = JsonReport {
144        driver: &report.driver_version,
145        compiler: JsonCompiler {
146            resolved: compiler.is_resolved(),
147            path: compiler.path.as_ref().map(|p| p.display().to_string()),
148            version: compiler.version.map(|v| v.to_string()),
149            origin: compiler.origin.map(|o| o.token()),
150            skew: compiler.skew.map(|s| s.token()),
151        },
152        all_ok: report.is_all_ok(),
153        capabilities: report
154            .capabilities
155            .iter()
156            .map(|cap| JsonCap {
157                capability: cap.capability.token(),
158                optional: cap.optional,
159                level: level_word(cap),
160                rows: cap
161                    .rows
162                    .iter()
163                    .map(|r| JsonRow {
164                        label: &r.label,
165                        level: level_token(r.level),
166                        detail: &r.detail,
167                        remedy: r.remedy.as_deref(),
168                    })
169                    .collect(),
170            })
171            .collect(),
172    };
173    // Pretty-printed and trailing-newline-terminated for a stable golden.
174    let mut s = serde_json::to_string_pretty(&value).expect("Report serialises");
175    s.push('\n');
176    s
177}
178
179fn level_token(level: Level) -> &'static str {
180    match level {
181        Level::Ok => "ok",
182        Level::Warn => "warn",
183        Level::Fail => "fail",
184    }
185}
186
187/// Parse a `--only <capability>` token (shared with the CLI layer).
188pub fn parse_capability(token: &str) -> Option<Capability> {
189    match token {
190        "compile" => Some(Capability::Compile),
191        "test" => Some(Capability::Test),
192        "deploy" => Some(Capability::Deploy),
193        "editor" => Some(Capability::Editor),
194        "build" => Some(Capability::BuildFromSource),
195        _ => None,
196    }
197}