1use 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#[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(&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 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 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 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 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#[derive(Debug, Clone, Serialize)]
211pub(crate) struct SbomSource {
212 pub name: String,
213 pub path: String,
214}
215
216#[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#[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#[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#[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 #[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 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 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 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 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 if result.matches.is_empty() {
371 std::process::exit(1);
372 }
373
374 Ok(())
375}
376
377fn 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#[cfg(feature = "enrichment")]
420fn enrich_if_needed(
421 mut sboms: Vec<NormalizedSbom>,
422 config: &crate::config::EnrichmentConfig,
423) -> Result<Vec<NormalizedSbom>> {
424 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
454fn 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 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 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 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
532fn 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 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
582fn 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
605fn 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
614fn 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#[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 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 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}