1use 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}