sbom_tools/config/
file.rs1use super::types::AppConfig;
6use std::path::{Path, PathBuf};
7
8const 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#[must_use]
30pub fn discover_config_file(explicit_path: Option<&Path>) -> Option<PathBuf> {
31 if let Some(path) = explicit_path
33 && path.exists()
34 {
35 return Some(path.to_path_buf());
36 }
37
38 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 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 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 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
70fn 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
81fn 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#[derive(Debug)]
102pub enum ConfigFileError {
103 NotFound(PathBuf),
105 Io(std::io::Error),
107 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
145pub 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#[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
171impl AppConfig {
176 pub fn merge(&mut self, other: &Self) {
180 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 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 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 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 if other.graph_diff.enabled {
236 self.graph_diff = other.graph_diff.clone();
237 }
238
239 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 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 if other.tui.theme != crate::config::ThemeName::Dark {
262 self.tui.theme = other.tui.theme.clone();
263 }
264
265 if other.enrichment.is_some() {
267 self.enrichment.clone_from(&other.enrichment);
268 }
269 }
270
271 #[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#[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#[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#[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}