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 return Some(path.to_path_buf());
35 }
36
37 if let Ok(cwd) = std::env::current_dir()
39 && let Some(path) = find_config_in_dir(&cwd) {
40 return Some(path);
41 }
42
43 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 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 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
66fn 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
77fn 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#[derive(Debug)]
98pub enum ConfigFileError {
99 NotFound(PathBuf),
101 Io(std::io::Error),
103 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
141pub 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#[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
167impl AppConfig {
172 pub fn merge(&mut self, other: &Self) {
176 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 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 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 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 if other.graph_diff.enabled {
228 self.graph_diff = other.graph_diff.clone();
229 }
230
231 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 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 if other.tui.theme != "dark" {
252 self.tui.theme.clone_from(&other.tui.theme);
253 }
254
255 if other.enrichment.is_some() {
257 self.enrichment.clone_from(&other.enrichment);
258 }
259 }
260
261 #[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#[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#[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#[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}