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