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#[must_use]
30pub fn discover_config_file(explicit_path: Option<&Path>) -> Option<PathBuf> {
31    // 1. Use explicit path if provided
32    if let Some(path) = explicit_path
33        && path.exists()
34    {
35        return Some(path.to_path_buf());
36    }
37
38    // 2. Search current directory
39    if let Ok(cwd) = std::env::current_dir()
40        && let Some(path) = find_config_in_dir(&cwd)
41    {
42        return Some(path);
43    }
44
45    // 3. Search git root (if in a repo)
46    if let Some(git_root) = find_git_root()
47        && let Some(path) = find_config_in_dir(&git_root)
48    {
49        return Some(path);
50    }
51
52    // 4. Search user config directory
53    if let Some(config_dir) = dirs::config_dir() {
54        let sbom_config_dir = config_dir.join("sbom-tools");
55        if let Some(path) = find_config_in_dir(&sbom_config_dir) {
56            return Some(path);
57        }
58    }
59
60    // 5. Search home directory
61    if let Some(home) = dirs::home_dir()
62        && let Some(path) = find_config_in_dir(&home)
63    {
64        return Some(path);
65    }
66
67    None
68}
69
70/// Find a config file in a specific directory.
71fn find_config_in_dir(dir: &Path) -> Option<PathBuf> {
72    for name in CONFIG_FILE_NAMES {
73        let path = dir.join(name);
74        if path.exists() {
75            return Some(path);
76        }
77    }
78    None
79}
80
81/// Find the git repository root by walking up the directory tree.
82fn find_git_root() -> Option<PathBuf> {
83    let cwd = std::env::current_dir().ok()?;
84    let mut current = cwd.as_path();
85
86    loop {
87        let git_dir = current.join(".git");
88        if git_dir.exists() {
89            return Some(current.to_path_buf());
90        }
91
92        current = current.parent()?;
93    }
94}
95
96// ============================================================================
97// Configuration File Loading
98// ============================================================================
99
100/// Error type for config file operations.
101#[derive(Debug)]
102pub enum ConfigFileError {
103    /// File not found
104    NotFound(PathBuf),
105    /// IO error reading file
106    Io(std::io::Error),
107    /// YAML parsing error
108    Parse(serde_yaml_ng::Error),
109}
110
111impl std::fmt::Display for ConfigFileError {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self {
114            Self::NotFound(path) => {
115                write!(f, "Config file not found: {}", path.display())
116            }
117            Self::Io(e) => write!(f, "Failed to read config file: {e}"),
118            Self::Parse(e) => write!(f, "Failed to parse config file: {e}"),
119        }
120    }
121}
122
123impl std::error::Error for ConfigFileError {
124    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
125        match self {
126            Self::NotFound(_) => None,
127            Self::Io(e) => Some(e),
128            Self::Parse(e) => Some(e),
129        }
130    }
131}
132
133impl From<std::io::Error> for ConfigFileError {
134    fn from(err: std::io::Error) -> Self {
135        Self::Io(err)
136    }
137}
138
139impl From<serde_yaml_ng::Error> for ConfigFileError {
140    fn from(err: serde_yaml_ng::Error) -> Self {
141        Self::Parse(err)
142    }
143}
144
145/// Load an `AppConfig` from a YAML file.
146pub fn load_config_file(path: &Path) -> Result<AppConfig, ConfigFileError> {
147    if !path.exists() {
148        return Err(ConfigFileError::NotFound(path.to_path_buf()));
149    }
150
151    let content = std::fs::read_to_string(path)?;
152    let config: AppConfig = serde_yaml_ng::from_str(&content)?;
153    Ok(config)
154}
155
156/// Load config from discovered file, or return default.
157#[must_use]
158pub fn load_or_default(explicit_path: Option<&Path>) -> (AppConfig, Option<PathBuf>) {
159    discover_config_file(explicit_path).map_or_else(
160        || (AppConfig::default(), None),
161        |path| match load_config_file(&path) {
162            Ok(config) => (config, Some(path)),
163            Err(e) => {
164                tracing::warn!("Failed to load config from {}: {}", path.display(), e);
165                (AppConfig::default(), None)
166            }
167        },
168    )
169}
170
171// ============================================================================
172// Configuration Merging
173// ============================================================================
174
175impl AppConfig {
176    /// Merge another config into this one, with `other` taking precedence.
177    ///
178    /// This is useful for layering CLI args over file config.
179    pub fn merge(&mut self, other: &Self) {
180        // Matching config
181        if other.matching.fuzzy_preset != crate::config::FuzzyPreset::Balanced {
182            self.matching.fuzzy_preset = other.matching.fuzzy_preset.clone();
183        }
184        if other.matching.threshold.is_some() {
185            self.matching.threshold = other.matching.threshold;
186        }
187        if other.matching.include_unchanged {
188            self.matching.include_unchanged = true;
189        }
190
191        // Output config - only override if explicitly set
192        if other.output.format != crate::reports::ReportFormat::Auto {
193            self.output.format = other.output.format;
194        }
195        if other.output.file.is_some() {
196            self.output.file.clone_from(&other.output.file);
197        }
198        if other.output.no_color {
199            self.output.no_color = true;
200        }
201        if other.output.export_template.is_some() {
202            self.output
203                .export_template
204                .clone_from(&other.output.export_template);
205        }
206
207        // Filtering config
208        if other.filtering.only_changes {
209            self.filtering.only_changes = true;
210        }
211        if other.filtering.min_severity.is_some() {
212            self.filtering
213                .min_severity
214                .clone_from(&other.filtering.min_severity);
215        }
216
217        // Behavior config (booleans - if set to true, override)
218        if other.behavior.fail_on_vuln {
219            self.behavior.fail_on_vuln = true;
220        }
221        if other.behavior.fail_on_change {
222            self.behavior.fail_on_change = true;
223        }
224        if other.behavior.quiet {
225            self.behavior.quiet = true;
226        }
227        if other.behavior.explain_matches {
228            self.behavior.explain_matches = true;
229        }
230        if other.behavior.recommend_threshold {
231            self.behavior.recommend_threshold = true;
232        }
233
234        // Graph diff config
235        if other.graph_diff.enabled {
236            self.graph_diff = other.graph_diff.clone();
237        }
238
239        // Rules config
240        if other.rules.rules_file.is_some() {
241            self.rules.rules_file.clone_from(&other.rules.rules_file);
242        }
243        if other.rules.dry_run {
244            self.rules.dry_run = true;
245        }
246
247        // Ecosystem rules config
248        if other.ecosystem_rules.config_file.is_some() {
249            self.ecosystem_rules
250                .config_file
251                .clone_from(&other.ecosystem_rules.config_file);
252        }
253        if other.ecosystem_rules.disabled {
254            self.ecosystem_rules.disabled = true;
255        }
256        if other.ecosystem_rules.detect_typosquats {
257            self.ecosystem_rules.detect_typosquats = true;
258        }
259
260        // TUI config
261        if other.tui.theme != crate::config::ThemeName::Dark {
262            self.tui.theme = other.tui.theme.clone();
263        }
264
265        // Enrichment config
266        if other.enrichment.is_some() {
267            self.enrichment.clone_from(&other.enrichment);
268        }
269    }
270
271    /// Load from file and merge with CLI overrides.
272    #[must_use]
273    pub fn from_file_with_overrides(
274        config_path: Option<&Path>,
275        cli_overrides: &Self,
276    ) -> (Self, Option<PathBuf>) {
277        let (mut config, loaded_from) = load_or_default(config_path);
278        config.merge(cli_overrides);
279        (config, loaded_from)
280    }
281}
282
283// ============================================================================
284// Example Config Generation
285// ============================================================================
286
287/// Generate an example config file content.
288#[must_use]
289pub fn generate_example_config() -> String {
290    let example = AppConfig::default();
291    format!(
292        r"# SBOM Diff Configuration
293# Place this file at .sbom-tools.yaml in your project root or ~/.config/sbom-tools/
294
295{}
296",
297        serde_yaml_ng::to_string(&example).unwrap_or_default()
298    )
299}
300
301/// Generate a commented example config with all options.
302#[must_use]
303pub fn generate_full_example_config() -> String {
304    r"# SBOM Diff Configuration File
305# ==============================
306#
307# This file configures sbom-tools behavior. Place it at:
308#   - .sbom-tools.yaml in your project root
309#   - ~/.config/sbom-tools/sbom-tools.yaml for global config
310#
311# CLI arguments always override file settings.
312
313# Matching configuration
314matching:
315  # Preset: strict, balanced, permissive, security-focused
316  fuzzy_preset: balanced
317  # Custom threshold (0.0-1.0), overrides preset
318  # threshold: 0.85
319  # Include unchanged components in output
320  include_unchanged: false
321
322# Output configuration
323output:
324  # Format: auto, json, text, sarif, markdown, html
325  format: auto
326  # Output file path (omit for stdout)
327  # file: report.json
328  # Disable colored output
329  no_color: false
330
331# Filtering options
332filtering:
333  # Only show items with changes
334  only_changes: false
335  # Minimum severity filter: critical, high, medium, low, info
336  # min_severity: high
337
338# Behavior flags
339behavior:
340  # Exit with code 2 if new vulnerabilities are introduced
341  fail_on_vuln: false
342  # Exit with code 1 if any changes detected
343  fail_on_change: false
344  # Suppress non-essential output
345  quiet: false
346  # Show detailed match explanations
347  explain_matches: false
348  # Recommend optimal matching threshold
349  recommend_threshold: false
350
351# Graph-aware diffing
352graph_diff:
353  enabled: false
354  detect_reparenting: true
355  detect_depth_changes: true
356
357# Custom matching rules
358rules:
359  # Path to matching rules YAML file
360  # rules_file: ./matching-rules.yaml
361  dry_run: false
362
363# Ecosystem-specific rules
364ecosystem_rules:
365  # Path to ecosystem rules config
366  # config_file: ./ecosystem-rules.yaml
367  disabled: false
368  detect_typosquats: false
369
370# TUI configuration
371tui:
372  # Theme: dark, light, high-contrast
373  theme: dark
374  show_line_numbers: true
375  mouse_enabled: true
376  initial_threshold: 0.8
377
378# Enrichment configuration (optional)
379# enrichment:
380#   enabled: true
381#   provider: osv
382#   cache_ttl: 3600
383#   max_concurrent: 10
384"
385    .to_string()
386}
387
388// ============================================================================
389// Tests
390// ============================================================================
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use std::io::Write;
396    use tempfile::TempDir;
397
398    #[test]
399    fn test_find_config_in_dir() {
400        let tmp = TempDir::new().unwrap();
401        let config_path = tmp.path().join(".sbom-tools.yaml");
402        std::fs::write(&config_path, "matching:\n  fuzzy_preset: strict\n").unwrap();
403
404        let found = find_config_in_dir(tmp.path());
405        assert_eq!(found, Some(config_path));
406    }
407
408    #[test]
409    fn test_find_config_in_dir_not_found() {
410        let tmp = TempDir::new().unwrap();
411        let found = find_config_in_dir(tmp.path());
412        assert_eq!(found, None);
413    }
414
415    #[test]
416    fn test_load_config_file() {
417        let tmp = TempDir::new().unwrap();
418        let config_path = tmp.path().join("config.yaml");
419
420        let yaml = r#"
421matching:
422  fuzzy_preset: strict
423  threshold: 0.9
424behavior:
425  fail_on_vuln: true
426"#;
427        std::fs::write(&config_path, yaml).unwrap();
428
429        let config = load_config_file(&config_path).unwrap();
430        assert_eq!(
431            config.matching.fuzzy_preset,
432            crate::config::FuzzyPreset::Strict
433        );
434        assert_eq!(config.matching.threshold, Some(0.9));
435        assert!(config.behavior.fail_on_vuln);
436    }
437
438    #[test]
439    fn test_load_config_file_not_found() {
440        let result = load_config_file(Path::new("/nonexistent/config.yaml"));
441        assert!(matches!(result, Err(ConfigFileError::NotFound(_))));
442    }
443
444    #[test]
445    fn test_config_merge() {
446        let mut base = AppConfig::default();
447        let override_config = AppConfig {
448            matching: super::super::types::MatchingConfig {
449                fuzzy_preset: crate::config::FuzzyPreset::Strict,
450                threshold: Some(0.95),
451                include_unchanged: false,
452            },
453            behavior: super::super::types::BehaviorConfig {
454                fail_on_vuln: true,
455                ..Default::default()
456            },
457            ..AppConfig::default()
458        };
459
460        base.merge(&override_config);
461
462        assert_eq!(
463            base.matching.fuzzy_preset,
464            crate::config::FuzzyPreset::Strict
465        );
466        assert_eq!(base.matching.threshold, Some(0.95));
467        assert!(base.behavior.fail_on_vuln);
468    }
469
470    #[test]
471    fn test_generate_example_config() {
472        let example = generate_example_config();
473        assert!(example.contains("matching:"));
474        assert!(example.contains("fuzzy_preset"));
475    }
476
477    #[test]
478    fn test_discover_explicit_path() {
479        let tmp = TempDir::new().unwrap();
480        let config_path = tmp.path().join("custom-config.yaml");
481        let mut file = std::fs::File::create(&config_path).unwrap();
482        writeln!(file, "matching:\n  fuzzy_preset: strict").unwrap();
483
484        let discovered = discover_config_file(Some(&config_path));
485        assert_eq!(discovered, Some(config_path));
486    }
487}