Skip to main content

aigent/
diagnostics.rs

1//! Structured diagnostics for validation, linting, and error reporting.
2//!
3//! Replaces the ad-hoc `Vec<String>` pattern with typed diagnostics carrying
4//! stable error codes, severity levels, and optional fix suggestions.
5
6use std::fmt;
7
8use serde::Serialize;
9
10/// Severity of a diagnostic message.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
12#[serde(rename_all = "lowercase")]
13pub enum Severity {
14    /// A rule violation that causes validation failure.
15    Error,
16    /// A potential issue that does not cause failure.
17    Warning,
18    /// An informational suggestion for improvement.
19    Info,
20}
21
22/// A structured diagnostic message from validation or linting.
23#[derive(Debug, Clone, Serialize)]
24pub struct Diagnostic {
25    /// Severity level.
26    pub severity: Severity,
27    /// Stable error code (e.g., `"E001"`, `"W001"`, `"I001"`).
28    pub code: &'static str,
29    /// Human-readable message.
30    pub message: String,
31    /// Field that caused the diagnostic (e.g., `"name"`, `"description"`).
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub field: Option<&'static str>,
34    /// Suggested fix (actionable text).
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub suggestion: Option<String>,
37}
38
39impl Diagnostic {
40    /// Create a new diagnostic with the given severity, code, and message.
41    #[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    /// Set the field that caused this diagnostic.
53    #[must_use]
54    pub fn with_field(mut self, field: &'static str) -> Self {
55        self.field = Some(field);
56        self
57    }
58
59    /// Set a suggested fix for this diagnostic.
60    #[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    /// Returns `true` if this diagnostic is an error.
67    #[must_use]
68    pub fn is_error(&self) -> bool {
69        self.severity == Severity::Error
70    }
71
72    /// Returns `true` if this diagnostic is a warning.
73    #[must_use]
74    pub fn is_warning(&self) -> bool {
75        self.severity == Severity::Warning
76    }
77
78    /// Returns `true` if this diagnostic is informational.
79    #[must_use]
80    pub fn is_info(&self) -> bool {
81        self.severity == Severity::Info
82    }
83}
84
85/// Display format preserves backward compatibility:
86/// - Errors: `"message"` (no prefix)
87/// - Warnings: `"warning: message"`
88/// - Info: `"info: message"`
89impl 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
99// ── Error code constants ────────────────────────────────────────────────
100
101// Infrastructure errors (E000)
102
103/// Infrastructure error (file not found, IO error, parse failure).
104pub const E000: &str = "E000";
105
106// Name validation errors (E001–E009)
107
108/// Name must not be empty.
109pub const E001: &str = "E001";
110/// Name exceeds 64 characters.
111pub const E002: &str = "E002";
112/// Name contains invalid character.
113pub const E003: &str = "E003";
114/// Name starts with hyphen.
115pub const E004: &str = "E004";
116/// Name ends with hyphen.
117pub const E005: &str = "E005";
118/// Name contains consecutive hyphens.
119pub const E006: &str = "E006";
120/// Name contains reserved word.
121pub const E007: &str = "E007";
122/// Name contains XML/HTML tags (reserved; currently caught by E003 character validation).
123pub const E008: &str = "E008";
124/// Name does not match directory name.
125pub const E009: &str = "E009";
126
127// Description validation errors (E010–E012)
128
129/// Description must not be empty.
130pub const E010: &str = "E010";
131/// Description exceeds 1024 characters.
132pub const E011: &str = "E011";
133/// Description contains XML/HTML tags.
134pub const E012: &str = "E012";
135
136// Compatibility validation errors (E013)
137
138/// Compatibility exceeds 500 characters.
139pub const E013: &str = "E013";
140
141// Field type errors (E014–E016)
142
143/// `name` field is not a string.
144pub const E014: &str = "E014";
145/// `description` field is not a string.
146pub const E015: &str = "E015";
147/// `compatibility` field is not a string.
148pub const E016: &str = "E016";
149
150// Missing field errors (E017–E018)
151
152/// Missing required field `name`.
153pub const E017: &str = "E017";
154/// Missing required field `description`.
155pub const E018: &str = "E018";
156
157// Warning codes (W001–W002)
158
159/// Unexpected metadata field.
160pub const W001: &str = "W001";
161/// Body exceeds 500 lines.
162pub const W002: &str = "W002";
163
164// Structure validation codes (S001–S006)
165
166/// Referenced file does not exist.
167pub const S001: &str = "S001";
168/// Script missing execute permission (Unix only).
169pub const S002: &str = "S002";
170/// Reference depth exceeds 1 level.
171pub const S003: &str = "S003";
172/// Excessive directory nesting depth.
173pub const S004: &str = "S004";
174/// Symlink detected in skill directory.
175pub const S005: &str = "S005";
176/// Path traversal in reference link.
177pub const S006: &str = "S006";
178
179// Conflict detection codes (C001–C003)
180
181/// Name collision across skill directories.
182pub const C001: &str = "C001";
183/// Description overlap between skills.
184pub const C002: &str = "C002";
185/// Total token budget exceeded.
186pub const C003: &str = "C003";
187
188// ── Plugin manifest codes (P001–P010) ──────────────────────────────────
189
190/// JSON syntax error in plugin.json.
191pub const P001: &str = "P001";
192/// `name` field missing in plugin.json.
193pub const P002: &str = "P002";
194/// `name` not kebab-case or contains spaces.
195pub const P003: &str = "P003";
196/// `version` not semver format (x.y.z).
197pub const P004: &str = "P004";
198/// `description` empty or missing.
199pub const P005: &str = "P005";
200/// Custom path uses an absolute filesystem path (only relative paths are allowed).
201pub const P006: &str = "P006";
202/// Declared component path does not exist on filesystem.
203pub const P007: &str = "P007";
204/// Hardcoded credential/token detected in string values.
205pub const P008: &str = "P008";
206/// MCP server URL uses HTTP/WS instead of HTTPS/WSS.
207pub const P009: &str = "P009";
208/// Missing recommended field (author, homepage, license).
209pub const P010: &str = "P010";
210
211// ── Hook validation codes (H001–H011) ──────────────────────────────────
212
213/// Invalid JSON syntax in hooks file.
214pub const H001: &str = "H001";
215/// Invalid hooks structure (not an object of event arrays).
216pub const H002: &str = "H002";
217/// Unknown event name.
218pub const H003: &str = "H003";
219/// Hook entry missing `hooks` array.
220pub const H004: &str = "H004";
221/// Hook missing `type` field.
222pub const H005: &str = "H005";
223/// Unknown hook type (not `command` or `prompt`).
224pub const H006: &str = "H006";
225/// Command hook missing `command` field.
226pub const H007: &str = "H007";
227/// Prompt hook missing `prompt` field.
228pub const H008: &str = "H008";
229/// Timeout outside recommended range (5–600 seconds).
230pub const H009: &str = "H009";
231/// Hardcoded absolute path in hook command.
232pub const H010: &str = "H010";
233/// Prompt hook on suboptimal event.
234pub const H011: &str = "H011";
235
236// ── Agent file validation codes (A001–A010) ────────────────────────────
237
238/// Agent frontmatter missing (no `---` delimiters).
239pub const A001: &str = "A001";
240/// Required agent field missing (name, description, model, color).
241pub const A002: &str = "A002";
242/// Agent name not kebab-case.
243pub const A003: &str = "A003";
244/// Agent name is generic.
245pub const A004: &str = "A004";
246/// Agent name length outside 3–50 chars.
247pub const A005: &str = "A005";
248/// Agent description length outside 10–5000 chars.
249pub const A006: &str = "A006";
250/// Agent model not one of: inherit, sonnet, opus, haiku.
251pub const A007: &str = "A007";
252/// Agent color not one of: blue, cyan, green, yellow, magenta, red.
253pub const A008: &str = "A008";
254/// Agent system prompt (body) missing or too short (<20 chars).
255pub const A009: &str = "A009";
256/// Agent system prompt too long (>10k chars).
257pub const A010: &str = "A010";
258
259// ── Command file validation codes (K001–K007) ──────────────────────────
260
261/// Command frontmatter syntax error (if `---` present but invalid YAML).
262pub const K001: &str = "K001";
263/// Command description exceeds 60 chars.
264pub const K002: &str = "K002";
265/// Command model not one of: sonnet, opus, haiku.
266pub const K003: &str = "K003";
267/// Command description does not start with a verb.
268pub const K004: &str = "K004";
269/// Command body is empty.
270pub const K005: &str = "K005";
271/// Command allowed-tools invalid format.
272pub const K006: &str = "K006";
273/// Missing command description (recommended for discoverability).
274pub const K007: &str = "K007";
275
276// ── Cross-component consistency codes (X001–X006) ──────────────────────
277
278/// Component directory is empty (no valid files found).
279pub const X001: &str = "X001";
280/// Command hook references script that doesn't exist.
281pub const X002: &str = "X002";
282/// Orphaned file in component directory (not referenced).
283pub const X003: &str = "X003";
284/// Naming inconsistency across components.
285pub const X004: &str = "X004";
286/// Total token budget across all skills exceeds threshold.
287pub const X005: &str = "X005";
288/// Duplicate component names across types.
289pub const X006: &str = "X006";
290
291/// Validation target profile for controlling which fields are considered known.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
293pub enum ValidationTarget {
294    /// Standard Anthropic specification fields only.
295    #[default]
296    Standard,
297    /// Standard fields plus Claude Code extension fields.
298    ClaudeCode,
299    /// No unknown-field warnings (all fields accepted).
300    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}