Skip to main content

depguard_settings/
validation_error.rs

1//! Structured validation errors with config key path tracking.
2//!
3//! This module provides a rich error type for configuration validation that
4//! clearly indicates which config key caused an error and provides helpful
5//! context for fixing the issue.
6
7use std::fmt;
8use std::path::PathBuf;
9
10/// A structured validation error that tracks the config key path.
11#[derive(Clone, Debug, PartialEq)]
12pub struct ValidationError {
13    /// The config key path (e.g., `checks.deps.no_wildcards.severity`)
14    key_path: String,
15    /// The validation error message
16    message: String,
17    /// Optional file path where the config was read from
18    file_path: Option<PathBuf>,
19    /// Optional line number in the config file
20    line: Option<usize>,
21    /// Optional suggested fix
22    suggestion: Option<String>,
23}
24
25impl ValidationError {
26    /// Create a new validation error for a config key.
27    pub fn new(key_path: impl Into<String>, message: impl Into<String>) -> Self {
28        Self {
29            key_path: key_path.into(),
30            message: message.into(),
31            file_path: None,
32            line: None,
33            suggestion: None,
34        }
35    }
36
37    /// Add a file path to the error.
38    pub fn with_file(mut self, path: impl Into<PathBuf>) -> Self {
39        self.file_path = Some(path.into());
40        self
41    }
42
43    /// Add a line number to the error.
44    pub fn with_line(mut self, line: usize) -> Self {
45        self.line = Some(line);
46        self
47    }
48
49    /// Add a suggested fix to the error.
50    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
51        self.suggestion = Some(suggestion.into());
52        self
53    }
54
55    /// Get the config key path.
56    pub fn key_path(&self) -> &str {
57        &self.key_path
58    }
59
60    /// Get the error message.
61    pub fn message(&self) -> &str {
62        &self.message
63    }
64
65    /// Get the file path, if available.
66    pub fn file_path(&self) -> Option<&std::path::Path> {
67        self.file_path.as_deref()
68    }
69
70    /// Get the line number, if available.
71    pub fn line(&self) -> Option<usize> {
72        self.line
73    }
74
75    /// Get the suggested fix, if available.
76    pub fn suggestion(&self) -> Option<&str> {
77        self.suggestion.as_deref()
78    }
79
80    /// Create a validation error for an unknown scope value.
81    pub fn unknown_scope(value: &str) -> Self {
82        Self::new("scope", format!("unknown scope: '{value}'"))
83            .with_suggestion("expected 'repo' or 'diff'")
84    }
85
86    /// Create a validation error for an unknown severity value.
87    pub fn unknown_severity(check_id: &str, value: &str) -> Self {
88        Self::new(
89            format!("checks.{check_id}.severity"),
90            format!("unknown severity: '{value}'"),
91        )
92        .with_suggestion("expected 'info', 'warning', or 'error'")
93    }
94
95    /// Create a validation error for an unknown fail_on value.
96    pub fn unknown_fail_on(value: &str) -> Self {
97        Self::new("fail_on", format!("unknown fail_on: '{value}'"))
98            .with_suggestion("expected 'error' or 'warning'")
99    }
100
101    /// Create a validation error for an unknown profile value.
102    pub fn unknown_profile(value: &str) -> Self {
103        Self::new("profile", format!("unknown profile: '{value}'"))
104            .with_suggestion("expected 'strict', 'warn', or 'compat'")
105    }
106
107    /// Create a validation error for an invalid glob pattern in an allowlist.
108    pub fn invalid_allow_glob(check_id: &str, pattern: &str, error: &str) -> Self {
109        Self::new(
110            format!("checks.{check_id}.allow"),
111            format!("invalid glob pattern '{pattern}': {error}"),
112        )
113    }
114
115    /// Create a validation error for an unknown check ID.
116    pub fn unknown_check_id(check_id: &str) -> Self {
117        Self::new(
118            format!("checks.{check_id}"),
119            format!("unknown check ID: '{check_id}'"),
120        )
121        .with_suggestion("run 'depguard explain' to see available checks")
122    }
123
124    /// Create a validation error for an invalid max_findings value.
125    pub fn invalid_max_findings(value: u32) -> Self {
126        Self::new(
127            "max_findings",
128            format!("invalid max_findings: {value} must be at least 1"),
129        )
130        .with_suggestion("set max_findings to a positive integer, or remove to use default (200)")
131    }
132
133    /// Create a validation error for ignore_publish_false on an unsupported check.
134    pub fn ignore_publish_false_not_supported(check_id: &str) -> Self {
135        Self::new(
136            format!("checks.{check_id}.ignore_publish_false"),
137            format!("ignore_publish_false is not supported for check '{check_id}'"),
138        )
139        .with_suggestion("this option is only valid for 'deps.path_requires_version' check")
140    }
141
142    /// Create a validation error for an invalid boolean value.
143    pub fn invalid_boolean(key_path: &str, value: &str) -> Self {
144        Self::new(key_path, format!("invalid boolean value: '{value}'"))
145            .with_suggestion("expected 'true' or 'false'")
146    }
147
148    /// Create a validation error for an invalid integer value.
149    pub fn invalid_integer(key_path: &str, value: &str) -> Self {
150        Self::new(key_path, format!("invalid integer value: '{value}'"))
151            .with_suggestion("expected a valid integer")
152    }
153
154    /// Create a validation error for a missing required field.
155    pub fn missing_required_field(key_path: &str) -> Self {
156        Self::new(key_path, format!("required field '{key_path}' is missing"))
157    }
158
159    /// Create a validation error for an invalid enum value with custom expected values.
160    pub fn invalid_enum_value(key_path: &str, value: &str, expected: &[&str]) -> Self {
161        let expected_str = expected.join("', '");
162        Self::new(key_path, format!("invalid value '{value}'"))
163            .with_suggestion(format!("expected one of: '{expected_str}'"))
164    }
165}
166
167impl fmt::Display for ValidationError {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        // Format: [file:line: ]key: message
170        if let Some(ref path) = self.file_path {
171            if let Some(line) = self.line {
172                write!(f, "{}:{}: ", path.display(), line)?;
173            } else {
174                write!(f, "{}: ", path.display())?;
175            }
176        }
177
178        write!(f, "{}: {}", self.key_path, self.message)?;
179
180        if let Some(ref suggestion) = self.suggestion {
181            write!(f, "\n  hint: {suggestion}")?;
182        }
183
184        Ok(())
185    }
186}
187
188impl std::error::Error for ValidationError {}
189
190/// A collection of validation errors.
191#[derive(Clone, Debug, Default, PartialEq)]
192pub struct ValidationErrors {
193    errors: Vec<ValidationError>,
194}
195
196impl ValidationErrors {
197    /// Create an empty collection.
198    pub fn new() -> Self {
199        Self { errors: Vec::new() }
200    }
201
202    /// Add a validation error.
203    pub fn push(&mut self, error: ValidationError) {
204        self.errors.push(error);
205    }
206
207    /// Check if there are any errors.
208    pub fn is_empty(&self) -> bool {
209        self.errors.is_empty()
210    }
211
212    /// Get the number of errors.
213    pub fn len(&self) -> usize {
214        self.errors.len()
215    }
216
217    /// Get an iterator over the errors.
218    pub fn iter(&self) -> impl Iterator<Item = &ValidationError> {
219        self.errors.iter()
220    }
221
222    /// Convert into a vector of errors.
223    pub fn into_inner(self) -> Vec<ValidationError> {
224        self.errors
225    }
226
227    /// Merge another collection of errors into this one.
228    pub fn extend(&mut self, other: ValidationErrors) {
229        self.errors.extend(other.errors);
230    }
231}
232
233impl fmt::Display for ValidationErrors {
234    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235        for (i, error) in self.errors.iter().enumerate() {
236            if i > 0 {
237                writeln!(f)?;
238            }
239            write!(f, "{error}")?;
240        }
241        Ok(())
242    }
243}
244
245impl std::error::Error for ValidationErrors {}
246
247impl From<Vec<ValidationError>> for ValidationErrors {
248    fn from(errors: Vec<ValidationError>) -> Self {
249        Self { errors }
250    }
251}
252
253impl From<ValidationError> for ValidationErrors {
254    fn from(error: ValidationError) -> Self {
255        Self {
256            errors: vec![error],
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn validation_error_display_basic() {
267        let err = ValidationError::new("scope", "unknown scope: 'invalid'");
268        assert_eq!(err.to_string(), "scope: unknown scope: 'invalid'");
269    }
270
271    #[test]
272    fn validation_error_display_with_suggestion() {
273        let err = ValidationError::new("scope", "unknown scope: 'invalid'")
274            .with_suggestion("expected 'repo' or 'diff'");
275        assert_eq!(
276            err.to_string(),
277            "scope: unknown scope: 'invalid'\n  hint: expected 'repo' or 'diff'"
278        );
279    }
280
281    #[test]
282    fn validation_error_display_with_file() {
283        let err = ValidationError::new("scope", "unknown scope: 'invalid'")
284            .with_file(PathBuf::from("depguard.toml"));
285        assert_eq!(
286            err.to_string(),
287            "depguard.toml: scope: unknown scope: 'invalid'"
288        );
289    }
290
291    #[test]
292    fn validation_error_display_with_file_and_line() {
293        let err = ValidationError::new("scope", "unknown scope: 'invalid'")
294            .with_file(PathBuf::from("depguard.toml"))
295            .with_line(5);
296        assert_eq!(
297            err.to_string(),
298            "depguard.toml:5: scope: unknown scope: 'invalid'"
299        );
300    }
301
302    #[test]
303    fn validation_error_display_full() {
304        let err = ValidationError::new(
305            "checks.deps.no_wildcards.severity",
306            "unknown severity: 'fatal'",
307        )
308        .with_file(PathBuf::from("depguard.toml"))
309        .with_line(10)
310        .with_suggestion("expected 'info', 'warning', or 'error'");
311        assert_eq!(
312            err.to_string(),
313            "depguard.toml:10: checks.deps.no_wildcards.severity: unknown severity: 'fatal'\n  hint: expected 'info', 'warning', or 'error'"
314        );
315    }
316
317    #[test]
318    fn unknown_scope_factory() {
319        let err = ValidationError::unknown_scope("invalid");
320        assert_eq!(err.key_path(), "scope");
321        assert!(err.message().contains("invalid"));
322        assert_eq!(err.suggestion(), Some("expected 'repo' or 'diff'"));
323    }
324
325    #[test]
326    fn unknown_severity_factory() {
327        let err = ValidationError::unknown_severity("deps.no_wildcards", "fatal");
328        assert_eq!(err.key_path(), "checks.deps.no_wildcards.severity");
329        assert!(err.message().contains("fatal"));
330        assert_eq!(
331            err.suggestion(),
332            Some("expected 'info', 'warning', or 'error'")
333        );
334    }
335
336    #[test]
337    fn unknown_fail_on_factory() {
338        let err = ValidationError::unknown_fail_on("never");
339        assert_eq!(err.key_path(), "fail_on");
340        assert!(err.message().contains("never"));
341        assert_eq!(err.suggestion(), Some("expected 'error' or 'warning'"));
342    }
343
344    #[test]
345    fn invalid_allow_glob_factory() {
346        let err = ValidationError::invalid_allow_glob("deps.no_wildcards", "[", "unclosed bracket");
347        assert_eq!(err.key_path(), "checks.deps.no_wildcards.allow");
348        assert!(err.message().contains("["));
349        assert!(err.message().contains("unclosed bracket"));
350    }
351
352    #[test]
353    fn validation_errors_collection() {
354        let mut errors = ValidationErrors::new();
355        assert!(errors.is_empty());
356        assert_eq!(errors.len(), 0);
357
358        errors.push(ValidationError::unknown_scope("invalid"));
359        errors.push(ValidationError::unknown_fail_on("never"));
360
361        assert!(!errors.is_empty());
362        assert_eq!(errors.len(), 2);
363
364        let error_strings: Vec<_> = errors.iter().map(|e| e.to_string()).collect();
365        assert_eq!(error_strings.len(), 2);
366    }
367
368    #[test]
369    fn validation_errors_display() {
370        let mut errors = ValidationErrors::new();
371        errors.push(ValidationError::unknown_scope("invalid"));
372        errors.push(ValidationError::unknown_fail_on("never"));
373
374        let display = errors.to_string();
375        assert!(display.contains("scope:"));
376        assert!(display.contains("fail_on:"));
377    }
378
379    #[test]
380    fn validation_errors_from_vec() {
381        let errors = ValidationErrors::from(vec![
382            ValidationError::unknown_scope("invalid"),
383            ValidationError::unknown_fail_on("never"),
384        ]);
385        assert_eq!(errors.len(), 2);
386    }
387
388    #[test]
389    fn validation_errors_extend() {
390        let mut errors1 = ValidationErrors::new();
391        errors1.push(ValidationError::unknown_scope("invalid"));
392
393        let mut errors2 = ValidationErrors::new();
394        errors2.push(ValidationError::unknown_fail_on("never"));
395
396        errors1.extend(errors2);
397        assert_eq!(errors1.len(), 2);
398    }
399
400    #[test]
401    fn invalid_max_findings_factory() {
402        let err = ValidationError::invalid_max_findings(0);
403        assert_eq!(err.key_path(), "max_findings");
404        assert!(err.message().contains("0"));
405        assert!(err.message().contains("at least 1"));
406        assert!(err.suggestion().is_some());
407    }
408
409    #[test]
410    fn ignore_publish_false_not_supported_factory() {
411        let err = ValidationError::ignore_publish_false_not_supported("deps.no_wildcards");
412        assert_eq!(
413            err.key_path(),
414            "checks.deps.no_wildcards.ignore_publish_false"
415        );
416        assert!(err.message().contains("not supported"));
417        assert!(err.message().contains("deps.no_wildcards"));
418        assert!(err.suggestion().is_some());
419    }
420
421    #[test]
422    fn invalid_boolean_factory() {
423        let err = ValidationError::invalid_boolean("checks.some_check.enabled", "yes");
424        assert_eq!(err.key_path(), "checks.some_check.enabled");
425        assert!(err.message().contains("yes"));
426        assert!(err.message().contains("boolean"));
427        assert_eq!(err.suggestion(), Some("expected 'true' or 'false'"));
428    }
429
430    #[test]
431    fn invalid_integer_factory() {
432        let err = ValidationError::invalid_integer("max_findings", "abc");
433        assert_eq!(err.key_path(), "max_findings");
434        assert!(err.message().contains("abc"));
435        assert!(err.message().contains("integer"));
436        assert_eq!(err.suggestion(), Some("expected a valid integer"));
437    }
438
439    #[test]
440    fn missing_required_field_factory() {
441        let err = ValidationError::missing_required_field("profile");
442        assert_eq!(err.key_path(), "profile");
443        assert!(err.message().contains("required"));
444        assert!(err.message().contains("profile"));
445    }
446
447    #[test]
448    fn invalid_enum_value_factory() {
449        let err = ValidationError::invalid_enum_value("scope", "invalid", &["repo", "diff"]);
450        assert_eq!(err.key_path(), "scope");
451        assert!(err.message().contains("invalid"));
452        assert_eq!(err.suggestion(), Some("expected one of: 'repo', 'diff'"));
453    }
454}