Skip to main content

sqz_engine/
pipeline.rs

1use crate::ansi_strip::AnsiStripper;
2use crate::dict_compressor::DictCompressor;
3use crate::entropy_truncator::EntropyTruncator;
4use crate::error::{Result, SqzError};
5use crate::preset::Preset;
6use crate::prompt_cache::PromptCacheDetector;
7use crate::stages::{
8    CollapseArraysStage, CondenseStage, CustomTransformsStage, FlattenStage, GitDiffFoldStage,
9    KeepFieldsStage, StripFieldsStage, StripNullsStage, TruncateStringsStage,
10};
11use crate::token_counter::TokenCounter;
12use crate::token_pruner::TokenPruner;
13use crate::toon::ToonEncoder;
14use crate::types::{CompressedContent, Content, ContentType, ModelFamily, StageConfig};
15
16/// Minimal session context passed to the pipeline. Just carries the session
17/// ID so stages can log or cache per-session.
18pub struct SessionContext {
19    pub session_id: String,
20}
21
22/// The compression pipeline orchestrator.
23///
24/// Runs content through a sequence of configurable stages (condense, strip
25/// nulls, collapse arrays, etc.), then applies post-processing: RLE
26/// compression, sliding-window dedup, entropy truncation, token pruning,
27/// dictionary compression, and TOON encoding for JSON.
28///
29/// Stages are sorted by priority — lower numbers run first. You can insert
30/// custom stages with [`insert_stage`](CompressionPipeline::insert_stage).
31pub struct CompressionPipeline {
32    stages: Vec<Box<dyn crate::stages::CompressionStage>>,
33    toon_encoder: ToonEncoder,
34    token_counter: TokenCounter,
35    token_pruner: TokenPruner,
36    dict_compressor: std::cell::RefCell<DictCompressor>,
37    entropy_truncator: EntropyTruncator,
38    #[allow(dead_code)]
39    prompt_cache_detector: PromptCacheDetector,
40}
41
42impl CompressionPipeline {
43    /// Construct the pipeline from a preset, creating all built-in stages
44    /// sorted by priority, plus the token pruner, dictionary compressor,
45    /// and entropy truncator.
46    pub fn new(_preset: &Preset) -> Self {
47        let mut stages: Vec<Box<dyn crate::stages::CompressionStage>> = vec![
48            Box::new(AnsiStripper),
49            Box::new(KeepFieldsStage),
50            Box::new(StripFieldsStage),
51            Box::new(CondenseStage),
52            Box::new(GitDiffFoldStage),
53            Box::new(StripNullsStage),
54            Box::new(FlattenStage),
55            Box::new(TruncateStringsStage),
56            Box::new(CollapseArraysStage),
57            Box::new(CustomTransformsStage),
58        ];
59        stages.sort_by_key(|s| s.priority());
60
61        Self {
62            stages,
63            toon_encoder: ToonEncoder,
64            token_counter: TokenCounter::new(),
65            token_pruner: TokenPruner::new(),
66            dict_compressor: std::cell::RefCell::new(DictCompressor::new()),
67            entropy_truncator: EntropyTruncator::new(),
68            prompt_cache_detector: PromptCacheDetector,
69        }
70    }
71
72    /// Run content through all enabled stages, then apply:
73    /// 1. Dictionary compression + TOON encoding for JSON
74    /// 2. Token pruning for prose/plain text
75    /// 3. Entropy-weighted truncation for long content
76    pub fn compress(
77        &self,
78        input: &str,
79        ctx: &SessionContext,
80        preset: &Preset,
81    ) -> Result<CompressedContent> {
82        self.compress_inner(input, ctx, preset, false)
83    }
84
85    /// Byte-faithful compression for file reads (`sqz_read_file`, `sqz_grep`,
86    /// `sqz_list_dir`). Returns the input verbatim apart from ANSI-escape
87    /// stripping: every content-altering stage is skipped — both the droppers
88    /// (entropy truncation, token pruning, JSON field projection, and the lossy
89    /// configured stages: condense, strip_fields, strip_nulls, flatten,
90    /// truncate_strings, collapse_arrays, git_diff_fold) and the
91    /// reversible-but-reformatting ones (RLE, sliding-window dedup, TOON/dict),
92    /// which would shift bytes or line numbers. No line, field, or segment is
93    /// ever dropped or moved. Token savings on this path come from the dedup
94    /// `§ref` cache on repeat reads, not from re-encoding.
95    /// See https://github.com/ojuschugh1/sqz/issues/32
96    pub fn compress_lossless(
97        &self,
98        input: &str,
99        ctx: &SessionContext,
100        preset: &Preset,
101    ) -> Result<CompressedContent> {
102        self.compress_inner(input, ctx, preset, true)
103    }
104
105    fn compress_inner(
106        &self,
107        input: &str,
108        _ctx: &SessionContext,
109        preset: &Preset,
110        lossless: bool,
111    ) -> Result<CompressedContent> {
112        let model_family = model_family_from_preset(preset);
113        let tokens_original = self.token_counter.count(input, &model_family);
114
115        let mut content = Content {
116            raw: input.to_owned(),
117            content_type: ContentType::PlainText,
118            metadata: crate::types::ContentMetadata {
119                source: None,
120                path: None,
121                language: None,
122            },
123            tokens_original,
124        };
125
126        let mut stages_applied: Vec<String> = Vec::new();
127
128        for stage in &self.stages {
129            // On the lossless (file-read) path, run only the reversible stages.
130            if lossless && !stage_is_lossless(stage.name()) {
131                continue;
132            }
133            let config = stage_config_from_preset(stage.name(), preset);
134            if config.enabled {
135                stage.process(&mut content, &config)?;
136                stages_applied.push(stage.name().to_owned());
137            }
138        }
139
140        // Post-stage processing: apply advanced compression techniques
141
142        let is_json = ToonEncoder::is_json(&content.raw);
143
144        // JSON projection: strip internal/debug fields, empty collections,
145        // deep nesting, and redundant timestamps before other JSON processing
146        if !lossless && is_json && content.raw.len() > 100 {
147            let proj_config = crate::json_projection::ProjectionConfig::default();
148            if let Ok(proj_result) = crate::json_projection::project_json(&content.raw, &proj_config) {
149                if proj_result.fields_removed > 0 {
150                    content.raw = proj_result.data;
151                    stages_applied.push("json_projection".to_owned());
152                }
153            }
154        }
155
156        // RLE: collapse repeated patterns in non-JSON content (generalizes condense).
157        // Skipped on the lossless path — even reversible `[×N]` markers make a
158        // file read non-verbatim. Only apply when content is long enough.
159        if !lossless && !is_json && content.raw.len() > 200 {
160            if let Ok(rle_result) = crate::rle_compressor::rle_compress(&content.raw, 3) {
161                if rle_result.runs_collapsed > 0 {
162                    // Safety check: verify critical markers are preserved
163                    let has_error = content.raw.contains("ERROR") || content.raw.contains("error:");
164                    let rle_has_error = rle_result.text.contains("ERROR") || rle_result.text.contains("error:");
165                    if !has_error || rle_has_error {
166                        content.raw = rle_result.text;
167                        stages_applied.push("rle".to_owned());
168                    }
169                }
170            }
171        }
172
173        // Sliding window dedup: catch repeated substrings across non-adjacent lines.
174        // Skipped on the lossless path — its `[→Ln]` back-references have no
175        // agent-facing expand path and make a file read non-faithful (#32).
176        if !lossless && !is_json && content.raw.len() > 300 {
177            if let Ok(sw_result) = crate::rle_compressor::sliding_window_dedup(&content.raw, 4) {
178                if sw_result.dedup_count > 0 {
179                    // Safety check: verify critical markers are preserved
180                    let has_error = content.raw.contains("ERROR") || content.raw.contains("error:");
181                    let sw_has_error = sw_result.text.contains("ERROR") || sw_result.text.contains("error:");
182                    if !has_error || sw_has_error {
183                        content.raw = sw_result.text;
184                        stages_applied.push("sliding_window_dedup".to_owned());
185                    }
186                }
187            }
188        }
189
190        // Entropy-weighted truncation for long non-JSON content.
191        // LOSSY — skipped on the lossless file-read path (issue #32).
192        if !lossless && !is_json && content.raw.len() > 500 {
193            if let Ok(trunc_result) = self.entropy_truncator.truncate_string(&content.raw) {
194                if trunc_result.segments_dropped > 0 {
195                    content.raw = trunc_result.text;
196                    stages_applied.push("entropy_truncate".to_owned());
197                }
198            }
199        }
200
201        // Token pruning for prose content (non-JSON, non-code).
202        // LOSSY — skipped on the lossless file-read path (issue #32).
203        if !lossless && !is_json && content.raw.len() > 100 && looks_like_prose(&content.raw) {
204            if let Ok(prune_result) = self.token_pruner.prune(&content.raw) {
205                if prune_result.tokens_removed > 0 {
206                    content.raw = prune_result.text;
207                    stages_applied.push("token_prune".to_owned());
208                }
209            }
210        }
211
212        // Apply dictionary compression + TOON encoding for JSON.
213        // Skipped on the lossless path so a JSON file read returns its bytes
214        // verbatim — TOON re-encoding is data-lossless but reformats and
215        // shifts line numbers, which breaks "read this file faithfully".
216        let data = if !lossless && ToonEncoder::is_json(&content.raw) {
217            // Dictionary compression — observe and try to compress.
218            // Only use dict compression if it actually saves bytes (header overhead
219            // can make small payloads larger).
220            self.dict_compressor.borrow_mut().observe(&content.raw);
221            let dict_result = self.dict_compressor.borrow().compress(&content.raw)?;
222
223            if dict_result.substitutions > 0 && dict_result.bytes_saved > 50 {
224                // Dict compression was beneficial — encode the JSON body with TOON
225                stages_applied.push("dict_compress".to_owned());
226                if let Some(newline_pos) = dict_result.data.find("\n{") {
227                    let header = &dict_result.data[..newline_pos + 1];
228                    let json_body = &dict_result.data[newline_pos + 1..];
229                    if let Ok(json) = serde_json::from_str::<serde_json::Value>(json_body) {
230                        let encoded = self.toon_encoder.encode(&json)?;
231                        stages_applied.push("toon_encode".to_owned());
232                        format!("{header}{encoded}")
233                    } else {
234                        dict_result.data
235                    }
236                } else {
237                    dict_result.data
238                }
239            } else {
240                // No beneficial dict substitutions — standard TOON encoding
241                let json: serde_json::Value = serde_json::from_str(&content.raw)
242                    .map_err(|e| SqzError::Other(format!("pipeline: JSON parse error: {e}")))?;
243                let encoded = self.toon_encoder.encode(&json)?;
244                stages_applied.push("toon_encode".to_owned());
245                encoded
246            }
247        } else {
248            content.raw
249        };
250
251        let tokens_compressed = self.token_counter.count(&data, &model_family);
252        let compression_ratio = if tokens_original == 0 {
253            1.0
254        } else {
255            tokens_compressed as f64 / tokens_original as f64
256        };
257
258        Ok(CompressedContent {
259            data,
260            tokens_compressed,
261            tokens_original,
262            stages_applied,
263            compression_ratio,
264            provenance: crate::types::Provenance::default(),
265            verify: None,
266        })
267    }
268
269    /// Insert a plugin stage and re-sort by priority.
270    pub fn insert_stage(&mut self, stage: Box<dyn crate::stages::CompressionStage>) {
271        self.stages.push(stage);
272        self.stages.sort_by_key(|s| s.priority());
273    }
274
275    /// Rebuild stage list from a new preset (hot-reload support).
276    /// Built-in stages are recreated; plugin stages are dropped and must be
277    /// re-inserted by the caller.
278    pub fn reload_preset(&mut self, _preset: &Preset) -> Result<()> {
279        let mut stages: Vec<Box<dyn crate::stages::CompressionStage>> = vec![
280            Box::new(AnsiStripper),
281            Box::new(KeepFieldsStage),
282            Box::new(StripFieldsStage),
283            Box::new(CondenseStage),
284            Box::new(GitDiffFoldStage),
285            Box::new(StripNullsStage),
286            Box::new(FlattenStage),
287            Box::new(TruncateStringsStage),
288            Box::new(CollapseArraysStage),
289            Box::new(CustomTransformsStage),
290        ];
291        stages.sort_by_key(|s| s.priority());
292        self.stages = stages;
293        // Reset session-level state in dict compressor
294        self.dict_compressor.borrow_mut().reset();
295        Ok(())
296    }
297}
298
299/// Derive the `ModelFamily` from the preset's model configuration.
300fn model_family_from_preset(preset: &Preset) -> ModelFamily {
301    match preset.model.family.to_lowercase().as_str() {
302        "anthropic" | "claude" => ModelFamily::AnthropicClaude,
303        "openai" | "gpt" => ModelFamily::OpenAiGpt,
304        "google" | "gemini" => ModelFamily::GoogleGemini,
305        other => ModelFamily::Local(other.to_string()),
306    }
307}
308
309/// Build a `StageConfig` for a named stage from the preset's compression config.
310fn stage_config_from_preset(name: &str, preset: &Preset) -> StageConfig {
311    let c = &preset.compression;
312    match name {
313        "ansi_strip" => StageConfig {
314            enabled: true,
315            options: serde_json::Value::Object(Default::default()),
316        },
317        "keep_fields" => {
318            if let Some(cfg) = &c.keep_fields {
319                StageConfig {
320                    enabled: cfg.enabled,
321                    options: serde_json::json!({ "fields": cfg.fields }),
322                }
323            } else {
324                StageConfig::default()
325            }
326        }
327        "strip_fields" => {
328            if let Some(cfg) = &c.strip_fields {
329                StageConfig {
330                    enabled: cfg.enabled,
331                    options: serde_json::json!({ "fields": cfg.fields }),
332                }
333            } else {
334                StageConfig::default()
335            }
336        }
337        "condense" => {
338            if let Some(cfg) = &c.condense {
339                StageConfig {
340                    enabled: cfg.enabled,
341                    options: serde_json::json!({
342                        "max_repeated_lines": cfg.max_repeated_lines
343                    }),
344                }
345            } else {
346                StageConfig::default()
347            }
348        }
349        "git_diff_fold" => {
350            if let Some(cfg) = &c.git_diff_fold {
351                StageConfig {
352                    enabled: cfg.enabled,
353                    options: serde_json::json!({
354                        "max_context_lines": cfg.max_context_lines
355                    }),
356                }
357            } else {
358                // Default: enabled with 2 context lines
359                StageConfig {
360                    enabled: true,
361                    options: serde_json::json!({ "max_context_lines": 2 }),
362                }
363            }
364        }
365        "strip_nulls" => {
366            if let Some(cfg) = &c.strip_nulls {
367                StageConfig {
368                    enabled: cfg.enabled,
369                    options: serde_json::Value::Object(Default::default()),
370                }
371            } else {
372                StageConfig::default()
373            }
374        }
375        "flatten" => {
376            if let Some(cfg) = &c.flatten {
377                StageConfig {
378                    enabled: cfg.enabled,
379                    options: serde_json::json!({ "max_depth": cfg.max_depth }),
380                }
381            } else {
382                StageConfig::default()
383            }
384        }
385        "truncate_strings" => {
386            if let Some(cfg) = &c.truncate_strings {
387                StageConfig {
388                    enabled: cfg.enabled,
389                    options: serde_json::json!({ "max_length": cfg.max_length }),
390                }
391            } else {
392                StageConfig::default()
393            }
394        }
395        "collapse_arrays" => {
396            if let Some(cfg) = &c.collapse_arrays {
397                StageConfig {
398                    enabled: cfg.enabled,
399                    options: serde_json::json!({
400                        "max_items": cfg.max_items,
401                        "summary_template": cfg.summary_template
402                    }),
403                }
404            } else {
405                StageConfig::default()
406            }
407        }
408        "custom_transforms" => {
409            if let Some(cfg) = &c.custom_transforms {
410                StageConfig {
411                    enabled: cfg.enabled,
412                    options: serde_json::Value::Object(Default::default()),
413                }
414            } else {
415                StageConfig::default()
416            }
417        }
418        _ => StageConfig::default(),
419    }
420}
421
422/// Whether a configured stage is safe to run on the lossless file-read path.
423/// Only stages that never drop or irreversibly alter content qualify; every
424/// other stage (condense, strip_fields, strip_nulls, flatten, truncate_strings,
425/// collapse_arrays, git_diff_fold, keep_fields) is skipped when `lossless`.
426fn stage_is_lossless(name: &str) -> bool {
427    matches!(name, "ansi_strip" | "custom_transforms")
428}
429
430/// Heuristic: does this text look like prose (documentation, error messages,
431/// README content) rather than code or structured data?
432fn looks_like_prose(text: &str) -> bool {
433    let lines: Vec<&str> = text.lines().take(20).collect();
434    if lines.is_empty() {
435        return false;
436    }
437
438    let mut prose_lines = 0;
439    let mut code_lines = 0;
440
441    for line in &lines {
442        let trimmed = line.trim();
443        if trimmed.is_empty() {
444            continue;
445        }
446        // Code indicators
447        if trimmed.ends_with('{')
448            || trimmed.ends_with('}')
449            || trimmed.ends_with(';')
450            || trimmed.starts_with("fn ")
451            || trimmed.starts_with("pub ")
452            || trimmed.starts_with("let ")
453            || trimmed.starts_with("const ")
454            || trimmed.starts_with("import ")
455            || trimmed.starts_with("def ")
456            || trimmed.starts_with("class ")
457            || trimmed.contains("::")
458            || trimmed.contains("->")
459            || trimmed.contains("=>")
460        {
461            code_lines += 1;
462        } else {
463            prose_lines += 1;
464        }
465    }
466
467    // Consider it prose if more than half the lines look like prose
468    prose_lines > code_lines
469}
470
471// ---------------------------------------------------------------------------
472// Tests
473// ---------------------------------------------------------------------------
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::preset::{
479        BudgetConfig, CollapseArraysConfig, CompressionConfig, CondenseConfig,
480        CustomTransformsConfig, ModelConfig, PresetMeta,
481        StripNullsConfig, ToolSelectionConfig, TruncateStringsConfig,
482        TerseModeConfig,
483    };
484
485    fn default_preset() -> Preset {
486        Preset {
487            preset: PresetMeta {
488                name: "test".into(),
489                version: "1.0".into(),
490                description: String::new(),
491            },
492            compression: CompressionConfig {
493                stages: vec![],
494                keep_fields: None,
495                strip_fields: None,
496                condense: Some(CondenseConfig {
497                    enabled: true,
498                    max_repeated_lines: 3,
499                }),
500                git_diff_fold: None,
501                strip_nulls: Some(StripNullsConfig { enabled: true }),
502                flatten: None,
503                truncate_strings: Some(TruncateStringsConfig {
504                    enabled: true,
505                    max_length: 500,
506                }),
507                collapse_arrays: Some(CollapseArraysConfig {
508                    enabled: true,
509                    max_items: 5,
510                    summary_template: "... and {remaining} more items".into(),
511                }),
512                custom_transforms: Some(CustomTransformsConfig { enabled: true }),
513            },
514            tool_selection: ToolSelectionConfig {
515                max_tools: 5,
516                similarity_threshold: 0.7,
517                default_tools: vec![],
518            },
519            budget: BudgetConfig {
520                warning_threshold: 0.70,
521                ceiling_threshold: 0.85,
522                default_window_size: 200_000,
523                agents: Default::default(),
524            },
525            terse_mode: TerseModeConfig {
526                enabled: false,
527                level: crate::preset::TerseLevel::Moderate,
528            },
529            model: ModelConfig {
530                family: "anthropic".into(),
531                primary: "claude-sonnet-4-20250514".into(),
532                local: String::new(),
533                complexity_threshold: 0.4,
534                pricing: None,
535            },
536        }
537    }
538
539    fn ctx() -> SessionContext {
540        SessionContext {
541            session_id: "test-session".into(),
542        }
543    }
544
545    /// Regression for https://github.com/ojuschugh1/sqz/issues/32 — the
546    /// file-read path (sqz_read_file / sqz_grep / sqz_list_dir) must never
547    /// silently drop content. `compress_lossless` keeps every segment; the
548    /// default `compress` truncates below-median-entropy segments.
549    #[test]
550    fn compress_lossless_never_drops_segments() {
551        let preset = default_preset();
552        let pipeline = CompressionPipeline::new(&preset);
553
554        // 10 distinct, blank-line-separated segments with bimodal entropy,
555        // well over the 500-byte entropy-truncation threshold.
556        let mut segments = Vec::new();
557        for i in 0..10 {
558            if i % 2 == 0 {
559                segments.push(format!("SEG{i} llllllllllllllllllllllllllllllllllllllll"));
560            } else {
561                segments.push(format!(
562                    "SEG{i} the quick brown fox jumps over {i} lazy dogs by silver rivers"
563                ));
564            }
565        }
566        let input = segments.join("\n\n");
567        assert!(input.len() > 500, "input must exceed truncation threshold");
568
569        // The default path is lossy — it drops below-median-entropy segments.
570        let lossy = pipeline.compress(&input, &ctx(), &preset).unwrap();
571        assert!(
572            lossy.data.contains("low-information segments omitted"),
573            "expected the default path to truncate; got: {}",
574            lossy.data
575        );
576
577        // The lossless path must preserve EVERY segment, byte-for-byte.
578        let lossless = pipeline.compress_lossless(&input, &ctx(), &preset).unwrap();
579        assert!(
580            !lossless.data.contains("omitted"),
581            "lossless path must not truncate; got: {}",
582            lossless.data
583        );
584        assert_eq!(
585            lossless.data, input,
586            "lossless path must return content unchanged"
587        );
588        assert!(!lossless.stages_applied.contains(&"entropy_truncate".to_owned()));
589        assert!(!lossless.stages_applied.contains(&"token_prune".to_owned()));
590    }
591
592    /// The lossless path returns JSON byte-for-byte — no stripped nulls,
593    /// collapsed arrays, truncated strings, projected-away fields, or TOON
594    /// re-encoding (which would shift line numbers).
595    #[test]
596    fn compress_lossless_preserves_json_fields() {
597        let preset = default_preset();
598        let pipeline = CompressionPipeline::new(&preset);
599        let json = r#"{"keep":1,"gone":null,"items":[1,2,3,4,5,6,7,8],"note":"x"}"#;
600
601        // Sanity: the default path transforms the JSON (strips the null,
602        // TOON-encodes), so it differs from the input.
603        let lossy = pipeline.compress(json, &ctx(), &preset).unwrap();
604        assert_ne!(lossy.data, json, "default path should transform JSON");
605
606        // The lossless path returns the JSON verbatim.
607        let result = pipeline.compress_lossless(json, &ctx(), &preset).unwrap();
608        assert_eq!(result.data, json, "lossless must return JSON byte-for-byte");
609    }
610
611    /// Even highly repetitive content is returned verbatim on the lossless
612    /// path — RLE `[×N]` collapsing is skipped so a file read stays faithful.
613    #[test]
614    fn compress_lossless_returns_repeated_lines_verbatim() {
615        let preset = default_preset();
616        let pipeline = CompressionPipeline::new(&preset);
617        let line = "this is a repeated diagnostic line that is quite long\n";
618        let input = format!(
619            "{}a unique trailing line of sufficient length here\n",
620            line.repeat(6)
621        );
622        assert!(input.len() > 300);
623
624        // Default path collapses the repeated run; lossless returns it untouched.
625        let lossy = pipeline.compress(&input, &ctx(), &preset).unwrap();
626        assert!(
627            lossy.data.len() < input.len(),
628            "default path should collapse repeats"
629        );
630
631        let result = pipeline.compress_lossless(&input, &ctx(), &preset).unwrap();
632        assert_eq!(
633            result.data, input,
634            "lossless must return repeated content verbatim"
635        );
636        assert!(!result.stages_applied.contains(&"rle".to_owned()));
637    }
638
639    #[test]
640    fn new_creates_pipeline_with_sorted_stages() {
641        let preset = default_preset();
642        let pipeline = CompressionPipeline::new(&preset);
643        // Verify stages are sorted by priority
644        let priorities: Vec<u32> = pipeline.stages.iter().map(|s| s.priority()).collect();
645        let mut sorted = priorities.clone();
646        sorted.sort();
647        assert_eq!(priorities, sorted);
648    }
649
650    #[test]
651    fn compress_plain_text_passthrough() {
652        let preset = default_preset();
653        let pipeline = CompressionPipeline::new(&preset);
654        let result = pipeline.compress("hello world", &ctx(), &preset).unwrap();
655        assert_eq!(result.data, "hello world");
656        assert!(!result.stages_applied.contains(&"toon_encode".to_owned()));
657    }
658
659    #[test]
660    fn compress_json_applies_toon() {
661        let preset = default_preset();
662        let pipeline = CompressionPipeline::new(&preset);
663        let json = r#"{"name":"Alice","age":30}"#;
664        let result = pipeline.compress(json, &ctx(), &preset).unwrap();
665        assert!(result.data.starts_with("TOON:"), "data: {}", result.data);
666        assert!(result.stages_applied.contains(&"toon_encode".to_owned()));
667    }
668
669    #[test]
670    fn compress_strips_nulls_from_json() {
671        let preset = default_preset();
672        let pipeline = CompressionPipeline::new(&preset);
673        let json = r#"{"a":1,"b":null}"#;
674        let result = pipeline.compress(json, &ctx(), &preset).unwrap();
675        // After strip_nulls, "b" is gone; TOON encodes the result
676        assert!(result.data.starts_with("TOON:"));
677        // Decode and verify null is gone
678        let decoded = ToonEncoder.decode(&result.data).unwrap();
679        assert!(decoded.get("b").is_none());
680        assert_eq!(decoded["a"], serde_json::json!(1));
681    }
682
683    #[test]
684    fn compress_returns_token_counts() {
685        let preset = default_preset();
686        let pipeline = CompressionPipeline::new(&preset);
687        let input = "a".repeat(100);
688        let result = pipeline.compress(&input, &ctx(), &preset).unwrap();
689        assert!(result.tokens_original > 0);
690        assert!(result.tokens_compressed > 0);
691    }
692
693    #[test]
694    fn compress_ratio_is_reasonable() {
695        let preset = default_preset();
696        let pipeline = CompressionPipeline::new(&preset);
697        let result = pipeline.compress("hello", &ctx(), &preset).unwrap();
698        assert!(result.compression_ratio > 0.0);
699    }
700
701    #[test]
702    fn insert_stage_re_sorts_by_priority() {
703        use crate::stages::CompressionStage;
704        use crate::types::StageConfig;
705
706        struct LowPriorityStage;
707        impl CompressionStage for LowPriorityStage {
708            fn name(&self) -> &str {
709                "low_priority"
710            }
711            fn priority(&self) -> u32 {
712                5 // lower than all built-in stages
713            }
714            fn process(
715                &self,
716                _content: &mut Content,
717                _config: &StageConfig,
718            ) -> crate::error::Result<()> {
719                Ok(())
720            }
721        }
722
723        let preset = default_preset();
724        let mut pipeline = CompressionPipeline::new(&preset);
725        pipeline.insert_stage(Box::new(LowPriorityStage));
726
727        let priorities: Vec<u32> = pipeline.stages.iter().map(|s| s.priority()).collect();
728        let mut sorted = priorities.clone();
729        sorted.sort();
730        assert_eq!(priorities, sorted);
731        assert_eq!(pipeline.stages[0].name(), "ansi_strip");
732        assert_eq!(pipeline.stages[1].name(), "low_priority");
733    }
734
735    #[test]
736    fn reload_preset_rebuilds_stages() {
737        let preset = default_preset();
738        let mut pipeline = CompressionPipeline::new(&preset);
739        let original_count = pipeline.stages.len();
740        pipeline.reload_preset(&preset).unwrap();
741        assert_eq!(pipeline.stages.len(), original_count);
742    }
743
744    #[test]
745    fn compress_keep_fields_filters_json() {
746        use crate::preset::KeepFieldsConfig;
747        let mut preset = default_preset();
748        preset.compression.keep_fields = Some(KeepFieldsConfig {
749            enabled: true,
750            fields: vec!["id".into(), "name".into()],
751        });
752        let pipeline = CompressionPipeline::new(&preset);
753        let json = r#"{"id":1,"name":"Bob","debug":"x"}"#;
754        let result = pipeline.compress(json, &ctx(), &preset).unwrap();
755        let decoded = ToonEncoder.decode(&result.data).unwrap();
756        assert!(decoded.get("debug").is_none());
757        assert_eq!(decoded["id"], serde_json::json!(1));
758    }
759
760    #[test]
761    fn compress_empty_string() {
762        let preset = default_preset();
763        let pipeline = CompressionPipeline::new(&preset);
764        let result = pipeline.compress("", &ctx(), &preset).unwrap();
765        assert_eq!(result.data, "");
766        assert_eq!(result.tokens_original, 0);
767    }
768
769    #[test]
770    fn stage_config_from_preset_unknown_stage() {
771        let preset = default_preset();
772        let config = stage_config_from_preset("nonexistent", &preset);
773        assert!(!config.enabled);
774    }
775
776    // ---------------------------------------------------------------------------
777    // Property tests
778    // ---------------------------------------------------------------------------
779
780    use proptest::prelude::*;
781
782    /// Generate a significant line from a fixed set of meaningful tokens.
783    fn significant_line_strategy() -> impl Strategy<Value = String> {
784        prop_oneof![
785            Just("error: connection refused".to_owned()),
786            Just("warning: deprecated API usage".to_owned()),
787            Just("failed: build step exited with code 1".to_owned()),
788            Just("success: deployment complete".to_owned()),
789            Just("status: all checks passed".to_owned()),
790            Just("error: file not found".to_owned()),
791            Just("warning: unused variable detected".to_owned()),
792        ]
793    }
794
795    /// Generate a noise line (repeated decorative content).
796    fn noise_line_strategy() -> impl Strategy<Value = String> {
797        prop_oneof![
798            Just("---".to_owned()),
799            Just("Loading...".to_owned()),
800            Just("================".to_owned()),
801            Just("...".to_owned()),
802        ]
803    }
804
805    /// Recursive strategy that generates arbitrary serde_json::Value instances.
806    /// Mirrors the strategy in toon.rs tests.
807    fn arb_json_value() -> impl Strategy<Value = serde_json::Value> {
808        let leaf = prop_oneof![
809            Just(serde_json::Value::Null),
810            any::<bool>().prop_map(serde_json::Value::Bool),
811            any::<i64>().prop_map(|n| serde_json::json!(n)),
812            any::<f64>()
813                .prop_filter("must be finite", |f| f.is_finite())
814                .prop_map(|f| serde_json::json!(f)),
815            ".*".prop_map(serde_json::Value::String),
816        ];
817
818        leaf.prop_recursive(4, 64, 8, |inner| {
819            prop_oneof![
820                prop::collection::vec(inner.clone(), 0..8)
821                    .prop_map(serde_json::Value::Array),
822                prop::collection::hash_map(".*", inner, 0..8).prop_map(|m| {
823                    serde_json::Value::Object(m.into_iter().collect())
824                }),
825            ]
826        })
827    }
828
829    proptest! {
830        /// **Validates: Requirements 17.1, 17.2, 13.2**
831        ///
832        /// Property 22: ASCII-safe output.
833        ///
834        /// For any JSON input, the Compression_Pipeline SHALL produce output
835        /// using only ASCII-safe characters: printable ASCII (0x20–0x7E) plus
836        /// standard whitespace (\t = 0x09, \n = 0x0A, \r = 0x0D).
837        #[test]
838        fn prop_pipeline_ascii_safe_output(v in arb_json_value()) {
839            let preset = default_preset();
840            let pipeline = CompressionPipeline::new(&preset);
841
842            let json_input = serde_json::to_string(&v).expect("serialize should not fail");
843            let result = pipeline.compress(&json_input, &ctx(), &preset)
844                .expect("compress should not fail");
845
846            for ch in result.data.chars() {
847                let cp = ch as u32;
848                let is_printable_ascii = cp >= 0x20 && cp <= 0x7E;
849                let is_standard_whitespace = cp == 0x09 || cp == 0x0A || cp == 0x0D;
850                prop_assert!(
851                    is_printable_ascii || is_standard_whitespace,
852                    "non-ASCII-safe character in output: U+{:04X} ({:?})\noutput: {:?}",
853                    cp, ch, result.data
854                );
855            }
856        }
857    }
858
859    proptest! {
860        /// **Validates: Requirements 1.3**
861        ///
862        /// Property 1: Compression preserves semantically significant content.
863        ///
864        /// For any CLI output containing significant tokens (errors, warnings,
865        /// status messages) mixed with noise (repeated identical lines), the
866        /// Compression_Pipeline SHALL produce output that:
867        ///   1. Contains all significant lines.
868        ///   2. Contains each noise line at most `max_repeated_lines` times.
869        #[test]
870        fn prop_compression_preserves_significant_content(
871            significant_lines in prop::collection::vec(significant_line_strategy(), 1..=5),
872            noise_line in noise_line_strategy(),
873            noise_repeat in 5u32..=10u32,
874        ) {
875            let preset = default_preset(); // condense enabled, max_repeated_lines=3
876            let pipeline = CompressionPipeline::new(&preset);
877
878            // Build interleaved input: noise, significant, noise, significant, ...
879            let mut lines: Vec<String> = Vec::new();
880            for sig in &significant_lines {
881                for _ in 0..noise_repeat {
882                    lines.push(noise_line.clone());
883                }
884                lines.push(sig.clone());
885            }
886            // Trailing noise block
887            for _ in 0..noise_repeat {
888                lines.push(noise_line.clone());
889            }
890
891            let input = lines.join("\n");
892            let result = pipeline.compress(&input, &ctx(), &preset).unwrap();
893            let output = &result.data;
894
895            // 1. All significant lines must appear in the output.
896            for sig in &significant_lines {
897                prop_assert!(
898                    output.contains(sig.as_str()),
899                    "significant line missing from output: {:?}\noutput: {:?}",
900                    sig,
901                    output
902                );
903            }
904
905            // 2. No consecutive run of the noise line exceeds max_repeated_lines (3).
906            let mut max_run = 0usize;
907            let mut current_run = 0usize;
908            for line in output.lines() {
909                if line == noise_line.as_str() {
910                    current_run += 1;
911                    max_run = max_run.max(current_run);
912                } else {
913                    current_run = 0;
914                }
915            }
916            prop_assert!(
917                max_run <= 3,
918                "noise line {:?} has a consecutive run of {} (max 3)\noutput: {:?}",
919                noise_line,
920                max_run,
921                output
922            );
923        }
924    }
925}