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
21pub fn discover_config_file(explicit_path: Option<&Path>) -> Option<PathBuf> {
30 if let Some(path) = explicit_path {
32 if path.exists() {
33 return Some(path.to_path_buf());
34 }
35 }
36
37 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 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 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 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
69fn 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
80fn 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#[derive(Debug)]
101pub enum ConfigFileError {
102 NotFound(PathBuf),
104 Io(std::io::Error),
106 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
144pub 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
155pub 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
169impl AppConfig {
174 pub fn merge(&mut self, other: &AppConfig) {
178 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 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 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 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 if other.graph_diff.enabled {
227 self.graph_diff = other.graph_diff.clone();
228 }
229
230 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 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 if other.tui.theme != "dark" {
251 self.tui.theme = other.tui.theme.clone();
252 }
253
254 if other.enrichment.is_some() {
256 self.enrichment = other.enrichment.clone();
257 }
258 }
259
260 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
271pub 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
288pub 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#[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}