Skip to main content

sbom_tools/config/
file.rs

1//! Configuration file loading and discovery.
2//!
3//! Supports loading configuration from YAML files with automatic discovery.
4
5use super::types::AppConfig;
6use std::path::{Path, PathBuf};
7
8// ============================================================================
9// Configuration File Discovery
10// ============================================================================
11
12/// Standard config file names to search for.
13const CONFIG_FILE_NAMES: &[&str] = &[
14    ".sbom-tools.yaml",
15    ".sbom-tools.yml",
16    "sbom-tools.yaml",
17    "sbom-tools.yml",
18    ".sbom-toolsrc",
19];
20
21/// Discover a config file by searching standard locations.
22///
23/// Search order:
24/// 1. Explicit path if provided
25/// 2. Current directory
26/// 3. Git repository root (if in a repo)
27/// 4. User config directory (~/.config/sbom-tools/)
28/// 5. Home directory
29///
30/// An explicit path is **authoritative**: it is returned as-is even when the
31/// file does not exist, so callers surface "config file not found" instead of
32/// silently falling back to a *different* discovered file (or defaults).
33/// Existence/parse failures are reported by [`load_config_file`].
34#[must_use]
35pub fn discover_config_file(explicit_path: Option<&Path>) -> Option<PathBuf> {
36    // 1. Use explicit path if provided — never fall through to discovery.
37    if let Some(path) = explicit_path {
38        return Some(path.to_path_buf());
39    }
40
41    // 2. Search current directory
42    if let Ok(cwd) = std::env::current_dir()
43        && let Some(path) = find_config_in_dir(&cwd)
44    {
45        return Some(path);
46    }
47
48    // 3. Search git root (if in a repo)
49    if let Some(git_root) = find_git_root()
50        && let Some(path) = find_config_in_dir(&git_root)
51    {
52        return Some(path);
53    }
54
55    // 4. Search user config directory
56    if let Some(config_dir) = dirs::config_dir() {
57        let sbom_config_dir = config_dir.join("sbom-tools");
58        if let Some(path) = find_config_in_dir(&sbom_config_dir) {
59            return Some(path);
60        }
61    }
62
63    // 5. Search home directory
64    if let Some(home) = dirs::home_dir()
65        && let Some(path) = find_config_in_dir(&home)
66    {
67        return Some(path);
68    }
69
70    None
71}
72
73/// Find a config file in a specific directory.
74fn find_config_in_dir(dir: &Path) -> Option<PathBuf> {
75    for name in CONFIG_FILE_NAMES {
76        let path = dir.join(name);
77        if path.exists() {
78            return Some(path);
79        }
80    }
81    None
82}
83
84/// Find the git repository root by walking up the directory tree.
85fn find_git_root() -> Option<PathBuf> {
86    let cwd = std::env::current_dir().ok()?;
87    let mut current = cwd.as_path();
88
89    loop {
90        let git_dir = current.join(".git");
91        if git_dir.exists() {
92            return Some(current.to_path_buf());
93        }
94
95        current = current.parent()?;
96    }
97}
98
99// ============================================================================
100// Configuration File Loading
101// ============================================================================
102
103/// Error type for config file operations.
104#[derive(Debug)]
105pub enum ConfigFileError {
106    /// File not found
107    NotFound(PathBuf),
108    /// IO error reading file
109    Io(std::io::Error),
110    /// YAML parsing error
111    Parse(serde_yaml_ng::Error),
112}
113
114impl std::fmt::Display for ConfigFileError {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        match self {
117            Self::NotFound(path) => {
118                write!(f, "Config file not found: {}", path.display())
119            }
120            Self::Io(e) => write!(f, "Failed to read config file: {e}"),
121            Self::Parse(e) => write!(f, "Failed to parse config file: {e}"),
122        }
123    }
124}
125
126impl std::error::Error for ConfigFileError {
127    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
128        match self {
129            Self::NotFound(_) => None,
130            Self::Io(e) => Some(e),
131            Self::Parse(e) => Some(e),
132        }
133    }
134}
135
136impl From<std::io::Error> for ConfigFileError {
137    fn from(err: std::io::Error) -> Self {
138        Self::Io(err)
139    }
140}
141
142impl From<serde_yaml_ng::Error> for ConfigFileError {
143    fn from(err: serde_yaml_ng::Error) -> Self {
144        Self::Parse(err)
145    }
146}
147
148/// Load an `AppConfig` from a YAML file.
149pub fn load_config_file(path: &Path) -> Result<AppConfig, ConfigFileError> {
150    if !path.exists() {
151        return Err(ConfigFileError::NotFound(path.to_path_buf()));
152    }
153
154    let content = std::fs::read_to_string(path)?;
155    let config: AppConfig = serde_yaml_ng::from_str(&content)?;
156    Ok(config)
157}
158
159/// Load config from discovered file, or return default.
160///
161/// Lenient: a file that fails to load is warned about and replaced with
162/// defaults. Note that an explicit path no longer falls through to discovery
163/// (see [`discover_config_file`]) — a missing/broken `--config` yields
164/// defaults with a warning here, and a hard error via [`load_strict`].
165/// Prefer [`load_strict`] anywhere the user passed `--config` explicitly.
166#[must_use]
167pub fn load_or_default(explicit_path: Option<&Path>) -> (AppConfig, Option<PathBuf>) {
168    discover_config_file(explicit_path).map_or_else(
169        || (AppConfig::default(), None),
170        |path| match load_config_file(&path) {
171            Ok(config) => (config, Some(path)),
172            Err(e) => {
173                tracing::warn!("Failed to load config from {}: {}", path.display(), e);
174                (AppConfig::default(), None)
175            }
176        },
177    )
178}
179
180/// Load config strictly: once a file is selected (explicitly via `--config`
181/// or through discovery), any failure to load it is a hard error.
182///
183/// Semantics (shared by *every* command, including `config show`/`path`):
184/// - explicit path missing      → `Err(NotFound)`, never discovery/defaults
185/// - selected file fails to parse → `Err(Parse)`, never "showing defaults"
186/// - no file anywhere            → built-in defaults (`loaded_from = None`)
187pub fn load_strict(
188    explicit_path: Option<&Path>,
189) -> Result<(AppConfig, Option<PathBuf>), ConfigFileError> {
190    match discover_config_file(explicit_path) {
191        None => Ok((AppConfig::default(), None)),
192        Some(path) => {
193            let config = load_config_file(&path)?;
194            Ok((config, Some(path)))
195        }
196    }
197}
198
199// ============================================================================
200// Configuration Merging
201// ============================================================================
202
203impl AppConfig {
204    /// Merge another config into this one, with `other` taking precedence.
205    ///
206    /// This is useful for layering CLI args over file config. A field in
207    /// `other` overrides the corresponding field in `self` only when it
208    /// differs from the built-in default — a default-valued field is treated
209    /// as "unset" and leaves `self` untouched. The sentinel is read from
210    /// `Self::default()` rather than an inlined literal so it can never drift
211    /// away from the actual default.
212    pub fn merge(&mut self, other: &Self) {
213        let defaults = Self::default();
214
215        // Matching config
216        if other.matching.fuzzy_preset != defaults.matching.fuzzy_preset {
217            self.matching.fuzzy_preset = other.matching.fuzzy_preset.clone();
218        }
219        if other.matching.threshold.is_some() {
220            self.matching.threshold = other.matching.threshold;
221        }
222        if other.matching.include_unchanged {
223            self.matching.include_unchanged = true;
224        }
225
226        // Output config - only override if explicitly set
227        if other.output.format != defaults.output.format {
228            self.output.format = other.output.format;
229        }
230        if other.output.file.is_some() {
231            self.output.file.clone_from(&other.output.file);
232        }
233        if other.output.no_color {
234            self.output.no_color = true;
235        }
236        if other.output.export_template.is_some() {
237            self.output
238                .export_template
239                .clone_from(&other.output.export_template);
240        }
241
242        // Filtering config
243        if other.filtering.only_changes {
244            self.filtering.only_changes = true;
245        }
246        if other.filtering.min_severity.is_some() {
247            self.filtering
248                .min_severity
249                .clone_from(&other.filtering.min_severity);
250        }
251
252        // Behavior config (booleans - if set to true, override)
253        if other.behavior.fail_on_vuln {
254            self.behavior.fail_on_vuln = true;
255        }
256        if other.behavior.fail_on_change {
257            self.behavior.fail_on_change = true;
258        }
259        if other.behavior.quiet {
260            self.behavior.quiet = true;
261        }
262        if other.behavior.explain_matches {
263            self.behavior.explain_matches = true;
264        }
265        if other.behavior.recommend_threshold {
266            self.behavior.recommend_threshold = true;
267        }
268
269        // Graph diff config
270        if other.graph_diff.enabled {
271            self.graph_diff = other.graph_diff.clone();
272        }
273
274        // Rules config
275        if other.rules.rules_file.is_some() {
276            self.rules.rules_file.clone_from(&other.rules.rules_file);
277        }
278        if other.rules.dry_run {
279            self.rules.dry_run = true;
280        }
281
282        // Ecosystem rules config
283        if other.ecosystem_rules.config_file.is_some() {
284            self.ecosystem_rules
285                .config_file
286                .clone_from(&other.ecosystem_rules.config_file);
287        }
288        if other.ecosystem_rules.disabled {
289            self.ecosystem_rules.disabled = true;
290        }
291        if other.ecosystem_rules.detect_typosquats {
292            self.ecosystem_rules.detect_typosquats = true;
293        }
294
295        // TUI config
296        if other.tui.theme != defaults.tui.theme {
297            self.tui.theme = other.tui.theme.clone();
298        }
299
300        // Compliance config
301        if !other.compliance.standards.is_empty() {
302            self.compliance
303                .standards
304                .clone_from(&other.compliance.standards);
305        }
306        if other.compliance.profile.is_some() {
307            self.compliance
308                .profile
309                .clone_from(&other.compliance.profile);
310        }
311        if other.compliance.min_score.is_some() {
312            self.compliance.min_score = other.compliance.min_score;
313        }
314        if other.compliance.fail_on_warning {
315            self.compliance.fail_on_warning = true;
316        }
317        if other.compliance.fail_on_noncompliant {
318            self.compliance.fail_on_noncompliant = true;
319        }
320        if other.compliance.cra_sidecar.is_some() {
321            self.compliance
322                .cra_sidecar
323                .clone_from(&other.compliance.cra_sidecar);
324        }
325        if other.compliance.cra_product_class.is_some() {
326            self.compliance
327                .cra_product_class
328                .clone_from(&other.compliance.cra_product_class);
329        }
330
331        // Enrichment config
332        if other.enrichment.is_some() {
333            self.enrichment.clone_from(&other.enrichment);
334        }
335    }
336
337    /// Load from file and merge with CLI overrides.
338    #[must_use]
339    pub fn from_file_with_overrides(
340        config_path: Option<&Path>,
341        cli_overrides: &Self,
342    ) -> (Self, Option<PathBuf>) {
343        let (mut config, loaded_from) = load_or_default(config_path);
344        config.merge(cli_overrides);
345        (config, loaded_from)
346    }
347}
348
349// ============================================================================
350// Example Config Generation
351// ============================================================================
352
353/// Generate an example config file content.
354#[must_use]
355pub fn generate_example_config() -> String {
356    let example = AppConfig::default();
357    format!(
358        r"# SBOM Diff Configuration
359# Place this file at .sbom-tools.yaml in your project root or ~/.config/sbom-tools/
360
361{}
362",
363        serde_yaml_ng::to_string(&example).unwrap_or_default()
364    )
365}
366
367/// Generate a commented example config with all options.
368#[must_use]
369pub fn generate_full_example_config() -> String {
370    r"# SBOM Diff Configuration File
371# ==============================
372#
373# This file configures sbom-tools behavior. Place it at:
374#   - .sbom-tools.yaml in your project root
375#   - ~/.config/sbom-tools/sbom-tools.yaml for global config
376#
377# CLI arguments always override file settings.
378
379# Matching configuration
380matching:
381  # Preset: strict, balanced, permissive, security-focused
382  fuzzy_preset: balanced
383  # Custom threshold (0.0-1.0), overrides preset
384  # threshold: 0.85
385  # Include unchanged components in output
386  include_unchanged: false
387
388# Output configuration
389output:
390  # Format: auto, tui, side-by-side, json, sarif, oscal-json, markdown,
391  # html, summary, table, csv, ndjson
392  format: auto
393  # Output file path (omit for stdout)
394  # file: report.json
395  # Disable colored output
396  no_color: false
397
398# Filtering options
399filtering:
400  # Only show items with changes
401  only_changes: false
402  # Minimum severity filter: critical, high, medium, low, info
403  # min_severity: high
404
405# Behavior flags
406behavior:
407  # Exit with code 2 if new vulnerabilities are introduced
408  fail_on_vuln: false
409  # Exit with code 1 if any changes detected
410  fail_on_change: false
411  # Suppress non-essential output
412  quiet: false
413  # Show detailed match explanations
414  explain_matches: false
415  # Recommend optimal matching threshold
416  recommend_threshold: false
417
418# Graph-aware diffing
419graph_diff:
420  enabled: false
421  detect_reparenting: true
422  detect_depth_changes: true
423
424# Custom matching rules
425rules:
426  # Path to matching rules YAML file
427  # rules_file: ./matching-rules.yaml
428  dry_run: false
429
430# Ecosystem-specific rules
431ecosystem_rules:
432  # Path to ecosystem rules config
433  # config_file: ./ecosystem-rules.yaml
434  disabled: false
435  detect_typosquats: false
436
437# TUI configuration
438tui:
439  # Theme: dark, light, high-contrast
440  theme: dark
441  show_line_numbers: true
442  mouse_enabled: true
443  initial_threshold: 0.8
444
445# Compliance defaults for `validate` / `quality` (CLI flags override)
446compliance:
447  # Default standard(s) for `validate --standard`. Canonical values:
448  # ntia, fda, cra, cra-phase1, ssdf, eo14028, cnsa2, pqc, bsi,
449  # oss-steward, eucc, ai-act, bsi-ai (aliases accepted)
450  # standards: [ntia, cra]
451  # Default scoring profile for `quality --profile`: minimal, standard,
452  # security, license-compliance, cra, bsi, comprehensive, cbom, ai-readiness
453  # profile: standard
454  # Fail `quality` when the overall score is below this (0-100)
455  # min_score: 70
456  # Exit non-zero when `validate` finds warnings
457  fail_on_warning: false
458  # Exit non-zero when `quality` reports NON-COMPLIANT
459  fail_on_noncompliant: false
460  # CRA sidecar metadata file (JSON or YAML); a configured path that fails
461  # to load is a hard error
462  # cra_sidecar: ./app.cra.json
463  # CRA product class: default, important-class-1, important-class-2, critical
464  # cra_product_class: default
465
466# Enrichment configuration (optional)
467# enrichment:
468#   enabled: true
469#   provider: osv
470#   cache_ttl: 3600
471#   max_concurrent: 10
472"
473    .to_string()
474}
475
476// ============================================================================
477// Tests
478// ============================================================================
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use std::io::Write;
484    use tempfile::TempDir;
485
486    #[test]
487    fn generated_full_example_config_loads_and_validates() {
488        // Regression: `config init` used to write `format: auto`, which the
489        // PascalCase-only ReportFormat deserializer rejected — the tool's own
490        // scaffold bricked every subsequent command until hand-edited.
491        let tmp = TempDir::new().unwrap();
492        let path = tmp.path().join(".sbom-tools.yaml");
493        std::fs::write(&path, generate_full_example_config()).unwrap();
494        let config =
495            load_config_file(&path).expect("the generated example config must parse cleanly");
496        use crate::config::Validatable;
497        let errors = config.validate();
498        assert!(
499            errors.is_empty(),
500            "the generated example config must validate: {errors:?}"
501        );
502    }
503
504    #[test]
505    fn test_find_config_in_dir() {
506        let tmp = TempDir::new().unwrap();
507        let config_path = tmp.path().join(".sbom-tools.yaml");
508        std::fs::write(&config_path, "matching:\n  fuzzy_preset: strict\n").unwrap();
509
510        let found = find_config_in_dir(tmp.path());
511        assert_eq!(found, Some(config_path));
512    }
513
514    #[test]
515    fn test_find_config_in_dir_not_found() {
516        let tmp = TempDir::new().unwrap();
517        let found = find_config_in_dir(tmp.path());
518        assert_eq!(found, None);
519    }
520
521    #[test]
522    fn test_load_config_file() {
523        let tmp = TempDir::new().unwrap();
524        let config_path = tmp.path().join("config.yaml");
525
526        let yaml = r#"
527matching:
528  fuzzy_preset: strict
529  threshold: 0.9
530behavior:
531  fail_on_vuln: true
532"#;
533        std::fs::write(&config_path, yaml).unwrap();
534
535        let config = load_config_file(&config_path).unwrap();
536        assert_eq!(
537            config.matching.fuzzy_preset,
538            crate::config::FuzzyPreset::Strict
539        );
540        assert_eq!(config.matching.threshold, Some(0.9));
541        assert!(config.behavior.fail_on_vuln);
542    }
543
544    #[test]
545    fn test_load_config_file_not_found() {
546        let result = load_config_file(Path::new("/nonexistent/config.yaml"));
547        assert!(matches!(result, Err(ConfigFileError::NotFound(_))));
548    }
549
550    #[test]
551    fn test_config_merge() {
552        let mut base = AppConfig::default();
553        let override_config = AppConfig {
554            matching: super::super::types::MatchingConfig {
555                fuzzy_preset: crate::config::FuzzyPreset::Strict,
556                threshold: Some(0.95),
557                include_unchanged: false,
558            },
559            behavior: super::super::types::BehaviorConfig {
560                fail_on_vuln: true,
561                ..Default::default()
562            },
563            ..AppConfig::default()
564        };
565
566        base.merge(&override_config);
567
568        assert_eq!(
569            base.matching.fuzzy_preset,
570            crate::config::FuzzyPreset::Strict
571        );
572        assert_eq!(base.matching.threshold, Some(0.95));
573        assert!(base.behavior.fail_on_vuln);
574    }
575
576    #[test]
577    fn test_merge_default_valued_override_does_not_clobber_base() {
578        // A default-valued override field must NOT overwrite a non-default base
579        // value — the sentinel is the built-in default, read dynamically.
580        let mut base = AppConfig {
581            matching: super::super::types::MatchingConfig {
582                fuzzy_preset: crate::config::FuzzyPreset::Strict,
583                ..Default::default()
584            },
585            output: super::super::types::OutputConfig {
586                format: crate::reports::ReportFormat::Json,
587                ..Default::default()
588            },
589            ..AppConfig::default()
590        };
591
592        // `other` carries only defaults (Balanced / Auto) — nothing should win.
593        base.merge(&AppConfig::default());
594
595        assert_eq!(
596            base.matching.fuzzy_preset,
597            crate::config::FuzzyPreset::Strict,
598            "default fuzzy_preset must not clobber a non-default base"
599        );
600        assert_eq!(
601            base.output.format,
602            crate::reports::ReportFormat::Json,
603            "default output.format must not clobber a non-default base"
604        );
605    }
606
607    #[test]
608    fn test_from_file_with_overrides_cli_wins() {
609        // File sets Strict; an explicit non-default CLI override (Permissive)
610        // must take precedence after the merge.
611        let tmp = TempDir::new().unwrap();
612        let path = tmp.path().join("cfg.yaml");
613        std::fs::write(&path, "matching:\n  fuzzy_preset: strict\n").unwrap();
614
615        let cli = AppConfig {
616            matching: super::super::types::MatchingConfig {
617                fuzzy_preset: crate::config::FuzzyPreset::Permissive,
618                ..Default::default()
619            },
620            ..AppConfig::default()
621        };
622
623        let (merged, loaded_from) = AppConfig::from_file_with_overrides(Some(&path), &cli);
624        assert_eq!(loaded_from.as_deref(), Some(path.as_path()));
625        assert_eq!(
626            merged.matching.fuzzy_preset,
627            crate::config::FuzzyPreset::Permissive
628        );
629    }
630
631    #[test]
632    fn test_generate_example_config() {
633        let example = generate_example_config();
634        assert!(example.contains("matching:"));
635        assert!(example.contains("fuzzy_preset"));
636    }
637
638    #[test]
639    fn unknown_top_level_section_is_a_parse_error_naming_the_key() {
640        // Regression: a typo'd section (`matchingg:`) used to be silently
641        // dropped while `config check` printed "# Valid".
642        let tmp = TempDir::new().unwrap();
643        let path = tmp.path().join("typo.yaml");
644        std::fs::write(&path, "matchingg:\n  fuzzy_preset: strict\n").unwrap();
645
646        let err = load_config_file(&path).expect_err("typo'd section must fail to load");
647        assert!(
648            err.to_string().contains("matchingg"),
649            "error must name the unknown key: {err}"
650        );
651    }
652
653    #[test]
654    fn unknown_nested_key_is_a_parse_error_naming_the_key() {
655        let tmp = TempDir::new().unwrap();
656        let path = tmp.path().join("typo-nested.yaml");
657        std::fs::write(&path, "behavior:\n  fail_on_vulns: true\n").unwrap();
658
659        let err = load_config_file(&path).expect_err("typo'd nested key must fail to load");
660        assert!(
661            err.to_string().contains("fail_on_vulns"),
662            "error must name the unknown key: {err}"
663        );
664
665        // Enrichment section too (it is Option-wrapped, not `default`-only).
666        let path2 = tmp.path().join("typo-enrichment.yaml");
667        std::fs::write(&path2, "enrichment:\n  cache_ttl: 3600\n").unwrap();
668        let err2 = load_config_file(&path2).expect_err("unknown enrichment key must fail");
669        assert!(err2.to_string().contains("cache_ttl"), "{err2}");
670    }
671
672    #[test]
673    fn discover_explicit_missing_path_is_authoritative() {
674        // A missing --config path must NOT silently fall back to discovery;
675        // it is returned as-is so loading reports NotFound.
676        let missing = Path::new("/nonexistent/sbom-tools-test.yaml");
677        assert_eq!(
678            discover_config_file(Some(missing)),
679            Some(missing.to_path_buf())
680        );
681    }
682
683    #[test]
684    fn load_strict_errors_on_missing_explicit_path() {
685        let result = load_strict(Some(Path::new("/nonexistent/sbom-tools-test.yaml")));
686        assert!(matches!(result, Err(ConfigFileError::NotFound(_))));
687    }
688
689    #[test]
690    fn load_strict_errors_on_broken_selected_file() {
691        let tmp = TempDir::new().unwrap();
692        let path = tmp.path().join("broken.yaml");
693        std::fs::write(&path, "matching: [not, a, mapping\n").unwrap();
694        let result = load_strict(Some(&path));
695        assert!(matches!(result, Err(ConfigFileError::Parse(_))));
696    }
697
698    #[test]
699    fn load_strict_loads_valid_explicit_file() {
700        let tmp = TempDir::new().unwrap();
701        let path = tmp.path().join("ok.yaml");
702        std::fs::write(&path, "matching:\n  fuzzy_preset: strict\n").unwrap();
703        let (config, loaded_from) = load_strict(Some(&path)).unwrap();
704        assert_eq!(
705            config.matching.fuzzy_preset,
706            crate::config::FuzzyPreset::Strict
707        );
708        assert_eq!(loaded_from.as_deref(), Some(path.as_path()));
709    }
710
711    #[test]
712    fn test_discover_explicit_path() {
713        let tmp = TempDir::new().unwrap();
714        let config_path = tmp.path().join("custom-config.yaml");
715        let mut file = std::fs::File::create(&config_path).unwrap();
716        writeln!(file, "matching:\n  fuzzy_preset: strict").unwrap();
717
718        let discovered = discover_config_file(Some(&config_path));
719        assert_eq!(discovered, Some(config_path));
720    }
721}