Skip to main content

lean_ctx/core/
compress_preview.rs

1//! compress_preview — a read-only "what would compression do to this?" inspector
2//! (#984, Headroom #1267).
3//!
4//! Returns the original alongside the exact bytes lean-ctx would emit, with token
5//! and byte accounting plus the line diff. It deliberately calls the **production**
6//! compressors — [`compressor::aggressive_compress`] for the file-read path and
7//! the shell engine's `compress_if_beneficial` for command output — so the
8//! preview is always what the agent would actually receive, never a re-derivation
9//! that could drift from the real pipeline (#498).
10
11use crate::core::compressor;
12use crate::core::tokens::count_tokens;
13
14/// Which production compressor a preview runs through.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Pipeline {
17    /// The `ctx_read` aggressive arm: strips comments/blank lines and losslessly
18    /// compacts JSON/CSV. The file extension selects the language-specific rules.
19    Read,
20    /// The `ctx_shell` output pass: the ~95-pattern beneficial compressor.
21    Shell,
22}
23
24impl Pipeline {
25    /// Stable label used in [`Preview::render`] (no spaces that would break a
26    /// machine split on the header line).
27    pub fn label(self) -> &'static str {
28        match self {
29            Pipeline::Read => "read/aggressive",
30            Pipeline::Shell => "shell",
31        }
32    }
33}
34
35/// A computed preview: the original, the compressed form lean-ctx would emit, and
36/// the token/byte accounting for both.
37pub struct Preview {
38    pub pipeline: Pipeline,
39    pub original: String,
40    pub compressed: String,
41    pub original_tokens: usize,
42    pub compressed_tokens: usize,
43}
44
45impl Preview {
46    pub fn original_bytes(&self) -> usize {
47        self.original.len()
48    }
49
50    pub fn compressed_bytes(&self) -> usize {
51        self.compressed.len()
52    }
53
54    /// Tokens removed. Saturating: a pipeline never inflates in practice, but the
55    /// accounting stays honest (never negative) if a pathological input did.
56    pub fn saved_tokens(&self) -> usize {
57        self.original_tokens.saturating_sub(self.compressed_tokens)
58    }
59
60    /// Compressed-to-original token ratio in `[0.0, 1.0]` (1.0 = no change, also
61    /// the empty-input convention). Lower is better.
62    pub fn token_ratio(&self) -> f64 {
63        if self.original_tokens == 0 {
64            return 1.0;
65        }
66        self.compressed_tokens as f64 / self.original_tokens as f64
67    }
68
69    /// Percent of tokens saved, rounded to one decimal (derived from the ratio).
70    pub fn saved_pct(&self) -> f64 {
71        ((1.0 - self.token_ratio()) * 1000.0).round() / 10.0
72    }
73
74    /// Line-level diff original→compressed via the shared differ.
75    pub fn diff(&self) -> String {
76        compressor::diff_content(&self.original, &self.compressed)
77    }
78
79    /// Human-readable report: an accounting header followed by the diff.
80    /// Deterministic (no timestamps/counters) so the output is cache-stable (#498).
81    pub fn render(&self) -> String {
82        let mut out = String::new();
83        out.push_str("compress preview — pipeline: ");
84        out.push_str(self.pipeline.label());
85        out.push('\n');
86        out.push_str(&format!(
87            "tokens: {} -> {}  (-{}, {:.1}% saved)\n",
88            self.original_tokens,
89            self.compressed_tokens,
90            self.saved_tokens(),
91            self.saved_pct(),
92        ));
93        out.push_str(&format!(
94            "bytes:  {} -> {}\n",
95            self.original_bytes(),
96            self.compressed_bytes(),
97        ));
98        out.push_str("-- diff (original -> compressed) --\n");
99        out.push_str(&self.diff());
100        if !out.ends_with('\n') {
101            out.push('\n');
102        }
103        out
104    }
105}
106
107/// Preview the read/aggressive pipeline for `content`, with an optional file
108/// extension that drives language-specific comment stripping and the JSON/CSV
109/// crushers.
110pub fn preview_read(content: &str, ext: Option<&str>) -> Preview {
111    let compressed = compressor::aggressive_compress(content, ext);
112    finish(Pipeline::Read, content, compressed)
113}
114
115/// Preview the shell pipeline for `output` produced by `command` (the command
116/// steers build/test-aware verbatim preservation).
117pub fn preview_shell(command: &str, output: &str) -> Preview {
118    let compressed = crate::shell::compress::engine::compress_if_beneficial_pub(command, output);
119    finish(Pipeline::Shell, output, compressed)
120}
121
122fn finish(pipeline: Pipeline, original: &str, compressed: String) -> Preview {
123    Preview {
124        pipeline,
125        original_tokens: count_tokens(original),
126        compressed_tokens: count_tokens(&compressed),
127        original: original.to_string(),
128        compressed,
129    }
130}
131
132/// Lowercased extension (no leading dot) of `path`, for [`preview_read`].
133pub fn ext_of(path: &str) -> Option<String> {
134    std::path::Path::new(path)
135        .extension()
136        .and_then(|e| e.to_str())
137        .map(str::to_ascii_lowercase)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn read_preview_strips_comments_and_saves_tokens() {
146        let src = "// a leading comment\nfn main() {\n    // inner note\n    let x = 1;\n}\n";
147        let p = preview_read(src, Some("rs"));
148        assert_eq!(p.pipeline, Pipeline::Read);
149        assert!(
150            !p.compressed.contains("leading comment") && !p.compressed.contains("inner note"),
151            "comments must be stripped: {}",
152            p.compressed
153        );
154        assert!(p.compressed_tokens < p.original_tokens, "tokens must drop");
155        assert!(p.saved_tokens() > 0);
156        assert!(p.token_ratio() < 1.0);
157        assert!(p.saved_pct() > 0.0);
158    }
159
160    #[test]
161    fn read_preview_compacts_pretty_json() {
162        let json =
163            "{\n  \"name\": \"lean-ctx\",\n  \"nested\": {\n    \"a\": 1,\n    \"b\": 2\n  }\n}\n";
164        let p = preview_read(json, Some("json"));
165        // Structured compaction strips insignificant whitespace losslessly.
166        assert!(!p.compressed.contains("\n  "), "json must be compacted");
167        assert!(p.compressed_tokens < p.original_tokens);
168    }
169
170    #[test]
171    fn no_change_input_reports_no_savings_and_clean_diff() {
172        let src = "let x = 1;";
173        let p = preview_read(src, Some("rs"));
174        assert_eq!(
175            p.compressed, p.original,
176            "already-minimal input is unchanged"
177        );
178        assert_eq!(p.saved_tokens(), 0);
179        assert!((p.token_ratio() - 1.0).abs() < f64::EPSILON);
180        assert_eq!(p.saved_pct(), 0.0);
181        assert_eq!(p.diff(), "(no changes)");
182    }
183
184    #[test]
185    fn shell_preview_compresses_repetitive_output() {
186        let output = "Compiling foo\n".repeat(40);
187        let p = preview_shell("cargo build", &output);
188        assert_eq!(p.pipeline, Pipeline::Shell);
189        assert!(
190            p.compressed_tokens <= p.original_tokens,
191            "shell compression never inflates"
192        );
193    }
194
195    #[test]
196    fn render_is_deterministic_and_self_describing() {
197        let src = "// c\nfn a() {}\n";
198        let p = preview_read(src, Some("rs"));
199        let a = p.render();
200        let b = p.render();
201        assert_eq!(a, b, "render must be deterministic (#498)");
202        assert!(a.contains("pipeline: read/aggressive"));
203        assert!(a.contains("tokens:"));
204        assert!(a.contains("bytes:"));
205    }
206
207    #[test]
208    fn ext_of_extracts_lowercased_extension() {
209        assert_eq!(ext_of("/a/b/File.RS").as_deref(), Some("rs"));
210        assert_eq!(ext_of("data.JSON").as_deref(), Some("json"));
211        assert_eq!(ext_of("Makefile"), None);
212        assert_eq!(ext_of("-"), None);
213    }
214}