Skip to main content

lean_ctx_patterns/
syft.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Syft SBOM output compression.
4//!
5//! Syft prints `✔` progress lines then a `NAME VERSION TYPE` table listing
6//! every package (often hundreds). We replace the list with a total count and
7//! a per-type breakdown, which is the part an agent reasons about.
8
9use crate::ansi::strip_ansi;
10
11pub fn compress(_cmd: &str, output: &str) -> Option<String> {
12    let trimmed = output.trim();
13    if trimmed.is_empty() {
14        return Some("syft: ok".to_string());
15    }
16
17    let lines: Vec<String> = trimmed
18        .lines()
19        .map(|l| strip_ansi(l).trim_end().to_string())
20        .collect();
21
22    let header = lines.iter().position(|l| {
23        let u = l.to_ascii_uppercase();
24        u.contains("NAME") && u.contains("VERSION") && u.contains("TYPE")
25    })?;
26
27    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
28    let mut total = 0usize;
29    for line in &lines[header + 1..] {
30        let cols = split_cols(line);
31        if cols.len() < 2 {
32            continue;
33        }
34        let pkg_type = cols[cols.len() - 1].clone();
35        total += 1;
36        *counts.entry(pkg_type).or_default() += 1;
37    }
38
39    if total == 0 {
40        return None;
41    }
42
43    // Stable, deterministic ordering: by count desc, then type asc.
44    let mut hist: Vec<(String, usize)> = counts.into_iter().collect();
45    hist.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
46    let breakdown: Vec<String> = hist.iter().map(|(t, n)| format!("{t}: {n}")).collect();
47
48    Some(format!("syft: {total} packages ({})", breakdown.join(", ")))
49}
50
51fn split_cols(line: &str) -> Vec<String> {
52    line.split("  ")
53        .map(str::trim)
54        .filter(|s| !s.is_empty())
55        .map(str::to_string)
56        .collect()
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    const SBOM: &str = " ✔ Parsed image\n ✔ Cataloged packages   [4 packages]\nNAME       VERSION    TYPE\nadduser    3.118      deb\napt        2.6.1      deb\nlodash     4.17.21    npm\nflask      2.3.0      python\n";
64
65    #[test]
66    fn counts_packages_by_type() {
67        let r = compress("syft nginx", SBOM).unwrap();
68        assert!(r.contains("syft: 4 packages"), "{r}");
69        assert!(r.contains("deb: 2"), "{r}");
70        assert!(r.contains("npm: 1"), "{r}");
71        assert!(r.contains("python: 1"), "{r}");
72        assert!(!r.contains("adduser"), "drops package list: {r}");
73    }
74
75    #[test]
76    fn empty_is_ok() {
77        assert_eq!(compress("syft nginx", "").unwrap(), "syft: ok");
78    }
79}