1use crate::error::{Result, SqzError};
2use crate::toon::ToonEncoder;
3use crate::types::{Content, ContentType, StageConfig};
4
5pub trait CompressionStage: Send + Sync {
10 fn name(&self) -> &str;
11 fn priority(&self) -> u32;
12 fn process(&self, content: &mut Content, config: &StageConfig) -> Result<()>;
13}
14
15fn with_json<F>(content: &mut Content, f: F) -> Result<()>
20where
21 F: FnOnce(&mut serde_json::Value) -> Result<()>,
22{
23 if !ToonEncoder::is_json(&content.raw) {
24 return Ok(());
25 }
26 let mut value: serde_json::Value = serde_json::from_str(&content.raw)?;
27 f(&mut value)?;
28 content.raw = serde_json::to_string(&value)?;
29 Ok(())
30}
31
32pub struct KeepFieldsStage;
40
41impl CompressionStage for KeepFieldsStage {
42 fn name(&self) -> &str {
43 "keep_fields"
44 }
45
46 fn priority(&self) -> u32 {
47 10
48 }
49
50 fn process(&self, content: &mut Content, config: &StageConfig) -> Result<()> {
51 if !config.enabled {
52 return Ok(());
53 }
54 let fields: Vec<String> = match config.options.get("fields") {
55 Some(v) => serde_json::from_value(v.clone())
56 .map_err(|e| SqzError::Other(format!("keep_fields: invalid fields option: {e}")))?,
57 None => return Ok(()),
58 };
59 if fields.is_empty() {
60 return Ok(());
61 }
62 with_json(content, |value| {
63 if let serde_json::Value::Object(map) = value {
64 map.retain(|k, _| fields.contains(k));
65 }
66 Ok(())
67 })
68 }
69}
70
71pub struct StripFieldsStage;
80
81fn strip_field_path(value: &mut serde_json::Value, path: &[&str]) {
82 if path.is_empty() {
83 return;
84 }
85 if let serde_json::Value::Object(map) = value {
86 if path.len() == 1 {
87 map.remove(path[0]);
88 } else {
89 if let Some(child) = map.get_mut(path[0]) {
90 strip_field_path(child, &path[1..]);
91 }
92 }
93 }
94}
95
96impl CompressionStage for StripFieldsStage {
97 fn name(&self) -> &str {
98 "strip_fields"
99 }
100
101 fn priority(&self) -> u32 {
102 20
103 }
104
105 fn process(&self, content: &mut Content, config: &StageConfig) -> Result<()> {
106 if !config.enabled {
107 return Ok(());
108 }
109 let fields: Vec<String> = match config.options.get("fields") {
110 Some(v) => serde_json::from_value(v.clone())
111 .map_err(|e| SqzError::Other(format!("strip_fields: invalid fields option: {e}")))?,
112 None => return Ok(()),
113 };
114 if fields.is_empty() {
115 return Ok(());
116 }
117 with_json(content, |value| {
118 for field in &fields {
119 let parts: Vec<&str> = field.split('.').collect();
120 strip_field_path(value, &parts);
121 }
122 Ok(())
123 })
124 }
125}
126
127pub struct CondenseStage;
136
137impl CompressionStage for CondenseStage {
138 fn name(&self) -> &str {
139 "condense"
140 }
141
142 fn priority(&self) -> u32 {
143 30
144 }
145
146 fn process(&self, content: &mut Content, config: &StageConfig) -> Result<()> {
147 if !config.enabled {
148 return Ok(());
149 }
150 match &content.content_type {
152 ContentType::PlainText | ContentType::CliOutput { .. } => {}
153 _ => return Ok(()),
154 }
155 if ToonEncoder::is_json(&content.raw) || looks_like_diff(&content.raw) {
156 return Ok(());
157 }
158
159 let max_repeated: u32 = config
160 .options
161 .get("max_repeated_lines")
162 .and_then(|v| v.as_u64())
163 .map(|v| v as u32)
164 .unwrap_or(3);
165
166 let mut result = Vec::new();
167 let mut current_line: Option<&str> = None;
168 let mut run_count: u32 = 0;
169
170 for line in content.raw.lines() {
171 match current_line {
172 Some(prev) if prev == line => {
173 run_count += 1;
174 if run_count <= max_repeated {
175 result.push(line);
176 }
177 }
178 _ => {
179 current_line = Some(line);
180 run_count = 1;
181 result.push(line);
182 }
183 }
184 }
185
186 let trailing_newline = content.raw.ends_with('\n');
188 content.raw = result.join("\n");
189 if trailing_newline {
190 content.raw.push('\n');
191 }
192 Ok(())
193 }
194}
195
196pub struct StripNullsStage;
204
205fn strip_nulls_recursive(value: &mut serde_json::Value) {
206 match value {
207 serde_json::Value::Object(map) => {
208 map.retain(|_, v| !v.is_null());
209 for v in map.values_mut() {
210 strip_nulls_recursive(v);
211 }
212 }
213 serde_json::Value::Array(arr) => {
214 for item in arr.iter_mut() {
215 strip_nulls_recursive(item);
216 }
217 }
218 _ => {}
219 }
220}
221
222impl CompressionStage for StripNullsStage {
223 fn name(&self) -> &str {
224 "strip_nulls"
225 }
226
227 fn priority(&self) -> u32 {
228 40
229 }
230
231 fn process(&self, content: &mut Content, config: &StageConfig) -> Result<()> {
232 if !config.enabled {
233 return Ok(());
234 }
235 with_json(content, |value| {
236 strip_nulls_recursive(value);
237 Ok(())
238 })
239 }
240}
241
242pub struct FlattenStage;
251
252fn flatten_value(
253 value: &serde_json::Value,
254 prefix: &str,
255 depth: u32,
256 max_depth: u32,
257 out: &mut serde_json::Map<String, serde_json::Value>,
258) {
259 if let serde_json::Value::Object(map) = value {
260 if depth < max_depth {
261 for (k, v) in map {
262 let new_key = if prefix.is_empty() {
263 k.clone()
264 } else {
265 format!("{prefix}.{k}")
266 };
267 flatten_value(v, &new_key, depth + 1, max_depth, out);
268 }
269 return;
270 }
271 }
272 out.insert(prefix.to_owned(), value.clone());
273}
274
275impl CompressionStage for FlattenStage {
276 fn name(&self) -> &str {
277 "flatten"
278 }
279
280 fn priority(&self) -> u32 {
281 50
282 }
283
284 fn process(&self, content: &mut Content, config: &StageConfig) -> Result<()> {
285 if !config.enabled {
286 return Ok(());
287 }
288 let max_depth: u32 = config
289 .options
290 .get("max_depth")
291 .and_then(|v| v.as_u64())
292 .map(|v| v as u32)
293 .unwrap_or(3);
294
295 with_json(content, |value| {
296 if let serde_json::Value::Object(map) = value {
297 let mut out = serde_json::Map::new();
298 for (k, v) in map.iter() {
299 flatten_value(v, k, 1, max_depth, &mut out);
300 }
301 *map = out;
302 }
303 Ok(())
304 })
305 }
306}
307
308pub struct TruncateStringsStage;
317
318fn truncate_strings_recursive(value: &mut serde_json::Value, max_length: usize) {
319 match value {
320 serde_json::Value::String(s) => {
321 if s.chars().count() > max_length {
322 let truncated: String = s.chars().take(max_length).collect();
323 *s = format!("{truncated}...");
324 }
325 }
326 serde_json::Value::Object(map) => {
327 for v in map.values_mut() {
328 truncate_strings_recursive(v, max_length);
329 }
330 }
331 serde_json::Value::Array(arr) => {
332 for item in arr.iter_mut() {
333 truncate_strings_recursive(item, max_length);
334 }
335 }
336 _ => {}
337 }
338}
339
340impl CompressionStage for TruncateStringsStage {
341 fn name(&self) -> &str {
342 "truncate_strings"
343 }
344
345 fn priority(&self) -> u32 {
346 60
347 }
348
349 fn process(&self, content: &mut Content, config: &StageConfig) -> Result<()> {
350 if !config.enabled {
351 return Ok(());
352 }
353 let max_length: usize = config
354 .options
355 .get("max_length")
356 .and_then(|v| v.as_u64())
357 .map(|v| v as usize)
358 .unwrap_or(500);
359
360 with_json(content, |value| {
361 truncate_strings_recursive(value, max_length);
362 Ok(())
363 })
364 }
365}
366
367pub struct CollapseArraysStage;
383
384pub const DEFAULT_COLLAPSE_MAX_CHARS: usize = 32_000;
386
387fn collapse_arrays_recursive(
388 value: &mut serde_json::Value,
389 max_items: usize,
390 max_chars: usize,
391 summary_template: &str,
392) {
393 match value {
394 serde_json::Value::Array(arr) => {
395 for item in arr.iter_mut() {
396 collapse_arrays_recursive(item, max_items, max_chars, summary_template);
397 }
398 if arr.len() <= max_items {
399 return;
400 }
401 let sizes: Vec<usize> = arr
402 .iter()
403 .map(|v| serde_json::to_string(v).map(|s| s.len()).unwrap_or(0))
404 .collect();
405 let total = sizes.iter().sum::<usize>() + sizes.len() + 1;
407 if total <= max_chars {
408 return;
409 }
410 let mut keep = 0;
411 let mut used = 2;
412 for size in &sizes {
413 if keep >= max_items && used + size + 1 > max_chars {
414 break;
415 }
416 used += size + 1;
417 keep += 1;
418 }
419 if keep >= arr.len() {
420 return;
421 }
422 let remaining = arr.len() - keep;
423 arr.truncate(keep);
424 let summary = summary_template.replace("{remaining}", &remaining.to_string());
425 arr.push(serde_json::Value::String(summary));
426 }
427 serde_json::Value::Object(map) => {
428 for v in map.values_mut() {
429 collapse_arrays_recursive(v, max_items, max_chars, summary_template);
430 }
431 }
432 _ => {}
433 }
434}
435
436impl CompressionStage for CollapseArraysStage {
437 fn name(&self) -> &str {
438 "collapse_arrays"
439 }
440
441 fn priority(&self) -> u32 {
442 70
443 }
444
445 fn process(&self, content: &mut Content, config: &StageConfig) -> Result<()> {
446 if !config.enabled {
447 return Ok(());
448 }
449 let max_items: usize = config
450 .options
451 .get("max_items")
452 .and_then(|v| v.as_u64())
453 .map(|v| v as usize)
454 .unwrap_or(5);
455 let max_chars: usize = config
456 .options
457 .get("max_chars")
458 .and_then(|v| v.as_u64())
459 .map(|v| v as usize)
460 .unwrap_or(DEFAULT_COLLAPSE_MAX_CHARS);
461 let summary_template = config
462 .options
463 .get("summary_template")
464 .and_then(|v| v.as_str())
465 .filter(|s| !s.is_empty())
466 .unwrap_or("... and {remaining} more items")
467 .to_owned();
468
469 with_json(content, |value| {
470 collapse_arrays_recursive(value, max_items, max_chars, &summary_template);
471 Ok(())
472 })
473 }
474}
475
476pub struct WordAbbreviateStage;
486
487const WORD_ABBREVIATIONS: &[(&str, &str)] = &[
490 ("implementation", "impl"),
491 ("implementations", "impls"),
492 ("configuration", "config"),
493 ("configurations", "configs"),
494 ("authentication", "auth"),
495 ("authorization", "authz"),
496 ("application", "app"),
497 ("applications", "apps"),
498 ("environment", "env"),
499 ("environments", "envs"),
500 ("development", "dev"),
501 ("production", "prod"),
502 ("repository", "repo"),
503 ("repositories", "repos"),
504 ("dependency", "dep"),
505 ("dependencies", "deps"),
506 ("documentation", "docs"),
507 ("information", "info"),
508 ("directory", "dir"),
509 ("directories", "dirs"),
510 ("parameter", "param"),
511 ("parameters", "params"),
512 ("argument", "arg"),
513 ("arguments", "args"),
514 ("function", "fn"),
515 ("functions", "fns"),
516 ("reference", "ref"),
517 ("references", "refs"),
518 ("specification", "spec"),
519 ("specifications", "specs"),
520 ("temporary", "tmp"),
521 ("administrator", "admin"),
522 ("administrators", "admins"),
523 ("database", "db"),
524 ("databases", "dbs"),
525 ("message", "msg"),
526 ("messages", "msgs"),
527 ("response", "resp"),
528 ("request", "req"),
529 ("requests", "reqs"),
530 ("attribute", "attr"),
531 ("attributes", "attrs"),
532 ("expression", "expr"),
533 ("expressions", "exprs"),
534 ("operation", "op"),
535 ("operations", "ops"),
536 ("maximum", "max"),
537 ("minimum", "min"),
538 ("boolean", "bool"),
539 ("integer", "int"),
540 ("previous", "prev"),
541 ("current", "curr"),
542 ("original", "orig"),
543 ("synchronize", "sync"),
544 ("asynchronous", "async"),
545 ("initialize", "init"),
546 ("allocation", "alloc"),
547 ("allocations", "allocs"),
548 ("generation", "gen"),
549 ("miscellaneous", "misc"),
550 ("statistics", "stats"),
551 ("connection", "conn"),
552 ("connections", "conns"),
553 ("transaction", "txn"),
554 ("transactions", "txns"),
555 ("management", "mgmt"),
556 ("notification", "notif"),
557 ("notifications", "notifs"),
558 ("permission", "perm"),
559 ("permissions", "perms"),
560 ("distribution", "distro"),
561 ("distributions", "distros"),
562 ("architecture", "arch"),
563 ("infrastructure", "infra"),
564 ("kubernetes", "k8s"),
565 ("namespace", "ns"),
566 ("namespaces", "nses"),
567 ("container", "ctr"),
568 ("containers", "ctrs"),
569 ("microservice", "svc"),
570 ("microservices", "svcs"),
571];
572
573impl CompressionStage for WordAbbreviateStage {
574 fn name(&self) -> &str {
575 "word_abbreviate"
576 }
577
578 fn priority(&self) -> u32 {
579 25 }
581
582 fn process(&self, content: &mut Content, config: &StageConfig) -> Result<()> {
583 if !config.enabled {
584 return Ok(());
585 }
586 match &content.content_type {
588 ContentType::PlainText | ContentType::CliOutput { .. } => {}
589 _ => return Ok(()),
590 }
591
592 let mut result = content.raw.clone();
593 for &(long, short) in WORD_ABBREVIATIONS {
594 result = replace_whole_word(&result, long, short);
597 }
598
599 content.raw = result;
600 Ok(())
601 }
602}
603
604pub fn abbreviate_words(text: &str) -> String {
609 let mut result = text.to_string();
610 for &(long, short) in WORD_ABBREVIATIONS {
611 result = replace_whole_word(&result, long, short);
612 }
613 result
614}
615
616fn replace_whole_word(text: &str, word: &str, replacement: &str) -> String {
619 if text.is_empty() || word.is_empty() {
620 return text.to_string();
621 }
622
623 let lower = text.to_lowercase();
624 let word_lower = word.to_lowercase();
625 let word_len = word.len();
626 let mut result = String::with_capacity(text.len());
627 let mut last_end = 0;
628
629 let text_bytes = text.as_bytes();
630
631 for (start, _) in lower.match_indices(&word_lower) {
632 let end = start + word_len;
633
634 let before_ok = start == 0
636 || !text_bytes[start - 1].is_ascii_alphanumeric();
637 let after_ok = end >= text.len()
639 || !text_bytes[end].is_ascii_alphanumeric();
640
641 if before_ok && after_ok {
642 result.push_str(&text[last_end..start]);
643 result.push_str(replacement);
644 last_end = end;
645 }
646 }
647
648 result.push_str(&text[last_end..]);
649 result
650}
651
652pub struct GitDiffFoldStage;
665
666impl CompressionStage for GitDiffFoldStage {
667 fn name(&self) -> &str {
668 "git_diff_fold"
669 }
670
671 fn priority(&self) -> u32 {
672 35
673 }
674
675 fn process(&self, content: &mut Content, config: &StageConfig) -> Result<()> {
676 if !config.enabled {
677 return Ok(());
678 }
679 match &content.content_type {
681 ContentType::PlainText | ContentType::CliOutput { .. } => {}
682 _ => return Ok(()),
683 }
684
685 if !looks_like_diff(&content.raw) {
686 return Ok(());
687 }
688
689 let max_ctx: usize = config
690 .options
691 .get("max_context_lines")
692 .and_then(|v| v.as_u64())
693 .map(|v| v as usize)
694 .unwrap_or(2);
695
696 let lines: Vec<&str> = content.raw.lines().collect();
697 let n = lines.len();
698
699 let is_changed: Vec<bool> = lines
701 .iter()
702 .map(|l| {
703 l.starts_with('+')
704 || l.starts_with('-')
705 || l.starts_with("@@")
706 || l.starts_with("diff ")
707 || l.starts_with("index ")
708 || l.starts_with("--- ")
709 || l.starts_with("+++ ")
710 })
711 .collect();
712
713 let mut keep = vec![false; n];
715 for i in 0..n {
716 if is_changed[i] {
717 keep[i] = true;
718 for j in i.saturating_sub(max_ctx)..i {
720 keep[j] = true;
721 }
722 for j in (i + 1)..n.min(i + 1 + max_ctx) {
724 keep[j] = true;
725 }
726 }
727 }
728
729 let mut result = Vec::new();
731 let mut fold_count = 0usize;
732
733 for i in 0..n {
734 if keep[i] {
735 if fold_count > 0 {
736 result.push(format!("[{fold_count} unchanged lines]"));
737 fold_count = 0;
738 }
739 result.push(lines[i].to_owned());
740 } else {
741 fold_count += 1;
742 }
743 }
744 if fold_count > 0 {
745 result.push(format!("[{fold_count} unchanged lines]"));
746 }
747
748 let trailing_newline = content.raw.ends_with('\n');
749 content.raw = result.join("\n");
750 if trailing_newline {
751 content.raw.push('\n');
752 }
753 Ok(())
754 }
755}
756
757fn looks_like_diff(raw: &str) -> bool {
758 raw.starts_with("diff --git ")
763 || raw.starts_with("diff -")
764 || raw.contains("\n@@ ") || raw.contains("\n--- a/") || raw.contains("\n+++ b/") }
768
769pub struct CustomTransformsStage;
776
777impl CompressionStage for CustomTransformsStage {
778 fn name(&self) -> &str {
779 "custom_transforms"
780 }
781
782 fn priority(&self) -> u32 {
783 80
784 }
785
786 fn process(&self, _content: &mut Content, config: &StageConfig) -> Result<()> {
787 if !config.enabled {
788 return Ok(());
789 }
790 Ok(())
792 }
793}
794
795#[cfg(test)]
800mod tests {
801 use super::*;
802 use crate::types::{ContentMetadata, ContentType};
803 use serde_json::json;
804
805 fn json_content(raw: &str) -> Content {
806 Content {
807 raw: raw.to_owned(),
808 content_type: ContentType::Json,
809 metadata: ContentMetadata {
810 source: None,
811 path: None,
812 language: None,
813 },
814 tokens_original: 0,
815 }
816 }
817
818 fn text_content(raw: &str) -> Content {
819 Content {
820 raw: raw.to_owned(),
821 content_type: ContentType::PlainText,
822 metadata: ContentMetadata {
823 source: None,
824 path: None,
825 language: None,
826 },
827 tokens_original: 0,
828 }
829 }
830
831 fn enabled_config(options: serde_json::Value) -> StageConfig {
832 StageConfig {
833 enabled: true,
834 options,
835 }
836 }
837
838 fn disabled_config() -> StageConfig {
839 StageConfig {
840 enabled: false,
841 options: json!({}),
842 }
843 }
844
845 #[test]
848 fn keep_fields_retains_specified() {
849 let mut c = json_content(r#"{"id":1,"name":"Alice","debug":"x"}"#);
850 let cfg = enabled_config(json!({"fields": ["id", "name"]}));
851 KeepFieldsStage.process(&mut c, &cfg).unwrap();
852 let v: serde_json::Value = serde_json::from_str(&c.raw).unwrap();
853 assert_eq!(v, json!({"id":1,"name":"Alice"}));
854 }
855
856 #[test]
857 fn keep_fields_disabled_passthrough() {
858 let raw = r#"{"id":1,"name":"Alice"}"#;
859 let mut c = json_content(raw);
860 KeepFieldsStage.process(&mut c, &disabled_config()).unwrap();
861 assert_eq!(c.raw, raw);
862 }
863
864 #[test]
865 fn keep_fields_non_json_passthrough() {
866 let raw = "not json at all";
867 let mut c = text_content(raw);
868 let cfg = enabled_config(json!({"fields": ["id"]}));
869 KeepFieldsStage.process(&mut c, &cfg).unwrap();
870 assert_eq!(c.raw, raw);
871 }
872
873 #[test]
876 fn strip_fields_removes_top_level() {
877 let mut c = json_content(r#"{"id":1,"debug":"x","name":"Bob"}"#);
878 let cfg = enabled_config(json!({"fields": ["debug"]}));
879 StripFieldsStage.process(&mut c, &cfg).unwrap();
880 let v: serde_json::Value = serde_json::from_str(&c.raw).unwrap();
881 assert_eq!(v, json!({"id":1,"name":"Bob"}));
882 }
883
884 #[test]
885 fn strip_fields_dot_notation() {
886 let mut c = json_content(r#"{"metadata":{"internal_id":"x","public":"y"},"name":"Bob"}"#);
887 let cfg = enabled_config(json!({"fields": ["metadata.internal_id"]}));
888 StripFieldsStage.process(&mut c, &cfg).unwrap();
889 let v: serde_json::Value = serde_json::from_str(&c.raw).unwrap();
890 assert_eq!(v, json!({"metadata":{"public":"y"},"name":"Bob"}));
891 }
892
893 #[test]
894 fn strip_fields_disabled_passthrough() {
895 let raw = r#"{"id":1}"#;
896 let mut c = json_content(raw);
897 StripFieldsStage.process(&mut c, &disabled_config()).unwrap();
898 assert_eq!(c.raw, raw);
899 }
900
901 #[test]
904 fn condense_collapses_repeated_lines() {
905 let raw = "a\na\na\na\na\nb\n";
906 let mut c = text_content(raw);
907 let cfg = enabled_config(json!({"max_repeated_lines": 3}));
908 CondenseStage.process(&mut c, &cfg).unwrap();
909 assert_eq!(c.raw, "a\na\na\nb\n");
910 }
911
912 #[test]
913 fn condense_keeps_up_to_max() {
914 let raw = "x\nx\nx\n";
915 let mut c = text_content(raw);
916 let cfg = enabled_config(json!({"max_repeated_lines": 3}));
917 CondenseStage.process(&mut c, &cfg).unwrap();
918 assert_eq!(c.raw, "x\nx\nx\n");
919 }
920
921 #[test]
922 fn condense_disabled_passthrough() {
923 let raw = "a\na\na\na\n";
924 let mut c = text_content(raw);
925 CondenseStage.process(&mut c, &disabled_config()).unwrap();
926 assert_eq!(c.raw, raw);
927 }
928
929 #[test]
930 fn condense_skips_json() {
931 let raw = r#"{"a":1}"#;
932 let mut c = json_content(raw);
933 let cfg = enabled_config(json!({"max_repeated_lines": 1}));
934 CondenseStage.process(&mut c, &cfg).unwrap();
935 assert_eq!(c.raw, raw);
936 }
937
938 #[test]
939 fn condense_leaves_json_text_and_diffs_alone() {
940 let cfg = enabled_config(json!({"max_repeated_lines": 3}));
941 let json = "[\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 1\n]\n";
942 let diff = "diff --git a/a.py b/a.py\n--- a/a.py\n+++ b/a.py\n@@ -1,8 +1,8 @@\n \n \n \n \n \n-old()\n+new()\n";
943 for raw in [json, diff] {
944 let mut c = text_content(raw);
945 CondenseStage.process(&mut c, &cfg).unwrap();
946 assert_eq!(c.raw, raw);
947 }
948 }
949
950 #[test]
953 fn strip_nulls_removes_null_fields() {
954 let mut c = json_content(r#"{"a":1,"b":null,"c":"x"}"#);
955 let cfg = enabled_config(json!({}));
956 StripNullsStage.process(&mut c, &cfg).unwrap();
957 let v: serde_json::Value = serde_json::from_str(&c.raw).unwrap();
958 assert_eq!(v, json!({"a":1,"c":"x"}));
959 }
960
961 #[test]
962 fn strip_nulls_recursive() {
963 let mut c = json_content(r#"{"a":{"b":null,"c":1}}"#);
964 let cfg = enabled_config(json!({}));
965 StripNullsStage.process(&mut c, &cfg).unwrap();
966 let v: serde_json::Value = serde_json::from_str(&c.raw).unwrap();
967 assert_eq!(v, json!({"a":{"c":1}}));
968 }
969
970 #[test]
971 fn strip_nulls_keeps_null_in_arrays() {
972 let mut c = json_content(r#"{"arr":[1,null,2]}"#);
973 let cfg = enabled_config(json!({}));
974 StripNullsStage.process(&mut c, &cfg).unwrap();
975 let v: serde_json::Value = serde_json::from_str(&c.raw).unwrap();
976 assert_eq!(v, json!({"arr":[1,null,2]}));
977 }
978
979 #[test]
980 fn strip_nulls_disabled_passthrough() {
981 let raw = r#"{"a":null}"#;
982 let mut c = json_content(raw);
983 StripNullsStage.process(&mut c, &disabled_config()).unwrap();
984 assert_eq!(c.raw, raw);
985 }
986
987 #[test]
990 fn flatten_nested_object() {
991 let mut c = json_content(r#"{"a":{"b":{"c":1}}}"#);
992 let cfg = enabled_config(json!({"max_depth": 3}));
993 FlattenStage.process(&mut c, &cfg).unwrap();
994 let v: serde_json::Value = serde_json::from_str(&c.raw).unwrap();
995 assert_eq!(v, json!({"a.b.c":1}));
996 }
997
998 #[test]
999 fn flatten_respects_max_depth() {
1000 let mut c = json_content(r#"{"a":{"b":{"c":1}}}"#);
1001 let cfg = enabled_config(json!({"max_depth": 1}));
1002 FlattenStage.process(&mut c, &cfg).unwrap();
1003 let v: serde_json::Value = serde_json::from_str(&c.raw).unwrap();
1004 assert_eq!(v, json!({"a":{"b":{"c":1}}}));
1006 }
1007
1008 #[test]
1009 fn flatten_disabled_passthrough() {
1010 let raw = r#"{"a":{"b":1}}"#;
1011 let mut c = json_content(raw);
1012 FlattenStage.process(&mut c, &disabled_config()).unwrap();
1013 assert_eq!(c.raw, raw);
1014 }
1015
1016 #[test]
1019 fn truncate_strings_long_value() {
1020 let long = "a".repeat(600);
1021 let raw = format!(r#"{{"key":"{}"}}"#, long);
1022 let mut c = json_content(&raw);
1023 let cfg = enabled_config(json!({"max_length": 500}));
1024 TruncateStringsStage.process(&mut c, &cfg).unwrap();
1025 let v: serde_json::Value = serde_json::from_str(&c.raw).unwrap();
1026 let s = v["key"].as_str().unwrap();
1027 assert!(s.ends_with("..."));
1028 assert_eq!(s.chars().count(), 503); }
1030
1031 #[test]
1032 fn truncate_strings_short_value_unchanged() {
1033 let raw = r#"{"key":"hello"}"#;
1034 let mut c = json_content(raw);
1035 let cfg = enabled_config(json!({"max_length": 500}));
1036 TruncateStringsStage.process(&mut c, &cfg).unwrap();
1037 let v: serde_json::Value = serde_json::from_str(&c.raw).unwrap();
1038 assert_eq!(v["key"].as_str().unwrap(), "hello");
1039 }
1040
1041 #[test]
1042 fn truncate_strings_disabled_passthrough() {
1043 let long = "a".repeat(600);
1044 let raw = format!(r#"{{"key":"{}"}}"#, long);
1045 let mut c = json_content(&raw);
1046 TruncateStringsStage.process(&mut c, &disabled_config()).unwrap();
1047 assert_eq!(c.raw, raw);
1048 }
1049
1050 #[test]
1053 fn collapse_arrays_truncates_long_array() {
1054 let mut c = json_content(r#"{"items":[1,2,3,4,5,6,7]}"#);
1055 let cfg = enabled_config(json!({
1056 "max_items": 5,
1057 "max_chars": 0,
1058 "summary_template": "... and {remaining} more items"
1059 }));
1060 CollapseArraysStage.process(&mut c, &cfg).unwrap();
1061 let v: serde_json::Value = serde_json::from_str(&c.raw).unwrap();
1062 let arr = v["items"].as_array().unwrap();
1063 assert_eq!(arr.len(), 6); assert_eq!(arr[5].as_str().unwrap(), "... and 2 more items");
1065 }
1066
1067 #[test]
1068 fn collapse_arrays_short_array_unchanged() {
1069 let raw = r#"{"items":[1,2,3]}"#;
1070 let mut c = json_content(raw);
1071 let cfg = enabled_config(json!({"max_items": 5}));
1072 CollapseArraysStage.process(&mut c, &cfg).unwrap();
1073 let v: serde_json::Value = serde_json::from_str(&c.raw).unwrap();
1074 assert_eq!(v["items"].as_array().unwrap().len(), 3);
1075 }
1076
1077 #[test]
1078 fn collapse_arrays_disabled_passthrough() {
1079 let raw = r#"{"items":[1,2,3,4,5,6,7]}"#;
1080 let mut c = json_content(raw);
1081 CollapseArraysStage.process(&mut c, &disabled_config()).unwrap();
1082 assert_eq!(c.raw, raw);
1083 }
1084
1085 #[test]
1088 fn git_diff_fold_folds_unchanged_lines() {
1089 let diff = concat!(
1091 "diff --git a/src/main.rs b/src/main.rs\n",
1092 "--- a/src/main.rs\n",
1093 "+++ b/src/main.rs\n",
1094 "@@ -1,12 +1,12 @@\n",
1095 " line1\n",
1096 " line2\n",
1097 " line3\n",
1098 " line4\n",
1099 " line5\n",
1100 " line6\n",
1101 "-old line\n",
1102 "+new line\n",
1103 " line7\n",
1104 " line8\n",
1105 " line9\n",
1106 " line10\n",
1107 " line11\n",
1108 " line12\n",
1109 );
1110 let mut c = text_content(diff);
1111 let cfg = enabled_config(serde_json::json!({"max_context_lines": 2}));
1112 GitDiffFoldStage.process(&mut c, &cfg).unwrap();
1113 assert!(c.raw.contains("-old line"), "output: {}", c.raw);
1115 assert!(c.raw.contains("+new line"), "output: {}", c.raw);
1116 assert!(c.raw.contains("@@ -1,12"), "output: {}", c.raw);
1118 assert!(c.raw.len() < diff.len(), "output should be shorter, got:\n{}", c.raw);
1120 assert!(c.raw.contains("unchanged lines"), "expected fold markers in:\n{}", c.raw);
1122 }
1123
1124 #[test]
1125 fn git_diff_fold_preserves_hunk_headers() {
1126 let diff = "@@ -1,5 +1,5 @@\n unchanged\n-old\n+new\n unchanged\n";
1127 let mut c = text_content(diff);
1128 let cfg = enabled_config(serde_json::json!({"max_context_lines": 1}));
1129 GitDiffFoldStage.process(&mut c, &cfg).unwrap();
1130 assert!(c.raw.contains("@@ -1,5 +1,5 @@"), "output: {}", c.raw);
1131 }
1132
1133 #[test]
1134 fn git_diff_fold_skips_non_diff_text() {
1135 let raw = "just some plain text\nno diff markers here\n";
1136 let mut c = text_content(raw);
1137 let cfg = enabled_config(serde_json::json!({"max_context_lines": 2}));
1138 GitDiffFoldStage.process(&mut c, &cfg).unwrap();
1139 assert_eq!(c.raw, raw);
1140 }
1141
1142 #[test]
1143 fn git_diff_fold_disabled_passthrough() {
1144 let diff = "diff --git a/f b/f\n-old\n+new\n unchanged\n unchanged\n unchanged\n";
1145 let mut c = text_content(diff);
1146 GitDiffFoldStage.process(&mut c, &disabled_config()).unwrap();
1147 assert_eq!(c.raw, diff);
1148 }
1149
1150 #[test]
1158 fn git_diff_fold_does_not_fold_ls_output() {
1159 let ls_output = concat!(
1160 "total 24\n",
1161 "drwxr-xr-x 6 user user 192 Apr 18 10:00 packages\n",
1162 "drwxr-xr-x 3 user user 96 Apr 18 10:00 configuration\n",
1163 "drwxr-xr-x 4 user user 128 Apr 18 10:00 documentation\n",
1164 "drwxr-xr-x 2 user user 64 Apr 18 10:00 environment\n",
1165 "-rw-r--r-- 1 user user 1024 Apr 18 10:00 README.md\n",
1166 );
1167 let mut c = text_content(ls_output);
1168 let cfg = enabled_config(serde_json::json!({"max_context_lines": 2}));
1169 GitDiffFoldStage.process(&mut c, &cfg).unwrap();
1170 assert!(c.raw.contains("packages"), "packages must survive: {}", c.raw);
1172 assert!(c.raw.contains("configuration"), "configuration must survive: {}", c.raw);
1173 assert!(c.raw.contains("documentation"), "documentation must survive: {}", c.raw);
1174 assert!(c.raw.contains("environment"), "environment must survive: {}", c.raw);
1175 assert!(c.raw.contains("README.md"), "README.md must survive: {}", c.raw);
1176 assert!(!c.raw.contains("unchanged lines"), "no folding should occur: {}", c.raw);
1177 }
1178
1179 #[test]
1180 fn git_diff_fold_does_not_fold_markdown_bullets() {
1181 let markdown = concat!(
1182 "# Features\n",
1183 "\n",
1184 "- First feature\n",
1185 "- Second feature\n",
1186 "- Third feature\n",
1187 "+ Added bonus\n",
1188 "\n",
1189 "## Details\n",
1190 );
1191 let mut c = text_content(markdown);
1192 let cfg = enabled_config(serde_json::json!({"max_context_lines": 2}));
1193 GitDiffFoldStage.process(&mut c, &cfg).unwrap();
1194 assert_eq!(c.raw, markdown, "markdown should pass through unchanged");
1195 }
1196
1197 #[test]
1198 fn git_diff_fold_still_works_on_real_diffs() {
1199 let diff = concat!(
1201 "diff --git a/src/main.rs b/src/main.rs\n",
1202 "--- a/src/main.rs\n",
1203 "+++ b/src/main.rs\n",
1204 "@@ -1,10 +1,10 @@\n",
1205 " line1\n",
1206 " line2\n",
1207 " line3\n",
1208 " line4\n",
1209 " line5\n",
1210 "-old line\n",
1211 "+new line\n",
1212 " line6\n",
1213 " line7\n",
1214 " line8\n",
1215 " line9\n",
1216 " line10\n",
1217 );
1218 let mut c = text_content(diff);
1219 let cfg = enabled_config(serde_json::json!({"max_context_lines": 2}));
1220 GitDiffFoldStage.process(&mut c, &cfg).unwrap();
1221 assert!(c.raw.contains("-old line"), "removed line preserved: {}", c.raw);
1223 assert!(c.raw.contains("+new line"), "added line preserved: {}", c.raw);
1224 assert!(c.raw.contains("unchanged lines"), "should fold context: {}", c.raw);
1226 assert!(
1228 c.raw.lines().count() < diff.lines().count(),
1229 "output should have fewer lines: {} vs {}",
1230 c.raw.lines().count(), diff.lines().count()
1231 );
1232 }
1233
1234 #[test]
1237 fn custom_transforms_is_noop() {
1238 let raw = r#"{"a":1}"#;
1239 let mut c = json_content(raw);
1240 let cfg = enabled_config(json!({}));
1241 CustomTransformsStage.process(&mut c, &cfg).unwrap();
1242 assert_eq!(c.raw, raw);
1243 }
1244
1245 #[test]
1246 fn custom_transforms_disabled_passthrough() {
1247 let raw = "some text";
1248 let mut c = text_content(raw);
1249 CustomTransformsStage.process(&mut c, &disabled_config()).unwrap();
1250 assert_eq!(c.raw, raw);
1251 }
1252
1253 fn records(n: usize, body_len: usize) -> serde_json::Value {
1254 let items: Vec<serde_json::Value> = (0..n)
1255 .map(|i| {
1256 let mut r = json!({"id": i, "title": format!("record {i}"), "body": "x".repeat(body_len)});
1257 if i % 3 == 0 {
1258 r["draft"] = json!(true);
1259 }
1260 r
1261 })
1262 .collect();
1263 json!({ "items": items })
1264 }
1265
1266 fn collapse(raw: &serde_json::Value, opts: serde_json::Value) -> Vec<serde_json::Value> {
1267 let mut c = json_content(&raw.to_string());
1268 CollapseArraysStage.process(&mut c, &enabled_config(opts)).unwrap();
1269 let v: serde_json::Value = serde_json::from_str(&c.raw).unwrap();
1270 v["items"].as_array().unwrap().clone()
1271 }
1272
1273 #[test]
1274 fn collapse_arrays_keeps_every_record_under_the_size_cap() {
1275 let arr = collapse(&records(10, 300), json!({"max_items": 5}));
1277 assert_eq!(arr.len(), 10);
1278 assert!(arr.iter().all(|r| r.is_object()));
1279 }
1280
1281 #[test]
1282 fn collapse_arrays_caps_large_arrays_by_size() {
1283 let arr = collapse(&records(100, 500), json!({"max_items": 5, "max_chars": 8000}));
1284 let summary = arr.last().unwrap().as_str().unwrap();
1285 let kept = arr.len() - 1;
1286 assert!(kept >= 5, "kept {kept}");
1287 assert_eq!(summary, format!("... and {} more items", 100 - kept));
1288 let kept_size = serde_json::to_string(&arr[..kept]).unwrap().len();
1289 assert!(kept_size <= 8000, "kept {kept_size} chars");
1290 assert_eq!(arr[0]["id"], 0, "leading records are the ones kept");
1291 }
1292
1293 #[test]
1294 fn collapse_arrays_keeps_max_items_when_items_are_huge() {
1295 let arr = collapse(&records(10, 5000), json!({"max_items": 5, "max_chars": 8000}));
1296 assert_eq!(arr.len(), 6);
1297 assert_eq!(arr[5].as_str().unwrap(), "... and 5 more items");
1298 }
1299
1300 #[test]
1301 fn collapse_arrays_zero_max_chars_caps_on_count() {
1302 let arr = collapse(&records(7, 10), json!({"max_items": 3, "max_chars": 0}));
1303 assert_eq!(arr.len(), 4);
1304 assert_eq!(arr[3].as_str().unwrap(), "... and 4 more items");
1305 }
1306
1307 #[test]
1308 fn collapse_arrays_empty_template_uses_default() {
1309 let arr = collapse(
1310 &records(7, 10),
1311 json!({"max_items": 3, "max_chars": 0, "summary_template": ""}),
1312 );
1313 assert_eq!(arr[3].as_str().unwrap(), "... and 4 more items");
1314 }
1315
1316 #[test]
1317 fn collapse_arrays_small_uniform_array_unchanged() {
1318 let raw = r#"{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]}"#;
1319 let mut c = json_content(raw);
1320 let cfg = enabled_config(json!({"max_items": 5}));
1321 CollapseArraysStage.process(&mut c, &cfg).unwrap();
1322 let v: serde_json::Value = serde_json::from_str(&c.raw).unwrap();
1323 assert_eq!(v["users"].as_array().unwrap().len(), 2);
1324 }
1325
1326 #[test]
1329 fn word_abbreviate_replaces_known_words() {
1330 let raw = "The implementation of the configuration is complete.";
1331 let mut c = text_content(raw);
1332 let cfg = enabled_config(json!({}));
1333 WordAbbreviateStage.process(&mut c, &cfg).unwrap();
1334 assert!(c.raw.contains("impl"), "should abbreviate 'implementation': {}", c.raw);
1335 assert!(c.raw.contains("config"), "should abbreviate 'configuration': {}", c.raw);
1336 assert!(!c.raw.contains("implementation"), "original word should be gone: {}", c.raw);
1337 }
1338
1339 #[test]
1340 fn word_abbreviate_preserves_partial_matches() {
1341 let raw = "We need to implement this feature.";
1343 let mut c = text_content(raw);
1344 let cfg = enabled_config(json!({}));
1345 WordAbbreviateStage.process(&mut c, &cfg).unwrap();
1346 assert!(c.raw.contains("implement"), "partial match should be preserved: {}", c.raw);
1347 }
1348
1349 #[test]
1350 fn word_abbreviate_disabled_passthrough() {
1351 let raw = "The implementation is complete.";
1352 let mut c = text_content(raw);
1353 WordAbbreviateStage.process(&mut c, &disabled_config()).unwrap();
1354 assert_eq!(c.raw, raw);
1355 }
1356
1357 #[test]
1358 fn word_abbreviate_skips_json() {
1359 let raw = r#"{"implementation":"value"}"#;
1360 let mut c = json_content(raw);
1361 let cfg = enabled_config(json!({}));
1362 WordAbbreviateStage.process(&mut c, &cfg).unwrap();
1363 assert_eq!(c.raw, raw, "JSON content should pass through unchanged");
1364 }
1365
1366 #[test]
1367 fn word_abbreviate_case_insensitive() {
1368 let raw = "The Implementation and CONFIGURATION are ready.";
1369 let mut c = text_content(raw);
1370 let cfg = enabled_config(json!({}));
1371 WordAbbreviateStage.process(&mut c, &cfg).unwrap();
1372 assert!(c.raw.contains("impl"), "should handle mixed case: {}", c.raw);
1373 assert!(c.raw.contains("config"), "should handle uppercase: {}", c.raw);
1374 }
1375
1376 #[test]
1377 fn replace_whole_word_basic() {
1378 assert_eq!(
1379 replace_whole_word("the implementation is done", "implementation", "impl"),
1380 "the impl is done"
1381 );
1382 }
1383
1384 #[test]
1385 fn replace_whole_word_no_partial() {
1386 let result = replace_whole_word("multiple implementations exist", "implementation", "impl");
1389 assert_eq!(result, "multiple implementations exist");
1392 }
1393
1394 #[test]
1395 fn replace_whole_word_at_boundaries() {
1396 assert_eq!(
1397 replace_whole_word("implementation", "implementation", "impl"),
1398 "impl"
1399 );
1400 assert_eq!(
1401 replace_whole_word("(implementation)", "implementation", "impl"),
1402 "(impl)"
1403 );
1404 }
1405
1406 #[test]
1407 fn replace_whole_word_empty_inputs() {
1408 assert_eq!(replace_whole_word("", "word", "w"), "");
1409 assert_eq!(replace_whole_word("text", "", "w"), "text");
1410 }
1411}