Skip to main content

sbom_tools/config/
types.rs

1//! Configuration types for sbom-tools operations.
2//!
3//! Provides structured configuration for diff, view, and multi-comparison operations.
4
5use crate::matching::FuzzyMatchConfig;
6use crate::reports::{ReportFormat, ReportType};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10
11// ============================================================================
12// Unified Application Configuration
13// ============================================================================
14
15/// Unified application configuration that can be loaded from CLI args or config files.
16///
17/// This is the top-level configuration struct that aggregates all configuration
18/// options. It can be constructed from CLI arguments, config files, or both
19/// (with CLI overriding file settings).
20#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
21#[serde(default)]
22pub struct AppConfig {
23    /// Matching configuration (thresholds, presets)
24    pub matching: MatchingConfig,
25    /// Output configuration (format, file, colors)
26    pub output: OutputConfig,
27    /// Filtering options
28    pub filtering: FilterConfig,
29    /// Behavior flags
30    pub behavior: BehaviorConfig,
31    /// Graph-aware diffing configuration
32    pub graph_diff: GraphAwareDiffConfig,
33    /// Custom matching rules configuration
34    pub rules: MatchingRulesPathConfig,
35    /// Ecosystem-specific rules configuration
36    pub ecosystem_rules: EcosystemRulesConfig,
37    /// TUI-specific configuration
38    pub tui: TuiConfig,
39    /// Enrichment configuration (OSV, etc.)
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub enrichment: Option<EnrichmentConfig>,
42}
43
44impl AppConfig {
45    /// Create a new `AppConfig` with default values.
46    #[must_use]
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Create an `AppConfig` builder.
52    pub fn builder() -> AppConfigBuilder {
53        AppConfigBuilder::default()
54    }
55}
56
57// ============================================================================
58// Builder for AppConfig
59// ============================================================================
60
61/// Builder for constructing `AppConfig` with fluent API.
62#[derive(Debug, Default)]
63#[must_use]
64pub struct AppConfigBuilder {
65    config: AppConfig,
66}
67
68impl AppConfigBuilder {
69    /// Set the fuzzy matching preset.
70    pub fn fuzzy_preset(mut self, preset: FuzzyPreset) -> Self {
71        self.config.matching.fuzzy_preset = preset;
72        self
73    }
74
75    /// Set the matching threshold.
76    pub const fn matching_threshold(mut self, threshold: f64) -> Self {
77        self.config.matching.threshold = Some(threshold);
78        self
79    }
80
81    /// Set the output format.
82    pub const fn output_format(mut self, format: ReportFormat) -> Self {
83        self.config.output.format = format;
84        self
85    }
86
87    /// Set the output file.
88    pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
89        self.config.output.file = file;
90        self
91    }
92
93    /// Disable colored output.
94    pub const fn no_color(mut self, no_color: bool) -> Self {
95        self.config.output.no_color = no_color;
96        self
97    }
98
99    /// Include unchanged components.
100    pub const fn include_unchanged(mut self, include: bool) -> Self {
101        self.config.matching.include_unchanged = include;
102        self
103    }
104
105    /// Enable fail-on-vulnerability mode.
106    pub const fn fail_on_vuln(mut self, fail: bool) -> Self {
107        self.config.behavior.fail_on_vuln = fail;
108        self
109    }
110
111    /// Enable fail-on-change mode.
112    pub const fn fail_on_change(mut self, fail: bool) -> Self {
113        self.config.behavior.fail_on_change = fail;
114        self
115    }
116
117    /// Enable quiet mode.
118    pub const fn quiet(mut self, quiet: bool) -> Self {
119        self.config.behavior.quiet = quiet;
120        self
121    }
122
123    /// Enable graph-aware diffing.
124    pub fn graph_diff(mut self, enabled: bool) -> Self {
125        self.config.graph_diff = if enabled {
126            GraphAwareDiffConfig::enabled()
127        } else {
128            GraphAwareDiffConfig::default()
129        };
130        self
131    }
132
133    /// Set matching rules file.
134    pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
135        self.config.rules.rules_file = file;
136        self
137    }
138
139    /// Set ecosystem rules file.
140    pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
141        self.config.ecosystem_rules.config_file = file;
142        self
143    }
144
145    /// Enable enrichment.
146    pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
147        self.config.enrichment = Some(config);
148        self
149    }
150
151    /// Build the `AppConfig`.
152    #[must_use]
153    pub fn build(self) -> AppConfig {
154        self.config
155    }
156}
157
158// ============================================================================
159// Config Enums
160// ============================================================================
161
162/// TUI theme name
163#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
164#[serde(rename_all = "kebab-case")]
165pub enum ThemeName {
166    /// Dark theme (default)
167    #[default]
168    Dark,
169    /// Light theme
170    Light,
171    /// High-contrast theme
172    HighContrast,
173}
174
175impl ThemeName {
176    /// Get the string representation of the theme name.
177    #[must_use]
178    pub fn as_str(&self) -> &'static str {
179        match self {
180            Self::Dark => "dark",
181            Self::Light => "light",
182            Self::HighContrast => "high-contrast",
183        }
184    }
185}
186
187impl std::fmt::Display for ThemeName {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        f.write_str(self.as_str())
190    }
191}
192
193impl std::str::FromStr for ThemeName {
194    type Err = String;
195
196    fn from_str(s: &str) -> Result<Self, Self::Err> {
197        match s.to_lowercase().as_str() {
198            "dark" => Ok(Self::Dark),
199            "light" => Ok(Self::Light),
200            "high-contrast" | "highcontrast" | "hc" => Ok(Self::HighContrast),
201            _ => Err(format!("unknown theme: {s}")),
202        }
203    }
204}
205
206/// Fuzzy matching preset
207#[derive(
208    Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, clap::ValueEnum,
209)]
210#[serde(rename_all = "kebab-case")]
211pub enum FuzzyPreset {
212    /// Strict matching (fewer false positives)
213    Strict,
214    /// Balanced matching (default)
215    #[default]
216    Balanced,
217    /// Permissive matching (fewer false negatives)
218    Permissive,
219    /// Strict matching optimized for multi-SBOM comparison
220    #[value(alias = "strict_multi")]
221    StrictMulti,
222    /// Balanced matching optimized for multi-SBOM comparison
223    #[value(alias = "balanced_multi")]
224    BalancedMulti,
225    /// Security-focused matching
226    #[value(alias = "security_focused")]
227    SecurityFocused,
228}
229
230impl FuzzyPreset {
231    /// Get the string representation of the preset.
232    #[must_use]
233    pub fn as_str(&self) -> &'static str {
234        match self {
235            Self::Strict => "strict",
236            Self::Balanced => "balanced",
237            Self::Permissive => "permissive",
238            Self::StrictMulti => "strict-multi",
239            Self::BalancedMulti => "balanced-multi",
240            Self::SecurityFocused => "security-focused",
241        }
242    }
243}
244
245impl std::str::FromStr for FuzzyPreset {
246    type Err = String;
247
248    fn from_str(s: &str) -> Result<Self, Self::Err> {
249        match s.to_lowercase().replace('_', "-").as_str() {
250            "strict" => Ok(Self::Strict),
251            "balanced" => Ok(Self::Balanced),
252            "permissive" => Ok(Self::Permissive),
253            "strict-multi" => Ok(Self::StrictMulti),
254            "balanced-multi" => Ok(Self::BalancedMulti),
255            "security-focused" => Ok(Self::SecurityFocused),
256            _ => Err(format!("unknown fuzzy preset: {s}")),
257        }
258    }
259}
260
261impl std::fmt::Display for FuzzyPreset {
262    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263        f.write_str(self.as_str())
264    }
265}
266
267// ============================================================================
268// TUI Preferences (persisted)
269// ============================================================================
270
271/// TUI preferences that persist across sessions.
272#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
273pub struct TuiPreferences {
274    /// Theme name
275    pub theme: ThemeName,
276    /// Last active tab in diff mode (e.g., "summary", "components")
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub last_tab: Option<String>,
279    /// Last active tab in view mode (e.g., "overview", "tree")
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub last_view_tab: Option<String>,
282}
283
284impl Default for TuiPreferences {
285    fn default() -> Self {
286        Self {
287            theme: ThemeName::Dark,
288            last_tab: None,
289            last_view_tab: None,
290        }
291    }
292}
293
294impl TuiPreferences {
295    /// Get the path to the preferences file.
296    #[must_use]
297    pub fn config_path() -> Option<PathBuf> {
298        dirs::config_dir().map(|p| p.join("sbom-tools").join("preferences.json"))
299    }
300
301    /// Load preferences from disk, or return defaults if not found.
302    #[must_use]
303    pub fn load() -> Self {
304        Self::config_path()
305            .and_then(|p| std::fs::read_to_string(p).ok())
306            .and_then(|s| serde_json::from_str(&s).ok())
307            .unwrap_or_default()
308    }
309
310    /// Save preferences to disk.
311    pub fn save(&self) -> std::io::Result<()> {
312        if let Some(path) = Self::config_path() {
313            if let Some(parent) = path.parent() {
314                std::fs::create_dir_all(parent)?;
315            }
316            let json = serde_json::to_string_pretty(self)
317                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
318            std::fs::write(path, json)?;
319        }
320        Ok(())
321    }
322}
323
324// ============================================================================
325// TUI Configuration
326// ============================================================================
327
328/// TUI-specific configuration.
329#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
330#[serde(default)]
331pub struct TuiConfig {
332    /// Theme name
333    pub theme: ThemeName,
334    /// Show line numbers in code views
335    pub show_line_numbers: bool,
336    /// Enable mouse support
337    pub mouse_enabled: bool,
338    /// Initial matching threshold for TUI threshold tuning
339    #[schemars(range(min = 0.0, max = 1.0))]
340    pub initial_threshold: f64,
341}
342
343impl Default for TuiConfig {
344    fn default() -> Self {
345        Self {
346            theme: ThemeName::Dark,
347            show_line_numbers: true,
348            mouse_enabled: true,
349            initial_threshold: 0.8,
350        }
351    }
352}
353
354// ============================================================================
355// Command-specific Configuration Types
356// ============================================================================
357
358/// Configuration for diff operations
359#[derive(Debug, Clone)]
360pub struct DiffConfig {
361    /// Paths to compare
362    pub paths: DiffPaths,
363    /// Output configuration
364    pub output: OutputConfig,
365    /// Matching configuration
366    pub matching: MatchingConfig,
367    /// Filtering options
368    pub filtering: FilterConfig,
369    /// Behavior flags
370    pub behavior: BehaviorConfig,
371    /// Graph-aware diffing configuration
372    pub graph_diff: GraphAwareDiffConfig,
373    /// Custom matching rules configuration
374    pub rules: MatchingRulesPathConfig,
375    /// Ecosystem-specific rules configuration
376    pub ecosystem_rules: EcosystemRulesConfig,
377    /// Enrichment configuration (always defined, runtime feature check)
378    pub enrichment: EnrichmentConfig,
379}
380
381/// Paths for diff operation
382#[derive(Debug, Clone)]
383pub struct DiffPaths {
384    /// Path to old/baseline SBOM
385    pub old: PathBuf,
386    /// Path to new SBOM
387    pub new: PathBuf,
388}
389
390/// Configuration for view operations
391#[derive(Debug, Clone)]
392pub struct ViewConfig {
393    /// Path to SBOM file
394    pub sbom_path: PathBuf,
395    /// Output configuration
396    pub output: OutputConfig,
397    /// Whether to validate against NTIA
398    pub validate_ntia: bool,
399    /// Filter by minimum vulnerability severity (critical, high, medium, low)
400    pub min_severity: Option<String>,
401    /// Only show components with vulnerabilities
402    pub vulnerable_only: bool,
403    /// Filter by ecosystem
404    pub ecosystem_filter: Option<String>,
405    /// Exit with code 2 if vulnerabilities are present
406    pub fail_on_vuln: bool,
407    /// BOM profile override (auto-detected if None)
408    pub bom_profile: Option<crate::model::BomProfile>,
409    /// Enrichment configuration
410    pub enrichment: EnrichmentConfig,
411    /// Optional CRA sidecar metadata path (auto-discovered next to the SBOM
412    /// when None). Supplements CRA compliance checks with manufacturer /
413    /// disclosure / lifecycle fields the SBOM doesn't carry.
414    pub cra_sidecar_path: Option<PathBuf>,
415    /// CRA Annex III/IV product class as a CLI string (kebab-case).
416    /// Sidecar `productClass` overrides this. Drives severity calibration
417    /// for vendor-hash, EOL, cycles, DoC, EUCC, PSIRT, attestation checks.
418    pub cra_product_class: Option<String>,
419}
420
421/// Configuration for multi-diff operations
422#[derive(Debug, Clone)]
423pub struct MultiDiffConfig {
424    /// Path to baseline SBOM
425    pub baseline: PathBuf,
426    /// Paths to target SBOMs
427    pub targets: Vec<PathBuf>,
428    /// Output configuration
429    pub output: OutputConfig,
430    /// Matching configuration
431    pub matching: MatchingConfig,
432    /// Filtering options
433    pub filtering: FilterConfig,
434    /// Behavior flags
435    pub behavior: BehaviorConfig,
436    /// Graph-aware diffing configuration
437    pub graph_diff: GraphAwareDiffConfig,
438    /// Custom matching rules configuration
439    pub rules: MatchingRulesPathConfig,
440    /// Ecosystem-specific rules configuration
441    pub ecosystem_rules: EcosystemRulesConfig,
442    /// Enrichment configuration
443    pub enrichment: EnrichmentConfig,
444}
445
446/// Configuration for timeline analysis
447#[derive(Debug, Clone)]
448pub struct TimelineConfig {
449    /// Paths to SBOMs in chronological order
450    pub sbom_paths: Vec<PathBuf>,
451    /// Output configuration
452    pub output: OutputConfig,
453    /// Matching configuration
454    pub matching: MatchingConfig,
455    /// Filtering options
456    pub filtering: FilterConfig,
457    /// Behavior flags
458    pub behavior: BehaviorConfig,
459    /// Graph-aware diffing configuration
460    pub graph_diff: GraphAwareDiffConfig,
461    /// Custom matching rules configuration
462    pub rules: MatchingRulesPathConfig,
463    /// Ecosystem-specific rules configuration
464    pub ecosystem_rules: EcosystemRulesConfig,
465    /// Enrichment configuration
466    pub enrichment: EnrichmentConfig,
467}
468
469/// Configuration for query operations (searching components across multiple SBOMs)
470#[derive(Debug, Clone)]
471pub struct QueryConfig {
472    /// Paths to SBOM files to search
473    pub sbom_paths: Vec<PathBuf>,
474    /// Output configuration
475    pub output: OutputConfig,
476    /// Enrichment configuration
477    pub enrichment: EnrichmentConfig,
478    /// Maximum number of results to return
479    pub limit: Option<usize>,
480    /// Group results by SBOM source
481    pub group_by_sbom: bool,
482}
483
484/// Configuration for matrix comparison
485#[derive(Debug, Clone)]
486pub struct MatrixConfig {
487    /// Paths to SBOMs
488    pub sbom_paths: Vec<PathBuf>,
489    /// Output configuration
490    pub output: OutputConfig,
491    /// Matching configuration
492    pub matching: MatchingConfig,
493    /// Similarity threshold for clustering (0.0-1.0)
494    pub cluster_threshold: f64,
495    /// Filtering options
496    pub filtering: FilterConfig,
497    /// Behavior flags
498    pub behavior: BehaviorConfig,
499    /// Graph-aware diffing configuration
500    pub graph_diff: GraphAwareDiffConfig,
501    /// Custom matching rules configuration
502    pub rules: MatchingRulesPathConfig,
503    /// Ecosystem-specific rules configuration
504    pub ecosystem_rules: EcosystemRulesConfig,
505    /// Enrichment configuration
506    pub enrichment: EnrichmentConfig,
507}
508
509/// Configuration for the `vex` subcommand.
510#[derive(Debug, Clone)]
511pub struct VexConfig {
512    /// Path to SBOM file
513    pub sbom_path: PathBuf,
514    /// Paths to external VEX documents
515    pub vex_paths: Vec<PathBuf>,
516    /// Output format
517    pub output_format: ReportFormat,
518    /// Output file path (None for stdout)
519    pub output_file: Option<PathBuf>,
520    /// Suppress non-essential output
521    pub quiet: bool,
522    /// Only show actionable vulnerabilities (exclude NotAffected/Fixed)
523    pub actionable_only: bool,
524    /// Filter by VEX state
525    pub filter_state: Option<String>,
526    /// Enrichment configuration (for OSV/EOL before VEX overlay)
527    pub enrichment: EnrichmentConfig,
528}
529
530// ============================================================================
531// Sub-configuration Types
532// ============================================================================
533
534/// Output-related configuration
535#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
536#[serde(default)]
537pub struct OutputConfig {
538    /// Output format
539    pub format: ReportFormat,
540    /// Output file path (None for stdout)
541    #[serde(skip_serializing_if = "Option::is_none")]
542    pub file: Option<PathBuf>,
543    /// Report types to include
544    pub report_types: ReportType,
545    /// Disable colored output
546    pub no_color: bool,
547    /// Streaming configuration for large SBOMs
548    pub streaming: StreamingConfig,
549    /// Optional export filename template for TUI exports.
550    ///
551    /// Placeholders: `{date}` (YYYY-MM-DD), `{time}` (HHMMSS),
552    /// `{format}` (json/md/html), `{command}` (diff/view).
553    #[serde(skip_serializing_if = "Option::is_none")]
554    pub export_template: Option<String>,
555}
556
557impl Default for OutputConfig {
558    fn default() -> Self {
559        Self {
560            format: ReportFormat::Auto,
561            file: None,
562            report_types: ReportType::All,
563            no_color: false,
564            streaming: StreamingConfig::default(),
565            export_template: None,
566        }
567    }
568}
569
570/// Streaming configuration for memory-efficient processing of large SBOMs.
571///
572/// When streaming is enabled, the tool uses streaming parsers and reporters
573/// to avoid loading entire SBOMs into memory. This is essential for SBOMs
574/// with thousands of components.
575#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
576#[serde(default)]
577pub struct StreamingConfig {
578    /// Enable streaming mode automatically for files larger than this threshold (in bytes).
579    /// Default: 10 MB (`10_485_760` bytes)
580    #[schemars(range(min = 0))]
581    pub threshold_bytes: u64,
582    /// Force streaming mode regardless of file size.
583    /// Useful for testing or when processing stdin.
584    pub force: bool,
585    /// Disable streaming mode entirely (always load full SBOMs into memory).
586    pub disabled: bool,
587    /// Enable streaming for stdin input (since size is unknown).
588    /// Default: true
589    pub stream_stdin: bool,
590}
591
592impl Default for StreamingConfig {
593    fn default() -> Self {
594        Self {
595            threshold_bytes: 10 * 1024 * 1024, // 10 MB
596            force: false,
597            disabled: false,
598            stream_stdin: true,
599        }
600    }
601}
602
603impl StreamingConfig {
604    /// Check if streaming should be used for a file of the given size.
605    #[must_use]
606    pub fn should_stream(&self, file_size: Option<u64>, is_stdin: bool) -> bool {
607        if self.disabled {
608            return false;
609        }
610        if self.force {
611            return true;
612        }
613        if is_stdin && self.stream_stdin {
614            return true;
615        }
616        file_size.map_or(self.stream_stdin, |size| size >= self.threshold_bytes)
617    }
618
619    /// Create a streaming config that always streams.
620    #[must_use]
621    pub fn always() -> Self {
622        Self {
623            force: true,
624            ..Default::default()
625        }
626    }
627
628    /// Create a streaming config that never streams.
629    #[must_use]
630    pub fn never() -> Self {
631        Self {
632            disabled: true,
633            ..Default::default()
634        }
635    }
636
637    /// Set the threshold in megabytes.
638    #[must_use]
639    pub const fn with_threshold_mb(mut self, mb: u64) -> Self {
640        self.threshold_bytes = mb * 1024 * 1024;
641        self
642    }
643}
644
645/// Matching and comparison configuration
646#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
647#[serde(default)]
648pub struct MatchingConfig {
649    /// Fuzzy matching preset
650    pub fuzzy_preset: FuzzyPreset,
651    /// Custom matching threshold (overrides preset)
652    #[serde(skip_serializing_if = "Option::is_none")]
653    #[schemars(range(min = 0.0, max = 1.0))]
654    pub threshold: Option<f64>,
655    /// Include unchanged components in output
656    pub include_unchanged: bool,
657}
658
659impl Default for MatchingConfig {
660    fn default() -> Self {
661        Self {
662            fuzzy_preset: FuzzyPreset::Balanced,
663            threshold: None,
664            include_unchanged: false,
665        }
666    }
667}
668
669impl MatchingConfig {
670    /// Convert preset name to `FuzzyMatchConfig`
671    #[must_use]
672    pub fn to_fuzzy_config(&self) -> FuzzyMatchConfig {
673        let mut config =
674            FuzzyMatchConfig::from_preset(self.fuzzy_preset.as_str()).unwrap_or_else(|| {
675                // Enum guarantees valid preset, but from_preset may not know all variants
676                FuzzyMatchConfig::balanced()
677            });
678
679        // Apply custom threshold if specified
680        if let Some(threshold) = self.threshold {
681            config = config.with_threshold(threshold);
682        }
683
684        config
685    }
686}
687
688/// Filtering options for diff results
689#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
690#[serde(default)]
691pub struct FilterConfig {
692    /// Only show items with changes
693    pub only_changes: bool,
694    /// Minimum severity filter
695    #[serde(skip_serializing_if = "Option::is_none")]
696    pub min_severity: Option<String>,
697    /// Exclude vulnerabilities with VEX status `not_affected` or fixed
698    #[serde(alias = "exclude_vex_not_affected")]
699    pub exclude_vex_resolved: bool,
700    /// Exit with error if introduced vulnerabilities lack VEX statements
701    pub fail_on_vex_gap: bool,
702}
703
704/// Behavior flags for diff operations
705#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
706#[serde(default)]
707pub struct BehaviorConfig {
708    /// Exit with code 2 if new vulnerabilities are introduced
709    pub fail_on_vuln: bool,
710    /// Exit with code 6 if any introduced vulnerability is in CISA's KEV catalog
711    #[serde(default)]
712    pub fail_on_kev: bool,
713    /// Exit with code 1 if any changes detected
714    pub fail_on_change: bool,
715    /// Suppress non-essential output
716    pub quiet: bool,
717    /// Show detailed match explanations for each matched component
718    pub explain_matches: bool,
719    /// Recommend optimal matching threshold based on the SBOMs
720    pub recommend_threshold: bool,
721}
722
723/// Graph-aware diffing configuration
724#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
725#[serde(default)]
726pub struct GraphAwareDiffConfig {
727    /// Enable graph-aware diffing
728    pub enabled: bool,
729    /// Detect component reparenting
730    pub detect_reparenting: bool,
731    /// Detect depth changes
732    pub detect_depth_changes: bool,
733    /// Maximum depth to analyze (0 = unlimited)
734    pub max_depth: u32,
735    /// Minimum impact level to include in output ("low", "medium", "high", "critical")
736    pub impact_threshold: Option<String>,
737    /// Relationship type filter — only include edges matching these types (empty = all)
738    pub relation_filter: Vec<String>,
739}
740
741impl GraphAwareDiffConfig {
742    /// Create enabled graph diff options with defaults
743    #[must_use]
744    pub const fn enabled() -> Self {
745        Self {
746            enabled: true,
747            detect_reparenting: true,
748            detect_depth_changes: true,
749            max_depth: 0,
750            impact_threshold: None,
751            relation_filter: Vec::new(),
752        }
753    }
754}
755
756/// Custom matching rules configuration
757#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
758#[serde(default)]
759pub struct MatchingRulesPathConfig {
760    /// Path to matching rules YAML file
761    #[serde(skip_serializing_if = "Option::is_none")]
762    pub rules_file: Option<PathBuf>,
763    /// Dry-run mode (show what would match without applying)
764    pub dry_run: bool,
765}
766
767/// Ecosystem-specific rules configuration
768#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
769#[serde(default)]
770pub struct EcosystemRulesConfig {
771    /// Path to ecosystem rules configuration file
772    #[serde(skip_serializing_if = "Option::is_none")]
773    pub config_file: Option<PathBuf>,
774    /// Disable ecosystem-specific normalization
775    pub disabled: bool,
776    /// Enable typosquat detection warnings
777    pub detect_typosquats: bool,
778}
779
780/// Enrichment configuration for vulnerability data sources.
781///
782/// This configuration is always defined regardless of the `enrichment` feature flag.
783/// When the feature is disabled, the configuration is silently ignored at runtime.
784#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
785#[serde(default)]
786pub struct EnrichmentConfig {
787    /// Enable enrichment (if false, no enrichment is performed)
788    pub enabled: bool,
789    /// Enrichment provider ("osv", "nvd", etc.)
790    pub provider: String,
791    /// Cache time-to-live in hours
792    #[schemars(range(min = 1))]
793    pub cache_ttl_hours: u64,
794    /// Maximum concurrent requests
795    #[schemars(range(min = 1))]
796    pub max_concurrent: usize,
797    /// Cache directory for vulnerability data
798    #[serde(skip_serializing_if = "Option::is_none")]
799    pub cache_dir: Option<std::path::PathBuf>,
800    /// Bypass cache and fetch fresh vulnerability data
801    pub bypass_cache: bool,
802    /// API timeout in seconds
803    #[schemars(range(min = 1))]
804    pub timeout_secs: u64,
805    /// Enable end-of-life detection via endoflife.date API
806    pub enable_eol: bool,
807    /// Enable CISA KEV (Known Exploited Vulnerabilities) enrichment
808    #[serde(default)]
809    pub enable_kev: bool,
810    /// Enable FIRST EPSS (Exploit Prediction Scoring System) enrichment
811    #[serde(default)]
812    pub enable_epss: bool,
813    /// Enable dependency staleness enrichment via package registries
814    #[serde(default)]
815    pub enable_staleness: bool,
816    /// Enable HuggingFace Hub enrichment for ML-model components (injects
817    /// weight hashes, task from `pipeline_tag`, license, and a staleness signal)
818    #[serde(default)]
819    pub enable_huggingface: bool,
820    /// Paths to external VEX documents (OpenVEX format)
821    #[serde(default, skip_serializing_if = "Vec::is_empty")]
822    pub vex_paths: Vec<std::path::PathBuf>,
823    /// OSV API base URL override (defaults to the public OSV API)
824    #[serde(skip_serializing_if = "Option::is_none")]
825    pub api_base: Option<String>,
826    /// CISA KEV catalog URL override (defaults to the public CISA feed).
827    /// Primarily a test seam for pointing the KEV enricher at a mock server.
828    #[serde(skip_serializing_if = "Option::is_none")]
829    pub kev_url: Option<String>,
830    /// FIRST EPSS scores URL override (defaults to the public FIRST feed).
831    /// Primarily a test seam for pointing the EPSS enricher at a mock server.
832    #[serde(skip_serializing_if = "Option::is_none")]
833    pub epss_url: Option<String>,
834    /// HuggingFace Hub API base URL override (defaults to the public Hub).
835    /// Primarily a test seam for pointing the HF enricher at a mock server.
836    #[serde(skip_serializing_if = "Option::is_none")]
837    pub huggingface_url: Option<String>,
838    /// Offline mode: never make network calls. Enrichment is served purely
839    /// from cache (including TTL-expired entries, with a staleness warning).
840    #[serde(default)]
841    pub offline: bool,
842}
843
844impl Default for EnrichmentConfig {
845    fn default() -> Self {
846        Self {
847            enabled: false,
848            provider: "osv".to_string(),
849            cache_ttl_hours: 24,
850            max_concurrent: 10,
851            cache_dir: None,
852            bypass_cache: false,
853            timeout_secs: 30,
854            enable_eol: false,
855            enable_kev: false,
856            enable_epss: false,
857            enable_staleness: false,
858            enable_huggingface: false,
859            vex_paths: Vec::new(),
860            api_base: None,
861            kev_url: None,
862            epss_url: None,
863            huggingface_url: None,
864            offline: false,
865        }
866    }
867}
868
869impl EnrichmentConfig {
870    /// Create an enabled enrichment config with OSV provider.
871    #[must_use]
872    pub fn osv() -> Self {
873        Self {
874            enabled: true,
875            provider: "osv".to_string(),
876            ..Default::default()
877        }
878    }
879
880    /// Create an enabled enrichment config with custom settings.
881    #[must_use]
882    pub fn with_cache_dir(mut self, dir: std::path::PathBuf) -> Self {
883        self.cache_dir = Some(dir);
884        self
885    }
886
887    /// Set the cache TTL in hours.
888    #[must_use]
889    pub const fn with_cache_ttl_hours(mut self, hours: u64) -> Self {
890        self.cache_ttl_hours = hours;
891        self
892    }
893
894    /// Enable cache bypass (refresh).
895    #[must_use]
896    pub const fn with_bypass_cache(mut self) -> Self {
897        self.bypass_cache = true;
898        self
899    }
900
901    /// Set the API timeout in seconds.
902    #[must_use]
903    pub const fn with_timeout_secs(mut self, secs: u64) -> Self {
904        self.timeout_secs = secs;
905        self
906    }
907
908    /// Set VEX document paths.
909    #[must_use]
910    pub fn with_vex_paths(mut self, paths: Vec<std::path::PathBuf>) -> Self {
911        self.vex_paths = paths;
912        self
913    }
914
915    /// Override the OSV API base URL.
916    #[must_use]
917    pub fn with_api_base(mut self, api_base: impl Into<String>) -> Self {
918        self.api_base = Some(api_base.into());
919        self
920    }
921
922    /// Enable CISA KEV enrichment.
923    #[must_use]
924    pub const fn with_kev(mut self) -> Self {
925        self.enable_kev = true;
926        self
927    }
928
929    /// Override the CISA KEV catalog URL (test seam).
930    #[must_use]
931    pub fn with_kev_url(mut self, kev_url: impl Into<String>) -> Self {
932        self.kev_url = Some(kev_url.into());
933        self
934    }
935
936    /// Enable FIRST EPSS enrichment.
937    #[must_use]
938    pub const fn with_epss(mut self) -> Self {
939        self.enable_epss = true;
940        self
941    }
942
943    /// Override the FIRST EPSS scores URL (test seam).
944    #[must_use]
945    pub fn with_epss_url(mut self, epss_url: impl Into<String>) -> Self {
946        self.epss_url = Some(epss_url.into());
947        self
948    }
949
950    /// Enable dependency staleness enrichment.
951    #[must_use]
952    pub const fn with_staleness(mut self) -> Self {
953        self.enable_staleness = true;
954        self
955    }
956
957    /// Enable HuggingFace Hub enrichment for ML-model components.
958    #[must_use]
959    pub const fn with_huggingface(mut self) -> Self {
960        self.enable_huggingface = true;
961        self
962    }
963
964    /// Override the HuggingFace Hub API base URL (test seam).
965    #[must_use]
966    pub fn with_huggingface_url(mut self, url: impl Into<String>) -> Self {
967        self.huggingface_url = Some(url.into());
968        self
969    }
970
971    /// Enable offline mode (serve enrichment purely from cache).
972    #[must_use]
973    pub const fn with_offline(mut self) -> Self {
974        self.offline = true;
975        self
976    }
977}
978
979// ============================================================================
980// Builder for DiffConfig
981// ============================================================================
982
983/// Builder for `DiffConfig`
984#[derive(Debug, Default)]
985pub struct DiffConfigBuilder {
986    old: Option<PathBuf>,
987    new: Option<PathBuf>,
988    output: OutputConfig,
989    matching: MatchingConfig,
990    filtering: FilterConfig,
991    behavior: BehaviorConfig,
992    graph_diff: GraphAwareDiffConfig,
993    rules: MatchingRulesPathConfig,
994    ecosystem_rules: EcosystemRulesConfig,
995    enrichment: EnrichmentConfig,
996}
997
998impl DiffConfigBuilder {
999    #[must_use]
1000    pub fn new() -> Self {
1001        Self::default()
1002    }
1003
1004    #[must_use]
1005    pub fn old_path(mut self, path: PathBuf) -> Self {
1006        self.old = Some(path);
1007        self
1008    }
1009
1010    #[must_use]
1011    pub fn new_path(mut self, path: PathBuf) -> Self {
1012        self.new = Some(path);
1013        self
1014    }
1015
1016    #[must_use]
1017    pub const fn output_format(mut self, format: ReportFormat) -> Self {
1018        self.output.format = format;
1019        self
1020    }
1021
1022    #[must_use]
1023    pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
1024        self.output.file = file;
1025        self
1026    }
1027
1028    #[must_use]
1029    pub const fn report_types(mut self, types: ReportType) -> Self {
1030        self.output.report_types = types;
1031        self
1032    }
1033
1034    #[must_use]
1035    pub const fn no_color(mut self, no_color: bool) -> Self {
1036        self.output.no_color = no_color;
1037        self
1038    }
1039
1040    #[must_use]
1041    pub fn fuzzy_preset(mut self, preset: FuzzyPreset) -> Self {
1042        self.matching.fuzzy_preset = preset;
1043        self
1044    }
1045
1046    #[must_use]
1047    pub const fn matching_threshold(mut self, threshold: Option<f64>) -> Self {
1048        self.matching.threshold = threshold;
1049        self
1050    }
1051
1052    #[must_use]
1053    pub const fn include_unchanged(mut self, include: bool) -> Self {
1054        self.matching.include_unchanged = include;
1055        self
1056    }
1057
1058    #[must_use]
1059    pub const fn only_changes(mut self, only: bool) -> Self {
1060        self.filtering.only_changes = only;
1061        self
1062    }
1063
1064    #[must_use]
1065    pub fn min_severity(mut self, severity: Option<String>) -> Self {
1066        self.filtering.min_severity = severity;
1067        self
1068    }
1069
1070    #[must_use]
1071    pub const fn fail_on_vuln(mut self, fail: bool) -> Self {
1072        self.behavior.fail_on_vuln = fail;
1073        self
1074    }
1075
1076    #[must_use]
1077    pub const fn fail_on_kev(mut self, fail: bool) -> Self {
1078        self.behavior.fail_on_kev = fail;
1079        self
1080    }
1081
1082    #[must_use]
1083    pub const fn fail_on_change(mut self, fail: bool) -> Self {
1084        self.behavior.fail_on_change = fail;
1085        self
1086    }
1087
1088    #[must_use]
1089    pub const fn quiet(mut self, quiet: bool) -> Self {
1090        self.behavior.quiet = quiet;
1091        self
1092    }
1093
1094    #[must_use]
1095    pub const fn explain_matches(mut self, explain: bool) -> Self {
1096        self.behavior.explain_matches = explain;
1097        self
1098    }
1099
1100    #[must_use]
1101    pub const fn recommend_threshold(mut self, recommend: bool) -> Self {
1102        self.behavior.recommend_threshold = recommend;
1103        self
1104    }
1105
1106    #[must_use]
1107    pub fn graph_diff(mut self, enabled: bool) -> Self {
1108        self.graph_diff = if enabled {
1109            GraphAwareDiffConfig::enabled()
1110        } else {
1111            GraphAwareDiffConfig::default()
1112        };
1113        self
1114    }
1115
1116    #[must_use]
1117    pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
1118        self.rules.rules_file = file;
1119        self
1120    }
1121
1122    #[must_use]
1123    pub const fn dry_run_rules(mut self, dry_run: bool) -> Self {
1124        self.rules.dry_run = dry_run;
1125        self
1126    }
1127
1128    #[must_use]
1129    pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
1130        self.ecosystem_rules.config_file = file;
1131        self
1132    }
1133
1134    #[must_use]
1135    pub const fn disable_ecosystem_rules(mut self, disabled: bool) -> Self {
1136        self.ecosystem_rules.disabled = disabled;
1137        self
1138    }
1139
1140    #[must_use]
1141    pub const fn detect_typosquats(mut self, detect: bool) -> Self {
1142        self.ecosystem_rules.detect_typosquats = detect;
1143        self
1144    }
1145
1146    #[must_use]
1147    pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
1148        self.enrichment = config;
1149        self
1150    }
1151
1152    #[must_use]
1153    pub const fn enable_enrichment(mut self, enabled: bool) -> Self {
1154        self.enrichment.enabled = enabled;
1155        self
1156    }
1157
1158    pub fn build(self) -> anyhow::Result<DiffConfig> {
1159        let old = self
1160            .old
1161            .ok_or_else(|| anyhow::anyhow!("old path is required"))?;
1162        let new = self
1163            .new
1164            .ok_or_else(|| anyhow::anyhow!("new path is required"))?;
1165
1166        Ok(DiffConfig {
1167            paths: DiffPaths { old, new },
1168            output: self.output,
1169            matching: self.matching,
1170            filtering: self.filtering,
1171            behavior: self.behavior,
1172            graph_diff: self.graph_diff,
1173            rules: self.rules,
1174            ecosystem_rules: self.ecosystem_rules,
1175            enrichment: self.enrichment,
1176        })
1177    }
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182    use super::*;
1183
1184    #[test]
1185    fn theme_name_serde_roundtrip() {
1186        // JSON deserialization (preferences.json format)
1187        let dark: ThemeName = serde_json::from_str(r#""dark""#).unwrap();
1188        assert_eq!(dark, ThemeName::Dark);
1189
1190        let light: ThemeName = serde_json::from_str(r#""light""#).unwrap();
1191        assert_eq!(light, ThemeName::Light);
1192
1193        let hc: ThemeName = serde_json::from_str(r#""high-contrast""#).unwrap();
1194        assert_eq!(hc, ThemeName::HighContrast);
1195
1196        // Roundtrip
1197        let serialized = serde_json::to_string(&ThemeName::HighContrast).unwrap();
1198        assert_eq!(serialized, r#""high-contrast""#);
1199    }
1200
1201    #[test]
1202    fn theme_name_from_str() {
1203        assert_eq!("dark".parse::<ThemeName>().unwrap(), ThemeName::Dark);
1204        assert_eq!("light".parse::<ThemeName>().unwrap(), ThemeName::Light);
1205        assert_eq!(
1206            "high-contrast".parse::<ThemeName>().unwrap(),
1207            ThemeName::HighContrast
1208        );
1209        assert_eq!("hc".parse::<ThemeName>().unwrap(), ThemeName::HighContrast);
1210        assert!("neon".parse::<ThemeName>().is_err());
1211    }
1212
1213    #[test]
1214    fn fuzzy_preset_serde_roundtrip() {
1215        let presets = [
1216            ("strict", FuzzyPreset::Strict),
1217            ("balanced", FuzzyPreset::Balanced),
1218            ("permissive", FuzzyPreset::Permissive),
1219            ("strict-multi", FuzzyPreset::StrictMulti),
1220            ("balanced-multi", FuzzyPreset::BalancedMulti),
1221            ("security-focused", FuzzyPreset::SecurityFocused),
1222        ];
1223
1224        for (json_str, expected) in presets {
1225            let json = format!(r#""{json_str}""#);
1226            let parsed: FuzzyPreset = serde_json::from_str(&json).unwrap();
1227            assert_eq!(parsed, expected, "failed to deserialize {json_str}");
1228
1229            let serialized = serde_json::to_string(&expected).unwrap();
1230            assert_eq!(serialized, json, "failed to serialize {expected:?}");
1231        }
1232    }
1233
1234    #[test]
1235    fn fuzzy_preset_from_str() {
1236        assert_eq!(
1237            "strict".parse::<FuzzyPreset>().unwrap(),
1238            FuzzyPreset::Strict
1239        );
1240        assert_eq!(
1241            "security-focused".parse::<FuzzyPreset>().unwrap(),
1242            FuzzyPreset::SecurityFocused
1243        );
1244        // Underscore variant accepted
1245        assert_eq!(
1246            "strict_multi".parse::<FuzzyPreset>().unwrap(),
1247            FuzzyPreset::StrictMulti
1248        );
1249        assert!("invalid".parse::<FuzzyPreset>().is_err());
1250    }
1251
1252    #[test]
1253    fn tui_preferences_json_backward_compat() {
1254        // Simulates loading a preferences.json written by older version
1255        let old_json = r#"{"theme":"high-contrast","last_tab":"components"}"#;
1256        let prefs: TuiPreferences = serde_json::from_str(old_json).unwrap();
1257        assert_eq!(prefs.theme, ThemeName::HighContrast);
1258        assert_eq!(prefs.last_tab.as_deref(), Some("components"));
1259    }
1260}