Skip to main content

sbom_tools/cli/
query.rs

1//! Multi-SBOM query command handler.
2//!
3//! Searches for components across multiple SBOMs by name, PURL, version,
4//! license, ecosystem, supplier, or vulnerability ID.
5
6use crate::config::QueryConfig;
7use crate::model::{Component, NormalizedSbom, NormalizedSbomIndex};
8use crate::pipeline::{OutputTarget, auto_detect_format, write_output};
9use crate::reports::ReportFormat;
10use anyhow::{Result, bail};
11use serde::Serialize;
12use std::collections::HashMap;
13
14// ============================================================================
15// Query Filter
16// ============================================================================
17
18/// Filter criteria for querying components across SBOMs.
19///
20/// All active filters are AND-combined: a component must match every
21/// non-None filter to be included in results.
22#[derive(Debug, Clone, Default)]
23pub struct QueryFilter {
24    /// Free-text pattern matching across name, purl, version, and id
25    pub pattern: Option<String>,
26    /// Name substring filter
27    pub name: Option<String>,
28    /// PURL substring filter
29    pub purl: Option<String>,
30    /// Version filter: exact match or semver range (e.g., "<2.17.0")
31    pub version: Option<String>,
32    /// License substring filter
33    pub license: Option<String>,
34    /// Ecosystem filter (case-insensitive exact match)
35    pub ecosystem: Option<String>,
36    /// Supplier name substring filter
37    pub supplier: Option<String>,
38    /// Vulnerability ID filter (exact match on vuln IDs)
39    pub affected_by: Option<String>,
40}
41
42impl QueryFilter {
43    /// Check if a component matches all active filters.
44    pub fn matches(
45        &self,
46        component: &Component,
47        sort_key: &crate::model::ComponentSortKey,
48    ) -> bool {
49        if let Some(ref pattern) = self.pattern {
50            let pattern_lower = pattern.to_lowercase();
51            if !sort_key.contains(&pattern_lower) {
52                return false;
53            }
54        }
55
56        if let Some(ref name) = self.name {
57            let name_lower = name.to_lowercase();
58            if !sort_key.name_lower.contains(&name_lower) {
59                return false;
60            }
61        }
62
63        if let Some(ref purl) = self.purl {
64            let purl_lower = purl.to_lowercase();
65            if !sort_key.purl_lower.contains(&purl_lower) {
66                return false;
67            }
68        }
69
70        if let Some(ref version) = self.version
71            && !self.matches_version(component, version)
72        {
73            return false;
74        }
75
76        if let Some(ref license) = self.license
77            && !self.matches_license(component, license)
78        {
79            return false;
80        }
81
82        if let Some(ref ecosystem) = self.ecosystem
83            && !self.matches_ecosystem(component, ecosystem)
84        {
85            return false;
86        }
87
88        if let Some(ref supplier) = self.supplier
89            && !self.matches_supplier(component, supplier)
90        {
91            return false;
92        }
93
94        if let Some(ref vuln_id) = self.affected_by
95            && !self.matches_vuln(component, vuln_id)
96        {
97            return false;
98        }
99
100        true
101    }
102
103    fn matches_version(&self, component: &Component, version_filter: &str) -> bool {
104        let comp_version = match &component.version {
105            Some(v) => v,
106            None => return false,
107        };
108
109        // If the filter starts with an operator, parse as semver range
110        let trimmed = version_filter.trim();
111        let has_operator = trimmed.starts_with('<')
112            || trimmed.starts_with('>')
113            || trimmed.starts_with('=')
114            || trimmed.starts_with('~')
115            || trimmed.starts_with('^')
116            || trimmed.contains(',');
117
118        if has_operator
119            && let Ok(req) = semver::VersionReq::parse(trimmed)
120            && let Ok(ver) = semver::Version::parse(comp_version)
121        {
122            return req.matches(&ver);
123        }
124
125        // Exact string match (case-insensitive)
126        comp_version.to_lowercase() == version_filter.to_lowercase()
127    }
128
129    fn matches_license(&self, component: &Component, license_filter: &str) -> bool {
130        let filter_lower = license_filter.to_lowercase();
131        component
132            .licenses
133            .all_licenses()
134            .iter()
135            .any(|l| l.expression.to_lowercase().contains(&filter_lower))
136    }
137
138    fn matches_ecosystem(&self, component: &Component, ecosystem_filter: &str) -> bool {
139        match &component.ecosystem {
140            Some(eco) => eco.to_string().to_lowercase() == ecosystem_filter.to_lowercase(),
141            None => false,
142        }
143    }
144
145    fn matches_supplier(&self, component: &Component, supplier_filter: &str) -> bool {
146        let filter_lower = supplier_filter.to_lowercase();
147        match &component.supplier {
148            Some(org) => org.name.to_lowercase().contains(&filter_lower),
149            None => false,
150        }
151    }
152
153    fn matches_vuln(&self, component: &Component, vuln_id: &str) -> bool {
154        let id_upper = vuln_id.to_uppercase();
155        component
156            .vulnerabilities
157            .iter()
158            .any(|v| v.id.to_uppercase() == id_upper)
159    }
160
161    /// Returns true if no filters are set (would match everything).
162    pub fn is_empty(&self) -> bool {
163        self.pattern.is_none()
164            && self.name.is_none()
165            && self.purl.is_none()
166            && self.version.is_none()
167            && self.license.is_none()
168            && self.ecosystem.is_none()
169            && self.supplier.is_none()
170            && self.affected_by.is_none()
171    }
172
173    /// Build a human-readable description of the active filters.
174    fn description(&self) -> String {
175        let mut parts = Vec::new();
176        if let Some(ref p) = self.pattern {
177            parts.push(format!("\"{p}\""));
178        }
179        if let Some(ref n) = self.name {
180            parts.push(format!("name=\"{n}\""));
181        }
182        if let Some(ref p) = self.purl {
183            parts.push(format!("purl=\"{p}\""));
184        }
185        if let Some(ref v) = self.version {
186            parts.push(format!("version={v}"));
187        }
188        if let Some(ref l) = self.license {
189            parts.push(format!("license=\"{l}\""));
190        }
191        if let Some(ref e) = self.ecosystem {
192            parts.push(format!("ecosystem={e}"));
193        }
194        if let Some(ref s) = self.supplier {
195            parts.push(format!("supplier=\"{s}\""));
196        }
197        if let Some(ref v) = self.affected_by {
198            parts.push(format!("affected-by={v}"));
199        }
200        if parts.is_empty() {
201            "*".to_string()
202        } else {
203            parts.join(" AND ")
204        }
205    }
206}
207
208// ============================================================================
209// Query Results
210// ============================================================================
211
212/// Source SBOM where a component was found.
213#[derive(Debug, Clone, Serialize)]
214pub(crate) struct SbomSource {
215    pub name: String,
216    pub path: String,
217}
218
219/// A single matched component (possibly found in multiple SBOMs).
220#[derive(Debug, Clone, Serialize)]
221pub(crate) struct QueryMatch {
222    pub name: String,
223    pub version: String,
224    pub ecosystem: String,
225    pub license: String,
226    pub purl: String,
227    pub supplier: String,
228    pub vuln_count: usize,
229    pub vuln_ids: Vec<String>,
230    pub found_in: Vec<SbomSource>,
231    pub eol_status: String,
232}
233
234/// Summary of an SBOM that was searched.
235#[derive(Debug, Clone, Serialize)]
236pub(crate) struct SbomSummary {
237    pub name: String,
238    pub path: String,
239    pub component_count: usize,
240    pub matches: usize,
241}
242
243/// Full query result.
244#[derive(Debug, Clone, Serialize)]
245pub(crate) struct QueryResult {
246    pub filter: String,
247    pub sboms_searched: usize,
248    pub total_components: usize,
249    pub matches: Vec<QueryMatch>,
250    pub sbom_summaries: Vec<SbomSummary>,
251}
252
253// ============================================================================
254// Core Implementation
255// ============================================================================
256
257/// Run the query command.
258#[allow(clippy::needless_pass_by_value)]
259pub fn run_query(config: QueryConfig, filter: QueryFilter) -> Result<()> {
260    if config.sbom_paths.is_empty() {
261        bail!("No SBOM files specified");
262    }
263
264    if filter.is_empty() {
265        bail!(
266            "No query filters specified. Provide a search pattern or use --name, --purl, --version, --license, --ecosystem, --supplier, or --affected-by"
267        );
268    }
269
270    let sboms = super::multi::parse_multiple_sboms(&config.sbom_paths)?;
271
272    // Optionally enrich with vulnerability data
273    #[cfg(feature = "enrichment")]
274    let sboms = enrich_if_needed(sboms, &config.enrichment)?;
275
276    let mut total_components = 0;
277    let mut sbom_summaries = Vec::with_capacity(sboms.len());
278
279    // Deduplicate matches by (name_lower, version)
280    let mut dedup_map: HashMap<(String, String), QueryMatch> = HashMap::new();
281
282    for (sbom, path) in sboms.iter().zip(config.sbom_paths.iter()) {
283        let sbom_name = super::multi::get_sbom_name(path);
284        let index = NormalizedSbomIndex::build(sbom);
285        let component_count = sbom.component_count();
286        total_components += component_count;
287
288        let mut match_count = 0;
289
290        for (_id, component) in &sbom.components {
291            let sort_key = index
292                .sort_key(&component.canonical_id)
293                .cloned()
294                .unwrap_or_default();
295
296            if !filter.matches(component, &sort_key) {
297                continue;
298            }
299
300            match_count += 1;
301            let dedup_key = (
302                component.name.to_lowercase(),
303                component.version.clone().unwrap_or_default(),
304            );
305
306            let source = SbomSource {
307                name: sbom_name.clone(),
308                path: path.to_string_lossy().to_string(),
309            };
310
311            dedup_map
312                .entry(dedup_key)
313                .and_modify(|existing| {
314                    // Merge: add source, union vuln IDs
315                    existing.found_in.push(source.clone());
316                    for vid in &component.vulnerabilities {
317                        let id_upper = vid.id.to_uppercase();
318                        if !existing
319                            .vuln_ids
320                            .iter()
321                            .any(|v| v.to_uppercase() == id_upper)
322                        {
323                            existing.vuln_ids.push(vid.id.clone());
324                        }
325                    }
326                    existing.vuln_count = existing.vuln_ids.len();
327                })
328                .or_insert_with(|| build_query_match(component, source));
329        }
330
331        sbom_summaries.push(SbomSummary {
332            name: sbom_name,
333            path: path.to_string_lossy().to_string(),
334            component_count,
335            matches: match_count,
336        });
337    }
338
339    let mut matches: Vec<QueryMatch> = dedup_map.into_values().collect();
340    matches.sort_by(|a, b| {
341        a.name
342            .to_lowercase()
343            .cmp(&b.name.to_lowercase())
344            .then_with(|| a.version.cmp(&b.version))
345    });
346
347    // Apply limit
348    if let Some(limit) = config.limit {
349        matches.truncate(limit);
350    }
351
352    let result = QueryResult {
353        filter: filter.description(),
354        sboms_searched: sbom_summaries.len(),
355        total_components,
356        matches,
357        sbom_summaries,
358    };
359
360    // Determine output format
361    let target = OutputTarget::from_option(config.output.file.clone());
362    let format = auto_detect_format(config.output.format, &target);
363
364    let output = match format {
365        ReportFormat::Json => serde_json::to_string_pretty(&result)?,
366        ReportFormat::Csv => format_csv_output(&result),
367        _ => {
368            if config.group_by_sbom {
369                format_table_grouped(&result)
370            } else {
371                format_table_output(&result)
372            }
373        }
374    };
375
376    write_output(&output, &target, false)?;
377
378    // Exit code: 1 if no matches
379    if result.matches.is_empty() {
380        std::process::exit(1);
381    }
382
383    Ok(())
384}
385
386/// Build a `QueryMatch` from a component and its source.
387fn build_query_match(component: &Component, source: SbomSource) -> QueryMatch {
388    let vuln_ids: Vec<String> = component
389        .vulnerabilities
390        .iter()
391        .map(|v| v.id.clone())
392        .collect();
393    let license = component
394        .licenses
395        .all_licenses()
396        .iter()
397        .map(|l| l.expression.as_str())
398        .collect::<Vec<_>>()
399        .join(", ");
400
401    QueryMatch {
402        name: component.name.clone(),
403        version: component.version.clone().unwrap_or_default(),
404        ecosystem: component
405            .ecosystem
406            .as_ref()
407            .map_or_else(String::new, ToString::to_string),
408        license,
409        purl: component.identifiers.purl.clone().unwrap_or_default(),
410        supplier: component
411            .supplier
412            .as_ref()
413            .map_or_else(String::new, |o| o.name.clone()),
414        vuln_count: vuln_ids.len(),
415        vuln_ids,
416        found_in: vec![source],
417        eol_status: component
418            .eol
419            .as_ref()
420            .map_or_else(String::new, |e| format!("{:?}", e.status)),
421    }
422}
423
424// ============================================================================
425// Enrichment (feature-gated)
426// ============================================================================
427
428#[cfg(feature = "enrichment")]
429fn enrich_if_needed(
430    mut sboms: Vec<NormalizedSbom>,
431    config: &crate::config::EnrichmentConfig,
432) -> Result<Vec<NormalizedSbom>> {
433    // VEX enrichment
434    if !config.vex_paths.is_empty() {
435        for sbom in &mut sboms {
436            crate::pipeline::enrich_vex(sbom, &config.vex_paths, false);
437        }
438    }
439    if config.enabled {
440        let osv_config = crate::pipeline::build_enrichment_config(config);
441        for sbom in &mut sboms {
442            crate::pipeline::enrich_sbom(sbom, &osv_config, false);
443        }
444    }
445    if config.enable_eol {
446        let eol_config = crate::enrichment::EolClientConfig {
447            cache_dir: config
448                .cache_dir
449                .clone()
450                .unwrap_or_else(crate::pipeline::dirs::eol_cache_dir),
451            cache_ttl: std::time::Duration::from_secs(config.cache_ttl_hours * 3600),
452            bypass_cache: config.bypass_cache,
453            timeout: std::time::Duration::from_secs(config.timeout_secs),
454            ..Default::default()
455        };
456        for sbom in &mut sboms {
457            crate::pipeline::enrich_eol(sbom, &eol_config, false);
458        }
459    }
460    Ok(sboms)
461}
462
463// ============================================================================
464// Output Formatting
465// ============================================================================
466
467/// Format results as a table for terminal output.
468fn format_table_output(result: &QueryResult) -> String {
469    let mut out = String::new();
470
471    out.push_str(&format!(
472        "Query: {} across {} SBOMs ({} total components)\n\n",
473        result.filter, result.sboms_searched, result.total_components
474    ));
475
476    if result.matches.is_empty() {
477        out.push_str("0 components found\n");
478        return out;
479    }
480
481    // Calculate column widths
482    let name_w = result
483        .matches
484        .iter()
485        .map(|m| m.name.len())
486        .max()
487        .unwrap_or(9)
488        .clamp(9, 40);
489    let ver_w = result
490        .matches
491        .iter()
492        .map(|m| m.version.len())
493        .max()
494        .unwrap_or(7)
495        .clamp(7, 20);
496    let eco_w = result
497        .matches
498        .iter()
499        .map(|m| m.ecosystem.len())
500        .max()
501        .unwrap_or(9)
502        .clamp(9, 15);
503    let lic_w = result
504        .matches
505        .iter()
506        .map(|m| m.license.len())
507        .max()
508        .unwrap_or(7)
509        .clamp(7, 20);
510
511    // Header
512    out.push_str(&format!(
513        "{:<name_w$}  {:<ver_w$}  {:<eco_w$}  {:<lic_w$}  {:>5}  FOUND IN\n",
514        "COMPONENT", "VERSION", "ECOSYSTEM", "LICENSE", "VULNS",
515    ));
516
517    // Rows
518    for m in &result.matches {
519        let name = truncate(&m.name, name_w);
520        let ver = truncate(&m.version, ver_w);
521        let eco = truncate(&m.ecosystem, eco_w);
522        let lic = truncate(&m.license, lic_w);
523        let found_in: Vec<&str> = m.found_in.iter().map(|s| s.name.as_str()).collect();
524
525        out.push_str(&format!(
526            "{name:<name_w$}  {ver:<ver_w$}  {eco:<eco_w$}  {lic:<lic_w$}  {:>5}  {}\n",
527            m.vuln_count,
528            found_in.join(", "),
529        ));
530    }
531
532    out.push_str(&format!(
533        "\n{} components found across {} SBOMs\n",
534        result.matches.len(),
535        result.sboms_searched
536    ));
537
538    out
539}
540
541/// Format results grouped by SBOM source.
542fn format_table_grouped(result: &QueryResult) -> String {
543    let mut out = String::new();
544
545    out.push_str(&format!(
546        "Query: {} across {} SBOMs ({} total components)\n\n",
547        result.filter, result.sboms_searched, result.total_components
548    ));
549
550    if result.matches.is_empty() {
551        out.push_str("0 components found\n");
552        return out;
553    }
554
555    // Group matches by SBOM
556    for summary in &result.sbom_summaries {
557        if summary.matches == 0 {
558            continue;
559        }
560
561        out.push_str(&format!(
562            "── {} ({} matches / {} components) ──\n",
563            summary.name, summary.matches, summary.component_count
564        ));
565
566        for m in &result.matches {
567            if m.found_in.iter().any(|s| s.name == summary.name) {
568                let vuln_str = if m.vuln_count > 0 {
569                    format!(" [{} vulns]", m.vuln_count)
570                } else {
571                    String::new()
572                };
573                out.push_str(&format!(
574                    "  {} {} ({}){}\n",
575                    m.name, m.version, m.ecosystem, vuln_str
576                ));
577            }
578        }
579        out.push('\n');
580    }
581
582    out.push_str(&format!(
583        "{} components found across {} SBOMs\n",
584        result.matches.len(),
585        result.sboms_searched
586    ));
587
588    out
589}
590
591/// Format results as CSV.
592fn format_csv_output(result: &QueryResult) -> String {
593    let mut out = String::from(
594        "Component,Version,Ecosystem,License,Vulns,Vulnerability IDs,Supplier,EOL Status,Found In\n",
595    );
596
597    for m in &result.matches {
598        let found_in: Vec<&str> = m.found_in.iter().map(|s| s.name.as_str()).collect();
599        out.push_str(&format!(
600            "{},{},{},{},{},{},{},{},{}\n",
601            csv_escape(&m.name),
602            csv_escape(&m.version),
603            csv_escape(&m.ecosystem),
604            csv_escape(&m.license),
605            m.vuln_count,
606            csv_escape(&m.vuln_ids.join("; ")),
607            csv_escape(&m.supplier),
608            csv_escape(&m.eol_status),
609            csv_escape(&found_in.join("; ")),
610        ));
611    }
612
613    out
614}
615
616/// Escape a CSV field value (quote if contains comma, quote, or newline).
617fn csv_escape(s: &str) -> String {
618    if s.contains(',') || s.contains('"') || s.contains('\n') {
619        format!("\"{}\"", s.replace('"', "\"\""))
620    } else {
621        s.to_string()
622    }
623}
624
625/// Truncate a string to the given width.
626fn truncate(s: &str, max: usize) -> String {
627    if s.len() <= max {
628        s.to_string()
629    } else if max > 3 {
630        format!("{}...", &s[..max - 3])
631    } else {
632        s[..max].to_string()
633    }
634}
635
636// ============================================================================
637// Tests
638// ============================================================================
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use crate::model::{Component, ComponentSortKey};
644
645    fn make_component(name: &str, version: &str, purl: Option<&str>) -> Component {
646        let mut c = Component::new(name.to_string(), format!("{name}@{version}"));
647        c.version = Some(version.to_string());
648        if let Some(p) = purl {
649            c.identifiers.purl = Some(p.to_string());
650        }
651        c
652    }
653
654    #[test]
655    fn test_filter_pattern_match() {
656        let filter = QueryFilter {
657            pattern: Some("log4j".to_string()),
658            ..Default::default()
659        };
660
661        let comp = make_component(
662            "log4j-core",
663            "2.14.1",
664            Some("pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1"),
665        );
666        let key = ComponentSortKey::from_component(&comp);
667        assert!(filter.matches(&comp, &key));
668
669        let comp2 = make_component("openssl", "1.1.1", None);
670        let key2 = ComponentSortKey::from_component(&comp2);
671        assert!(!filter.matches(&comp2, &key2));
672    }
673
674    #[test]
675    fn test_filter_name_match() {
676        let filter = QueryFilter {
677            name: Some("openssl".to_string()),
678            ..Default::default()
679        };
680
681        let comp = make_component("openssl", "3.0.0", None);
682        let key = ComponentSortKey::from_component(&comp);
683        assert!(filter.matches(&comp, &key));
684
685        let comp2 = make_component("libssl", "1.0", None);
686        let key2 = ComponentSortKey::from_component(&comp2);
687        assert!(!filter.matches(&comp2, &key2));
688    }
689
690    #[test]
691    fn test_filter_version_exact() {
692        let filter = QueryFilter {
693            version: Some("2.14.1".to_string()),
694            ..Default::default()
695        };
696
697        let comp = make_component("log4j-core", "2.14.1", None);
698        let key = ComponentSortKey::from_component(&comp);
699        assert!(filter.matches(&comp, &key));
700
701        let comp2 = make_component("log4j-core", "2.17.0", None);
702        let key2 = ComponentSortKey::from_component(&comp2);
703        assert!(!filter.matches(&comp2, &key2));
704    }
705
706    #[test]
707    fn test_filter_version_semver_range() {
708        let filter = QueryFilter {
709            version: Some("<2.17.0".to_string()),
710            ..Default::default()
711        };
712
713        let comp = make_component("log4j-core", "2.14.1", None);
714        let key = ComponentSortKey::from_component(&comp);
715        assert!(filter.matches(&comp, &key));
716
717        let comp2 = make_component("log4j-core", "2.17.0", None);
718        let key2 = ComponentSortKey::from_component(&comp2);
719        assert!(!filter.matches(&comp2, &key2));
720
721        let comp3 = make_component("log4j-core", "2.18.0", None);
722        let key3 = ComponentSortKey::from_component(&comp3);
723        assert!(!filter.matches(&comp3, &key3));
724    }
725
726    #[test]
727    fn test_filter_license_match() {
728        let filter = QueryFilter {
729            license: Some("Apache".to_string()),
730            ..Default::default()
731        };
732
733        let mut comp = make_component("log4j-core", "2.14.1", None);
734        comp.licenses
735            .add_declared(crate::model::LicenseExpression::new(
736                "Apache-2.0".to_string(),
737            ));
738        let key = ComponentSortKey::from_component(&comp);
739        assert!(filter.matches(&comp, &key));
740
741        let comp2 = make_component("some-lib", "1.0.0", None);
742        let key2 = ComponentSortKey::from_component(&comp2);
743        assert!(!filter.matches(&comp2, &key2));
744    }
745
746    #[test]
747    fn test_filter_ecosystem_match() {
748        let filter = QueryFilter {
749            ecosystem: Some("npm".to_string()),
750            ..Default::default()
751        };
752
753        let mut comp = make_component("lodash", "4.17.21", None);
754        comp.ecosystem = Some(crate::model::Ecosystem::Npm);
755        let key = ComponentSortKey::from_component(&comp);
756        assert!(filter.matches(&comp, &key));
757
758        let mut comp2 = make_component("serde", "1.0", None);
759        comp2.ecosystem = Some(crate::model::Ecosystem::Cargo);
760        let key2 = ComponentSortKey::from_component(&comp2);
761        assert!(!filter.matches(&comp2, &key2));
762    }
763
764    #[test]
765    fn test_filter_affected_by() {
766        let filter = QueryFilter {
767            affected_by: Some("CVE-2021-44228".to_string()),
768            ..Default::default()
769        };
770
771        let mut comp = make_component("log4j-core", "2.14.1", None);
772        comp.vulnerabilities
773            .push(crate::model::VulnerabilityRef::new(
774                "CVE-2021-44228".to_string(),
775                crate::model::VulnerabilitySource::Osv,
776            ));
777        let key = ComponentSortKey::from_component(&comp);
778        assert!(filter.matches(&comp, &key));
779
780        let comp2 = make_component("log4j-core", "2.17.0", None);
781        let key2 = ComponentSortKey::from_component(&comp2);
782        assert!(!filter.matches(&comp2, &key2));
783    }
784
785    #[test]
786    fn test_filter_combined() {
787        let filter = QueryFilter {
788            name: Some("log4j".to_string()),
789            version: Some("<2.17.0".to_string()),
790            ..Default::default()
791        };
792
793        let comp = make_component("log4j-core", "2.14.1", None);
794        let key = ComponentSortKey::from_component(&comp);
795        assert!(filter.matches(&comp, &key));
796
797        // Name matches but version doesn't
798        let comp2 = make_component("log4j-core", "2.17.0", None);
799        let key2 = ComponentSortKey::from_component(&comp2);
800        assert!(!filter.matches(&comp2, &key2));
801
802        // Version matches but name doesn't
803        let comp3 = make_component("openssl", "2.14.1", None);
804        let key3 = ComponentSortKey::from_component(&comp3);
805        assert!(!filter.matches(&comp3, &key3));
806    }
807
808    #[test]
809    fn test_dedup_merges_sources() {
810        let source1 = SbomSource {
811            name: "sbom1".to_string(),
812            path: "sbom1.json".to_string(),
813        };
814        let source2 = SbomSource {
815            name: "sbom2".to_string(),
816            path: "sbom2.json".to_string(),
817        };
818
819        let comp = make_component("lodash", "4.17.21", None);
820
821        let mut dedup_map: HashMap<(String, String), QueryMatch> = HashMap::new();
822        let key = ("lodash".to_string(), "4.17.21".to_string());
823
824        dedup_map.insert(key.clone(), build_query_match(&comp, source1));
825        dedup_map.entry(key).and_modify(|existing| {
826            existing.found_in.push(source2);
827        });
828
829        let match_entry = dedup_map.values().next().expect("should have one entry");
830        assert_eq!(match_entry.found_in.len(), 2);
831        assert_eq!(match_entry.found_in[0].name, "sbom1");
832        assert_eq!(match_entry.found_in[1].name, "sbom2");
833    }
834
835    #[test]
836    fn test_filter_is_empty() {
837        let filter = QueryFilter::default();
838        assert!(filter.is_empty());
839
840        let filter = QueryFilter {
841            pattern: Some("test".to_string()),
842            ..Default::default()
843        };
844        assert!(!filter.is_empty());
845    }
846
847    #[test]
848    fn test_filter_description() {
849        let filter = QueryFilter {
850            pattern: Some("log4j".to_string()),
851            version: Some("<2.17.0".to_string()),
852            ..Default::default()
853        };
854        let desc = filter.description();
855        assert!(desc.contains("\"log4j\""));
856        assert!(desc.contains("version=<2.17.0"));
857        assert!(desc.contains("AND"));
858    }
859
860    #[test]
861    fn test_csv_escape() {
862        assert_eq!(csv_escape("hello"), "hello");
863        assert_eq!(csv_escape("hello,world"), "\"hello,world\"");
864        assert_eq!(csv_escape("say \"hi\""), "\"say \"\"hi\"\"\"");
865    }
866
867    #[test]
868    fn test_truncate() {
869        assert_eq!(truncate("short", 10), "short");
870        assert_eq!(truncate("long string here", 10), "long st...");
871        assert_eq!(truncate("ab", 2), "ab");
872    }
873
874    #[test]
875    fn test_format_table_empty_results() {
876        let result = QueryResult {
877            filter: "\"nonexistent\"".to_string(),
878            sboms_searched: 1,
879            total_components: 100,
880            matches: vec![],
881            sbom_summaries: vec![],
882        };
883        let output = format_table_output(&result);
884        assert!(output.contains("0 components found"));
885    }
886
887    #[test]
888    fn test_format_csv_output() {
889        let result = QueryResult {
890            filter: "test".to_string(),
891            sboms_searched: 1,
892            total_components: 10,
893            matches: vec![QueryMatch {
894                name: "lodash".to_string(),
895                version: "4.17.21".to_string(),
896                ecosystem: "npm".to_string(),
897                license: "MIT".to_string(),
898                purl: "pkg:npm/lodash@4.17.21".to_string(),
899                supplier: String::new(),
900                vuln_count: 0,
901                vuln_ids: vec![],
902                found_in: vec![SbomSource {
903                    name: "sbom1".to_string(),
904                    path: "sbom1.json".to_string(),
905                }],
906                eol_status: String::new(),
907            }],
908            sbom_summaries: vec![],
909        };
910        let csv = format_csv_output(&result);
911        assert!(csv.starts_with("Component,Version"));
912        assert!(csv.contains("lodash,4.17.21,npm,MIT"));
913    }
914}