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