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    /// Optional CRA sidecar metadata path (auto-discovered next to the SBOM
407    /// when None). Supplements CRA compliance checks with manufacturer /
408    /// disclosure / lifecycle fields the SBOM doesn't carry.
409    pub cra_sidecar_path: Option<PathBuf>,
410    /// CRA Annex III/IV product class as a CLI string (kebab-case).
411    /// Sidecar `productClass` overrides this. Drives severity calibration
412    /// for vendor-hash, EOL, cycles, DoC, EUCC, PSIRT, attestation checks.
413    pub cra_product_class: Option<String>,
414}
415
416/// Configuration for multi-diff operations
417#[derive(Debug, Clone)]
418pub struct MultiDiffConfig {
419    /// Path to baseline SBOM
420    pub baseline: PathBuf,
421    /// Paths to target SBOMs
422    pub targets: Vec<PathBuf>,
423    /// Output configuration
424    pub output: OutputConfig,
425    /// Matching configuration
426    pub matching: MatchingConfig,
427    /// Filtering options
428    pub filtering: FilterConfig,
429    /// Behavior flags
430    pub behavior: BehaviorConfig,
431    /// Graph-aware diffing configuration
432    pub graph_diff: GraphAwareDiffConfig,
433    /// Custom matching rules configuration
434    pub rules: MatchingRulesPathConfig,
435    /// Ecosystem-specific rules configuration
436    pub ecosystem_rules: EcosystemRulesConfig,
437    /// Enrichment configuration
438    pub enrichment: EnrichmentConfig,
439}
440
441/// Configuration for timeline analysis
442#[derive(Debug, Clone)]
443pub struct TimelineConfig {
444    /// Paths to SBOMs in chronological order
445    pub sbom_paths: Vec<PathBuf>,
446    /// Output configuration
447    pub output: OutputConfig,
448    /// Matching configuration
449    pub matching: MatchingConfig,
450    /// Filtering options
451    pub filtering: FilterConfig,
452    /// Behavior flags
453    pub behavior: BehaviorConfig,
454    /// Graph-aware diffing configuration
455    pub graph_diff: GraphAwareDiffConfig,
456    /// Custom matching rules configuration
457    pub rules: MatchingRulesPathConfig,
458    /// Ecosystem-specific rules configuration
459    pub ecosystem_rules: EcosystemRulesConfig,
460    /// Enrichment configuration
461    pub enrichment: EnrichmentConfig,
462}
463
464/// Configuration for query operations (searching components across multiple SBOMs)
465#[derive(Debug, Clone)]
466pub struct QueryConfig {
467    /// Paths to SBOM files to search
468    pub sbom_paths: Vec<PathBuf>,
469    /// Output configuration
470    pub output: OutputConfig,
471    /// Enrichment configuration
472    pub enrichment: EnrichmentConfig,
473    /// Maximum number of results to return
474    pub limit: Option<usize>,
475    /// Group results by SBOM source
476    pub group_by_sbom: bool,
477}
478
479/// Configuration for matrix comparison
480#[derive(Debug, Clone)]
481pub struct MatrixConfig {
482    /// Paths to SBOMs
483    pub sbom_paths: Vec<PathBuf>,
484    /// Output configuration
485    pub output: OutputConfig,
486    /// Matching configuration
487    pub matching: MatchingConfig,
488    /// Similarity threshold for clustering (0.0-1.0)
489    pub cluster_threshold: f64,
490    /// Filtering options
491    pub filtering: FilterConfig,
492    /// Behavior flags
493    pub behavior: BehaviorConfig,
494    /// Graph-aware diffing configuration
495    pub graph_diff: GraphAwareDiffConfig,
496    /// Custom matching rules configuration
497    pub rules: MatchingRulesPathConfig,
498    /// Ecosystem-specific rules configuration
499    pub ecosystem_rules: EcosystemRulesConfig,
500    /// Enrichment configuration
501    pub enrichment: EnrichmentConfig,
502}
503
504/// Configuration for the `vex` subcommand.
505#[derive(Debug, Clone)]
506pub struct VexConfig {
507    /// Path to SBOM file
508    pub sbom_path: PathBuf,
509    /// Paths to external VEX documents
510    pub vex_paths: Vec<PathBuf>,
511    /// Output format
512    pub output_format: ReportFormat,
513    /// Output file path (None for stdout)
514    pub output_file: Option<PathBuf>,
515    /// Suppress non-essential output
516    pub quiet: bool,
517    /// Only show actionable vulnerabilities (exclude NotAffected/Fixed)
518    pub actionable_only: bool,
519    /// Filter by VEX state
520    pub filter_state: Option<String>,
521    /// Enrichment configuration (for OSV/EOL before VEX overlay)
522    pub enrichment: EnrichmentConfig,
523}
524
525// ============================================================================
526// Sub-configuration Types
527// ============================================================================
528
529/// Output-related configuration
530#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
531#[serde(default)]
532pub struct OutputConfig {
533    /// Output format
534    pub format: ReportFormat,
535    /// Output file path (None for stdout)
536    #[serde(skip_serializing_if = "Option::is_none")]
537    pub file: Option<PathBuf>,
538    /// Report types to include
539    pub report_types: ReportType,
540    /// Disable colored output
541    pub no_color: bool,
542    /// Streaming configuration for large SBOMs
543    pub streaming: StreamingConfig,
544    /// Optional export filename template for TUI exports.
545    ///
546    /// Placeholders: `{date}` (YYYY-MM-DD), `{time}` (HHMMSS),
547    /// `{format}` (json/md/html), `{command}` (diff/view).
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub export_template: Option<String>,
550}
551
552impl Default for OutputConfig {
553    fn default() -> Self {
554        Self {
555            format: ReportFormat::Auto,
556            file: None,
557            report_types: ReportType::All,
558            no_color: false,
559            streaming: StreamingConfig::default(),
560            export_template: None,
561        }
562    }
563}
564
565/// Streaming configuration for memory-efficient processing of large SBOMs.
566///
567/// When streaming is enabled, the tool uses streaming parsers and reporters
568/// to avoid loading entire SBOMs into memory. This is essential for SBOMs
569/// with thousands of components.
570#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
571#[serde(default)]
572pub struct StreamingConfig {
573    /// Enable streaming mode automatically for files larger than this threshold (in bytes).
574    /// Default: 10 MB (`10_485_760` bytes)
575    #[schemars(range(min = 0))]
576    pub threshold_bytes: u64,
577    /// Force streaming mode regardless of file size.
578    /// Useful for testing or when processing stdin.
579    pub force: bool,
580    /// Disable streaming mode entirely (always load full SBOMs into memory).
581    pub disabled: bool,
582    /// Enable streaming for stdin input (since size is unknown).
583    /// Default: true
584    pub stream_stdin: bool,
585}
586
587impl Default for StreamingConfig {
588    fn default() -> Self {
589        Self {
590            threshold_bytes: 10 * 1024 * 1024, // 10 MB
591            force: false,
592            disabled: false,
593            stream_stdin: true,
594        }
595    }
596}
597
598impl StreamingConfig {
599    /// Check if streaming should be used for a file of the given size.
600    #[must_use]
601    pub fn should_stream(&self, file_size: Option<u64>, is_stdin: bool) -> bool {
602        if self.disabled {
603            return false;
604        }
605        if self.force {
606            return true;
607        }
608        if is_stdin && self.stream_stdin {
609            return true;
610        }
611        file_size.map_or(self.stream_stdin, |size| size >= self.threshold_bytes)
612    }
613
614    /// Create a streaming config that always streams.
615    #[must_use]
616    pub fn always() -> Self {
617        Self {
618            force: true,
619            ..Default::default()
620        }
621    }
622
623    /// Create a streaming config that never streams.
624    #[must_use]
625    pub fn never() -> Self {
626        Self {
627            disabled: true,
628            ..Default::default()
629        }
630    }
631
632    /// Set the threshold in megabytes.
633    #[must_use]
634    pub const fn with_threshold_mb(mut self, mb: u64) -> Self {
635        self.threshold_bytes = mb * 1024 * 1024;
636        self
637    }
638}
639
640/// Matching and comparison configuration
641#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
642#[serde(default)]
643pub struct MatchingConfig {
644    /// Fuzzy matching preset
645    pub fuzzy_preset: FuzzyPreset,
646    /// Custom matching threshold (overrides preset)
647    #[serde(skip_serializing_if = "Option::is_none")]
648    #[schemars(range(min = 0.0, max = 1.0))]
649    pub threshold: Option<f64>,
650    /// Include unchanged components in output
651    pub include_unchanged: bool,
652}
653
654impl Default for MatchingConfig {
655    fn default() -> Self {
656        Self {
657            fuzzy_preset: FuzzyPreset::Balanced,
658            threshold: None,
659            include_unchanged: false,
660        }
661    }
662}
663
664impl MatchingConfig {
665    /// Convert preset name to `FuzzyMatchConfig`
666    #[must_use]
667    pub fn to_fuzzy_config(&self) -> FuzzyMatchConfig {
668        let mut config =
669            FuzzyMatchConfig::from_preset(self.fuzzy_preset.as_str()).unwrap_or_else(|| {
670                // Enum guarantees valid preset, but from_preset may not know all variants
671                FuzzyMatchConfig::balanced()
672            });
673
674        // Apply custom threshold if specified
675        if let Some(threshold) = self.threshold {
676            config = config.with_threshold(threshold);
677        }
678
679        config
680    }
681}
682
683/// Filtering options for diff results
684#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
685#[serde(default)]
686pub struct FilterConfig {
687    /// Only show items with changes
688    pub only_changes: bool,
689    /// Minimum severity filter
690    #[serde(skip_serializing_if = "Option::is_none")]
691    pub min_severity: Option<String>,
692    /// Exclude vulnerabilities with VEX status `not_affected` or fixed
693    #[serde(alias = "exclude_vex_not_affected")]
694    pub exclude_vex_resolved: bool,
695    /// Exit with error if introduced vulnerabilities lack VEX statements
696    pub fail_on_vex_gap: bool,
697}
698
699/// Behavior flags for diff operations
700#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
701#[serde(default)]
702pub struct BehaviorConfig {
703    /// Exit with code 2 if new vulnerabilities are introduced
704    pub fail_on_vuln: bool,
705    /// Exit with code 1 if any changes detected
706    pub fail_on_change: bool,
707    /// Suppress non-essential output
708    pub quiet: bool,
709    /// Show detailed match explanations for each matched component
710    pub explain_matches: bool,
711    /// Recommend optimal matching threshold based on the SBOMs
712    pub recommend_threshold: bool,
713}
714
715/// Graph-aware diffing configuration
716#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
717#[serde(default)]
718pub struct GraphAwareDiffConfig {
719    /// Enable graph-aware diffing
720    pub enabled: bool,
721    /// Detect component reparenting
722    pub detect_reparenting: bool,
723    /// Detect depth changes
724    pub detect_depth_changes: bool,
725    /// Maximum depth to analyze (0 = unlimited)
726    pub max_depth: u32,
727    /// Minimum impact level to include in output ("low", "medium", "high", "critical")
728    pub impact_threshold: Option<String>,
729    /// Relationship type filter — only include edges matching these types (empty = all)
730    pub relation_filter: Vec<String>,
731}
732
733impl GraphAwareDiffConfig {
734    /// Create enabled graph diff options with defaults
735    #[must_use]
736    pub const fn enabled() -> Self {
737        Self {
738            enabled: true,
739            detect_reparenting: true,
740            detect_depth_changes: true,
741            max_depth: 0,
742            impact_threshold: None,
743            relation_filter: Vec::new(),
744        }
745    }
746}
747
748/// Custom matching rules configuration
749#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
750#[serde(default)]
751pub struct MatchingRulesPathConfig {
752    /// Path to matching rules YAML file
753    #[serde(skip_serializing_if = "Option::is_none")]
754    pub rules_file: Option<PathBuf>,
755    /// Dry-run mode (show what would match without applying)
756    pub dry_run: bool,
757}
758
759/// Ecosystem-specific rules configuration
760#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
761#[serde(default)]
762pub struct EcosystemRulesConfig {
763    /// Path to ecosystem rules configuration file
764    #[serde(skip_serializing_if = "Option::is_none")]
765    pub config_file: Option<PathBuf>,
766    /// Disable ecosystem-specific normalization
767    pub disabled: bool,
768    /// Enable typosquat detection warnings
769    pub detect_typosquats: bool,
770}
771
772/// Enrichment configuration for vulnerability data sources.
773///
774/// This configuration is always defined regardless of the `enrichment` feature flag.
775/// When the feature is disabled, the configuration is silently ignored at runtime.
776#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
777#[serde(default)]
778pub struct EnrichmentConfig {
779    /// Enable enrichment (if false, no enrichment is performed)
780    pub enabled: bool,
781    /// Enrichment provider ("osv", "nvd", etc.)
782    pub provider: String,
783    /// Cache time-to-live in hours
784    #[schemars(range(min = 1))]
785    pub cache_ttl_hours: u64,
786    /// Maximum concurrent requests
787    #[schemars(range(min = 1))]
788    pub max_concurrent: usize,
789    /// Cache directory for vulnerability data
790    #[serde(skip_serializing_if = "Option::is_none")]
791    pub cache_dir: Option<std::path::PathBuf>,
792    /// Bypass cache and fetch fresh vulnerability data
793    pub bypass_cache: bool,
794    /// API timeout in seconds
795    #[schemars(range(min = 1))]
796    pub timeout_secs: u64,
797    /// Enable end-of-life detection via endoflife.date API
798    pub enable_eol: bool,
799    /// Paths to external VEX documents (OpenVEX format)
800    #[serde(default, skip_serializing_if = "Vec::is_empty")]
801    pub vex_paths: Vec<std::path::PathBuf>,
802}
803
804impl Default for EnrichmentConfig {
805    fn default() -> Self {
806        Self {
807            enabled: false,
808            provider: "osv".to_string(),
809            cache_ttl_hours: 24,
810            max_concurrent: 10,
811            cache_dir: None,
812            bypass_cache: false,
813            timeout_secs: 30,
814            enable_eol: false,
815            vex_paths: Vec::new(),
816        }
817    }
818}
819
820impl EnrichmentConfig {
821    /// Create an enabled enrichment config with OSV provider.
822    #[must_use]
823    pub fn osv() -> Self {
824        Self {
825            enabled: true,
826            provider: "osv".to_string(),
827            ..Default::default()
828        }
829    }
830
831    /// Create an enabled enrichment config with custom settings.
832    #[must_use]
833    pub fn with_cache_dir(mut self, dir: std::path::PathBuf) -> Self {
834        self.cache_dir = Some(dir);
835        self
836    }
837
838    /// Set the cache TTL in hours.
839    #[must_use]
840    pub const fn with_cache_ttl_hours(mut self, hours: u64) -> Self {
841        self.cache_ttl_hours = hours;
842        self
843    }
844
845    /// Enable cache bypass (refresh).
846    #[must_use]
847    pub const fn with_bypass_cache(mut self) -> Self {
848        self.bypass_cache = true;
849        self
850    }
851
852    /// Set the API timeout in seconds.
853    #[must_use]
854    pub const fn with_timeout_secs(mut self, secs: u64) -> Self {
855        self.timeout_secs = secs;
856        self
857    }
858
859    /// Set VEX document paths.
860    #[must_use]
861    pub fn with_vex_paths(mut self, paths: Vec<std::path::PathBuf>) -> Self {
862        self.vex_paths = paths;
863        self
864    }
865}
866
867// ============================================================================
868// Builder for DiffConfig
869// ============================================================================
870
871/// Builder for `DiffConfig`
872#[derive(Debug, Default)]
873pub struct DiffConfigBuilder {
874    old: Option<PathBuf>,
875    new: Option<PathBuf>,
876    output: OutputConfig,
877    matching: MatchingConfig,
878    filtering: FilterConfig,
879    behavior: BehaviorConfig,
880    graph_diff: GraphAwareDiffConfig,
881    rules: MatchingRulesPathConfig,
882    ecosystem_rules: EcosystemRulesConfig,
883    enrichment: EnrichmentConfig,
884}
885
886impl DiffConfigBuilder {
887    #[must_use]
888    pub fn new() -> Self {
889        Self::default()
890    }
891
892    #[must_use]
893    pub fn old_path(mut self, path: PathBuf) -> Self {
894        self.old = Some(path);
895        self
896    }
897
898    #[must_use]
899    pub fn new_path(mut self, path: PathBuf) -> Self {
900        self.new = Some(path);
901        self
902    }
903
904    #[must_use]
905    pub const fn output_format(mut self, format: ReportFormat) -> Self {
906        self.output.format = format;
907        self
908    }
909
910    #[must_use]
911    pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
912        self.output.file = file;
913        self
914    }
915
916    #[must_use]
917    pub const fn report_types(mut self, types: ReportType) -> Self {
918        self.output.report_types = types;
919        self
920    }
921
922    #[must_use]
923    pub const fn no_color(mut self, no_color: bool) -> Self {
924        self.output.no_color = no_color;
925        self
926    }
927
928    #[must_use]
929    pub fn fuzzy_preset(mut self, preset: FuzzyPreset) -> Self {
930        self.matching.fuzzy_preset = preset;
931        self
932    }
933
934    #[must_use]
935    pub const fn matching_threshold(mut self, threshold: Option<f64>) -> Self {
936        self.matching.threshold = threshold;
937        self
938    }
939
940    #[must_use]
941    pub const fn include_unchanged(mut self, include: bool) -> Self {
942        self.matching.include_unchanged = include;
943        self
944    }
945
946    #[must_use]
947    pub const fn only_changes(mut self, only: bool) -> Self {
948        self.filtering.only_changes = only;
949        self
950    }
951
952    #[must_use]
953    pub fn min_severity(mut self, severity: Option<String>) -> Self {
954        self.filtering.min_severity = severity;
955        self
956    }
957
958    #[must_use]
959    pub const fn fail_on_vuln(mut self, fail: bool) -> Self {
960        self.behavior.fail_on_vuln = fail;
961        self
962    }
963
964    #[must_use]
965    pub const fn fail_on_change(mut self, fail: bool) -> Self {
966        self.behavior.fail_on_change = fail;
967        self
968    }
969
970    #[must_use]
971    pub const fn quiet(mut self, quiet: bool) -> Self {
972        self.behavior.quiet = quiet;
973        self
974    }
975
976    #[must_use]
977    pub const fn explain_matches(mut self, explain: bool) -> Self {
978        self.behavior.explain_matches = explain;
979        self
980    }
981
982    #[must_use]
983    pub const fn recommend_threshold(mut self, recommend: bool) -> Self {
984        self.behavior.recommend_threshold = recommend;
985        self
986    }
987
988    #[must_use]
989    pub fn graph_diff(mut self, enabled: bool) -> Self {
990        self.graph_diff = if enabled {
991            GraphAwareDiffConfig::enabled()
992        } else {
993            GraphAwareDiffConfig::default()
994        };
995        self
996    }
997
998    #[must_use]
999    pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
1000        self.rules.rules_file = file;
1001        self
1002    }
1003
1004    #[must_use]
1005    pub const fn dry_run_rules(mut self, dry_run: bool) -> Self {
1006        self.rules.dry_run = dry_run;
1007        self
1008    }
1009
1010    #[must_use]
1011    pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
1012        self.ecosystem_rules.config_file = file;
1013        self
1014    }
1015
1016    #[must_use]
1017    pub const fn disable_ecosystem_rules(mut self, disabled: bool) -> Self {
1018        self.ecosystem_rules.disabled = disabled;
1019        self
1020    }
1021
1022    #[must_use]
1023    pub const fn detect_typosquats(mut self, detect: bool) -> Self {
1024        self.ecosystem_rules.detect_typosquats = detect;
1025        self
1026    }
1027
1028    #[must_use]
1029    pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
1030        self.enrichment = config;
1031        self
1032    }
1033
1034    #[must_use]
1035    pub const fn enable_enrichment(mut self, enabled: bool) -> Self {
1036        self.enrichment.enabled = enabled;
1037        self
1038    }
1039
1040    pub fn build(self) -> anyhow::Result<DiffConfig> {
1041        let old = self
1042            .old
1043            .ok_or_else(|| anyhow::anyhow!("old path is required"))?;
1044        let new = self
1045            .new
1046            .ok_or_else(|| anyhow::anyhow!("new path is required"))?;
1047
1048        Ok(DiffConfig {
1049            paths: DiffPaths { old, new },
1050            output: self.output,
1051            matching: self.matching,
1052            filtering: self.filtering,
1053            behavior: self.behavior,
1054            graph_diff: self.graph_diff,
1055            rules: self.rules,
1056            ecosystem_rules: self.ecosystem_rules,
1057            enrichment: self.enrichment,
1058        })
1059    }
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064    use super::*;
1065
1066    #[test]
1067    fn theme_name_serde_roundtrip() {
1068        // JSON deserialization (preferences.json format)
1069        let dark: ThemeName = serde_json::from_str(r#""dark""#).unwrap();
1070        assert_eq!(dark, ThemeName::Dark);
1071
1072        let light: ThemeName = serde_json::from_str(r#""light""#).unwrap();
1073        assert_eq!(light, ThemeName::Light);
1074
1075        let hc: ThemeName = serde_json::from_str(r#""high-contrast""#).unwrap();
1076        assert_eq!(hc, ThemeName::HighContrast);
1077
1078        // Roundtrip
1079        let serialized = serde_json::to_string(&ThemeName::HighContrast).unwrap();
1080        assert_eq!(serialized, r#""high-contrast""#);
1081    }
1082
1083    #[test]
1084    fn theme_name_from_str() {
1085        assert_eq!("dark".parse::<ThemeName>().unwrap(), ThemeName::Dark);
1086        assert_eq!("light".parse::<ThemeName>().unwrap(), ThemeName::Light);
1087        assert_eq!(
1088            "high-contrast".parse::<ThemeName>().unwrap(),
1089            ThemeName::HighContrast
1090        );
1091        assert_eq!("hc".parse::<ThemeName>().unwrap(), ThemeName::HighContrast);
1092        assert!("neon".parse::<ThemeName>().is_err());
1093    }
1094
1095    #[test]
1096    fn fuzzy_preset_serde_roundtrip() {
1097        let presets = [
1098            ("strict", FuzzyPreset::Strict),
1099            ("balanced", FuzzyPreset::Balanced),
1100            ("permissive", FuzzyPreset::Permissive),
1101            ("strict-multi", FuzzyPreset::StrictMulti),
1102            ("balanced-multi", FuzzyPreset::BalancedMulti),
1103            ("security-focused", FuzzyPreset::SecurityFocused),
1104        ];
1105
1106        for (json_str, expected) in presets {
1107            let json = format!(r#""{json_str}""#);
1108            let parsed: FuzzyPreset = serde_json::from_str(&json).unwrap();
1109            assert_eq!(parsed, expected, "failed to deserialize {json_str}");
1110
1111            let serialized = serde_json::to_string(&expected).unwrap();
1112            assert_eq!(serialized, json, "failed to serialize {expected:?}");
1113        }
1114    }
1115
1116    #[test]
1117    fn fuzzy_preset_from_str() {
1118        assert_eq!(
1119            "strict".parse::<FuzzyPreset>().unwrap(),
1120            FuzzyPreset::Strict
1121        );
1122        assert_eq!(
1123            "security-focused".parse::<FuzzyPreset>().unwrap(),
1124            FuzzyPreset::SecurityFocused
1125        );
1126        // Underscore variant accepted
1127        assert_eq!(
1128            "strict_multi".parse::<FuzzyPreset>().unwrap(),
1129            FuzzyPreset::StrictMulti
1130        );
1131        assert!("invalid".parse::<FuzzyPreset>().is_err());
1132    }
1133
1134    #[test]
1135    fn tui_preferences_json_backward_compat() {
1136        // Simulates loading a preferences.json written by older version
1137        let old_json = r#"{"theme":"high-contrast","last_tab":"components"}"#;
1138        let prefs: TuiPreferences = serde_json::from_str(old_json).unwrap();
1139        assert_eq!(prefs.theme, ThemeName::HighContrast);
1140        assert_eq!(prefs.last_tab.as_deref(), Some("components"));
1141    }
1142}