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