Skip to main content

lean_ctx/core/patterns/
syft.rs

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