1#![forbid(unsafe_code)]
6
7mod model;
8mod presets;
9mod resolve;
10mod validation_error;
11
12pub use model::{CheckConfig, DepguardConfigV1};
13pub use resolve::{Overrides, ResolvedConfig};
14pub use validation_error::{ValidationError, ValidationErrors};
15
16pub fn parse_config_toml(input: &str) -> anyhow::Result<DepguardConfigV1> {
18 let cfg: DepguardConfigV1 = toml::from_str(input)?;
19 Ok(cfg)
20}
21
22pub fn resolve_config(
24 cfg: DepguardConfigV1,
25 overrides: Overrides,
26) -> anyhow::Result<ResolvedConfig> {
27 resolve::resolve_config(cfg, overrides)
28}
29
30#[cfg(test)]
31mod tests {
32 use super::*;
33 use depguard_domain_core::policy::{FailOn, Scope};
34 use depguard_types::Severity;
35
36 #[test]
37 fn parse_empty_config() {
38 let cfg = parse_config_toml("").unwrap();
39 assert_eq!(cfg.profile, None);
40 assert_eq!(cfg.scope, None);
41 assert_eq!(cfg.baseline, None);
42 assert!(cfg.checks.is_empty());
43 }
44
45 #[test]
46 fn parse_minimal_config() {
47 let toml = r#"
48 profile = "warn"
49 scope = "diff"
50 baseline = ".depguard-baseline.json"
51 "#;
52 let cfg = parse_config_toml(toml).unwrap();
53 assert_eq!(cfg.profile, Some("warn".to_string()));
54 assert_eq!(cfg.scope, Some("diff".to_string()));
55 assert_eq!(cfg.baseline, Some(".depguard-baseline.json".to_string()));
56 }
57
58 #[test]
59 fn parse_config_with_checks() {
60 let toml = r#"
61 profile = "strict"
62
63 [checks."deps.no_wildcards"]
64 enabled = false
65
66 [checks."deps.path_safety"]
67 severity = "warning"
68 allow = ["vendor/*"]
69 "#;
70 let cfg = parse_config_toml(toml).unwrap();
71
72 let wildcard_cfg = cfg.checks.get("deps.no_wildcards").unwrap();
73 assert_eq!(wildcard_cfg.enabled, Some(false));
74
75 let path_cfg = cfg.checks.get("deps.path_safety").unwrap();
76 assert_eq!(path_cfg.severity, Some("warning".to_string()));
77 assert_eq!(path_cfg.allow, vec!["vendor/*"]);
78 }
79
80 #[test]
81 fn resolve_default_profile() {
82 let cfg = DepguardConfigV1::default();
83 let resolved = resolve_config(cfg, Overrides::default()).unwrap();
84
85 assert_eq!(resolved.effective.profile, "strict");
86 assert_eq!(resolved.effective.fail_on, FailOn::Error);
87 assert_eq!(resolved.effective.scope, Scope::Repo);
88 }
89
90 #[test]
91 fn resolve_warn_profile() {
92 let cfg = DepguardConfigV1 {
93 profile: Some("warn".to_string()),
94 ..Default::default()
95 };
96 let resolved = resolve_config(cfg, Overrides::default()).unwrap();
97
98 assert_eq!(resolved.effective.profile, "warn");
99 assert_eq!(resolved.effective.fail_on, FailOn::Warning);
100 }
101
102 #[test]
103 fn resolve_compat_profile() {
104 let cfg = DepguardConfigV1 {
105 profile: Some("compat".to_string()),
106 ..Default::default()
107 };
108 let resolved = resolve_config(cfg, Overrides::default()).unwrap();
109
110 assert_eq!(resolved.effective.profile, "compat");
111 assert_eq!(resolved.effective.fail_on, FailOn::Error);
112
113 let check = resolved.effective.checks.get("deps.no_wildcards").unwrap();
115 assert_eq!(check.severity, Severity::Warning);
116 }
117
118 #[test]
119 fn cli_overrides_take_precedence() {
120 let cfg = DepguardConfigV1 {
121 profile: Some("warn".to_string()),
122 scope: Some("repo".to_string()),
123 max_findings: Some(100),
124 ..Default::default()
125 };
126 let overrides = Overrides {
127 profile: Some("strict".to_string()),
128 scope: Some("diff".to_string()),
129 max_findings: Some(50),
130 baseline: Some("custom-baseline.json".to_string()),
131 };
132 let resolved = resolve_config(cfg, overrides).unwrap();
133
134 assert_eq!(resolved.effective.profile, "strict");
135 assert_eq!(resolved.effective.scope, Scope::Diff);
136 assert_eq!(resolved.effective.max_findings, 50);
137 assert_eq!(
138 resolved.baseline_path.as_deref(),
139 Some("custom-baseline.json")
140 );
141 }
142
143 #[test]
144 fn per_check_overrides() {
145 let toml = r#"
146 [checks."deps.no_wildcards"]
147 enabled = false
148
149 [checks."deps.path_safety"]
150 severity = "info"
151 allow = ["special-path"]
152 "#;
153 let cfg = parse_config_toml(toml).unwrap();
154 let resolved = resolve_config(cfg, Overrides::default()).unwrap();
155
156 let wildcard = resolved.effective.checks.get("deps.no_wildcards").unwrap();
157 assert!(!wildcard.enabled);
158
159 let path_safety = resolved.effective.checks.get("deps.path_safety").unwrap();
160 assert_eq!(path_safety.severity, Severity::Info);
161 assert_eq!(path_safety.allow, vec!["special-path"]);
162 }
163
164 #[test]
165 fn per_check_ignore_publish_false_override() {
166 let toml = r#"
167 [checks."deps.path_requires_version"]
168 ignore_publish_false = true
169 "#;
170 let cfg = parse_config_toml(toml).unwrap();
171 let resolved = resolve_config(cfg, Overrides::default()).unwrap();
172
173 let check = resolved
174 .effective
175 .checks
176 .get("deps.path_requires_version")
177 .expect("check");
178 assert!(check.ignore_publish_false);
179 }
180
181 #[test]
182 fn invalid_scope_returns_error() {
183 let cfg = DepguardConfigV1 {
184 scope: Some("invalid".to_string()),
185 ..Default::default()
186 };
187 let result = resolve_config(cfg, Overrides::default());
188 assert!(result.is_err());
189 let err_msg = result.unwrap_err().to_string();
190 assert!(
192 err_msg.contains("scope:"),
193 "error message should contain key path: {err_msg}"
194 );
195 assert!(
196 err_msg.contains("unknown scope"),
197 "error message should contain 'unknown scope': {err_msg}"
198 );
199 }
200
201 #[test]
202 fn invalid_severity_returns_error() {
203 let toml = r#"
204 [checks."deps.no_wildcards"]
205 severity = "fatal"
206 "#;
207 let cfg = parse_config_toml(toml).unwrap();
208 let result = resolve_config(cfg, Overrides::default());
209 assert!(result.is_err());
210 let err_msg = result.unwrap_err().to_string();
211 assert!(
213 err_msg.contains("checks.deps.no_wildcards.severity"),
214 "error message should contain key path: {err_msg}"
215 );
216 assert!(
217 err_msg.contains("unknown severity"),
218 "error message should contain 'unknown severity': {err_msg}"
219 );
220 }
221
222 #[test]
223 fn invalid_allowlist_glob_returns_error() {
224 let toml = r#"
225 [checks."deps.no_wildcards"]
226 allow = ["["]
227 "#;
228 let cfg = parse_config_toml(toml).unwrap();
229 let result = resolve_config(cfg, Overrides::default());
230 assert!(result.is_err());
231 let err_msg = result.unwrap_err().to_string();
232 assert!(
234 err_msg.contains("checks.deps.no_wildcards.allow"),
235 "error message should contain key path: {err_msg}"
236 );
237 assert!(
238 err_msg.contains("invalid glob pattern"),
239 "error message should contain 'invalid glob pattern': {err_msg}"
240 );
241 }
242
243 #[test]
244 fn fail_on_config_overrides_profile() {
245 let cfg = DepguardConfigV1 {
246 profile: Some("strict".to_string()),
247 fail_on: Some("warn".to_string()),
248 ..Default::default()
249 };
250 let resolved = resolve_config(cfg, Overrides::default()).unwrap();
251 assert_eq!(resolved.effective.fail_on, FailOn::Warning);
253 }
254
255 #[test]
256 fn invalid_fail_on_returns_error() {
257 let cfg = DepguardConfigV1 {
258 fail_on: Some("never".to_string()),
259 ..Default::default()
260 };
261 let result = resolve_config(cfg, Overrides::default());
262 assert!(result.is_err());
263 let err_msg = result.unwrap_err().to_string();
264 assert!(
266 err_msg.contains("fail_on:"),
267 "error message should contain key path: {err_msg}"
268 );
269 assert!(
270 err_msg.contains("unknown fail_on"),
271 "error message should contain 'unknown fail_on': {err_msg}"
272 );
273 }
274
275 #[test]
276 fn additional_checks_have_stable_default_severities() {
277 let cfg = DepguardConfigV1::default();
278 let resolved = resolve_config(cfg, Overrides::default()).unwrap();
279 let strict = depguard_check_catalog::checks_for_profile("strict");
280 assert_eq!(resolved.effective.checks.len(), strict.len());
281
282 for check in strict {
283 let actual = resolved
284 .effective
285 .checks
286 .get(check.id)
287 .expect("catalog check should be present");
288 let expected_enabled =
289 check.enabled && depguard_check_catalog::is_check_available(check.id);
290 assert_eq!(
291 actual.enabled, expected_enabled,
292 "check {} enabled default should match catalog",
293 check.id
294 );
295 assert_eq!(
296 actual.severity, check.severity,
297 "check {} severity should match catalog",
298 check.id
299 );
300 }
301 }
302
303 #[test]
304 fn enabling_default_features_without_severity_uses_warning_default() {
305 let toml = r#"
306 [checks."deps.default_features_explicit"]
307 enabled = true
308 "#;
309 let cfg = parse_config_toml(toml).unwrap();
310 let resolved = resolve_config(cfg, Overrides::default()).unwrap();
311
312 let check = resolved
313 .effective
314 .checks
315 .get("deps.default_features_explicit")
316 .expect("default_features check should exist");
317 assert!(check.enabled);
318 assert_eq!(check.severity, Severity::Warning);
319 }
320
321 #[test]
322 fn validation_error_can_be_extracted_from_anyhow() {
323 use crate::ValidationError;
324
325 let cfg = DepguardConfigV1 {
326 scope: Some("invalid".to_string()),
327 ..Default::default()
328 };
329 let result = resolve_config(cfg, Overrides::default());
330 assert!(result.is_err());
331
332 let err = result.unwrap_err();
333 let validation_err = err.downcast_ref::<ValidationError>();
335 assert!(
336 validation_err.is_some(),
337 "should be able to downcast to ValidationError"
338 );
339
340 let ve = validation_err.unwrap();
341 assert_eq!(ve.key_path(), "scope");
342 assert!(ve.message().contains("invalid"));
343 assert_eq!(ve.suggestion(), Some("expected 'repo' or 'diff'"));
344 assert_eq!(ve.file_path(), None);
345 assert_eq!(ve.line(), None);
346 }
347
348 #[test]
349 fn validation_error_with_file_info() {
350 use crate::ValidationError;
351 use std::path::PathBuf;
352
353 let err = ValidationError::unknown_severity("deps.no_wildcards", "fatal")
354 .with_file(PathBuf::from("depguard.toml"))
355 .with_line(10);
356
357 let display = err.to_string();
358 assert!(
359 display.contains("depguard.toml:10"),
360 "should contain file and line: {display}"
361 );
362 assert!(
363 display.contains("checks.deps.no_wildcards.severity"),
364 "should contain key path: {display}"
365 );
366 }
367
368 #[test]
369 fn validation_errors_can_aggregate_multiple() {
370 use crate::{ValidationError, ValidationErrors};
371
372 let mut errors = ValidationErrors::new();
373 errors.push(ValidationError::unknown_scope("invalid"));
374 errors.push(ValidationError::unknown_fail_on("never"));
375 errors.push(ValidationError::unknown_severity("some.check", "bad"));
376
377 assert_eq!(errors.len(), 3);
378 assert!(!errors.is_empty());
379
380 let count = errors.iter().count();
382 assert_eq!(count, 3);
383
384 let display = errors.to_string();
386 assert!(display.contains("scope:"));
387 assert!(display.contains("fail_on:"));
388 assert!(display.contains("checks.some.check.severity:"));
389 }
390
391 #[test]
392 fn validation_error_backwards_compatible_with_anyhow() {
393 let cfg = DepguardConfigV1 {
395 fail_on: Some("invalid_value".to_string()),
396 ..Default::default()
397 };
398 let result = resolve_config(cfg, Overrides::default());
399
400 let err_msg = result.unwrap_err().to_string();
401 assert!(err_msg.contains("fail_on:"));
403 assert!(err_msg.contains("unknown fail_on"));
404 assert!(err_msg.contains("hint:") || err_msg.contains("expected"));
405 }
406
407 #[test]
408 fn invalid_profile_returns_error() {
409 let cfg = DepguardConfigV1 {
410 profile: Some("invalid_profile".to_string()),
411 ..Default::default()
412 };
413 let result = resolve_config(cfg, Overrides::default());
414 assert!(result.is_err());
415 let err_msg = result.unwrap_err().to_string();
416 assert!(
417 err_msg.contains("profile:"),
418 "error message should contain key path: {err_msg}"
419 );
420 assert!(
421 err_msg.contains("unknown profile"),
422 "error message should contain 'unknown profile': {err_msg}"
423 );
424 }
425
426 #[test]
427 fn invalid_max_findings_zero_returns_error() {
428 let cfg = DepguardConfigV1 {
429 max_findings: Some(0),
430 ..Default::default()
431 };
432 let result = resolve_config(cfg, Overrides::default());
433 assert!(result.is_err());
434 let err_msg = result.unwrap_err().to_string();
435 assert!(
436 err_msg.contains("max_findings:"),
437 "error message should contain key path: {err_msg}"
438 );
439 assert!(
440 err_msg.contains("at least 1"),
441 "error message should contain 'at least 1': {err_msg}"
442 );
443 }
444
445 #[test]
446 fn ignore_publish_false_on_unsupported_check_returns_error() {
447 let toml = r#"
448 [checks."deps.no_wildcards"]
449 ignore_publish_false = true
450 "#;
451 let cfg = parse_config_toml(toml).unwrap();
452 let result = resolve_config(cfg, Overrides::default());
453 assert!(result.is_err());
454 let err_msg = result.unwrap_err().to_string();
455 assert!(
456 err_msg.contains("checks.deps.no_wildcards.ignore_publish_false"),
457 "error message should contain key path: {err_msg}"
458 );
459 assert!(
460 err_msg.contains("not supported"),
461 "error message should contain 'not supported': {err_msg}"
462 );
463 }
464
465 #[test]
466 fn ignore_publish_false_on_supported_check_works() {
467 let toml = r#"
468 [checks."deps.path_requires_version"]
469 ignore_publish_false = true
470 "#;
471 let cfg = parse_config_toml(toml).unwrap();
472 let result = resolve_config(cfg, Overrides::default());
473 assert!(result.is_ok());
474 let resolved = result.unwrap();
475 let check = resolved
476 .effective
477 .checks
478 .get("deps.path_requires_version")
479 .expect("check should exist");
480 assert!(check.ignore_publish_false);
481 }
482
483 #[test]
484 fn valid_profile_aliases_work() {
485 for profile in ["strict", "warn", "team", "compat", "oss"] {
486 let cfg = DepguardConfigV1 {
487 profile: Some(profile.to_string()),
488 ..Default::default()
489 };
490 let result = resolve_config(cfg, Overrides::default());
491 assert!(
492 result.is_ok(),
493 "profile '{profile}' should be valid: {:?}",
494 result.err()
495 );
496 }
497 }
498}