use crate::matching::FuzzyMatchConfig;
use crate::reports::{ReportFormat, ReportType};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct AppConfig {
pub matching: MatchingConfig,
pub output: OutputConfig,
pub filtering: FilterConfig,
pub behavior: BehaviorConfig,
pub graph_diff: GraphAwareDiffConfig,
pub rules: MatchingRulesPathConfig,
pub ecosystem_rules: EcosystemRulesConfig,
pub tui: TuiConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub enrichment: Option<EnrichmentConfig>,
}
impl AppConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn builder() -> AppConfigBuilder {
AppConfigBuilder::default()
}
}
#[derive(Debug, Default)]
#[must_use]
pub struct AppConfigBuilder {
config: AppConfig,
}
impl AppConfigBuilder {
pub fn fuzzy_preset(mut self, preset: FuzzyPreset) -> Self {
self.config.matching.fuzzy_preset = preset;
self
}
pub const fn matching_threshold(mut self, threshold: f64) -> Self {
self.config.matching.threshold = Some(threshold);
self
}
pub const fn output_format(mut self, format: ReportFormat) -> Self {
self.config.output.format = format;
self
}
pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
self.config.output.file = file;
self
}
pub const fn no_color(mut self, no_color: bool) -> Self {
self.config.output.no_color = no_color;
self
}
pub const fn include_unchanged(mut self, include: bool) -> Self {
self.config.matching.include_unchanged = include;
self
}
pub const fn fail_on_vuln(mut self, fail: bool) -> Self {
self.config.behavior.fail_on_vuln = fail;
self
}
pub const fn fail_on_change(mut self, fail: bool) -> Self {
self.config.behavior.fail_on_change = fail;
self
}
pub const fn quiet(mut self, quiet: bool) -> Self {
self.config.behavior.quiet = quiet;
self
}
pub fn graph_diff(mut self, enabled: bool) -> Self {
self.config.graph_diff = if enabled {
GraphAwareDiffConfig::enabled()
} else {
GraphAwareDiffConfig::default()
};
self
}
pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
self.config.rules.rules_file = file;
self
}
pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
self.config.ecosystem_rules.config_file = file;
self
}
pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
self.config.enrichment = Some(config);
self
}
#[must_use]
pub fn build(self) -> AppConfig {
self.config
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum ThemeName {
#[default]
Dark,
Light,
HighContrast,
}
impl ThemeName {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Dark => "dark",
Self::Light => "light",
Self::HighContrast => "high-contrast",
}
}
}
impl std::fmt::Display for ThemeName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for ThemeName {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"dark" => Ok(Self::Dark),
"light" => Ok(Self::Light),
"high-contrast" | "highcontrast" | "hc" => Ok(Self::HighContrast),
_ => Err(format!("unknown theme: {s}")),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum FuzzyPreset {
Strict,
#[default]
Balanced,
Permissive,
StrictMulti,
BalancedMulti,
SecurityFocused,
}
impl FuzzyPreset {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Strict => "strict",
Self::Balanced => "balanced",
Self::Permissive => "permissive",
Self::StrictMulti => "strict-multi",
Self::BalancedMulti => "balanced-multi",
Self::SecurityFocused => "security-focused",
}
}
}
impl std::str::FromStr for FuzzyPreset {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().replace('_', "-").as_str() {
"strict" => Ok(Self::Strict),
"balanced" => Ok(Self::Balanced),
"permissive" => Ok(Self::Permissive),
"strict-multi" => Ok(Self::StrictMulti),
"balanced-multi" => Ok(Self::BalancedMulti),
"security-focused" => Ok(Self::SecurityFocused),
_ => Err(format!("unknown fuzzy preset: {s}")),
}
}
}
impl std::fmt::Display for FuzzyPreset {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TuiPreferences {
pub theme: ThemeName,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_tab: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_view_tab: Option<String>,
}
impl Default for TuiPreferences {
fn default() -> Self {
Self {
theme: ThemeName::Dark,
last_tab: None,
last_view_tab: None,
}
}
}
impl TuiPreferences {
#[must_use]
pub fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("sbom-tools").join("preferences.json"))
}
#[must_use]
pub fn load() -> Self {
Self::config_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(&self) -> std::io::Result<()> {
if let Some(path) = Self::config_path() {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(path, json)?;
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct TuiConfig {
pub theme: ThemeName,
pub show_line_numbers: bool,
pub mouse_enabled: bool,
#[schemars(range(min = 0.0, max = 1.0))]
pub initial_threshold: f64,
}
impl Default for TuiConfig {
fn default() -> Self {
Self {
theme: ThemeName::Dark,
show_line_numbers: true,
mouse_enabled: true,
initial_threshold: 0.8,
}
}
}
#[derive(Debug, Clone)]
pub struct DiffConfig {
pub paths: DiffPaths,
pub output: OutputConfig,
pub matching: MatchingConfig,
pub filtering: FilterConfig,
pub behavior: BehaviorConfig,
pub graph_diff: GraphAwareDiffConfig,
pub rules: MatchingRulesPathConfig,
pub ecosystem_rules: EcosystemRulesConfig,
pub enrichment: EnrichmentConfig,
}
#[derive(Debug, Clone)]
pub struct DiffPaths {
pub old: PathBuf,
pub new: PathBuf,
}
#[derive(Debug, Clone)]
pub struct ViewConfig {
pub sbom_path: PathBuf,
pub output: OutputConfig,
pub validate_ntia: bool,
pub min_severity: Option<String>,
pub vulnerable_only: bool,
pub ecosystem_filter: Option<String>,
pub fail_on_vuln: bool,
pub bom_profile: Option<crate::model::BomProfile>,
pub enrichment: EnrichmentConfig,
}
#[derive(Debug, Clone)]
pub struct MultiDiffConfig {
pub baseline: PathBuf,
pub targets: Vec<PathBuf>,
pub output: OutputConfig,
pub matching: MatchingConfig,
pub filtering: FilterConfig,
pub behavior: BehaviorConfig,
pub graph_diff: GraphAwareDiffConfig,
pub rules: MatchingRulesPathConfig,
pub ecosystem_rules: EcosystemRulesConfig,
pub enrichment: EnrichmentConfig,
}
#[derive(Debug, Clone)]
pub struct TimelineConfig {
pub sbom_paths: Vec<PathBuf>,
pub output: OutputConfig,
pub matching: MatchingConfig,
pub filtering: FilterConfig,
pub behavior: BehaviorConfig,
pub graph_diff: GraphAwareDiffConfig,
pub rules: MatchingRulesPathConfig,
pub ecosystem_rules: EcosystemRulesConfig,
pub enrichment: EnrichmentConfig,
}
#[derive(Debug, Clone)]
pub struct QueryConfig {
pub sbom_paths: Vec<PathBuf>,
pub output: OutputConfig,
pub enrichment: EnrichmentConfig,
pub limit: Option<usize>,
pub group_by_sbom: bool,
}
#[derive(Debug, Clone)]
pub struct MatrixConfig {
pub sbom_paths: Vec<PathBuf>,
pub output: OutputConfig,
pub matching: MatchingConfig,
pub cluster_threshold: f64,
pub filtering: FilterConfig,
pub behavior: BehaviorConfig,
pub graph_diff: GraphAwareDiffConfig,
pub rules: MatchingRulesPathConfig,
pub ecosystem_rules: EcosystemRulesConfig,
pub enrichment: EnrichmentConfig,
}
#[derive(Debug, Clone)]
pub struct VexConfig {
pub sbom_path: PathBuf,
pub vex_paths: Vec<PathBuf>,
pub output_format: ReportFormat,
pub output_file: Option<PathBuf>,
pub quiet: bool,
pub actionable_only: bool,
pub filter_state: Option<String>,
pub enrichment: EnrichmentConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct OutputConfig {
pub format: ReportFormat,
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<PathBuf>,
pub report_types: ReportType,
pub no_color: bool,
pub streaming: StreamingConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub export_template: Option<String>,
}
impl Default for OutputConfig {
fn default() -> Self {
Self {
format: ReportFormat::Auto,
file: None,
report_types: ReportType::All,
no_color: false,
streaming: StreamingConfig::default(),
export_template: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct StreamingConfig {
#[schemars(range(min = 0))]
pub threshold_bytes: u64,
pub force: bool,
pub disabled: bool,
pub stream_stdin: bool,
}
impl Default for StreamingConfig {
fn default() -> Self {
Self {
threshold_bytes: 10 * 1024 * 1024, force: false,
disabled: false,
stream_stdin: true,
}
}
}
impl StreamingConfig {
#[must_use]
pub fn should_stream(&self, file_size: Option<u64>, is_stdin: bool) -> bool {
if self.disabled {
return false;
}
if self.force {
return true;
}
if is_stdin && self.stream_stdin {
return true;
}
file_size.map_or(self.stream_stdin, |size| size >= self.threshold_bytes)
}
#[must_use]
pub fn always() -> Self {
Self {
force: true,
..Default::default()
}
}
#[must_use]
pub fn never() -> Self {
Self {
disabled: true,
..Default::default()
}
}
#[must_use]
pub const fn with_threshold_mb(mut self, mb: u64) -> Self {
self.threshold_bytes = mb * 1024 * 1024;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct MatchingConfig {
pub fuzzy_preset: FuzzyPreset,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 0.0, max = 1.0))]
pub threshold: Option<f64>,
pub include_unchanged: bool,
}
impl Default for MatchingConfig {
fn default() -> Self {
Self {
fuzzy_preset: FuzzyPreset::Balanced,
threshold: None,
include_unchanged: false,
}
}
}
impl MatchingConfig {
#[must_use]
pub fn to_fuzzy_config(&self) -> FuzzyMatchConfig {
let mut config =
FuzzyMatchConfig::from_preset(self.fuzzy_preset.as_str()).unwrap_or_else(|| {
FuzzyMatchConfig::balanced()
});
if let Some(threshold) = self.threshold {
config = config.with_threshold(threshold);
}
config
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct FilterConfig {
pub only_changes: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_severity: Option<String>,
#[serde(alias = "exclude_vex_not_affected")]
pub exclude_vex_resolved: bool,
pub fail_on_vex_gap: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct BehaviorConfig {
pub fail_on_vuln: bool,
pub fail_on_change: bool,
pub quiet: bool,
pub explain_matches: bool,
pub recommend_threshold: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct GraphAwareDiffConfig {
pub enabled: bool,
pub detect_reparenting: bool,
pub detect_depth_changes: bool,
pub max_depth: u32,
pub impact_threshold: Option<String>,
pub relation_filter: Vec<String>,
}
impl GraphAwareDiffConfig {
#[must_use]
pub const fn enabled() -> Self {
Self {
enabled: true,
detect_reparenting: true,
detect_depth_changes: true,
max_depth: 0,
impact_threshold: None,
relation_filter: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct MatchingRulesPathConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub rules_file: Option<PathBuf>,
pub dry_run: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct EcosystemRulesConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub config_file: Option<PathBuf>,
pub disabled: bool,
pub detect_typosquats: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct EnrichmentConfig {
pub enabled: bool,
pub provider: String,
#[schemars(range(min = 1))]
pub cache_ttl_hours: u64,
#[schemars(range(min = 1))]
pub max_concurrent: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_dir: Option<std::path::PathBuf>,
pub bypass_cache: bool,
#[schemars(range(min = 1))]
pub timeout_secs: u64,
pub enable_eol: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub vex_paths: Vec<std::path::PathBuf>,
}
impl Default for EnrichmentConfig {
fn default() -> Self {
Self {
enabled: false,
provider: "osv".to_string(),
cache_ttl_hours: 24,
max_concurrent: 10,
cache_dir: None,
bypass_cache: false,
timeout_secs: 30,
enable_eol: false,
vex_paths: Vec::new(),
}
}
}
impl EnrichmentConfig {
#[must_use]
pub fn osv() -> Self {
Self {
enabled: true,
provider: "osv".to_string(),
..Default::default()
}
}
#[must_use]
pub fn with_cache_dir(mut self, dir: std::path::PathBuf) -> Self {
self.cache_dir = Some(dir);
self
}
#[must_use]
pub const fn with_cache_ttl_hours(mut self, hours: u64) -> Self {
self.cache_ttl_hours = hours;
self
}
#[must_use]
pub const fn with_bypass_cache(mut self) -> Self {
self.bypass_cache = true;
self
}
#[must_use]
pub const fn with_timeout_secs(mut self, secs: u64) -> Self {
self.timeout_secs = secs;
self
}
#[must_use]
pub fn with_vex_paths(mut self, paths: Vec<std::path::PathBuf>) -> Self {
self.vex_paths = paths;
self
}
}
#[derive(Debug, Default)]
pub struct DiffConfigBuilder {
old: Option<PathBuf>,
new: Option<PathBuf>,
output: OutputConfig,
matching: MatchingConfig,
filtering: FilterConfig,
behavior: BehaviorConfig,
graph_diff: GraphAwareDiffConfig,
rules: MatchingRulesPathConfig,
ecosystem_rules: EcosystemRulesConfig,
enrichment: EnrichmentConfig,
}
impl DiffConfigBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn old_path(mut self, path: PathBuf) -> Self {
self.old = Some(path);
self
}
#[must_use]
pub fn new_path(mut self, path: PathBuf) -> Self {
self.new = Some(path);
self
}
#[must_use]
pub const fn output_format(mut self, format: ReportFormat) -> Self {
self.output.format = format;
self
}
#[must_use]
pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
self.output.file = file;
self
}
#[must_use]
pub const fn report_types(mut self, types: ReportType) -> Self {
self.output.report_types = types;
self
}
#[must_use]
pub const fn no_color(mut self, no_color: bool) -> Self {
self.output.no_color = no_color;
self
}
#[must_use]
pub fn fuzzy_preset(mut self, preset: FuzzyPreset) -> Self {
self.matching.fuzzy_preset = preset;
self
}
#[must_use]
pub const fn matching_threshold(mut self, threshold: Option<f64>) -> Self {
self.matching.threshold = threshold;
self
}
#[must_use]
pub const fn include_unchanged(mut self, include: bool) -> Self {
self.matching.include_unchanged = include;
self
}
#[must_use]
pub const fn only_changes(mut self, only: bool) -> Self {
self.filtering.only_changes = only;
self
}
#[must_use]
pub fn min_severity(mut self, severity: Option<String>) -> Self {
self.filtering.min_severity = severity;
self
}
#[must_use]
pub const fn fail_on_vuln(mut self, fail: bool) -> Self {
self.behavior.fail_on_vuln = fail;
self
}
#[must_use]
pub const fn fail_on_change(mut self, fail: bool) -> Self {
self.behavior.fail_on_change = fail;
self
}
#[must_use]
pub const fn quiet(mut self, quiet: bool) -> Self {
self.behavior.quiet = quiet;
self
}
#[must_use]
pub const fn explain_matches(mut self, explain: bool) -> Self {
self.behavior.explain_matches = explain;
self
}
#[must_use]
pub const fn recommend_threshold(mut self, recommend: bool) -> Self {
self.behavior.recommend_threshold = recommend;
self
}
#[must_use]
pub fn graph_diff(mut self, enabled: bool) -> Self {
self.graph_diff = if enabled {
GraphAwareDiffConfig::enabled()
} else {
GraphAwareDiffConfig::default()
};
self
}
#[must_use]
pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
self.rules.rules_file = file;
self
}
#[must_use]
pub const fn dry_run_rules(mut self, dry_run: bool) -> Self {
self.rules.dry_run = dry_run;
self
}
#[must_use]
pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
self.ecosystem_rules.config_file = file;
self
}
#[must_use]
pub const fn disable_ecosystem_rules(mut self, disabled: bool) -> Self {
self.ecosystem_rules.disabled = disabled;
self
}
#[must_use]
pub const fn detect_typosquats(mut self, detect: bool) -> Self {
self.ecosystem_rules.detect_typosquats = detect;
self
}
#[must_use]
pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
self.enrichment = config;
self
}
#[must_use]
pub const fn enable_enrichment(mut self, enabled: bool) -> Self {
self.enrichment.enabled = enabled;
self
}
pub fn build(self) -> anyhow::Result<DiffConfig> {
let old = self
.old
.ok_or_else(|| anyhow::anyhow!("old path is required"))?;
let new = self
.new
.ok_or_else(|| anyhow::anyhow!("new path is required"))?;
Ok(DiffConfig {
paths: DiffPaths { old, new },
output: self.output,
matching: self.matching,
filtering: self.filtering,
behavior: self.behavior,
graph_diff: self.graph_diff,
rules: self.rules,
ecosystem_rules: self.ecosystem_rules,
enrichment: self.enrichment,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn theme_name_serde_roundtrip() {
let dark: ThemeName = serde_json::from_str(r#""dark""#).unwrap();
assert_eq!(dark, ThemeName::Dark);
let light: ThemeName = serde_json::from_str(r#""light""#).unwrap();
assert_eq!(light, ThemeName::Light);
let hc: ThemeName = serde_json::from_str(r#""high-contrast""#).unwrap();
assert_eq!(hc, ThemeName::HighContrast);
let serialized = serde_json::to_string(&ThemeName::HighContrast).unwrap();
assert_eq!(serialized, r#""high-contrast""#);
}
#[test]
fn theme_name_from_str() {
assert_eq!("dark".parse::<ThemeName>().unwrap(), ThemeName::Dark);
assert_eq!("light".parse::<ThemeName>().unwrap(), ThemeName::Light);
assert_eq!(
"high-contrast".parse::<ThemeName>().unwrap(),
ThemeName::HighContrast
);
assert_eq!("hc".parse::<ThemeName>().unwrap(), ThemeName::HighContrast);
assert!("neon".parse::<ThemeName>().is_err());
}
#[test]
fn fuzzy_preset_serde_roundtrip() {
let presets = [
("strict", FuzzyPreset::Strict),
("balanced", FuzzyPreset::Balanced),
("permissive", FuzzyPreset::Permissive),
("strict-multi", FuzzyPreset::StrictMulti),
("balanced-multi", FuzzyPreset::BalancedMulti),
("security-focused", FuzzyPreset::SecurityFocused),
];
for (json_str, expected) in presets {
let json = format!(r#""{json_str}""#);
let parsed: FuzzyPreset = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, expected, "failed to deserialize {json_str}");
let serialized = serde_json::to_string(&expected).unwrap();
assert_eq!(serialized, json, "failed to serialize {expected:?}");
}
}
#[test]
fn fuzzy_preset_from_str() {
assert_eq!(
"strict".parse::<FuzzyPreset>().unwrap(),
FuzzyPreset::Strict
);
assert_eq!(
"security-focused".parse::<FuzzyPreset>().unwrap(),
FuzzyPreset::SecurityFocused
);
assert_eq!(
"strict_multi".parse::<FuzzyPreset>().unwrap(),
FuzzyPreset::StrictMulti
);
assert!("invalid".parse::<FuzzyPreset>().is_err());
}
#[test]
fn tui_preferences_json_backward_compat() {
let old_json = r#"{"theme":"high-contrast","last_tab":"components"}"#;
let prefs: TuiPreferences = serde_json::from_str(old_json).unwrap();
assert_eq!(prefs.theme, ThemeName::HighContrast);
assert_eq!(prefs.last_tab.as_deref(), Some("components"));
}
}