1use 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#[derive(Debug, Clone, Default)]
23pub struct QueryFilter {
24 pub pattern: Option<String>,
26 pub name: Option<String>,
28 pub purl: Option<String>,
30 pub version: Option<String>,
32 pub license: Option<String>,
34 pub ecosystem: Option<String>,
36 pub supplier: Option<String>,
38 pub affected_by: Option<String>,
40}
41
42impl QueryFilter {
43 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 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 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 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 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#[derive(Debug, Clone, Serialize)]
214pub(crate) struct SbomSource {
215 pub name: String,
216 pub path: String,
217}
218
219#[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#[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#[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#[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 #[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 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 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 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 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 if result.matches.is_empty() {
380 std::process::exit(1);
381 }
382
383 Ok(())
384}
385
386fn 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#[cfg(feature = "enrichment")]
429fn enrich_if_needed(
430 mut sboms: Vec<NormalizedSbom>,
431 config: &crate::config::EnrichmentConfig,
432) -> Result<Vec<NormalizedSbom>> {
433 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
463fn 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 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 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 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
541fn 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 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
591fn 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
616fn 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
625fn 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#[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 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 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}