Skip to main content

depguard_settings/
lib.rs

1//! Config parsing and profile/preset resolution.
2//!
3//! This crate is intentionally IO-free: it parses and resolves configuration provided as strings.
4
5#![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
16/// Parse `depguard.toml` (or equivalent) into a typed model.
17pub fn parse_config_toml(input: &str) -> anyhow::Result<DepguardConfigV1> {
18    let cfg: DepguardConfigV1 = toml::from_str(input)?;
19    Ok(cfg)
20}
21
22/// Resolve the effective config used by the engine (profiles + overrides + per-check config).
23pub 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        // compat uses warning severity by default
114        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        // Check for the key path and error type
191        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        // Check for the key path and error type
212        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        // Check for the key path and error type
233        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        // strict profile defaults to FailOn::Error, but config overrides to Warning
252        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        // Check for the key path and error type
265        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        // The ValidationError should be extractable via downcast
334        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        // Verify iteration works
381        let count = errors.iter().count();
382        assert_eq!(count, 3);
383
384        // Verify display shows all errors
385        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        // Ensure that ValidationError works seamlessly with anyhow's context
394        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        // The error message should still be human-readable
402        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}