Skip to main content

sqz_engine/
stages.rs

1use crate::error::{Result, SqzError};
2use crate::toon::ToonEncoder;
3use crate::types::{Content, ContentType, StageConfig};
4
5/// A single compression stage in the pipeline.
6///
7/// Each stage transforms `Content` in place according to its `StageConfig`.
8/// Stages must check `config.enabled` and return early (no-op) when disabled.
9pub 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
15// ---------------------------------------------------------------------------
16// Helper: parse raw as JSON, apply a transform, serialize back
17// ---------------------------------------------------------------------------
18
19fn 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
32// ---------------------------------------------------------------------------
33// Stage 1: keep_fields
34// ---------------------------------------------------------------------------
35
36/// For JSON content, keep only the specified top-level fields; drop all others.
37/// Config options: `fields` — array of field name strings.
38/// Non-JSON content passes through unchanged.
39pub 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
71// ---------------------------------------------------------------------------
72// Stage 2: strip_fields
73// ---------------------------------------------------------------------------
74
75/// For JSON content, remove specified fields by key name.
76/// Supports dot-notation for nested fields (e.g. "metadata.internal_id").
77/// Config options: `fields` — array of field path strings.
78/// Non-JSON content passes through unchanged.
79pub 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
127// ---------------------------------------------------------------------------
128// Stage 3: condense
129// ---------------------------------------------------------------------------
130
131/// For plain text / CLI output, collapse runs of repeated identical lines
132/// down to at most `max_repeated_lines`.
133/// Config options: `max_repeated_lines` (u32, default 3).
134/// Non-plain-text content passes through unchanged.
135pub 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        // Only apply to plain text and CLI output
151        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        // Preserve trailing newline if original had one
187        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
196// ---------------------------------------------------------------------------
197// Stage 4: strip_nulls
198// ---------------------------------------------------------------------------
199
200/// For JSON content, recursively remove all null-valued fields from objects.
201/// Arrays keep their null elements.
202/// Config options: `enabled` (bool).
203pub 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
242// ---------------------------------------------------------------------------
243// Stage 5: flatten
244// ---------------------------------------------------------------------------
245
246/// For JSON content, flatten nested objects up to `max_depth` levels using
247/// dot-notation for flattened keys (e.g. `{"a":{"b":1}}` → `{"a.b":1}`).
248/// Config options: `max_depth` (u32, default 3).
249/// Non-JSON content passes through unchanged.
250pub 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
308// ---------------------------------------------------------------------------
309// Stage 6: truncate_strings
310// ---------------------------------------------------------------------------
311
312/// For JSON content, truncate string values longer than `max_length` chars,
313/// appending "..." to indicate truncation.
314/// Config options: `max_length` (u32, default 500).
315/// Non-JSON content passes through unchanged.
316pub 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
367// ---------------------------------------------------------------------------
368// Stage 7: collapse_arrays
369// ---------------------------------------------------------------------------
370
371/// For JSON content, cap arrays that are both long and large. An array with
372/// more than `max_items` elements whose compact JSON is over `max_chars`
373/// keeps leading elements up to `max_chars` (never fewer than `max_items`)
374/// and ends with a summary string element. Smaller arrays stay whole, so a
375/// search result or a page of records keeps every entry.
376///
377/// Config options:
378///   - `max_items` (u32, default 5)
379///   - `max_chars` (u32, default 32000; 0 caps on `max_items` alone)
380///   - `summary_template` (string, default "... and {remaining} more items")
381/// Non-JSON content passes through unchanged.
382pub struct CollapseArraysStage;
383
384/// About 8K tokens of compact JSON per array.
385pub 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            // Brackets plus one comma between elements.
406            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
476// ---------------------------------------------------------------------------
477// Stage 7a: word_abbreviate
478// ---------------------------------------------------------------------------
479
480/// For plain text / CLI output, replace common long words with standard
481/// abbreviations (e.g. "implementation" → "impl", "configuration" → "config").
482///
483/// Only replaces whole words (not substrings) and only in prose-like content.
484/// Config options: `enabled` (bool).
485pub struct WordAbbreviateStage;
486
487/// Built-in abbreviation table: (long_form, short_form).
488/// Only includes unambiguous, widely-understood abbreviations.
489const 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 // After strip_fields (20), before condense (30)
580    }
581
582    fn process(&self, content: &mut Content, config: &StageConfig) -> Result<()> {
583        if !config.enabled {
584            return Ok(());
585        }
586        // Only apply to plain text and CLI output
587        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            // Replace whole words only (case-insensitive for the check,
595            // but preserve surrounding context)
596            result = replace_whole_word(&result, long, short);
597        }
598
599        content.raw = result;
600        Ok(())
601    }
602}
603
604/// Apply word abbreviation to a plain text string.
605///
606/// This is a convenience function for callers that want to abbreviate
607/// outside the pipeline stage system (e.g. CLI proxy post-processing).
608pub 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
616/// Replace whole-word occurrences of `word` with `replacement`.
617/// A "whole word" is bounded by non-alphanumeric characters or string edges.
618fn 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        // Check word boundary before
635        let before_ok = start == 0
636            || !text_bytes[start - 1].is_ascii_alphanumeric();
637        // Check word boundary after
638        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
652// ---------------------------------------------------------------------------
653// Stage 7b: git_diff_fold
654// ---------------------------------------------------------------------------
655
656/// For git diff output, fold consecutive unchanged context lines (lines
657/// starting with a space) into a compact `[N unchanged lines]` marker.
658/// This preserves all changed lines (+/-) and hunk headers (@@) while
659/// dramatically reducing noise from context lines.
660///
661/// Config options:
662///   - `max_context_lines` (u32, default 2) — keep this many context lines
663///     before/after each changed block before folding the rest.
664pub 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        // Only apply to plain text / CLI output that looks like a diff
680        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        // Mark which lines are "changed" (added, removed, or hunk headers)
700        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        // For each context line, determine if it's within max_ctx of a changed line
714        let mut keep = vec![false; n];
715        for i in 0..n {
716            if is_changed[i] {
717                keep[i] = true;
718                // Keep max_ctx lines before
719                for j in i.saturating_sub(max_ctx)..i {
720                    keep[j] = true;
721                }
722                // Keep max_ctx lines after
723                for j in (i + 1)..n.min(i + 1 + max_ctx) {
724                    keep[j] = true;
725                }
726            }
727        }
728
729        // Build output, folding consecutive non-kept lines
730        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    // Real diff detection: require strong structural signals, not just
759    // lines starting with +/-. The old check (`contains("\n+") || contains("\n-")`)
760    // false-positived on ls -l output (regular files start with -rw-),
761    // Markdown bullet lists, CSV with negative numbers, etc.
762    raw.starts_with("diff --git ")
763        || raw.starts_with("diff -")
764        || raw.contains("\n@@ ")       // hunk header
765        || raw.contains("\n--- a/")    // unified diff file header
766        || raw.contains("\n+++ b/")    // unified diff file header
767}
768
769// ---------------------------------------------------------------------------
770// Stage 8: custom_transforms
771// ---------------------------------------------------------------------------
772
773/// No-op stage that serves as the insertion point for plugin stages.
774/// Passes content through unchanged.
775pub 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        // No-op: plugin stages are inserted here by the pipeline
791        Ok(())
792    }
793}
794
795// ---------------------------------------------------------------------------
796// Tests
797// ---------------------------------------------------------------------------
798
799#[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    // --- keep_fields ---
846
847    #[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    // --- strip_fields ---
874
875    #[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    // --- condense ---
902
903    #[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    // --- strip_nulls ---
951
952    #[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    // --- flatten ---
988
989    #[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        // At max_depth=1, top-level values are not descended into
1005        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    // --- truncate_strings ---
1017
1018    #[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); // 500 + "..."
1029    }
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    // --- collapse_arrays ---
1051
1052    #[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); // 5 kept + 1 summary
1064        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    // --- git_diff_fold ---
1086
1087    #[test]
1088    fn git_diff_fold_folds_unchanged_lines() {
1089        // Use a realistic diff with many unchanged context lines
1090        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        // Changed lines must be preserved
1114        assert!(c.raw.contains("-old line"), "output: {}", c.raw);
1115        assert!(c.raw.contains("+new line"), "output: {}", c.raw);
1116        // Hunk header must be preserved
1117        assert!(c.raw.contains("@@ -1,12"), "output: {}", c.raw);
1118        // Output should be shorter (folded lines 1-4 and 9-12)
1119        assert!(c.raw.len() < diff.len(), "output should be shorter, got:\n{}", c.raw);
1120        // Should contain fold markers
1121        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    // --- git_diff_fold false-positive regression tests ---
1151    // https://github.com/ojuschugh1/sqz/issues/1 (Reddit report)
1152    //
1153    // ls -l output contains lines starting with - (regular file permissions:
1154    // -rw-r--r--) which the old diff detector treated as diff deletions.
1155    // Directory entries were silently dropped from the model's view.
1156
1157    #[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        // ALL directory entries must be preserved — none should be folded
1171        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        // Verify the fix didn't break actual diff folding
1200        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        // Changed lines must be preserved
1222        assert!(c.raw.contains("-old line"), "removed line preserved: {}", c.raw);
1223        assert!(c.raw.contains("+new line"), "added line preserved: {}", c.raw);
1224        // Should fold some context lines
1225        assert!(c.raw.contains("unchanged lines"), "should fold context: {}", c.raw);
1226        // Output should have fewer lines (fold markers replace multiple lines)
1227        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    // --- custom_transforms ---
1235
1236    #[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        // Ten search results with uneven keys: nothing may be dropped.
1276        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    // --- word_abbreviate ---
1327
1328    #[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        // "implement" should NOT be abbreviated — only "implementation" is in the table
1342        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        // "implementations" contains "implementation" but shouldn't match
1387        // because the 's' after makes it not a word boundary
1388        let result = replace_whole_word("multiple implementations exist", "implementation", "impl");
1389        // The word "implementations" has "implementation" followed by 's' which is alphanumeric,
1390        // so it should NOT be replaced
1391        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}