Skip to main content

lean_ctx/core/patterns/
ollama.rs

1//! Ollama CLI output compression.
2//!
3//! Handles `ollama list`/`ps` (drop the low-value ID hash column, prefix a
4//! model count) and `ollama pull`/`push` (strip download progress bars, keep
5//! the final status + layer count). Content commands (`run`, `chat`, `serve`,
6//! `show`) are left untouched — their output is the model's answer, not noise.
7
8use crate::core::compressor::strip_ansi;
9
10pub fn compress(cmd: &str, output: &str) -> Option<String> {
11    let trimmed = output.trim();
12    if trimmed.is_empty() {
13        return Some("ollama: ok".to_string());
14    }
15
16    if cmd.contains(" list") || cmd.contains(" ls") {
17        return Some(compress_table(trimmed, "model(s)"));
18    }
19    if cmd.contains(" ps") {
20        return Some(compress_table(trimmed, "running"));
21    }
22    if cmd.contains(" pull") || cmd.contains(" push") {
23        return Some(compress_transfer(trimmed));
24    }
25
26    // run/chat/serve/show emit model content — never compress.
27    None
28}
29
30/// Drop the `ID` column (a low-value 12-char hash) and the header row, prefix
31/// a count.
32fn compress_table(output: &str, noun: &str) -> String {
33    let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
34    if lines.len() < 2 {
35        return output.to_string();
36    }
37    let header = split_cols(lines[0]);
38    let drop = header.iter().position(|c| c.eq_ignore_ascii_case("ID"));
39
40    let mut rows: Vec<String> = Vec::new();
41    for line in &lines[1..] {
42        let mut cols = split_cols(line);
43        if let Some(i) = drop
44            && i < cols.len()
45        {
46            cols.remove(i);
47        }
48        rows.push(cols.join("  "));
49    }
50    format!("ollama: {} {}\n{}", rows.len(), noun, rows.join("\n"))
51}
52
53/// Strip per-layer progress bars from `pull`/`push`, keep the final status.
54fn compress_transfer(output: &str) -> String {
55    let mut layers = 0usize;
56    let mut success = false;
57    let mut errors: Vec<String> = Vec::new();
58
59    for raw in output.lines() {
60        let line = strip_ansi(raw);
61        let line = line.trim();
62        if line.is_empty() {
63            continue;
64        }
65        if (line.starts_with("pulling") || line.starts_with("pushing")) && line.contains('%') {
66            layers += 1;
67        } else if line == "success" {
68            success = true;
69        } else if line.contains("Error") || line.contains("error") {
70            errors.push(line.to_string());
71        }
72    }
73
74    if !errors.is_empty() {
75        return format!("ollama: FAILED\n  {}", errors.join("\n  "));
76    }
77    if success {
78        return format!("ollama: success ({layers} layers)");
79    }
80    format!("ollama: {layers} layers")
81}
82
83/// Split a table row on runs of 2+ spaces (columns may contain single spaces,
84/// e.g. "2.0 GB" or "3 days ago").
85fn split_cols(line: &str) -> Vec<String> {
86    let mut cols = Vec::new();
87    let mut cur = String::new();
88    let mut spaces = 0;
89    for ch in line.trim().chars() {
90        if ch == ' ' {
91            spaces += 1;
92            continue;
93        }
94        if spaces >= 2 && !cur.is_empty() {
95            cols.push(std::mem::take(&mut cur));
96        } else if spaces == 1 && !cur.is_empty() {
97            cur.push(' ');
98        }
99        spaces = 0;
100        cur.push(ch);
101    }
102    if !cur.is_empty() {
103        cols.push(cur);
104    }
105    cols
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    const LIST: &str = "NAME                    ID              SIZE      MODIFIED\nllama3.2:latest         a80c4f17acd5    2.0 GB    3 days ago\nqwen2.5-coder:7b        2b0496514337    4.7 GB    2 weeks ago\n";
113
114    #[test]
115    fn list_drops_id_keeps_name_size() {
116        let r = compress("ollama list", LIST).unwrap();
117        assert!(r.contains("2 model(s)"), "{r}");
118        assert!(r.contains("llama3.2:latest"), "{r}");
119        assert!(r.contains("2.0 GB"), "{r}");
120        assert!(r.contains("3 days ago"), "keeps multi-word column: {r}");
121        assert!(!r.contains("a80c4f17acd5"), "drops ID hash: {r}");
122    }
123
124    #[test]
125    fn pull_collapses_progress() {
126        let out = "pulling manifest\npulling aabbccdd... 100% ▕████████▏ 2.0 GB\npulling 1234efgh... 100% ▕██▏ 1.2 KB\nverifying sha256 digest\nwriting manifest\nsuccess\n";
127        let r = compress("ollama pull llama3.2", out).unwrap();
128        assert_eq!(r, "ollama: success (2 layers)");
129    }
130
131    #[test]
132    fn run_is_not_compressed() {
133        assert!(
134            compress(
135                "ollama run llama3.2 'hi'",
136                "The capital of France is Paris."
137            )
138            .is_none()
139        );
140    }
141
142    #[test]
143    fn empty_is_ok() {
144        assert_eq!(compress("ollama list", "").unwrap(), "ollama: ok");
145    }
146}