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(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
208#[serde(rename_all = "kebab-case")]
209pub enum FuzzyPreset {
210    /// Strict matching (fewer false positives)
211    Strict,
212    /// Balanced matching (default)
213    #[default]
214    Balanced,
215    /// Permissive matching (fewer false negatives)
216    Permissive,
217    /// Strict matching optimized for multi-SBOM comparison
218    StrictMulti,
219    /// Balanced matching optimized for multi-SBOM comparison
220    BalancedMulti,
221    /// Security-focused matching
222    SecurityFocused,
223}
224
225impl FuzzyPreset {
226    /// Get the string representation of the preset.
227    #[must_use]
228    pub fn as_str(&self) -> &'static str {
229        match self {
230            Self::Strict => "strict",
231            Self::Balanced => "balanced",
232            Self::Permissive => "permissive",
233            Self::StrictMulti => "strict-multi",
234            Self::BalancedMulti => "balanced-multi",
235            Self::SecurityFocused => "security-focused",
236        }
237    }
238}
239
240impl std::str::FromStr for FuzzyPreset {
241    type Err = String;
242
243    fn from_str(s: &str) -> Result<Self, Self::Err> {
244        match s.to_lowercase().replace('_', "-").as_str() {
245            "strict" => Ok(Self::Strict),
246            "balanced" => Ok(Self::Balanced),
247            "permissive" => Ok(Self::Permissive),
248            "strict-multi" => Ok(Self::StrictMulti),
249            "balanced-multi" => Ok(Self::BalancedMulti),
250            "security-focused" => Ok(Self::SecurityFocused),
251            _ => Err(format!("unknown fuzzy preset: {s}")),
252        }
253    }
254}
255
256impl std::fmt::Display for FuzzyPreset {
257    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258        f.write_str(self.as_str())
259    }
260}
261
262// ============================================================================
263// TUI Preferences (persisted)
264// ============================================================================
265
266/// TUI preferences that persist across sessions.
267#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
268pub struct TuiPreferences {
269    /// Theme name
270    pub theme: ThemeName,
271    /// Last active tab in diff mode (e.g., "summary", "components")
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub last_tab: Option<String>,
274    /// Last active tab in view mode (e.g., "overview", "tree")
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub last_view_tab: Option<String>,
277}
278
279impl Default for TuiPreferences {
280    fn default() -> Self {
281        Self {
282            theme: ThemeName::Dark,
283            last_tab: None,
284            last_view_tab: None,
285        }
286    }
287}
288
289impl TuiPreferences {
290    /// Get the path to the preferences file.
291    #[must_use]
292    pub fn config_path() -> Option<PathBuf> {
293        dirs::config_dir().map(|p| p.join("sbom-tools").join("preferences.json"))
294    }
295
296    /// Load preferences from disk, or return defaults if not found.
297    #[must_use]
298    pub fn load() -> Self {
299        Self::config_path()
300            .and_then(|p| std::fs::read_to_string(p).ok())
301            .and_then(|s| serde_json::from_str(&s).ok())
302            .unwrap_or_default()
303    }
304
305    /// Save preferences to disk.
306    pub fn save(&self) -> std::io::Result<()> {
307        if let Some(path) = Self::config_path() {
308            if let Some(parent) = path.parent() {
309                std::fs::create_dir_all(parent)?;
310            }
311            let json = serde_json::to_string_pretty(self)
312                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
313            std::fs::write(path, json)?;
314        }
315        Ok(())
316    }
317}
318
319// ============================================================================
320// TUI Configuration
321// ============================================================================
322
323/// TUI-specific configuration.
324#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
325#[serde(default)]
326pub struct TuiConfig {
327    /// Theme name
328    pub theme: ThemeName,
329    /// Show line numbers in code views
330    pub show_line_numbers: bool,
331    /// Enable mouse support
332    pub mouse_enabled: bool,
333    /// Initial matching threshold for TUI threshold tuning
334    #[schemars(range(min = 0.0, max = 1.0))]
335    pub initial_threshold: f64,
336}
337
338impl Default for TuiConfig {
339    fn default() -> Self {
340        Self {
341            theme: ThemeName::Dark,
342            show_line_numbers: true,
343            mouse_enabled: true,
344            initial_threshold: 0.8,
345        }
346    }
347}
348
349// ============================================================================
350// Command-specific Configuration Types
351// ============================================================================
352
353/// Configuration for diff operations
354#[derive(Debug, Clone)]
355pub struct DiffConfig {
356    /// Paths to compare
357    pub paths: DiffPaths,
358    /// Output configuration
359    pub output: OutputConfig,
360    /// Matching configuration
361    pub matching: MatchingConfig,
362    /// Filtering options
363    pub filtering: FilterConfig,
364    /// Behavior flags
365    pub behavior: BehaviorConfig,
366    /// Graph-aware diffing configuration
367    pub graph_diff: GraphAwareDiffConfig,
368    /// Custom matching rules configuration
369    pub rules: MatchingRulesPathConfig,
370    /// Ecosystem-specific rules configuration
371    pub ecosystem_rules: EcosystemRulesConfig,
372    /// Enrichment configuration (always defined, runtime feature check)
373    pub enrichment: EnrichmentConfig,
374}
375
376/// Paths for diff operation
377#[derive(Debug, Clone)]
378pub struct DiffPaths {
379    /// Path to old/baseline SBOM
380    pub old: PathBuf,
381    /// Path to new SBOM
382    pub new: PathBuf,
383}
384
385/// Configuration for view operations
386#[derive(Debug, Clone)]
387pub struct ViewConfig {
388    /// Path to SBOM file
389    pub sbom_path: PathBuf,
390    /// Output configuration
391    pub output: OutputConfig,
392    /// Whether to validate against NTIA
393    pub validate_ntia: bool,
394    /// Filter by minimum vulnerability severity (critical, high, medium, low)
395    pub min_severity: Option<String>,
396    /// Only show components with vulnerabilities
397    pub vulnerable_only: bool,
398    /// Filter by ecosystem
399    pub ecosystem_filter: Option<String>,
400    /// Exit with code 2 if vulnerabilities are present
401    pub fail_on_vuln: bool,
402    /// BOM profile override (auto-detected if None)
403    pub bom_profile: Option<crate::model::BomProfile>,
404    /// Enrichment configuration
405    pub enrichment: EnrichmentConfig,
406}
407
408/// Configuration for multi-diff operations
409#[derive(Debug, Clone)]
410pub struct MultiDiffConfig {
411    /// Path to baseline SBOM
412    pub baseline: PathBuf,
413    /// Paths to target SBOMs
414    pub targets: Vec<PathBuf>,
415    /// Output configuration
416    pub output: OutputConfig,
417    /// Matching configuration
418    pub matching: MatchingConfig,
419    /// Filtering options
420    pub filtering: FilterConfig,
421    /// Behavior flags
422    pub behavior: BehaviorConfig,
423    /// Graph-aware diffing configuration
424    pub graph_diff: GraphAwareDiffConfig,
425    /// Custom matching rules configuration
426    pub rules: MatchingRulesPathConfig,
427    /// Ecosystem-specific rules configuration
428    pub ecosystem_rules: EcosystemRulesConfig,
429    /// Enrichment configuration
430    pub enrichment: EnrichmentConfig,
431}
432
433/// Configuration for timeline analysis
434#[derive(Debug, Clone)]
435pub struct TimelineConfig {
436    /// Paths to SBOMs in chronological order
437    pub sbom_paths: Vec<PathBuf>,
438    /// Output configuration
439    pub output: OutputConfig,
440    /// Matching configuration
441    pub matching: MatchingConfig,
442    /// Filtering options
443    pub filtering: FilterConfig,
444    /// Behavior flags
445    pub behavior: BehaviorConfig,
446    /// Graph-aware diffing configuration
447    pub graph_diff: GraphAwareDiffConfig,
448    /// Custom matching rules configuration
449    pub rules: MatchingRulesPathConfig,
450    /// Ecosystem-specific rules configuration
451    pub ecosystem_rules: EcosystemRulesConfig,
452    /// Enrichment configuration
453    pub enrichment: EnrichmentConfig,
454}
455
456/// Configuration for query operations (searching components across multiple SBOMs)
457#[derive(Debug, Clone)]
458pub struct QueryConfig {
459    /// Paths to SBOM files to search
460    pub sbom_paths: Vec<PathBuf>,
461    /// Output configuration
462    pub output: OutputConfig,
463    /// Enrichment configuration
464    pub enrichment: EnrichmentConfig,
465    /// Maximum number of results to return
466    pub limit: Option<usize>,
467    /// Group results by SBOM source
468    pub group_by_sbom: bool,
469}
470
471/// Configuration for matrix comparison
472#[derive(Debug, Clone)]
473pub struct MatrixConfig {
474    /// Paths to SBOMs
475    pub sbom_paths: Vec<PathBuf>,
476    /// Output configuration
477    pub output: OutputConfig,
478    /// Matching configuration
479    pub matching: MatchingConfig,
480    /// Similarity threshold for clustering (0.0-1.0)
481    pub cluster_threshold: f64,
482    /// Filtering options
483    pub filtering: FilterConfig,
484    /// Behavior flags
485    pub behavior: BehaviorConfig,
486    /// Graph-aware diffing configuration
487    pub graph_diff: GraphAwareDiffConfig,
488    /// Custom matching rules configuration
489    pub rules: MatchingRulesPathConfig,
490    /// Ecosystem-specific rules configuration
491    pub ecosystem_rules: EcosystemRulesConfig,
492    /// Enrichment configuration
493    pub enrichment: EnrichmentConfig,
494}
495
496/// Configuration for the `vex` subcommand.
497#[derive(Debug, Clone)]
498pub struct VexConfig {
499    /// Path to SBOM file
500    pub sbom_path: PathBuf,
501    /// Paths to external VEX documents
502    pub vex_paths: Vec<PathBuf>,
503    /// Output format
504    pub output_format: ReportFormat,
505    /// Output file path (None for stdout)
506    pub output_file: Option<PathBuf>,
507    /// Suppress non-essential output
508    pub quiet: bool,
509    /// Only show actionable vulnerabilities (exclude NotAffected/Fixed)
510    pub actionable_only: bool,
511    /// Filter by VEX state
512    pub filter_state: Option<String>,
513    /// Enrichment configuration (for OSV/EOL before VEX overlay)
514    pub enrichment: EnrichmentConfig,
515}
516
517// ============================================================================
518// Sub-configuration Types
519// ============================================================================
520
521/// Output-related configuration
522#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
523#[serde(default)]
524pub struct OutputConfig {
525    /// Output format
526    pub format: ReportFormat,
527    /// Output file path (None for stdout)
528    #[serde(skip_serializing_if = "Option::is_none")]
529    pub file: Option<PathBuf>,
530    /// Report types to include
531    pub report_types: ReportType,
532    /// Disable colored output
533    pub no_color: bool,
534    /// Streaming configuration for large SBOMs
535    pub streaming: StreamingConfig,
536    /// Optional export filename template for TUI exports.
537    ///
538    /// Placeholders: `{date}` (YYYY-MM-DD), `{time}` (HHMMSS),
539    /// `{format}` (json/md/html), `{command}` (diff/view).
540    #[serde(skip_serializing_if = "Option::is_none")]
541    pub export_template: Option<String>,
542}
543
544impl Default for OutputConfig {
545    fn default() -> Self {
546        Self {
547            format: ReportFormat::Auto,
548            file: None,
549            report_types: ReportType::All,
550            no_color: false,
551            streaming: StreamingConfig::default(),
552            export_template: None,
553        }
554    }
555}
556
557/// Streaming configuration for memory-efficient processing of large SBOMs.
558///
559/// When streaming is enabled, the tool uses streaming parsers and reporters
560/// to avoid loading entire SBOMs into memory. This is essential for SBOMs
561/// with thousands of components.
562#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
563#[serde(default)]
564pub struct StreamingConfig {
565    /// Enable streaming mode automatically for files larger than this threshold (in bytes).
566    /// Default: 10 MB (`10_485_760` bytes)
567    #[schemars(range(min = 0))]
568    pub threshold_bytes: u64,
569    /// Force streaming mode regardless of file size.
570    /// Useful for testing or when processing stdin.
571    pub force: bool,
572    /// Disable streaming mode entirely (always load full SBOMs into memory).
573    pub disabled: bool,
574    /// Enable streaming for stdin input (since size is unknown).
575    /// Default: true
576    pub stream_stdin: bool,
577}
578
579impl Default for StreamingConfig {
580    fn default() -> Self {
581        Self {
582            threshold_bytes: 10 * 1024 * 1024, // 10 MB
583            force: false,
584            disabled: false,
585            stream_stdin: true,
586        }
587    }
588}
589
590impl StreamingConfig {
591    /// Check if streaming should be used for a file of the given size.
592    #[must_use]
593    pub fn should_stream(&self, file_size: Option<u64>, is_stdin: bool) -> bool {
594        if self.disabled {
595            return false;
596        }
597        if self.force {
598            return true;
599        }
600        if is_stdin && self.stream_stdin {
601            return true;
602        }
603        file_size.map_or(self.stream_stdin, |size| size >= self.threshold_bytes)
604    }
605
606    /// Create a streaming config that always streams.
607    #[must_use]
608    pub fn always() -> Self {
609        Self {
610            force: true,
611            ..Default::default()
612        }
613    }
614
615    /// Create a streaming config that never streams.
616    #[must_use]
617    pub fn never() -> Self {
618        Self {
619            disabled: true,
620            ..Default::default()
621        }
622    }
623
624    /// Set the threshold in megabytes.
625    #[must_use]
626    pub const fn with_threshold_mb(mut self, mb: u64) -> Self {
627        self.threshold_bytes = mb * 1024 * 1024;
628        self
629    }
630}
631
632/// Matching and comparison configuration
633#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
634#[serde(default)]
635pub struct MatchingConfig {
636    /// Fuzzy matching preset
637    pub fuzzy_preset: FuzzyPreset,
638    /// Custom matching threshold (overrides preset)
639    #[serde(skip_serializing_if = "Option::is_none")]
640    #[schemars(range(min = 0.0, max = 1.0))]
641    pub threshold: Option<f64>,
642    /// Include unchanged components in output
643    pub include_unchanged: bool,
644}
645
646impl Default for MatchingConfig {
647    fn default() -> Self {
648        Self {
649            fuzzy_preset: FuzzyPreset::Balanced,
650            threshold: None,
651            include_unchanged: false,
652        }
653    }
654}
655
656impl MatchingConfig {
657    /// Convert preset name to `FuzzyMatchConfig`
658    #[must_use]
659    pub fn to_fuzzy_config(&self) -> FuzzyMatchConfig {
660        let mut config =
661            FuzzyMatchConfig::from_preset(self.fuzzy_preset.as_str()).unwrap_or_else(|| {
662                // Enum guarantees valid preset, but from_preset may not know all variants
663                FuzzyMatchConfig::balanced()
664            });
665
666        // Apply custom threshold if specified
667        if let Some(threshold) = self.threshold {
668            config = config.with_threshold(threshold);
669        }
670
671        config
672    }
673}
674
675/// Filtering options for diff results
676#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
677#[serde(default)]
678pub struct FilterConfig {
679    /// Only show items with changes
680    pub only_changes: bool,
681    /// Minimum severity filter
682    #[serde(skip_serializing_if = "Option::is_none")]
683    pub min_severity: Option<String>,
684    /// Exclude vulnerabilities with VEX status `not_affected` or fixed
685    #[serde(alias = "exclude_vex_not_affected")]
686    pub exclude_vex_resolved: bool,
687    /// Exit with error if introduced vulnerabilities lack VEX statements
688    pub fail_on_vex_gap: bool,
689}
690
691/// Behavior flags for diff operations
692#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
693#[serde(default)]
694pub struct BehaviorConfig {
695    /// Exit with code 2 if new vulnerabilities are introduced
696    pub fail_on_vuln: bool,
697    /// Exit with code 1 if any changes detected
698    pub fail_on_change: bool,
699    /// Suppress non-essential output
700    pub quiet: bool,
701    /// Show detailed match explanations for each matched component
702    pub explain_matches: bool,
703    /// Recommend optimal matching threshold based on the SBOMs
704    pub recommend_threshold: bool,
705}
706
707/// Graph-aware diffing configuration
708#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
709#[serde(default)]
710pub struct GraphAwareDiffConfig {
711    /// Enable graph-aware diffing
712    pub enabled: bool,
713    /// Detect component reparenting
714    pub detect_reparenting: bool,
715    /// Detect depth changes
716    pub detect_depth_changes: bool,
717    /// Maximum depth to analyze (0 = unlimited)
718    pub max_depth: u32,
719    /// Minimum impact level to include in output ("low", "medium", "high", "critical")
720    pub impact_threshold: Option<String>,
721    /// Relationship type filter — only include edges matching these types (empty = all)
722    pub relation_filter: Vec<String>,
723}
724
725impl GraphAwareDiffConfig {
726    /// Create enabled graph diff options with defaults
727    #[must_use]
728    pub const fn enabled() -> Self {
729        Self {
730            enabled: true,
731            detect_reparenting: true,
732            detect_depth_changes: true,
733            max_depth: 0,
734            impact_threshold: None,
735            relation_filter: Vec::new(),
736        }
737    }
738}
739
740/// Custom matching rules configuration
741#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
742#[serde(default)]
743pub struct MatchingRulesPathConfig {
744    /// Path to matching rules YAML file
745    #[serde(skip_serializing_if = "Option::is_none")]
746    pub rules_file: Option<PathBuf>,
747    /// Dry-run mode (show what would match without applying)
748    pub dry_run: bool,
749}
750
751/// Ecosystem-specific rules configuration
752#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
753#[serde(default)]
754pub struct EcosystemRulesConfig {
755    /// Path to ecosystem rules configuration file
756    #[serde(skip_serializing_if = "Option::is_none")]
757    pub config_file: Option<PathBuf>,
758    /// Disable ecosystem-specific normalization
759    pub disabled: bool,
760    /// Enable typosquat detection warnings
761    pub detect_typosquats: bool,
762}
763
764/// Enrichment configuration for vulnerability data sources.
765///
766/// This configuration is always defined regardless of the `enrichment` feature flag.
767/// When the feature is disabled, the configuration is silently ignored at runtime.
768#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
769#[serde(default)]
770pub struct EnrichmentConfig {
771    /// Enable enrichment (if false, no enrichment is performed)
772    pub enabled: bool,
773    /// Enrichment provider ("osv", "nvd", etc.)
774    pub provider: String,
775    /// Cache time-to-live in hours
776    #[schemars(range(min = 1))]
777    pub cache_ttl_hours: u64,
778    /// Maximum concurrent requests
779    #[schemars(range(min = 1))]
780    pub max_concurrent: usize,
781    /// Cache directory for vulnerability data
782    #[serde(skip_serializing_if = "Option::is_none")]
783    pub cache_dir: Option<std::path::PathBuf>,
784    /// Bypass cache and fetch fresh vulnerability data
785    pub bypass_cache: bool,
786    /// API timeout in seconds
787    #[schemars(range(min = 1))]
788    pub timeout_secs: u64,
789    /// Enable end-of-life detection via endoflife.date API
790    pub enable_eol: bool,
791    /// Paths to external VEX documents (OpenVEX format)
792    #[serde(default, skip_serializing_if = "Vec::is_empty")]
793    pub vex_paths: Vec<std::path::PathBuf>,
794}
795
796impl Default for EnrichmentConfig {
797    fn default() -> Self {
798        Self {
799            enabled: false,
800            provider: "osv".to_string(),
801            cache_ttl_hours: 24,
802            max_concurrent: 10,
803            cache_dir: None,
804            bypass_cache: false,
805            timeout_secs: 30,
806            enable_eol: false,
807            vex_paths: Vec::new(),
808        }
809    }
810}
811
812impl EnrichmentConfig {
813    /// Create an enabled enrichment config with OSV provider.
814    #[must_use]
815    pub fn osv() -> Self {
816        Self {
817            enabled: true,
818            provider: "osv".to_string(),
819            ..Default::default()
820        }
821    }
822
823    /// Create an enabled enrichment config with custom settings.
824    #[must_use]
825    pub fn with_cache_dir(mut self, dir: std::path::PathBuf) -> Self {
826        self.cache_dir = Some(dir);
827        self
828    }
829
830    /// Set the cache TTL in hours.
831    #[must_use]
832    pub const fn with_cache_ttl_hours(mut self, hours: u64) -> Self {
833        self.cache_ttl_hours = hours;
834        self
835    }
836
837    /// Enable cache bypass (refresh).
838    #[must_use]
839    pub const fn with_bypass_cache(mut self) -> Self {
840        self.bypass_cache = true;
841        self
842    }
843
844    /// Set the API timeout in seconds.
845    #[must_use]
846    pub const fn with_timeout_secs(mut self, secs: u64) -> Self {
847        self.timeout_secs = secs;
848        self
849    }
850
851    /// Set VEX document paths.
852    #[must_use]
853    pub fn with_vex_paths(mut self, paths: Vec<std::path::PathBuf>) -> Self {
854        self.vex_paths = paths;
855        self
856    }
857}
858
859// ============================================================================
860// Builder for DiffConfig
861// ============================================================================
862
863/// Builder for `DiffConfig`
864#[derive(Debug, Default)]
865pub struct DiffConfigBuilder {
866    old: Option<PathBuf>,
867    new: Option<PathBuf>,
868    output: OutputConfig,
869    matching: MatchingConfig,
870    filtering: FilterConfig,
871    behavior: BehaviorConfig,
872    graph_diff: GraphAwareDiffConfig,
873    rules: MatchingRulesPathConfig,
874    ecosystem_rules: EcosystemRulesConfig,
875    enrichment: EnrichmentConfig,
876}
877
878impl DiffConfigBuilder {
879    #[must_use]
880    pub fn new() -> Self {
881        Self::default()
882    }
883
884    #[must_use]
885    pub fn old_path(mut self, path: PathBuf) -> Self {
886        self.old = Some(path);
887        self
888    }
889
890    #[must_use]
891    pub fn new_path(mut self, path: PathBuf) -> Self {
892        self.new = Some(path);
893        self
894    }
895
896    #[must_use]
897    pub const fn output_format(mut self, format: ReportFormat) -> Self {
898        self.output.format = format;
899        self
900    }
901
902    #[must_use]
903    pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
904        self.output.file = file;
905        self
906    }
907
908    #[must_use]
909    pub const fn report_types(mut self, types: ReportType) -> Self {
910        self.output.report_types = types;
911        self
912    }
913
914    #[must_use]
915    pub const fn no_color(mut self, no_color: bool) -> Self {
916        self.output.no_color = no_color;
917        self
918    }
919
920    #[must_use]
921    pub fn fuzzy_preset(mut self, preset: FuzzyPreset) -> Self {
922        self.matching.fuzzy_preset = preset;
923        self
924    }
925
926    #[must_use]
927    pub const fn matching_threshold(mut self, threshold: Option<f64>) -> Self {
928        self.matching.threshold = threshold;
929        self
930    }
931
932    #[must_use]
933    pub const fn include_unchanged(mut self, include: bool) -> Self {
934        self.matching.include_unchanged = include;
935        self
936    }
937
938    #[must_use]
939    pub const fn only_changes(mut self, only: bool) -> Self {
940        self.filtering.only_changes = only;
941        self
942    }
943
944    #[must_use]
945    pub fn min_severity(mut self, severity: Option<String>) -> Self {
946        self.filtering.min_severity = severity;
947        self
948    }
949
950    #[must_use]
951    pub const fn fail_on_vuln(mut self, fail: bool) -> Self {
952        self.behavior.fail_on_vuln = fail;
953        self
954    }
955
956    #[must_use]
957    pub const fn fail_on_change(mut self, fail: bool) -> Self {
958        self.behavior.fail_on_change = fail;
959        self
960    }
961
962    #[must_use]
963    pub const fn quiet(mut self, quiet: bool) -> Self {
964        self.behavior.quiet = quiet;
965        self
966    }
967
968    #[must_use]
969    pub const fn explain_matches(mut self, explain: bool) -> Self {
970        self.behavior.explain_matches = explain;
971        self
972    }
973
974    #[must_use]
975    pub const fn recommend_threshold(mut self, recommend: bool) -> Self {
976        self.behavior.recommend_threshold = recommend;
977        self
978    }
979
980    #[must_use]
981    pub fn graph_diff(mut self, enabled: bool) -> Self {
982        self.graph_diff = if enabled {
983            GraphAwareDiffConfig::enabled()
984        } else {
985            GraphAwareDiffConfig::default()
986        };
987        self
988    }
989
990    #[must_use]
991    pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
992        self.rules.rules_file = file;
993        self
994    }
995
996    #[must_use]
997    pub const fn dry_run_rules(mut self, dry_run: bool) -> Self {
998        self.rules.dry_run = dry_run;
999        self
1000    }
1001
1002    #[must_use]
1003    pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
1004        self.ecosystem_rules.config_file = file;
1005        self
1006    }
1007
1008    #[must_use]
1009    pub const fn disable_ecosystem_rules(mut self, disabled: bool) -> Self {
1010        self.ecosystem_rules.disabled = disabled;
1011        self
1012    }
1013
1014    #[must_use]
1015    pub const fn detect_typosquats(mut self, detect: bool) -> Self {
1016        self.ecosystem_rules.detect_typosquats = detect;
1017        self
1018    }
1019
1020    #[must_use]
1021    pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
1022        self.enrichment = config;
1023        self
1024    }
1025
1026    #[must_use]
1027    pub const fn enable_enrichment(mut self, enabled: bool) -> Self {
1028        self.enrichment.enabled = enabled;
1029        self
1030    }
1031
1032    pub fn build(self) -> anyhow::Result<DiffConfig> {
1033        let old = self
1034            .old
1035            .ok_or_else(|| anyhow::anyhow!("old path is required"))?;
1036        let new = self
1037            .new
1038            .ok_or_else(|| anyhow::anyhow!("new path is required"))?;
1039
1040        Ok(DiffConfig {
1041            paths: DiffPaths { old, new },
1042            output: self.output,
1043            matching: self.matching,
1044            filtering: self.filtering,
1045            behavior: self.behavior,
1046            graph_diff: self.graph_diff,
1047            rules: self.rules,
1048            ecosystem_rules: self.ecosystem_rules,
1049            enrichment: self.enrichment,
1050        })
1051    }
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056    use super::*;
1057
1058    #[test]
1059    fn theme_name_serde_roundtrip() {
1060        // JSON deserialization (preferences.json format)
1061        let dark: ThemeName = serde_json::from_str(r#""dark""#).unwrap();
1062        assert_eq!(dark, ThemeName::Dark);
1063
1064        let light: ThemeName = serde_json::from_str(r#""light""#).unwrap();
1065        assert_eq!(light, ThemeName::Light);
1066
1067        let hc: ThemeName = serde_json::from_str(r#""high-contrast""#).unwrap();
1068        assert_eq!(hc, ThemeName::HighContrast);
1069
1070        // Roundtrip
1071        let serialized = serde_json::to_string(&ThemeName::HighContrast).unwrap();
1072        assert_eq!(serialized, r#""high-contrast""#);
1073    }
1074
1075    #[test]
1076    fn theme_name_from_str() {
1077        assert_eq!("dark".parse::<ThemeName>().unwrap(), ThemeName::Dark);
1078        assert_eq!("light".parse::<ThemeName>().unwrap(), ThemeName::Light);
1079        assert_eq!(
1080            "high-contrast".parse::<ThemeName>().unwrap(),
1081            ThemeName::HighContrast
1082        );
1083        assert_eq!("hc".parse::<ThemeName>().unwrap(), ThemeName::HighContrast);
1084        assert!("neon".parse::<ThemeName>().is_err());
1085    }
1086
1087    #[test]
1088    fn fuzzy_preset_serde_roundtrip() {
1089        let presets = [
1090            ("strict", FuzzyPreset::Strict),
1091            ("balanced", FuzzyPreset::Balanced),
1092            ("permissive", FuzzyPreset::Permissive),
1093            ("strict-multi", FuzzyPreset::StrictMulti),
1094            ("balanced-multi", FuzzyPreset::BalancedMulti),
1095            ("security-focused", FuzzyPreset::SecurityFocused),
1096        ];
1097
1098        for (json_str, expected) in presets {
1099            let json = format!(r#""{json_str}""#);
1100            let parsed: FuzzyPreset = serde_json::from_str(&json).unwrap();
1101            assert_eq!(parsed, expected, "failed to deserialize {json_str}");
1102
1103            let serialized = serde_json::to_string(&expected).unwrap();
1104            assert_eq!(serialized, json, "failed to serialize {expected:?}");
1105        }
1106    }
1107
1108    #[test]
1109    fn fuzzy_preset_from_str() {
1110        assert_eq!(
1111            "strict".parse::<FuzzyPreset>().unwrap(),
1112            FuzzyPreset::Strict
1113        );
1114        assert_eq!(
1115            "security-focused".parse::<FuzzyPreset>().unwrap(),
1116            FuzzyPreset::SecurityFocused
1117        );
1118        // Underscore variant accepted
1119        assert_eq!(
1120            "strict_multi".parse::<FuzzyPreset>().unwrap(),
1121            FuzzyPreset::StrictMulti
1122        );
1123        assert!("invalid".parse::<FuzzyPreset>().is_err());
1124    }
1125
1126    #[test]
1127    fn tui_preferences_json_backward_compat() {
1128        // Simulates loading a preferences.json written by older version
1129        let old_json = r#"{"theme":"high-contrast","last_tab":"components"}"#;
1130        let prefs: TuiPreferences = serde_json::from_str(old_json).unwrap();
1131        assert_eq!(prefs.theme, ThemeName::HighContrast);
1132        assert_eq!(prefs.last_tab.as_deref(), Some("components"));
1133    }
1134}