lean_ctx/tools/
ctx_compress_memory.rs1use std::path::Path;
2
3use crate::core::tokens::count_tokens;
4
5pub fn handle(path: &str) -> String {
6 if let Err(e) = crate::core::pathjail::enforce_writable(Path::new(path)) {
9 return format!("ERROR: {e}");
10 }
11
12 let content = match std::fs::read_to_string(path) {
13 Ok(c) => c,
14 Err(e) => return format!("ERROR: Cannot read {path}: {e}"),
15 };
16
17 let original_tokens = count_tokens(&content);
18
19 let backup_path = build_backup_path(path);
20 if !Path::new(&backup_path).exists()
21 && let Err(e) = std::fs::write(&backup_path, &content)
22 {
23 return format!("ERROR: Cannot create backup {backup_path}: {e}");
24 }
25
26 let compressed = compress_memory_file(&content);
27 let compressed_tokens = count_tokens(&compressed);
28
29 if let Err(e) = std::fs::write(path, &compressed) {
30 return format!("ERROR: Cannot write compressed file: {e}");
31 }
32
33 let saved = original_tokens.saturating_sub(compressed_tokens);
34 let pct = if original_tokens > 0 {
35 (saved as f64 / original_tokens as f64 * 100.0).round() as usize
36 } else {
37 0
38 };
39
40 format!(
41 "Compressed {}: {} → {} tokens ({saved} saved, {pct}%)\n\
42 Backup: {backup_path}",
43 Path::new(path)
44 .file_name()
45 .and_then(|f| f.to_str())
46 .unwrap_or(path),
47 original_tokens,
48 compressed_tokens,
49 )
50}
51
52fn build_backup_path(path: &str) -> String {
53 let p = Path::new(path);
54 let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
55 let parent = p.parent().unwrap_or_else(|| Path::new("."));
56 parent
57 .join(format!("{stem}.original.md"))
58 .to_string_lossy()
59 .to_string()
60}
61
62fn compress_memory_file(content: &str) -> String {
63 let mut output = Vec::new();
64 let mut in_code_block = false;
65 let mut code_fence = String::new();
66
67 for line in content.lines() {
68 let trimmed = line.trim();
69
70 if !in_code_block && is_code_fence_start(trimmed) {
71 in_code_block = true;
72 code_fence = trimmed
73 .chars()
74 .take_while(|c| *c == '`' || *c == '~')
75 .collect();
76 output.push(line.to_string());
77 continue;
78 }
79
80 if in_code_block {
81 output.push(line.to_string());
82 if trimmed.starts_with(&code_fence) && trimmed.len() <= code_fence.len() + 1 {
83 in_code_block = false;
84 code_fence.clear();
85 }
86 continue;
87 }
88
89 if is_protected_line(trimmed) {
90 output.push(line.to_string());
91 continue;
92 }
93
94 if trimmed.is_empty() {
95 if output.last().is_some_and(|l| l.trim().is_empty()) {
96 continue;
97 }
98 output.push(String::new());
99 continue;
100 }
101
102 let compressed = compress_prose_line(line);
103 if !compressed.trim().is_empty() {
104 output.push(compressed);
105 }
106 }
107
108 output.join("\n")
109}
110
111fn is_code_fence_start(line: &str) -> bool {
112 line.starts_with("```") || line.starts_with("~~~")
113}
114
115fn is_protected_line(line: &str) -> bool {
116 if line.starts_with('#') {
117 return true;
118 }
119 if line.starts_with("- ") || line.starts_with("* ") || line.starts_with("> ") {
120 return true;
121 }
122 if line.starts_with('|') {
123 return true;
124 }
125 if contains_url_or_path(line) && line.split_whitespace().count() <= 3 {
126 return true;
127 }
128 false
129}
130
131fn contains_url_or_path(line: &str) -> bool {
132 line.contains("http://")
133 || line.contains("https://")
134 || line.contains("ftp://")
135 || (line.contains('/') && line.contains('.') && !line.contains(' '))
136}
137
138fn compress_prose_line(line: &str) -> String {
139 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
140 let trimmed = line.trim();
141
142 let mut words: Vec<&str> = trimmed.split_whitespace().collect();
143
144 words.retain(|w| !is_filler_word(w));
145
146 let mut result: Vec<String> = Vec::new();
147 let mut i = 0;
148 while i < words.len() {
149 if let Some((replacement, skip)) = try_shorten_phrase(&words, i) {
150 result.push(replacement.to_string());
151 i += skip;
152 } else {
153 result.push(words[i].to_string());
154 i += 1;
155 }
156 }
157
158 format!("{}{}", leading_ws, result.join(" "))
159}
160
161fn is_filler_word(word: &str) -> bool {
162 let w = word.to_lowercase();
163 let w = w.trim_matches(|c: char| c.is_ascii_punctuation());
164 matches!(
165 w,
166 "just" | "really" | "basically" | "actually" | "simply" | "please" | "very" | "quite"
167 )
168}
169
170fn try_shorten_phrase(words: &[&str], pos: usize) -> Option<(&'static str, usize)> {
171 if pos + 2 < words.len() {
172 let three = format!(
173 "{} {} {}",
174 words[pos].to_lowercase(),
175 words[pos + 1].to_lowercase(),
176 words[pos + 2].to_lowercase()
177 );
178 match three.as_str() {
179 "in order to" => return Some(("to", 3)),
180 "as well as" => return Some(("and", 3)),
181 "due to the" => return Some(("because", 3)),
182 "make sure to" => return Some(("ensure", 3)),
183 "a lot of" => return Some(("many", 3)),
184 "on top of" => return Some(("besides", 3)),
185 _ => {}
186 }
187 }
188
189 if pos + 1 < words.len() {
190 let two = format!(
191 "{} {}",
192 words[pos].to_lowercase(),
193 words[pos + 1].to_lowercase()
194 );
195 match two.as_str() {
196 "make sure" => return Some(("ensure", 2)),
197 "a lot" => return Some(("many", 2)),
198 "as well" => return Some(("also", 2)),
199 "in order" => return Some(("to", 2)),
200 "prior to" => return Some(("before", 2)),
201 "due to" => return Some(("because", 2)),
202 _ => {}
203 }
204 }
205
206 None
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn preserves_code_blocks() {
215 let input = "Some text just really here.\n\n```rust\nfn main() {\n println!(\"hello\");\n}\n```\n\nMore text.";
216 let result = compress_memory_file(input);
217 assert!(result.contains("fn main()"));
218 assert!(result.contains("println!"));
219 }
220
221 #[test]
222 fn preserves_headings() {
223 let input = "# Main Heading\n\nJust some filler text here.\n\n## Sub Heading";
224 let result = compress_memory_file(input);
225 assert!(result.contains("# Main Heading"));
226 assert!(result.contains("## Sub Heading"));
227 }
228
229 #[test]
230 fn preserves_urls() {
231 let input = "Visit https://example.com for details.\nJust some really basic text.";
232 let result = compress_memory_file(input);
233 assert!(result.contains("https://example.com"));
234 }
235
236 #[test]
237 fn removes_filler_words() {
238 let input = "You should just really basically make sure to check this.";
239 let result = compress_prose_line(input);
240 assert!(!result.contains("just"));
241 assert!(!result.contains("really"));
242 assert!(!result.contains("basically"));
243 assert!(result.contains("ensure"));
244 }
245
246 #[test]
247 fn shortens_phrases() {
248 let input = "In order to fix this, make sure to check the config.";
249 let result = compress_prose_line(input);
250 assert!(!result.contains("In order to"));
251 assert!(result.contains("to"));
252 assert!(result.contains("ensure"));
253 }
254
255 #[test]
256 fn collapses_blank_lines() {
257 let input = "Line 1\n\n\n\nLine 2\n\n\nLine 3";
258 let result = compress_memory_file(input);
259 assert!(!result.contains("\n\n\n"));
260 }
261
262 #[test]
263 fn preserves_tables() {
264 let input = "| Col A | Col B |\n|-------|-------|\n| val1 | val2 |";
265 let result = compress_memory_file(input);
266 assert!(result.contains("| Col A | Col B |"));
267 }
268
269 #[test]
270 fn backup_path_computed_correctly() {
271 assert_eq!(
272 Path::new(&build_backup_path("/home/user/.cursorrules")),
273 Path::new("/home/user")
274 .join(".cursorrules.original.md")
275 .as_path()
276 );
277 assert_eq!(
278 Path::new(&build_backup_path("/project/CLAUDE.md")),
279 Path::new("/project").join("CLAUDE.original.md").as_path()
280 );
281 }
282
283 #[cfg(not(feature = "no-jail"))]
286 #[test]
287 fn handle_denies_write_into_read_only_root() {
288 let _iso = crate::core::data_dir::isolated_data_dir();
289 let dir = tempfile::tempdir().unwrap();
290 let ro = dir.path().join("refrepo");
291 std::fs::create_dir_all(&ro).unwrap();
292 let file = ro.join("CLAUDE.md");
293 let original = "# Title\n\nJust some really verbose prose here.\n";
294 std::fs::write(&file, original).unwrap();
295
296 let ro_canon = crate::core::pathjail::canonicalize_or_self(&ro);
297 crate::test_env::set_var(
298 "LEAN_CTX_READ_ONLY_ROOTS",
299 ro_canon.to_string_lossy().as_ref(),
300 );
301 let out = handle(file.to_string_lossy().as_ref());
302 crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
303
304 assert!(
305 out.starts_with("ERROR") && out.contains("read-only"),
306 "compaction into a read-only root must be refused: {out}"
307 );
308 assert_eq!(
309 std::fs::read_to_string(&file).unwrap(),
310 original,
311 "the file must be untouched"
312 );
313 assert!(
314 !ro.join("CLAUDE.original.md").exists(),
315 "no backup may be written into a read-only root"
316 );
317 }
318}