Skip to main content

standout_dispatch/
diagnostic.rs

1//! The diagnostic document: the one shape a failure takes on stdout under a
2//! structured output mode.
3//!
4//! A handler returns a [`Diagnostic`] as its error when it has a `detail` or a
5//! source `range` to report; any other error type reaches the document with
6//! its `Display` text as `summary` and an empty `detail`. `kind` is the
7//! [`DiagnosticKind`] projected from the [`RunErrorKind`] the framework
8//! assigned when the error crossed the dispatch boundary, so a value a handler
9//! constructs carries a placeholder that the framework overwrites. The wire
10//! vocabulary is fixed: every `FinalWrite` payload is the one `final-write`,
11//! while hook phases stay distinct. `framework` is the one kind outside that
12//! projection: a `severity: warning` entry the framework raises on its own
13//! account under `ndjson`, which no run failure classifies.
14
15use serde::{Deserialize, Serialize};
16use std::fmt;
17
18use crate::contract::ContractSurface;
19use crate::handler::RunErrorKind;
20use crate::hooks::HookPhase;
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct Diagnostic {
24    #[serde(rename = "type", with = "document_type")]
25    document_type: (),
26    schema_version: u32,
27    pub severity: Severity,
28    pub kind: DiagnosticKind,
29    pub summary: String,
30    pub detail: String,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub range: Option<DiagnosticRange>,
33}
34
35impl ContractSurface for Diagnostic {
36    const SCHEMA_VERSION: u32 = 1;
37}
38
39impl Diagnostic {
40    pub fn error(summary: impl Into<String>) -> Self {
41        Self::new(Severity::Error, summary)
42    }
43
44    pub fn warning(summary: impl Into<String>) -> Self {
45        Self::new(Severity::Warning, summary)
46    }
47
48    fn new(severity: Severity, summary: impl Into<String>) -> Self {
49        Self {
50            document_type: (),
51            schema_version: Self::SCHEMA_VERSION,
52            severity,
53            kind: DiagnosticKind::Handler,
54            summary: summary.into(),
55            detail: String::new(),
56            range: None,
57        }
58    }
59
60    pub fn detail(mut self, detail: impl Into<String>) -> Self {
61        self.detail = detail.into();
62        self
63    }
64
65    pub fn range(mut self, filename: impl Into<String>, line: u64, column: u64) -> Self {
66        self.range = Some(DiagnosticRange {
67            filename: filename.into(),
68            start: DiagnosticPosition { line, column },
69        });
70        self
71    }
72
73    pub const fn schema_version(&self) -> u32 {
74        self.schema_version
75    }
76}
77
78impl fmt::Display for Diagnostic {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        if let Some(range) = &self.range {
81            write!(
82                f,
83                "{}:{}:{}: ",
84                range.filename, range.start.line, range.start.column
85            )?;
86        }
87        f.write_str(&self.summary)?;
88        if !self.detail.is_empty() {
89            write!(f, "\n{}", self.detail)?;
90        }
91        Ok(())
92    }
93}
94
95impl std::error::Error for Diagnostic {}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
98#[serde(rename_all = "lowercase")]
99pub enum Severity {
100    Error,
101    Warning,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
105#[serde(rename_all = "kebab-case")]
106pub enum DiagnosticKind {
107    ClapUsage,
108    DefaultCommand,
109    Handler,
110    HookPreDispatch,
111    HookPostDispatch,
112    HookPostOutput,
113    Render,
114    FinalWrite,
115    External,
116    App,
117    Config,
118    Framework,
119}
120
121impl From<RunErrorKind> for DiagnosticKind {
122    fn from(kind: RunErrorKind) -> Self {
123        match kind {
124            RunErrorKind::ClapUsage => Self::ClapUsage,
125            RunErrorKind::DefaultCommand => Self::DefaultCommand,
126            RunErrorKind::Handler => Self::Handler,
127            RunErrorKind::Hook(HookPhase::PreDispatch) => Self::HookPreDispatch,
128            RunErrorKind::Hook(HookPhase::PostDispatch) => Self::HookPostDispatch,
129            RunErrorKind::Hook(HookPhase::PostOutput) => Self::HookPostOutput,
130            RunErrorKind::Render => Self::Render,
131            RunErrorKind::FinalWrite(_) => Self::FinalWrite,
132            RunErrorKind::External => Self::External,
133            RunErrorKind::App => Self::App,
134            RunErrorKind::Config => Self::Config,
135        }
136    }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct DiagnosticRange {
141    pub filename: String,
142    pub start: DiagnosticPosition,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146pub struct DiagnosticPosition {
147    pub line: u64,
148    pub column: u64,
149}
150
151mod document_type {
152    use serde::de::Error;
153    use serde::{Deserialize, Deserializer, Serializer};
154
155    const TAG: &str = "diagnostic";
156
157    pub fn serialize<S: Serializer>(_: &(), serializer: S) -> Result<S::Ok, S::Error> {
158        serializer.serialize_str(TAG)
159    }
160
161    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<(), D::Error> {
162        let tag = String::deserialize(deserializer)?;
163        if tag == TAG {
164            Ok(())
165        } else {
166            Err(D::Error::custom(format!(
167                "expected a \"{TAG}\" document, found type {tag:?}"
168            )))
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::handler::OutputKind;
177
178    #[test]
179    fn a_ranged_diagnostic_serializes_flat_with_the_fixed_type_tag() {
180        let diagnostic = Diagnostic::error("config line 2 does not parse")
181            .detail("expected `resource <name> <state>`")
182            .range("main.tfl", 2, 1);
183        let json = serde_json::to_value(&diagnostic).unwrap();
184        assert_eq!(
185            json,
186            serde_json::json!({
187                "type": "diagnostic",
188                "schema_version": 1,
189                "severity": "error",
190                "kind": "handler",
191                "summary": "config line 2 does not parse",
192                "detail": "expected `resource <name> <state>`",
193                "range": { "filename": "main.tfl", "start": { "line": 2, "column": 1 } },
194            })
195        );
196        let back: Diagnostic = serde_json::from_value(json).unwrap();
197        assert_eq!(back, diagnostic);
198    }
199
200    #[test]
201    fn an_unranged_diagnostic_omits_the_range_key() {
202        let mut diagnostic = Diagnostic::warning("soft");
203        diagnostic.kind = DiagnosticKind::HookPostOutput;
204        let json = serde_json::to_string(&diagnostic).unwrap();
205        assert_eq!(
206            json,
207            r#"{"type":"diagnostic","schema_version":1,"severity":"warning","kind":"hook-post-output","summary":"soft","detail":""}"#
208        );
209        assert_eq!(
210            serde_json::from_str::<Diagnostic>(&json).unwrap(),
211            diagnostic
212        );
213    }
214
215    #[test]
216    fn every_run_error_kind_projects_onto_the_fixed_wire_vocabulary() {
217        let expected = [
218            (RunErrorKind::ClapUsage, "clap-usage"),
219            (RunErrorKind::DefaultCommand, "default-command"),
220            (RunErrorKind::Handler, "handler"),
221            (
222                RunErrorKind::Hook(HookPhase::PreDispatch),
223                "hook-pre-dispatch",
224            ),
225            (
226                RunErrorKind::Hook(HookPhase::PostDispatch),
227                "hook-post-dispatch",
228            ),
229            (
230                RunErrorKind::Hook(HookPhase::PostOutput),
231                "hook-post-output",
232            ),
233            (RunErrorKind::Render, "render"),
234            (RunErrorKind::FinalWrite(OutputKind::Text), "final-write"),
235            (RunErrorKind::FinalWrite(OutputKind::Binary), "final-write"),
236            (
237                RunErrorKind::FinalWrite(OutputKind::Artifact),
238                "final-write",
239            ),
240            (RunErrorKind::External, "external"),
241            (RunErrorKind::App, "app"),
242            (RunErrorKind::Config, "config"),
243        ];
244        for (kind, name) in expected {
245            let wire = DiagnosticKind::from(kind);
246            assert_eq!(serde_json::to_value(wire).unwrap(), name, "{kind:?}");
247            assert_eq!(
248                serde_json::from_value::<DiagnosticKind>(name.into()).unwrap(),
249                wire
250            );
251        }
252        assert!(serde_json::from_value::<DiagnosticKind>("final-write-text".into()).is_err());
253    }
254
255    #[test]
256    fn a_document_of_another_type_is_refused() {
257        let error = serde_json::from_str::<Diagnostic>(
258            r#"{"type":"result","schema_version":1,"severity":"error","kind":"handler","summary":"","detail":""}"#,
259        )
260        .unwrap_err();
261        assert!(error.to_string().contains("\"diagnostic\""), "{error}");
262    }
263
264    #[test]
265    fn display_is_the_human_prose_form() {
266        assert_eq!(Diagnostic::error("boom").to_string(), "boom");
267        assert_eq!(
268            Diagnostic::error("boom")
269                .detail("why")
270                .range("a.cfg", 3, 7)
271                .to_string(),
272            "a.cfg:3:7: boom\nwhy"
273        );
274    }
275}