1use 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]
35pub fn discover_config_file(explicit_path: Option<&Path>) -> Option<PathBuf> {
36 if let Some(path) = explicit_path {
38 return Some(path.to_path_buf());
39 }
40
41 if let Ok(cwd) = std::env::current_dir()
43 && let Some(path) = find_config_in_dir(&cwd)
44 {
45 return Some(path);
46 }
47
48 if let Some(git_root) = find_git_root()
50 && let Some(path) = find_config_in_dir(&git_root)
51 {
52 return Some(path);
53 }
54
55 if let Some(config_dir) = dirs::config_dir() {
57 let sbom_config_dir = config_dir.join("sbom-tools");
58 if let Some(path) = find_config_in_dir(&sbom_config_dir) {
59 return Some(path);
60 }
61 }
62
63 if let Some(home) = dirs::home_dir()
65 && let Some(path) = find_config_in_dir(&home)
66 {
67 return Some(path);
68 }
69
70 None
71}
72
73fn find_config_in_dir(dir: &Path) -> Option<PathBuf> {
75 for name in CONFIG_FILE_NAMES {
76 let path = dir.join(name);
77 if path.exists() {
78 return Some(path);
79 }
80 }
81 None
82}
83
84fn find_git_root() -> Option<PathBuf> {
86 let cwd = std::env::current_dir().ok()?;
87 let mut current = cwd.as_path();
88
89 loop {
90 let git_dir = current.join(".git");
91 if git_dir.exists() {
92 return Some(current.to_path_buf());
93 }
94
95 current = current.parent()?;
96 }
97}
98
99#[derive(Debug)]
105pub enum ConfigFileError {
106 NotFound(PathBuf),
108 Io(std::io::Error),
110 Parse(serde_yaml_ng::Error),
112}
113
114impl std::fmt::Display for ConfigFileError {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 match self {
117 Self::NotFound(path) => {
118 write!(f, "Config file not found: {}", path.display())
119 }
120 Self::Io(e) => write!(f, "Failed to read config file: {e}"),
121 Self::Parse(e) => write!(f, "Failed to parse config file: {e}"),
122 }
123 }
124}
125
126impl std::error::Error for ConfigFileError {
127 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
128 match self {
129 Self::NotFound(_) => None,
130 Self::Io(e) => Some(e),
131 Self::Parse(e) => Some(e),
132 }
133 }
134}
135
136impl From<std::io::Error> for ConfigFileError {
137 fn from(err: std::io::Error) -> Self {
138 Self::Io(err)
139 }
140}
141
142impl From<serde_yaml_ng::Error> for ConfigFileError {
143 fn from(err: serde_yaml_ng::Error) -> Self {
144 Self::Parse(err)
145 }
146}
147
148pub fn load_config_file(path: &Path) -> Result<AppConfig, ConfigFileError> {
150 if !path.exists() {
151 return Err(ConfigFileError::NotFound(path.to_path_buf()));
152 }
153
154 let content = std::fs::read_to_string(path)?;
155 let config: AppConfig = serde_yaml_ng::from_str(&content)?;
156 Ok(config)
157}
158
159#[must_use]
167pub fn load_or_default(explicit_path: Option<&Path>) -> (AppConfig, Option<PathBuf>) {
168 discover_config_file(explicit_path).map_or_else(
169 || (AppConfig::default(), None),
170 |path| match load_config_file(&path) {
171 Ok(config) => (config, Some(path)),
172 Err(e) => {
173 tracing::warn!("Failed to load config from {}: {}", path.display(), e);
174 (AppConfig::default(), None)
175 }
176 },
177 )
178}
179
180pub fn load_strict(
188 explicit_path: Option<&Path>,
189) -> Result<(AppConfig, Option<PathBuf>), ConfigFileError> {
190 match discover_config_file(explicit_path) {
191 None => Ok((AppConfig::default(), None)),
192 Some(path) => {
193 let config = load_config_file(&path)?;
194 Ok((config, Some(path)))
195 }
196 }
197}
198
199impl AppConfig {
204 pub fn merge(&mut self, other: &Self) {
213 let defaults = Self::default();
214
215 if other.matching.fuzzy_preset != defaults.matching.fuzzy_preset {
217 self.matching.fuzzy_preset = other.matching.fuzzy_preset.clone();
218 }
219 if other.matching.threshold.is_some() {
220 self.matching.threshold = other.matching.threshold;
221 }
222 if other.matching.include_unchanged {
223 self.matching.include_unchanged = true;
224 }
225
226 if other.output.format != defaults.output.format {
228 self.output.format = other.output.format;
229 }
230 if other.output.file.is_some() {
231 self.output.file.clone_from(&other.output.file);
232 }
233 if other.output.no_color {
234 self.output.no_color = true;
235 }
236 if other.output.export_template.is_some() {
237 self.output
238 .export_template
239 .clone_from(&other.output.export_template);
240 }
241
242 if other.filtering.only_changes {
244 self.filtering.only_changes = true;
245 }
246 if other.filtering.min_severity.is_some() {
247 self.filtering
248 .min_severity
249 .clone_from(&other.filtering.min_severity);
250 }
251
252 if other.behavior.fail_on_vuln {
254 self.behavior.fail_on_vuln = true;
255 }
256 if other.behavior.fail_on_change {
257 self.behavior.fail_on_change = true;
258 }
259 if other.behavior.quiet {
260 self.behavior.quiet = true;
261 }
262 if other.behavior.explain_matches {
263 self.behavior.explain_matches = true;
264 }
265 if other.behavior.recommend_threshold {
266 self.behavior.recommend_threshold = true;
267 }
268
269 if other.graph_diff.enabled {
271 self.graph_diff = other.graph_diff.clone();
272 }
273
274 if other.rules.rules_file.is_some() {
276 self.rules.rules_file.clone_from(&other.rules.rules_file);
277 }
278 if other.rules.dry_run {
279 self.rules.dry_run = true;
280 }
281
282 if other.ecosystem_rules.config_file.is_some() {
284 self.ecosystem_rules
285 .config_file
286 .clone_from(&other.ecosystem_rules.config_file);
287 }
288 if other.ecosystem_rules.disabled {
289 self.ecosystem_rules.disabled = true;
290 }
291 if other.ecosystem_rules.detect_typosquats {
292 self.ecosystem_rules.detect_typosquats = true;
293 }
294
295 if other.tui.theme != defaults.tui.theme {
297 self.tui.theme = other.tui.theme.clone();
298 }
299
300 if !other.compliance.standards.is_empty() {
302 self.compliance
303 .standards
304 .clone_from(&other.compliance.standards);
305 }
306 if other.compliance.profile.is_some() {
307 self.compliance
308 .profile
309 .clone_from(&other.compliance.profile);
310 }
311 if other.compliance.min_score.is_some() {
312 self.compliance.min_score = other.compliance.min_score;
313 }
314 if other.compliance.fail_on_warning {
315 self.compliance.fail_on_warning = true;
316 }
317 if other.compliance.fail_on_noncompliant {
318 self.compliance.fail_on_noncompliant = true;
319 }
320 if other.compliance.cra_sidecar.is_some() {
321 self.compliance
322 .cra_sidecar
323 .clone_from(&other.compliance.cra_sidecar);
324 }
325 if other.compliance.cra_product_class.is_some() {
326 self.compliance
327 .cra_product_class
328 .clone_from(&other.compliance.cra_product_class);
329 }
330
331 if other.enrichment.is_some() {
333 self.enrichment.clone_from(&other.enrichment);
334 }
335 }
336
337 #[must_use]
339 pub fn from_file_with_overrides(
340 config_path: Option<&Path>,
341 cli_overrides: &Self,
342 ) -> (Self, Option<PathBuf>) {
343 let (mut config, loaded_from) = load_or_default(config_path);
344 config.merge(cli_overrides);
345 (config, loaded_from)
346 }
347}
348
349#[must_use]
355pub fn generate_example_config() -> String {
356 let example = AppConfig::default();
357 format!(
358 r"# SBOM Diff Configuration
359# Place this file at .sbom-tools.yaml in your project root or ~/.config/sbom-tools/
360
361{}
362",
363 serde_yaml_ng::to_string(&example).unwrap_or_default()
364 )
365}
366
367#[must_use]
369pub fn generate_full_example_config() -> String {
370 r"# SBOM Diff Configuration File
371# ==============================
372#
373# This file configures sbom-tools behavior. Place it at:
374# - .sbom-tools.yaml in your project root
375# - ~/.config/sbom-tools/sbom-tools.yaml for global config
376#
377# CLI arguments always override file settings.
378
379# Matching configuration
380matching:
381 # Preset: strict, balanced, permissive, security-focused
382 fuzzy_preset: balanced
383 # Custom threshold (0.0-1.0), overrides preset
384 # threshold: 0.85
385 # Include unchanged components in output
386 include_unchanged: false
387
388# Output configuration
389output:
390 # Format: auto, tui, side-by-side, json, sarif, oscal-json, markdown,
391 # html, summary, table, csv, ndjson
392 format: auto
393 # Output file path (omit for stdout)
394 # file: report.json
395 # Disable colored output
396 no_color: false
397
398# Filtering options
399filtering:
400 # Only show items with changes
401 only_changes: false
402 # Minimum severity filter: critical, high, medium, low, info
403 # min_severity: high
404
405# Behavior flags
406behavior:
407 # Exit with code 2 if new vulnerabilities are introduced
408 fail_on_vuln: false
409 # Exit with code 1 if any changes detected
410 fail_on_change: false
411 # Suppress non-essential output
412 quiet: false
413 # Show detailed match explanations
414 explain_matches: false
415 # Recommend optimal matching threshold
416 recommend_threshold: false
417
418# Graph-aware diffing
419graph_diff:
420 enabled: false
421 detect_reparenting: true
422 detect_depth_changes: true
423
424# Custom matching rules
425rules:
426 # Path to matching rules YAML file
427 # rules_file: ./matching-rules.yaml
428 dry_run: false
429
430# Ecosystem-specific rules
431ecosystem_rules:
432 # Path to ecosystem rules config
433 # config_file: ./ecosystem-rules.yaml
434 disabled: false
435 detect_typosquats: false
436
437# TUI configuration
438tui:
439 # Theme: dark, light, high-contrast
440 theme: dark
441 show_line_numbers: true
442 mouse_enabled: true
443 initial_threshold: 0.8
444
445# Compliance defaults for `validate` / `quality` (CLI flags override)
446compliance:
447 # Default standard(s) for `validate --standard`. Canonical values:
448 # ntia, fda, cra, cra-phase1, ssdf, eo14028, cnsa2, pqc, bsi,
449 # oss-steward, eucc, ai-act, bsi-ai (aliases accepted)
450 # standards: [ntia, cra]
451 # Default scoring profile for `quality --profile`: minimal, standard,
452 # security, license-compliance, cra, bsi, comprehensive, cbom, ai-readiness
453 # profile: standard
454 # Fail `quality` when the overall score is below this (0-100)
455 # min_score: 70
456 # Exit non-zero when `validate` finds warnings
457 fail_on_warning: false
458 # Exit non-zero when `quality` reports NON-COMPLIANT
459 fail_on_noncompliant: false
460 # CRA sidecar metadata file (JSON or YAML); a configured path that fails
461 # to load is a hard error
462 # cra_sidecar: ./app.cra.json
463 # CRA product class: default, important-class-1, important-class-2, critical
464 # cra_product_class: default
465
466# Enrichment configuration (optional)
467# enrichment:
468# enabled: true
469# provider: osv
470# cache_ttl: 3600
471# max_concurrent: 10
472"
473 .to_string()
474}
475
476#[cfg(test)]
481mod tests {
482 use super::*;
483 use std::io::Write;
484 use tempfile::TempDir;
485
486 #[test]
487 fn generated_full_example_config_loads_and_validates() {
488 let tmp = TempDir::new().unwrap();
492 let path = tmp.path().join(".sbom-tools.yaml");
493 std::fs::write(&path, generate_full_example_config()).unwrap();
494 let config =
495 load_config_file(&path).expect("the generated example config must parse cleanly");
496 use crate::config::Validatable;
497 let errors = config.validate();
498 assert!(
499 errors.is_empty(),
500 "the generated example config must validate: {errors:?}"
501 );
502 }
503
504 #[test]
505 fn test_find_config_in_dir() {
506 let tmp = TempDir::new().unwrap();
507 let config_path = tmp.path().join(".sbom-tools.yaml");
508 std::fs::write(&config_path, "matching:\n fuzzy_preset: strict\n").unwrap();
509
510 let found = find_config_in_dir(tmp.path());
511 assert_eq!(found, Some(config_path));
512 }
513
514 #[test]
515 fn test_find_config_in_dir_not_found() {
516 let tmp = TempDir::new().unwrap();
517 let found = find_config_in_dir(tmp.path());
518 assert_eq!(found, None);
519 }
520
521 #[test]
522 fn test_load_config_file() {
523 let tmp = TempDir::new().unwrap();
524 let config_path = tmp.path().join("config.yaml");
525
526 let yaml = r#"
527matching:
528 fuzzy_preset: strict
529 threshold: 0.9
530behavior:
531 fail_on_vuln: true
532"#;
533 std::fs::write(&config_path, yaml).unwrap();
534
535 let config = load_config_file(&config_path).unwrap();
536 assert_eq!(
537 config.matching.fuzzy_preset,
538 crate::config::FuzzyPreset::Strict
539 );
540 assert_eq!(config.matching.threshold, Some(0.9));
541 assert!(config.behavior.fail_on_vuln);
542 }
543
544 #[test]
545 fn test_load_config_file_not_found() {
546 let result = load_config_file(Path::new("/nonexistent/config.yaml"));
547 assert!(matches!(result, Err(ConfigFileError::NotFound(_))));
548 }
549
550 #[test]
551 fn test_config_merge() {
552 let mut base = AppConfig::default();
553 let override_config = AppConfig {
554 matching: super::super::types::MatchingConfig {
555 fuzzy_preset: crate::config::FuzzyPreset::Strict,
556 threshold: Some(0.95),
557 include_unchanged: false,
558 },
559 behavior: super::super::types::BehaviorConfig {
560 fail_on_vuln: true,
561 ..Default::default()
562 },
563 ..AppConfig::default()
564 };
565
566 base.merge(&override_config);
567
568 assert_eq!(
569 base.matching.fuzzy_preset,
570 crate::config::FuzzyPreset::Strict
571 );
572 assert_eq!(base.matching.threshold, Some(0.95));
573 assert!(base.behavior.fail_on_vuln);
574 }
575
576 #[test]
577 fn test_merge_default_valued_override_does_not_clobber_base() {
578 let mut base = AppConfig {
581 matching: super::super::types::MatchingConfig {
582 fuzzy_preset: crate::config::FuzzyPreset::Strict,
583 ..Default::default()
584 },
585 output: super::super::types::OutputConfig {
586 format: crate::reports::ReportFormat::Json,
587 ..Default::default()
588 },
589 ..AppConfig::default()
590 };
591
592 base.merge(&AppConfig::default());
594
595 assert_eq!(
596 base.matching.fuzzy_preset,
597 crate::config::FuzzyPreset::Strict,
598 "default fuzzy_preset must not clobber a non-default base"
599 );
600 assert_eq!(
601 base.output.format,
602 crate::reports::ReportFormat::Json,
603 "default output.format must not clobber a non-default base"
604 );
605 }
606
607 #[test]
608 fn test_from_file_with_overrides_cli_wins() {
609 let tmp = TempDir::new().unwrap();
612 let path = tmp.path().join("cfg.yaml");
613 std::fs::write(&path, "matching:\n fuzzy_preset: strict\n").unwrap();
614
615 let cli = AppConfig {
616 matching: super::super::types::MatchingConfig {
617 fuzzy_preset: crate::config::FuzzyPreset::Permissive,
618 ..Default::default()
619 },
620 ..AppConfig::default()
621 };
622
623 let (merged, loaded_from) = AppConfig::from_file_with_overrides(Some(&path), &cli);
624 assert_eq!(loaded_from.as_deref(), Some(path.as_path()));
625 assert_eq!(
626 merged.matching.fuzzy_preset,
627 crate::config::FuzzyPreset::Permissive
628 );
629 }
630
631 #[test]
632 fn test_generate_example_config() {
633 let example = generate_example_config();
634 assert!(example.contains("matching:"));
635 assert!(example.contains("fuzzy_preset"));
636 }
637
638 #[test]
639 fn unknown_top_level_section_is_a_parse_error_naming_the_key() {
640 let tmp = TempDir::new().unwrap();
643 let path = tmp.path().join("typo.yaml");
644 std::fs::write(&path, "matchingg:\n fuzzy_preset: strict\n").unwrap();
645
646 let err = load_config_file(&path).expect_err("typo'd section must fail to load");
647 assert!(
648 err.to_string().contains("matchingg"),
649 "error must name the unknown key: {err}"
650 );
651 }
652
653 #[test]
654 fn unknown_nested_key_is_a_parse_error_naming_the_key() {
655 let tmp = TempDir::new().unwrap();
656 let path = tmp.path().join("typo-nested.yaml");
657 std::fs::write(&path, "behavior:\n fail_on_vulns: true\n").unwrap();
658
659 let err = load_config_file(&path).expect_err("typo'd nested key must fail to load");
660 assert!(
661 err.to_string().contains("fail_on_vulns"),
662 "error must name the unknown key: {err}"
663 );
664
665 let path2 = tmp.path().join("typo-enrichment.yaml");
667 std::fs::write(&path2, "enrichment:\n cache_ttl: 3600\n").unwrap();
668 let err2 = load_config_file(&path2).expect_err("unknown enrichment key must fail");
669 assert!(err2.to_string().contains("cache_ttl"), "{err2}");
670 }
671
672 #[test]
673 fn discover_explicit_missing_path_is_authoritative() {
674 let missing = Path::new("/nonexistent/sbom-tools-test.yaml");
677 assert_eq!(
678 discover_config_file(Some(missing)),
679 Some(missing.to_path_buf())
680 );
681 }
682
683 #[test]
684 fn load_strict_errors_on_missing_explicit_path() {
685 let result = load_strict(Some(Path::new("/nonexistent/sbom-tools-test.yaml")));
686 assert!(matches!(result, Err(ConfigFileError::NotFound(_))));
687 }
688
689 #[test]
690 fn load_strict_errors_on_broken_selected_file() {
691 let tmp = TempDir::new().unwrap();
692 let path = tmp.path().join("broken.yaml");
693 std::fs::write(&path, "matching: [not, a, mapping\n").unwrap();
694 let result = load_strict(Some(&path));
695 assert!(matches!(result, Err(ConfigFileError::Parse(_))));
696 }
697
698 #[test]
699 fn load_strict_loads_valid_explicit_file() {
700 let tmp = TempDir::new().unwrap();
701 let path = tmp.path().join("ok.yaml");
702 std::fs::write(&path, "matching:\n fuzzy_preset: strict\n").unwrap();
703 let (config, loaded_from) = load_strict(Some(&path)).unwrap();
704 assert_eq!(
705 config.matching.fuzzy_preset,
706 crate::config::FuzzyPreset::Strict
707 );
708 assert_eq!(loaded_from.as_deref(), Some(path.as_path()));
709 }
710
711 #[test]
712 fn test_discover_explicit_path() {
713 let tmp = TempDir::new().unwrap();
714 let config_path = tmp.path().join("custom-config.yaml");
715 let mut file = std::fs::File::create(&config_path).unwrap();
716 writeln!(file, "matching:\n fuzzy_preset: strict").unwrap();
717
718 let discovered = discover_config_file(Some(&config_path));
719 assert_eq!(discovered, Some(config_path));
720 }
721}