1use std::fmt;
8use std::path::PathBuf;
9
10#[derive(Clone, Debug, PartialEq)]
12pub struct ValidationError {
13 key_path: String,
15 message: String,
17 file_path: Option<PathBuf>,
19 line: Option<usize>,
21 suggestion: Option<String>,
23}
24
25impl ValidationError {
26 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 pub fn with_file(mut self, path: impl Into<PathBuf>) -> Self {
39 self.file_path = Some(path.into());
40 self
41 }
42
43 pub fn with_line(mut self, line: usize) -> Self {
45 self.line = Some(line);
46 self
47 }
48
49 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
51 self.suggestion = Some(suggestion.into());
52 self
53 }
54
55 pub fn key_path(&self) -> &str {
57 &self.key_path
58 }
59
60 pub fn message(&self) -> &str {
62 &self.message
63 }
64
65 pub fn file_path(&self) -> Option<&std::path::Path> {
67 self.file_path.as_deref()
68 }
69
70 pub fn line(&self) -> Option<usize> {
72 self.line
73 }
74
75 pub fn suggestion(&self) -> Option<&str> {
77 self.suggestion.as_deref()
78 }
79
80 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 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 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 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 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 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 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 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 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 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 pub fn missing_required_field(key_path: &str) -> Self {
156 Self::new(key_path, format!("required field '{key_path}' is missing"))
157 }
158
159 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 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#[derive(Clone, Debug, Default, PartialEq)]
192pub struct ValidationErrors {
193 errors: Vec<ValidationError>,
194}
195
196impl ValidationErrors {
197 pub fn new() -> Self {
199 Self { errors: Vec::new() }
200 }
201
202 pub fn push(&mut self, error: ValidationError) {
204 self.errors.push(error);
205 }
206
207 pub fn is_empty(&self) -> bool {
209 self.errors.is_empty()
210 }
211
212 pub fn len(&self) -> usize {
214 self.errors.len()
215 }
216
217 pub fn iter(&self) -> impl Iterator<Item = &ValidationError> {
219 self.errors.iter()
220 }
221
222 pub fn into_inner(self) -> Vec<ValidationError> {
224 self.errors
225 }
226
227 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}