use super::types::AppConfig;
use std::path::{Path, PathBuf};
const CONFIG_FILE_NAMES: &[&str] = &[
".sbom-tools.yaml",
".sbom-tools.yml",
"sbom-tools.yaml",
"sbom-tools.yml",
".sbom-toolsrc",
];
#[must_use]
pub fn discover_config_file(explicit_path: Option<&Path>) -> Option<PathBuf> {
if let Some(path) = explicit_path
&& path.exists()
{
return Some(path.to_path_buf());
}
if let Ok(cwd) = std::env::current_dir()
&& let Some(path) = find_config_in_dir(&cwd)
{
return Some(path);
}
if let Some(git_root) = find_git_root()
&& let Some(path) = find_config_in_dir(&git_root)
{
return Some(path);
}
if let Some(config_dir) = dirs::config_dir() {
let sbom_config_dir = config_dir.join("sbom-tools");
if let Some(path) = find_config_in_dir(&sbom_config_dir) {
return Some(path);
}
}
if let Some(home) = dirs::home_dir()
&& let Some(path) = find_config_in_dir(&home)
{
return Some(path);
}
None
}
fn find_config_in_dir(dir: &Path) -> Option<PathBuf> {
for name in CONFIG_FILE_NAMES {
let path = dir.join(name);
if path.exists() {
return Some(path);
}
}
None
}
fn find_git_root() -> Option<PathBuf> {
let cwd = std::env::current_dir().ok()?;
let mut current = cwd.as_path();
loop {
let git_dir = current.join(".git");
if git_dir.exists() {
return Some(current.to_path_buf());
}
current = current.parent()?;
}
}
#[derive(Debug)]
pub enum ConfigFileError {
NotFound(PathBuf),
Io(std::io::Error),
Parse(serde_yaml_ng::Error),
}
impl std::fmt::Display for ConfigFileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound(path) => {
write!(f, "Config file not found: {}", path.display())
}
Self::Io(e) => write!(f, "Failed to read config file: {e}"),
Self::Parse(e) => write!(f, "Failed to parse config file: {e}"),
}
}
}
impl std::error::Error for ConfigFileError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::NotFound(_) => None,
Self::Io(e) => Some(e),
Self::Parse(e) => Some(e),
}
}
}
impl From<std::io::Error> for ConfigFileError {
fn from(err: std::io::Error) -> Self {
Self::Io(err)
}
}
impl From<serde_yaml_ng::Error> for ConfigFileError {
fn from(err: serde_yaml_ng::Error) -> Self {
Self::Parse(err)
}
}
pub fn load_config_file(path: &Path) -> Result<AppConfig, ConfigFileError> {
if !path.exists() {
return Err(ConfigFileError::NotFound(path.to_path_buf()));
}
let content = std::fs::read_to_string(path)?;
let config: AppConfig = serde_yaml_ng::from_str(&content)?;
Ok(config)
}
#[must_use]
pub fn load_or_default(explicit_path: Option<&Path>) -> (AppConfig, Option<PathBuf>) {
discover_config_file(explicit_path).map_or_else(
|| (AppConfig::default(), None),
|path| match load_config_file(&path) {
Ok(config) => (config, Some(path)),
Err(e) => {
tracing::warn!("Failed to load config from {}: {}", path.display(), e);
(AppConfig::default(), None)
}
},
)
}
impl AppConfig {
pub fn merge(&mut self, other: &Self) {
if other.matching.fuzzy_preset != crate::config::FuzzyPreset::Balanced {
self.matching.fuzzy_preset = other.matching.fuzzy_preset.clone();
}
if other.matching.threshold.is_some() {
self.matching.threshold = other.matching.threshold;
}
if other.matching.include_unchanged {
self.matching.include_unchanged = true;
}
if other.output.format != crate::reports::ReportFormat::Auto {
self.output.format = other.output.format;
}
if other.output.file.is_some() {
self.output.file.clone_from(&other.output.file);
}
if other.output.no_color {
self.output.no_color = true;
}
if other.output.export_template.is_some() {
self.output
.export_template
.clone_from(&other.output.export_template);
}
if other.filtering.only_changes {
self.filtering.only_changes = true;
}
if other.filtering.min_severity.is_some() {
self.filtering
.min_severity
.clone_from(&other.filtering.min_severity);
}
if other.behavior.fail_on_vuln {
self.behavior.fail_on_vuln = true;
}
if other.behavior.fail_on_change {
self.behavior.fail_on_change = true;
}
if other.behavior.quiet {
self.behavior.quiet = true;
}
if other.behavior.explain_matches {
self.behavior.explain_matches = true;
}
if other.behavior.recommend_threshold {
self.behavior.recommend_threshold = true;
}
if other.graph_diff.enabled {
self.graph_diff = other.graph_diff.clone();
}
if other.rules.rules_file.is_some() {
self.rules.rules_file.clone_from(&other.rules.rules_file);
}
if other.rules.dry_run {
self.rules.dry_run = true;
}
if other.ecosystem_rules.config_file.is_some() {
self.ecosystem_rules
.config_file
.clone_from(&other.ecosystem_rules.config_file);
}
if other.ecosystem_rules.disabled {
self.ecosystem_rules.disabled = true;
}
if other.ecosystem_rules.detect_typosquats {
self.ecosystem_rules.detect_typosquats = true;
}
if other.tui.theme != crate::config::ThemeName::Dark {
self.tui.theme = other.tui.theme.clone();
}
if other.enrichment.is_some() {
self.enrichment.clone_from(&other.enrichment);
}
}
#[must_use]
pub fn from_file_with_overrides(
config_path: Option<&Path>,
cli_overrides: &Self,
) -> (Self, Option<PathBuf>) {
let (mut config, loaded_from) = load_or_default(config_path);
config.merge(cli_overrides);
(config, loaded_from)
}
}
#[must_use]
pub fn generate_example_config() -> String {
let example = AppConfig::default();
format!(
r"# SBOM Diff Configuration
# Place this file at .sbom-tools.yaml in your project root or ~/.config/sbom-tools/
{}
",
serde_yaml_ng::to_string(&example).unwrap_or_default()
)
}
#[must_use]
pub fn generate_full_example_config() -> String {
r"# SBOM Diff Configuration File
# ==============================
#
# This file configures sbom-tools behavior. Place it at:
# - .sbom-tools.yaml in your project root
# - ~/.config/sbom-tools/sbom-tools.yaml for global config
#
# CLI arguments always override file settings.
# Matching configuration
matching:
# Preset: strict, balanced, permissive, security-focused
fuzzy_preset: balanced
# Custom threshold (0.0-1.0), overrides preset
# threshold: 0.85
# Include unchanged components in output
include_unchanged: false
# Output configuration
output:
# Format: auto, json, text, sarif, markdown, html
format: auto
# Output file path (omit for stdout)
# file: report.json
# Disable colored output
no_color: false
# Filtering options
filtering:
# Only show items with changes
only_changes: false
# Minimum severity filter: critical, high, medium, low, info
# min_severity: high
# Behavior flags
behavior:
# Exit with code 2 if new vulnerabilities are introduced
fail_on_vuln: false
# Exit with code 1 if any changes detected
fail_on_change: false
# Suppress non-essential output
quiet: false
# Show detailed match explanations
explain_matches: false
# Recommend optimal matching threshold
recommend_threshold: false
# Graph-aware diffing
graph_diff:
enabled: false
detect_reparenting: true
detect_depth_changes: true
# Custom matching rules
rules:
# Path to matching rules YAML file
# rules_file: ./matching-rules.yaml
dry_run: false
# Ecosystem-specific rules
ecosystem_rules:
# Path to ecosystem rules config
# config_file: ./ecosystem-rules.yaml
disabled: false
detect_typosquats: false
# TUI configuration
tui:
# Theme: dark, light, high-contrast
theme: dark
show_line_numbers: true
mouse_enabled: true
initial_threshold: 0.8
# Enrichment configuration (optional)
# enrichment:
# enabled: true
# provider: osv
# cache_ttl: 3600
# max_concurrent: 10
"
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn test_find_config_in_dir() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join(".sbom-tools.yaml");
std::fs::write(&config_path, "matching:\n fuzzy_preset: strict\n").unwrap();
let found = find_config_in_dir(tmp.path());
assert_eq!(found, Some(config_path));
}
#[test]
fn test_find_config_in_dir_not_found() {
let tmp = TempDir::new().unwrap();
let found = find_config_in_dir(tmp.path());
assert_eq!(found, None);
}
#[test]
fn test_load_config_file() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.yaml");
let yaml = r#"
matching:
fuzzy_preset: strict
threshold: 0.9
behavior:
fail_on_vuln: true
"#;
std::fs::write(&config_path, yaml).unwrap();
let config = load_config_file(&config_path).unwrap();
assert_eq!(
config.matching.fuzzy_preset,
crate::config::FuzzyPreset::Strict
);
assert_eq!(config.matching.threshold, Some(0.9));
assert!(config.behavior.fail_on_vuln);
}
#[test]
fn test_load_config_file_not_found() {
let result = load_config_file(Path::new("/nonexistent/config.yaml"));
assert!(matches!(result, Err(ConfigFileError::NotFound(_))));
}
#[test]
fn test_config_merge() {
let mut base = AppConfig::default();
let override_config = AppConfig {
matching: super::super::types::MatchingConfig {
fuzzy_preset: crate::config::FuzzyPreset::Strict,
threshold: Some(0.95),
include_unchanged: false,
},
behavior: super::super::types::BehaviorConfig {
fail_on_vuln: true,
..Default::default()
},
..AppConfig::default()
};
base.merge(&override_config);
assert_eq!(
base.matching.fuzzy_preset,
crate::config::FuzzyPreset::Strict
);
assert_eq!(base.matching.threshold, Some(0.95));
assert!(base.behavior.fail_on_vuln);
}
#[test]
fn test_generate_example_config() {
let example = generate_example_config();
assert!(example.contains("matching:"));
assert!(example.contains("fuzzy_preset"));
}
#[test]
fn test_discover_explicit_path() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("custom-config.yaml");
let mut file = std::fs::File::create(&config_path).unwrap();
writeln!(file, "matching:\n fuzzy_preset: strict").unwrap();
let discovered = discover_config_file(Some(&config_path));
assert_eq!(discovered, Some(config_path));
}
}