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