1use similar::{ChangeTag, TextDiff};
2
3macro_rules! static_regex {
4 ($pattern:expr_2021) => {{
5 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
6 RE.get_or_init(|| {
7 regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
8 })
9 }};
10}
11
12pub fn strip_ansi(s: &str) -> String {
14 if !s.contains('\x1b') {
15 return s.to_string();
16 }
17 let mut result = String::with_capacity(s.len());
18 let mut in_escape = false;
19 for c in s.chars() {
20 if c == '\x1b' {
21 in_escape = true;
22 continue;
23 }
24 if in_escape {
25 if c.is_ascii_alphabetic() {
26 in_escape = false;
27 }
28 continue;
29 }
30 result.push(c);
31 }
32 result
33}
34
35pub fn ansi_density(s: &str) -> f64 {
37 if s.is_empty() {
38 return 0.0;
39 }
40 let escape_bytes = s.chars().filter(|&c| c == '\x1b').count();
41 escape_bytes as f64 / s.len() as f64
42}
43
44pub fn aggressive_compress(content: &str, ext: Option<&str>) -> String {
46 let markdown_family = matches!(ext, Some("md" | "markdown" | "mdown"));
54 let structured_txt =
55 matches!(ext, Some("txt")) && crate::core::markdown_compact::has_markdown_headings(content);
56 if (markdown_family || structured_txt)
57 && let Some(compacted) = crate::core::markdown_compact::compact_markdown(content)
58 {
59 return compacted;
60 }
61 if let Some(compacted) = crate::core::structured_compact::compact_structured(content, ext) {
62 return compacted;
63 }
64
65 if let Some(delim) = tabular_delimiter(ext)
70 && let Some(crushed) = crate::core::tabular_crush::crush_text_if_beneficial(content, delim)
71 {
72 return crushed;
73 }
74
75 if is_yaml_ext(ext)
81 && let Some(crushed) = crate::core::yaml_crush::crush_text_if_beneficial(content)
82 {
83 return crushed;
84 }
85
86 let mut result: Vec<String> = Vec::new();
87 let is_python = matches!(ext, Some("py"));
88 let is_html = matches!(ext, Some("html" | "htm" | "xml" | "svg"));
89 let is_sql = matches!(ext, Some("sql"));
90 let is_shell = matches!(ext, Some("sh" | "bash" | "zsh" | "fish"));
91
92 let mut in_block_comment = false;
93
94 for line in content.lines() {
95 let trimmed = line.trim();
96
97 if trimmed.is_empty() {
98 continue;
99 }
100
101 if in_block_comment {
102 if trimmed.contains("*/") || (is_html && trimmed.contains("-->")) {
103 in_block_comment = false;
104 }
105 continue;
106 }
107
108 if trimmed.starts_with("/*") || (is_html && trimmed.starts_with("<!--")) {
109 if !(trimmed.contains("*/") || trimmed.contains("-->")) {
110 in_block_comment = true;
111 }
112 continue;
113 }
114
115 if trimmed.starts_with("//") && !trimmed.starts_with("///") {
116 continue;
117 }
118 if trimmed.starts_with('*') || trimmed.starts_with("*/") {
119 continue;
120 }
121 if is_python && trimmed.starts_with('#') {
122 continue;
123 }
124 if is_sql && trimmed.starts_with("--") {
125 continue;
126 }
127 if is_shell && trimmed.starts_with('#') && !trimmed.starts_with("#!") {
128 continue;
129 }
130 if !is_python && trimmed.starts_with('#') && trimmed.contains('[') {
131 continue;
132 }
133
134 if trimmed == "}" || trimmed == "};" || trimmed == ");" || trimmed == "});" {
135 if let Some(last) = result.last() {
136 let last_trimmed = last.trim();
137 if matches!(last_trimmed, "}" | "};" | ");" | "});") {
138 if let Some(last_mut) = result.last_mut() {
139 last_mut.push_str(trimmed);
140 }
141 continue;
142 }
143 }
144 result.push(trimmed.to_string());
145 continue;
146 }
147
148 let normalized = normalize_indentation(line);
149 result.push(normalized);
150 }
151
152 result.join("\n")
153}
154
155pub fn lightweight_cleanup(content: &str) -> String {
158 let lines: Vec<&str> = content.lines().collect();
159 let total = lines.len();
160
161 let mut result: Vec<String> = Vec::new();
162 let mut blank_count = 0u32;
163 let mut brace_run: Vec<&str> = Vec::new();
164
165 let flush_brace_run = |run: &mut Vec<&str>, out: &mut Vec<String>| {
166 if total <= 200 || run.len() <= 5 {
167 for l in run.iter() {
168 out.push(l.to_string());
169 }
170 } else {
171 out.push(run[0].to_string());
172 out.push(run[1].to_string());
173 out.push(format!("[{} brace-only lines collapsed]", run.len() - 2));
174 }
175 run.clear();
176 };
177
178 for line in &lines {
179 let trimmed = line.trim();
180
181 if trimmed.is_empty() {
182 flush_brace_run(&mut brace_run, &mut result);
183 blank_count += 1;
184 if blank_count <= 1 {
185 result.push(String::new());
186 }
187 continue;
188 }
189 blank_count = 0;
190
191 if matches!(trimmed, "}" | "};" | ");" | "});" | ")") {
192 brace_run.push(trimmed);
193 continue;
194 }
195
196 flush_brace_run(&mut brace_run, &mut result);
197 result.push(line.to_string());
198 }
199 flush_brace_run(&mut brace_run, &mut result);
200
201 result.join("\n")
202}
203
204pub fn safeguard_ratio(original: &str, compressed: &str) -> String {
208 let orig_tokens = super::tokens::count_tokens(original);
209 let comp_tokens = super::tokens::count_tokens(compressed);
210
211 if orig_tokens == 0 {
212 return compressed.to_string();
213 }
214
215 if comp_tokens > orig_tokens {
216 return original.to_string();
217 }
218
219 let ratio = comp_tokens as f64 / orig_tokens as f64;
220 if ratio < 0.05 && orig_tokens < 2000 {
221 original.to_string()
222 } else {
223 compressed.to_string()
224 }
225}
226
227pub(crate) fn tabular_delimiter(ext: Option<&str>) -> Option<char> {
229 match ext {
230 Some("csv") => Some(','),
231 Some("tsv" | "tab") => Some('\t'),
232 _ => None,
233 }
234}
235
236pub(crate) fn is_yaml_ext(ext: Option<&str>) -> bool {
238 matches!(ext, Some("yaml" | "yml"))
239}
240
241fn normalize_indentation(line: &str) -> String {
242 let content = line.trim_start();
243 let leading = line.len() - content.len();
244 let has_tabs = line.starts_with('\t');
245 let reduced = if has_tabs { leading } else { leading / 2 };
246 format!("{}{}", " ".repeat(reduced), content)
247}
248
249pub fn diff_content(old_content: &str, new_content: &str) -> String {
251 if old_content == new_content {
252 return "(no changes)".to_string();
253 }
254
255 let diff = TextDiff::from_lines(old_content, new_content);
256 let mut changes = Vec::new();
257 let mut additions = 0usize;
258 let mut deletions = 0usize;
259
260 for change in diff.iter_all_changes() {
261 let line_no = change.new_index().or(change.old_index()).map(|i| i + 1);
262 let text = change.value().trim_end_matches('\n');
263 match change.tag() {
264 ChangeTag::Insert => {
265 additions += 1;
266 if let Some(n) = line_no {
267 changes.push(format!("+{n}: {text}"));
268 }
269 }
270 ChangeTag::Delete => {
271 deletions += 1;
272 if let Some(n) = line_no {
273 changes.push(format!("-{n}: {text}"));
274 }
275 }
276 ChangeTag::Equal => {}
277 }
278 }
279
280 if changes.is_empty() {
281 return "(no changes)".to_string();
282 }
283
284 changes.push(format!("\ndiff +{additions}/-{deletions} lines"));
285 changes.join("\n")
286}
287
288pub fn verbatim_compact(text: &str) -> String {
290 let mut lines: Vec<String> = Vec::new();
291 let mut blank_count = 0u32;
292 let mut prev_line: Option<String> = None;
293 let mut repeat_count = 0u32;
294
295 for line in text.lines() {
296 let trimmed = line.trim();
297
298 if trimmed.is_empty() {
299 blank_count += 1;
300 if blank_count <= 1 {
301 flush_repeats(&mut lines, &mut prev_line, &mut repeat_count);
302 lines.push(String::new());
303 }
304 continue;
305 }
306 blank_count = 0;
307
308 if is_boilerplate_line(trimmed) {
309 continue;
310 }
311
312 let normalized = normalize_whitespace(trimmed);
313 let stripped = strip_timestamps_hashes(&normalized);
314
315 if let Some(ref prev) = prev_line
316 && *prev == stripped
317 {
318 repeat_count += 1;
319 continue;
320 }
321
322 flush_repeats(&mut lines, &mut prev_line, &mut repeat_count);
323 prev_line = Some(stripped.clone());
324 repeat_count = 1;
325 lines.push(stripped);
326 }
327
328 flush_repeats(&mut lines, &mut prev_line, &mut repeat_count);
329 lines.join("\n")
330}
331
332pub fn task_aware_compress(
334 content: &str,
335 ext: Option<&str>,
336 intent: &super::intent_engine::StructuredIntent,
337) -> String {
338 use super::intent_engine::{IntentScope, TaskType};
339
340 let budget_ratio = match intent.scope {
341 IntentScope::SingleFile => 0.7,
342 IntentScope::MultiFile => 0.5,
343 IntentScope::CrossModule => 0.35,
344 IntentScope::ProjectWide => 0.25,
345 };
346
347 match intent.task_type {
348 TaskType::FixBug | TaskType::Debug => {
349 let filtered = super::task_relevance::information_bottleneck_filter_typed(
350 content,
351 &intent.keywords,
352 budget_ratio,
353 Some(intent.task_type),
354 &[],
355 );
356 safeguard_ratio(content, &filtered)
357 }
358 TaskType::Refactor | TaskType::Review => {
359 let cleaned = lightweight_cleanup(content);
360 let filtered = super::task_relevance::information_bottleneck_filter_typed(
361 &cleaned,
362 &intent.keywords,
363 budget_ratio.max(0.5),
364 Some(intent.task_type),
365 &[],
366 );
367 safeguard_ratio(content, &filtered)
368 }
369 TaskType::Generate | TaskType::Test => {
370 let compressed = aggressive_compress(content, ext);
371 safeguard_ratio(content, &compressed)
372 }
373 TaskType::Explore | TaskType::Config | TaskType::Deploy => {
374 let cleaned = lightweight_cleanup(content);
375 safeguard_ratio(content, &cleaned)
376 }
377 }
378}
379
380fn flush_repeats(lines: &mut [String], prev_line: &mut Option<String>, count: &mut u32) {
381 if *count > 1
382 && let &mut Some(ref prev) = prev_line
383 {
384 let last_idx = lines.len().saturating_sub(1);
385 if last_idx < lines.len() {
386 lines[last_idx] = format!("[{count}x] {prev}");
387 }
388 }
389 *count = 0;
390 *prev_line = None;
391}
392
393fn normalize_whitespace(line: &str) -> String {
394 let mut result = String::with_capacity(line.len());
395 let mut prev_space = false;
396 for ch in line.chars() {
397 if ch == ' ' || ch == '\t' {
398 if !prev_space {
399 result.push(' ');
400 prev_space = true;
401 }
402 } else {
403 result.push(ch);
404 prev_space = false;
405 }
406 }
407 result
408}
409
410fn strip_timestamps_hashes(line: &str) -> String {
411 let ts_re =
412 static_regex!(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?");
413 let hash_re = static_regex!(r"\b[0-9a-f]{32,64}\b");
414
415 let s = ts_re.replace_all(line, "[TS]");
416 let s = hash_re.replace_all(&s, "[HASH]");
417 s.into_owned()
418}
419
420fn is_boilerplate_line(trimmed: &str) -> bool {
421 let lower = trimmed.to_lowercase();
422 if lower.starts_with("copyright")
423 || lower.starts_with("licensed under")
424 || lower.starts_with("license:")
425 || lower.starts_with("all rights reserved")
426 {
427 return true;
428 }
429 if lower.starts_with("generated by") || lower.starts_with("auto-generated") {
430 return true;
431 }
432 if trimmed.len() >= 4 {
433 let chars: Vec<char> = trimmed.chars().collect();
434 let first = chars[0];
435 if matches!(first, '=' | '-' | '*' | '─' | '━') {
436 let same = chars.iter().filter(|c| **c == first).count();
437 if same as f64 / chars.len() as f64 > 0.8 {
438 return true;
439 }
440 }
441 }
442 false
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 #[test]
450 fn test_diff_insertion() {
451 let old = "line1\nline2\nline3";
452 let new = "line1\nline2\nnew_line\nline3";
453 let result = diff_content(old, new);
454 assert!(result.contains('+'), "should show additions");
455 assert!(result.contains("new_line"));
456 }
457
458 #[test]
459 fn test_diff_deletion() {
460 let old = "line1\nline2\nline3";
461 let new = "line1\nline3";
462 let result = diff_content(old, new);
463 assert!(result.contains('-'), "should show deletions");
464 assert!(result.contains("line2"));
465 }
466
467 #[test]
468 fn test_diff_no_changes() {
469 let content = "same\ncontent";
470 assert_eq!(diff_content(content, content), "(no changes)");
471 }
472
473 #[test]
474 fn test_lightweight_cleanup_collapses_braces() {
475 let mut lines: Vec<String> = (0..210).map(|i| format!("line {i}")).collect();
476 lines.extend(
477 ["}", "}", "}", "}", "}", "}", "}", "}"]
478 .iter()
479 .map(std::string::ToString::to_string),
480 );
481 lines.push("fn next() {}".to_string());
482 let input = lines.join("\n");
483 let result = lightweight_cleanup(&input);
484 assert!(
485 result.contains("[6 brace-only lines collapsed]"),
486 "should collapse long brace runs in large files"
487 );
488 assert!(result.contains("fn next()"));
489 }
490
491 #[test]
492 fn test_lightweight_cleanup_blank_lines() {
493 let input = "line1\n\n\n\n\nline2";
494 let result = lightweight_cleanup(input);
495 let blank_runs = result.split("line1").nth(1).unwrap();
496 let blanks = blank_runs.matches('\n').count();
497 assert!(blanks <= 2, "should collapse multiple blank lines");
498 }
499
500 #[test]
501 fn test_safeguard_ratio_prevents_over_compression_on_small_output() {
502 let original = "a ".repeat(100); let too_compressed = "a";
504 let result = safeguard_ratio(&original, too_compressed);
505 assert_eq!(
506 result, original,
507 "should return original when ratio < 0.05 and output is small"
508 );
509 }
510
511 #[test]
512 fn test_safeguard_ratio_allows_strong_compression_on_large_output() {
513 let original = "line content here\n".repeat(1000); let compressed = "summary: 1000 lines";
515 let result = safeguard_ratio(&original, compressed);
516 assert_eq!(
517 result, compressed,
518 "should allow strong compression for large outputs"
519 );
520 }
521
522 #[test]
523 fn test_aggressive_strips_comments() {
524 let code = "fn main() {\n // a comment\n let x = 1;\n}";
525 let result = aggressive_compress(code, Some("rs"));
526 assert!(!result.contains("// a comment"));
527 assert!(result.contains("let x = 1"));
528 }
529
530 #[test]
531 fn test_aggressive_python_comments() {
532 let code = "def main():\n # comment\n x = 1";
533 let result = aggressive_compress(code, Some("py"));
534 assert!(!result.contains("# comment"));
535 assert!(result.contains("x = 1"));
536 }
537
538 #[test]
539 fn test_aggressive_preserves_doc_comments() {
540 let code = "/// Doc comment\nfn main() {}";
541 let result = aggressive_compress(code, Some("rs"));
542 assert!(result.contains("/// Doc comment"));
543 }
544
545 #[test]
546 fn test_aggressive_block_comment() {
547 let code = "/* start\n * middle\n */ end\nfn main() {}";
548 let result = aggressive_compress(code, Some("rs"));
549 assert!(!result.contains("start"));
550 assert!(!result.contains("middle"));
551 assert!(result.contains("fn main()"));
552 }
553
554 #[test]
555 fn test_strip_ansi_removes_escape_codes() {
556 let input = "\x1b[31mERROR\x1b[0m: something failed";
557 let result = strip_ansi(input);
558 assert_eq!(result, "ERROR: something failed");
559 assert!(!result.contains('\x1b'));
560 }
561
562 #[test]
563 fn test_strip_ansi_passthrough_clean_text() {
564 let input = "clean text without escapes";
565 let result = strip_ansi(input);
566 assert_eq!(result, input);
567 }
568
569 #[test]
570 fn test_ansi_density_zero_for_clean() {
571 assert_eq!(ansi_density("hello world"), 0.0);
572 }
573
574 #[test]
575 fn test_ansi_density_nonzero_for_colored() {
576 let input = "\x1b[31mred\x1b[0m";
577 assert!(ansi_density(input) > 0.0);
578 }
579}