1use std::fmt;
7
8use serde::Serialize;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
12#[serde(rename_all = "lowercase")]
13pub enum Severity {
14 Error,
16 Warning,
18 Info,
20}
21
22#[derive(Debug, Clone, Serialize)]
24pub struct Diagnostic {
25 pub severity: Severity,
27 pub code: &'static str,
29 pub message: String,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub field: Option<&'static str>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub suggestion: Option<String>,
37}
38
39impl Diagnostic {
40 #[must_use]
42 pub fn new(severity: Severity, code: &'static str, message: impl Into<String>) -> Self {
43 Self {
44 severity,
45 code,
46 message: message.into(),
47 field: None,
48 suggestion: None,
49 }
50 }
51
52 #[must_use]
54 pub fn with_field(mut self, field: &'static str) -> Self {
55 self.field = Some(field);
56 self
57 }
58
59 #[must_use]
61 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
62 self.suggestion = Some(suggestion.into());
63 self
64 }
65
66 #[must_use]
68 pub fn is_error(&self) -> bool {
69 self.severity == Severity::Error
70 }
71
72 #[must_use]
74 pub fn is_warning(&self) -> bool {
75 self.severity == Severity::Warning
76 }
77
78 #[must_use]
80 pub fn is_info(&self) -> bool {
81 self.severity == Severity::Info
82 }
83}
84
85impl fmt::Display for Diagnostic {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 match self.severity {
92 Severity::Error => write!(f, "{}", self.message),
93 Severity::Warning => write!(f, "warning: {}", self.message),
94 Severity::Info => write!(f, "info: {}", self.message),
95 }
96 }
97}
98
99pub const E000: &str = "E000";
105
106pub const E001: &str = "E001";
110pub const E002: &str = "E002";
112pub const E003: &str = "E003";
114pub const E004: &str = "E004";
116pub const E005: &str = "E005";
118pub const E006: &str = "E006";
120pub const E007: &str = "E007";
122pub const E008: &str = "E008";
124pub const E009: &str = "E009";
126
127pub const E010: &str = "E010";
131pub const E011: &str = "E011";
133pub const E012: &str = "E012";
135
136pub const E013: &str = "E013";
140
141pub const E014: &str = "E014";
145pub const E015: &str = "E015";
147pub const E016: &str = "E016";
149
150pub const E017: &str = "E017";
154pub const E018: &str = "E018";
156
157pub const W001: &str = "W001";
161pub const W002: &str = "W002";
163
164pub const S001: &str = "S001";
168pub const S002: &str = "S002";
170pub const S003: &str = "S003";
172pub const S004: &str = "S004";
174pub const S005: &str = "S005";
176pub const S006: &str = "S006";
178
179pub const C001: &str = "C001";
183pub const C002: &str = "C002";
185pub const C003: &str = "C003";
187
188pub const P001: &str = "P001";
192pub const P002: &str = "P002";
194pub const P003: &str = "P003";
196pub const P004: &str = "P004";
198pub const P005: &str = "P005";
200pub const P006: &str = "P006";
202pub const P007: &str = "P007";
204pub const P008: &str = "P008";
206pub const P009: &str = "P009";
208pub const P010: &str = "P010";
210
211pub const H001: &str = "H001";
215pub const H002: &str = "H002";
217pub const H003: &str = "H003";
219pub const H004: &str = "H004";
221pub const H005: &str = "H005";
223pub const H006: &str = "H006";
225pub const H007: &str = "H007";
227pub const H008: &str = "H008";
229pub const H009: &str = "H009";
231pub const H010: &str = "H010";
233pub const H011: &str = "H011";
235
236pub const A001: &str = "A001";
240pub const A002: &str = "A002";
242pub const A003: &str = "A003";
244pub const A004: &str = "A004";
246pub const A005: &str = "A005";
248pub const A006: &str = "A006";
250pub const A007: &str = "A007";
252pub const A008: &str = "A008";
254pub const A009: &str = "A009";
256pub const A010: &str = "A010";
258
259pub const K001: &str = "K001";
263pub const K002: &str = "K002";
265pub const K003: &str = "K003";
267pub const K004: &str = "K004";
269pub const K005: &str = "K005";
271pub const K006: &str = "K006";
273pub const K007: &str = "K007";
275
276pub const X001: &str = "X001";
280pub const X002: &str = "X002";
282pub const X003: &str = "X003";
284pub const X004: &str = "X004";
286pub const X005: &str = "X005";
288pub const X006: &str = "X006";
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
293pub enum ValidationTarget {
294 #[default]
296 Standard,
297 ClaudeCode,
299 Permissive,
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn error_display_no_prefix() {
309 let d = Diagnostic::new(Severity::Error, E001, "name must not be empty");
310 assert_eq!(d.to_string(), "name must not be empty");
311 }
312
313 #[test]
314 fn warning_display_with_prefix() {
315 let d = Diagnostic::new(Severity::Warning, W001, "unexpected metadata field: 'foo'");
316 assert_eq!(d.to_string(), "warning: unexpected metadata field: 'foo'");
317 }
318
319 #[test]
320 fn info_display_with_prefix() {
321 let d = Diagnostic::new(Severity::Info, "I001", "description uses first person");
322 assert_eq!(d.to_string(), "info: description uses first person");
323 }
324
325 #[test]
326 fn is_error_true_for_errors() {
327 let d = Diagnostic::new(Severity::Error, E001, "test");
328 assert!(d.is_error());
329 assert!(!d.is_warning());
330 assert!(!d.is_info());
331 }
332
333 #[test]
334 fn is_warning_true_for_warnings() {
335 let d = Diagnostic::new(Severity::Warning, W001, "test");
336 assert!(!d.is_error());
337 assert!(d.is_warning());
338 assert!(!d.is_info());
339 }
340
341 #[test]
342 fn is_info_true_for_info() {
343 let d = Diagnostic::new(Severity::Info, "I001", "test");
344 assert!(!d.is_error());
345 assert!(!d.is_warning());
346 assert!(d.is_info());
347 }
348
349 #[test]
350 fn with_field_sets_field() {
351 let d = Diagnostic::new(Severity::Error, E001, "test").with_field("name");
352 assert_eq!(d.field, Some("name"));
353 }
354
355 #[test]
356 fn with_suggestion_sets_suggestion() {
357 let d = Diagnostic::new(Severity::Error, E003, "invalid character")
358 .with_suggestion("Use lowercase letters only");
359 assert_eq!(d.suggestion.as_deref(), Some("Use lowercase letters only"));
360 }
361
362 #[test]
363 fn new_has_no_field_or_suggestion() {
364 let d = Diagnostic::new(Severity::Error, E001, "test");
365 assert!(d.field.is_none());
366 assert!(d.suggestion.is_none());
367 }
368
369 #[test]
370 fn builder_pattern_chains() {
371 let d = Diagnostic::new(Severity::Error, E003, "invalid character: 'X'")
372 .with_field("name")
373 .with_suggestion("Use lowercase: 'x'");
374 assert_eq!(d.code, E003);
375 assert_eq!(d.field, Some("name"));
376 assert!(d.suggestion.is_some());
377 }
378
379 #[test]
380 fn serialize_json_error() {
381 let d = Diagnostic::new(Severity::Error, E001, "name must not be empty").with_field("name");
382 let json = serde_json::to_value(&d).unwrap();
383 assert_eq!(json["severity"], "error");
384 assert_eq!(json["code"], "E001");
385 assert_eq!(json["message"], "name must not be empty");
386 assert_eq!(json["field"], "name");
387 assert!(json.get("suggestion").is_none());
388 }
389
390 #[test]
391 fn serialize_json_warning_with_suggestion() {
392 let d = Diagnostic::new(Severity::Warning, W001, "unexpected field: 'foo'")
393 .with_field("metadata")
394 .with_suggestion("Remove the field");
395 let json = serde_json::to_value(&d).unwrap();
396 assert_eq!(json["severity"], "warning");
397 assert_eq!(json["suggestion"], "Remove the field");
398 }
399
400 #[test]
401 fn serialize_json_omits_none_fields() {
402 let d = Diagnostic::new(Severity::Error, E001, "test");
403 let json = serde_json::to_value(&d).unwrap();
404 assert!(json.get("field").is_none());
405 assert!(json.get("suggestion").is_none());
406 }
407
408 #[test]
409 fn error_codes_are_unique() {
410 let codes = [
411 E000, E001, E002, E003, E004, E005, E006, E007, E008, E009, E010, E011, E012, E013,
412 E014, E015, E016, E017, E018, W001, W002, S001, S002, S003, S004, S005, S006, C001,
413 C002, C003, P001, P002, P003, P004, P005, P006, P007, P008, P009, P010, H001, H002,
414 H003, H004, H005, H006, H007, H008, H009, H010, H011, A001, A002, A003, A004, A005,
415 A006, A007, A008, A009, A010, K001, K002, K003, K004, K005, K006, K007, X001, X002,
416 X003, X004, X005, X006,
417 ];
418 let mut seen = std::collections::HashSet::new();
419 for code in &codes {
420 assert!(seen.insert(code), "duplicate error code: {code}");
421 }
422 }
423
424 #[test]
425 fn validation_target_default_is_standard() {
426 let target = ValidationTarget::default();
427 assert_eq!(target, ValidationTarget::Standard);
428 }
429}