old_crypto_rs/
helpers.rs

1//! A set of helpers functions.
2//!
3use std::collections::HashSet;
4
5use eyre::Result;
6
7/// Removes all duplicate characters from a string, preserving the first occurrence of each.
8///
9/// This function iterates through the input string and builds a new string containing only
10/// the first occurrence of each character, maintaining the original order. It uses a `HashSet`
11/// to track which characters have already been encountered.
12///
13/// # Algorithm
14///
15/// 1. Create an empty `HashSet` to track seen characters
16/// 2. Create an empty result `String`
17/// 3. For each character in the input:
18///    - If the character hasn't been seen (insert returns true), append it to the result
19///    - If the character has been seen (insert returns false), skip it
20/// 4. Return the result string
21///
22/// # Arguments
23///
24/// * `str` - A string slice to be condensed
25///
26/// # Returns
27///
28/// A new `String` with all duplicate characters removed, preserving the order of first occurrences
29///
30/// # Examples
31///
32/// ```
33/// use old_crypto_rs::helpers::condense;
34///
35/// // Simple duplicates
36/// let result = condense("AAAAA");
37/// assert_eq!(result, "A");
38///
39/// // Mixed duplicates
40/// let result = condense("ARABESQUE");
41/// assert_eq!(result, "ARBESQU");
42///
43/// // Longer string with duplicates
44/// let result = condense("ARABESQUEABCDEFGHIKLMNOPQRSTUVWXYZ");
45/// assert_eq!(result, "ARBESQUCDFGHIKLMNOPTVWXYZ");
46///
47/// // No duplicates
48/// let result = condense("ABCDE");
49/// assert_eq!(result, "ABCDE");
50/// ```
51///
52/// # Performance
53///
54/// Time complexity: O(n) where n is the length of the input string
55/// Space complexity: O(k) where k is the number of unique characters
56///
57/// # See Also
58///
59/// * [`condense_str`] - An optimized version using a bitset for ASCII characters
60///
61pub fn condense(str: &str) -> String {
62    let mut seen = HashSet::new();
63    let mut condensed = String::new();
64
65    for ch in str.chars() {
66        if seen.insert(ch) {
67            condensed.push(ch);
68        }
69    }
70    condensed
71}
72
73/// Efficiently removes all duplicate characters from a string, returning a new String.
74///
75/// This implementation uses a bitset for ASCII characters to achieve O(n) time complexity
76/// and minimal overhead.
77///
78pub fn condense_str(s: &str) -> String {
79    let mut seen_ascii = [false; 256];
80    let mut res = String::with_capacity(s.len());
81
82    for c in s.chars() {
83        if !seen_ascii[c as usize] {
84            seen_ascii[c as usize] = true;
85            res.push(c);
86        }
87    }
88    res
89}
90
91/// insert one character inside the array
92/// 
93pub fn insert(src: &[u8], obj: u8, ind: usize) -> Vec<u8> {
94    let mut dst = Vec::with_capacity(src.len() + 1);
95    dst.extend_from_slice(&src[..ind]);
96    dst.push(obj);
97    dst.extend_from_slice(&src[ind..]);
98    dst.to_vec()
99}
100
101/// Expands a byte slice by inserting 'X' between consecutive duplicate characters.
102///
103/// This function processes the input in pairs (digrams), inserting the byte `b'X'`
104/// between any two consecutive identical characters. This is commonly used in
105/// classical ciphers like Playfair to ensure all digrams consist of different characters.
106///
107/// # Algorithm
108///
109/// 1. Copy the source slice into a mutable vector
110/// 2. Iterate through the vector in steps of 2 (processing digrams)
111/// 3. If two consecutive characters are identical, insert `b'X'` between them
112/// 4. Continue processing, which causes the inserted 'X' to become part of the next digram
113///
114/// # Arguments
115///
116/// * `src` - A byte slice to be expanded
117///
118/// # Returns
119///
120/// A new `Vec<u8>` with 'X' characters inserted between consecutive duplicates
121///
122/// # Examples
123///
124/// ```
125/// use old_crypto_rs::helpers::expand;
126///
127/// // Simple duplicate
128/// let input = b"AAA";
129/// let result = expand(input);
130/// assert_eq!(result, b"AXAXA");
131///
132/// // No duplicates
133/// let input = b"ARABESQUE";
134/// let result = expand(input);
135/// assert_eq!(result, b"ARABESQUE");
136///
137/// // Multiple consecutive duplicates
138/// let input = b"AAAA";
139/// let result = expand(input);
140/// assert_eq!(result, b"AXAXAXA");
141///
142/// // Duplicates in different positions
143/// let input = b"LANNONCE";
144/// let result = expand(input);
145/// assert_eq!(result, b"LANXNONCE");
146/// ```
147///
148/// # Performance
149///
150/// Time complexity: O(n) where n is the length of the input, though insertions may cause
151/// reallocation in worst case scenarios with many consecutive duplicates.
152/// Space complexity: O(n + k) where k is the number of 'X' characters inserted.
153///
154/// # See Also
155///
156/// * [`insert`] - The helper function used to insert characters
157///
158pub fn expand(src: &[u8]) -> Vec<u8> {
159    let mut res = src.to_vec();
160    let mut i = 0;
161    while i < res.len().saturating_sub(1) {
162        if res[i] == res[i + 1] {
163            res = insert(&res, b'X', i + 1);
164        }
165        i += 2;
166    }
167    res
168}
169
170/// The 26-letter alphabet we know and love in the Western part of the world
171pub const REGULAR_ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
172
173/// Default alphabet containing A-Z plus special characters '/' and '-'.
174/// The '/' character is used as a digit escape marker in encryption.
175pub const SC_ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ/-";
176
177/*
178  # Form an alphabet formed with a keyword, re-shuffle everything to
179  # make it less predictable (i.e. checkerboard effect)
180  #
181  # Shuffle the alphabet a bit to avoid sequential allocation of the
182  # code numbers.  This is actually performing a transposition with the word
183  # itself as key.
184  #
185  # Regular rectangle
186  # -----------------
187  # Key is ARABESQUE condensed into ARBESQU (len = 7) (height = 4)
188  # Let word be ARBESQUCDFGHIJKLMNOPTVWXYZ/-
189  #
190  # First passes will generate
191  #
192  # A  RBESQUCDFGHIJKLMNOPTVWXYZ/-   c=0  0 x 6
193  # AC  RBESQUDFGHIJKLMNOPTVWXYZ/-   c=6  1 x 6
194  # ACK  RBESQUDFGHIJLMNOPTVWXYZ/-   c=12 2 x 6
195  # ACKV  RBESQUDFGHIJLMNOPTWXYZ/-   c=18 3 x 6
196  # ACKVR  BESQUDFGHIJLMNOPTWXYZ/-   c=0  0 x 5
197  # ACKVRD  BESQUFGHIJLMNOPTWXYZ/-   c=5  1 x 5
198  # ...
199  # ACKVRDLWBFMXEGNYSHOZQIP/UJT-
200  #
201  # Irregular rectangle
202  # -------------------
203  # Key is SUBWAY condensed info SUBWAY (len = 6) (height = 5)
204  #
205  # S  UBWAYCDEFGHIJKLMNOPQRTVXZ/-   c=0  0 x 5
206  # SC  UBWAYDEFGHIJKLMNOPQRTVXZ/-   c=5  1 x 5
207  # SCI  UBWAYDEFGHJKLMNOPQRTVXZ/-   c=10 2 x 5
208  # SCIO  UBWAYDEFGHJKLMNPQRTVXZ/-   c=15 3 x 5
209  # SCIOX  UBWAYDEFGHJKLMNPQRTVZ/-   c=20 4 x 5
210  # SCIOXU  BWAYDEFGHJKLMNPQRTVZ/-   c=0  0 x 4
211  # ...
212  # SCIOXUDJPZBEKQ/WFLR-AG  YHMNTV   c=1  1 x 1
213  # SCIOXUDJPZBEKQ/WFLR-AGM  YHNTV   c=2  2 x 1
214  # SCIOXUDJPZBEKQ/WFLR-AGMT  YHNV   c=3  3 x 1
215  # SCIOXUDJPZBEKQ/WFLR-AGMTYHNV
216*/
217
218/// Shuffles an alphabet using a keyword to create a mixed alphabet for cipher use.
219///
220/// # Algorithm
221///
222/// 1. Condense the concatenation of `key` and `alphabet` to remove duplicates
223/// 2. Calculate dimensions: `length` = condensed key length, `height` = alphabet length / length
224/// 3. Extract characters in a specific pattern based on these dimensions, working backwards
225///    through columns and forwards through rows
226///
227/// # Arguments
228///
229/// * `key` - The keyword used to shuffle the alphabet (will be condensed to remove duplicates)
230/// * `alphabet` - The alphabet string to be shuffled
231///
232/// # Returns
233///
234/// A new `String` containing the shuffled alphabet
235///
236/// # Examples
237///
238/// Regular rectangle (key length divides alphabet evenly):
239/// ```
240/// use old_crypto_rs::helpers::shuffle;
241///
242/// let key = "ARABESQUE";  // Condenses to "ARBESQU" (length = 7)
243/// let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ/-";  // 28 chars, height = 4
244/// let result = shuffle(key, alphabet);
245/// assert_eq!(result, "ACKVRDLWBFMXEGNYSHOZQIP/UJT-");
246/// ```
247///
248/// Irregular rectangle (key length does not divide alphabet evenly):
249/// ```
250/// use old_crypto_rs::helpers::shuffle;
251///
252/// let key = "SUBWAY";  // Condenses to "SUBWAY" (length = 6)
253/// let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ/-";  // 28 chars, height = 5
254/// let result = shuffle(key, alphabet);
255/// assert_eq!(result, "SCIOXUDJPZBEKQ/WFLR-AGMTYHNV");
256/// ```
257///
258/// # Performance
259///
260/// Time complexity: O(n * m) where n is the key length and m is the height
261/// Space complexity: O(n + m) for the working vector and result string
262///
263pub fn shuffle(key: &str, alphabet: &str) -> String {
264    let mut word = Vec::with_capacity(key.len() + alphabet.len());
265    let mut seen = [false; 256];
266    let mut length = 0;
267    for c in key.chars() {
268        if (c as usize) < 256 && !seen[c as usize] {
269            seen[c as usize] = true;
270            word.push(c as u8);
271            length += 1;
272        }
273    }
274    if length == 0 {
275        return alphabet.to_string();
276    }
277
278    for c in alphabet.chars() {
279        if (c as usize) < 256 && !seen[c as usize] {
280            seen[c as usize] = true;
281            word.push(c as u8);
282        }
283    }
284
285    let mut height = alphabet.len() / length;
286    if alphabet.len() % length != 0 {
287        height += 1;
288    }
289
290    let mut res = String::with_capacity(word.len());
291    for i in (0..length).rev() {
292        for j in 0..=height {
293            if word.len() <= height.saturating_sub(1) {
294                res.push_str(std::str::from_utf8(&word).unwrap());
295                return res;
296            } else {
297                if i * j < word.len() {
298                    let c = word.remove(i * j);
299                    res.push(c as char);
300                }
301            }
302        }
303    }
304    res
305}
306
307/// Shuffles an alphabet using a keyword to create a mixed alphabet for cipher use.
308///
309/// BTW: the transposition comment about `shuffle` was coming from the Go version...
310/// and is wrong.
311///
312/// The main issue with  plain `shuffle()` is that the first letter in the final
313/// alphabet is always the same as the key.  This version does not have this problem.
314///
315/// This optimized version performs transposition directly without creating intermediate
316/// objects, making it as fast as the plain shuffle, if not faster.
317///
318/// inlining FTW :)
319///
320pub fn transp_shuffle(key: &str, alphabet: &str) -> Result<String> {
321    // Short-circuit if key is empty
322    //
323    if key.is_empty() {
324        return Ok(alphabet.to_string());
325    }
326
327    // Condense key inline using bitset (same as shuffle())
328    //
329    let mut seen = [false; 256];
330    let mut condensed_key = Vec::with_capacity(key.len());
331    for c in key.chars() {
332        if (c as usize) < 256 && !seen[c as usize] {
333            seen[c as usize] = true;
334            condensed_key.push(c as u8);
335        }
336    }
337
338    // Compute numeric key inline (to_numeric equivalent)
339    //
340    let klen = condensed_key.len();
341    let mut indexed: Vec<(usize, u8)> = condensed_key.iter().enumerate().map(|(i, &b)| (i, b)).collect();
342    indexed.sort_unstable_by_key(|&(_, b)| b);
343
344    let mut tkey = vec![0u8; klen];
345    for (rank, (original_idx, _)) in indexed.into_iter().enumerate() {
346        tkey[original_idx] = rank as u8;
347    }
348
349    // Precompute column positions to avoid repeated searches
350    //
351    let mut col_positions = vec![0usize; klen];
352    for i in 0..klen {
353        col_positions[i] = tkey.iter().position(|&x| x == i as u8).unwrap();
354    }
355
356    // Perform transposition directly into String
357    //
358    let src = alphabet.as_bytes();
359    let mut result = Vec::with_capacity(alphabet.len());
360
361    // Build the result
362    //
363    for &j in &col_positions {
364        let mut curr = j;
365        while curr < src.len() {
366            result.push(src[curr]);
367            curr += klen;
368        }
369    }
370
371    Ok(String::from_utf8(result)?)
372}
373
374/// Converts a string key into a numeric representation based on alphabetical order.
375///
376/// This function is commonly used to derive numeric keys for transposition ciphers.
377/// It assigns a rank to each character in the key based on its Unicode scalar value.
378/// If characters are identical, their original positions determine their relative rank.
379///
380/// # Arguments
381///
382/// * `key` - The string slice to be converted.
383///
384/// # Returns
385///
386/// A `Vec<u8>` where each element corresponds to the alphabetical rank (0-indexed)
387/// of the character at that position in the original string.
388///
389/// # Examples
390///
391/// ```
392/// use old_crypto_rs::helpers::to_numeric;
393///
394/// let key = "ZEBRAS";
395/// let numeric = to_numeric(key);
396/// // A=0, B=1, E=2, R=3, S=4, Z=5
397/// // Z (5), E (2), B (1), R (3), A (0), S (4)
398/// assert_eq!(numeric, vec![5, 2, 1, 3, 0, 4]);
399/// ```
400///
401pub fn to_numeric(key: &str) -> Vec<u8> {
402    let letters = key.as_bytes();
403    let mut indexed: Vec<(usize, u8)> = letters.iter().enumerate().map(|(i, &b)| (i, b)).collect();
404    indexed.sort_by_key(|&(_, b)| b);
405
406    let mut ar = vec![0u8; letters.len()];
407    for (rank, (original_idx, _)) in indexed.into_iter().enumerate() {
408        ar[original_idx] = rank as u8;
409    }
410    ar
411}
412
413/// Inserts a space every `n` characters in a string.
414///
415/// This is typically used to format ciphertext into readable blocks.
416///
417/// # Arguments
418///
419/// * `ct` - The input string slice.
420/// * `n` - The number of characters in each block.
421///
422/// # Returns
423///
424/// A new `String` with spaces inserted every `n` characters.
425///
426/// # Examples
427///
428/// ```
429/// use old_crypto_rs::helpers::by_n;
430///
431/// let input = "ABCDEFGHIJ";
432/// let result = by_n(input, 5);
433/// assert_eq!(result, "ABCDE FGHIJ");
434/// ```
435///
436pub fn by_n(ct: &str, n: usize) -> String {
437    let mut out = String::new();
438    let mut count = 0;
439    for ch in ct.chars() {
440        if count > 0 && count % n == 0 {
441            out.push(' ');
442        }
443        out.push(ch);
444        count += 1;
445    }
446    out
447}
448
449/// Formats a string into blocks of 5 characters separated by spaces.
450///
451/// This function takes a string slice and returns a new `String` where a space
452/// is inserted every 5 characters.
453///
454/// # Arguments
455///
456/// * `input` - A string slice to be formatted
457///
458/// # Returns
459///
460/// A new `String` with spaces inserted every 5 characters.
461pub fn output_as_block(input: &str) -> String {
462    by_n(input, 5)
463}
464
465/// Replaces consecutive identical characters by inserting a fill character between them.
466///
467/// For example, "AA" becomes "AQA" if the fill character is 'Q'. This is often used
468/// in classical ciphers to handle double letters.
469///
470/// >NOTE: this function works regardless of alignment; that is, it does not check if the double
471/// letters are on a 2-character boundary.  Therefore, it is useless for `Playfair`, or any
472/// bi-grammatic ciphers.
473///
474/// e.g.
475/// HELLOWORLD -> HE LX LO WO RL DX
476/// but  also
477/// CETOOTEST -> CE TO XO TE ST
478///
479/// # Arguments
480///
481/// * `str` - The input string to process.
482/// * `fill` - The character to insert between identical consecutive characters.
483///
484/// # Returns
485///
486/// A new `String` with the fill character inserted where necessary.
487///
488pub fn fix_double(str: &str, fill: char) -> String {
489    let mut fixed = String::with_capacity(str.len());
490    let mut chars = str.chars();
491
492    if let Some(first) = chars.next() {
493        fixed.push(first);
494        let mut prev = first;
495        for ch in chars {
496            if ch == prev {
497                fixed.push(fill);
498            }
499            fixed.push(ch);
500            prev = ch;
501        }
502    }
503
504    fixed
505}
506
507/// Replaces consecutive identical characters by inserting a fill character between them,
508/// but only when the duplicate pair falls on a 2-character boundary.
509///
510/// This function is specifically designed for bi-grammatic ciphers like Playfair, where
511/// doubles must be fixed only if they would form a single digram (2-character block).
512///
513/// Unlike `fix_double`, this function respects alignment. It processes the string in
514/// 2-character chunks and only inserts the fill character when both characters in a
515/// digram are identical.
516///
517/// e.g.
518/// HELLOWORLD -> HE LX LO WO RL DX (LL becomes LX, DD becomes DX)
519/// CETOOTEST  -> CE TO OT ES TX (OO is on boundary, so no change needed as they're in different digrams)
520///
521/// # Arguments
522///
523/// * `str` - The input string to process.
524/// * `fill` - The character to insert between identical characters in the same digram.
525///
526/// # Returns
527///
528/// A new `String` with the fill character inserted where necessary, respecting 2-character boundaries.
529///
530pub fn fix_double_aligned(str: &str, fill: char) -> String {
531    let chars: Vec<char> = str.chars().collect();
532    let mut fixed = String::with_capacity(str.len());
533    let mut i = 0;
534
535    while i < chars.len() {
536        if i + 1 < chars.len() && chars[i] == chars[i + 1] {
537            // Double on boundary - insert fill character
538            fixed.push(chars[i]);
539            fixed.push(fill);
540            i += 1; // Move forward by 1, so the second character becomes part of next digram
541        } else if i + 1 < chars.len() {
542            // Normal digram - add both characters
543            fixed.push(chars[i]);
544            fixed.push(chars[i + 1]);
545            i += 2;
546        } else {
547            // Last character (odd length)
548            fixed.push(chars[i]);
549            i += 1;
550        }
551    }
552
553    fixed
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use rstest::*;
560
561    #[rstest]
562    #[case("ABCDE", "ABCDE")]
563    #[case("AAAAA", "A")]
564    #[case("ARABESQUE", "ARBESQU")]
565    #[case("ARABESQUEABCDEFGHIKLMNOPQRSTUVWXYZ", "ARBESQUCDFGHIKLMNOPTVWXYZ")]
566    #[case("PLAYFAIRABCDEFGHIKLMNOPQRSTUVWXYZ", "PLAYFIRBCDEGHKMNOQSTUVWXZ")]
567    #[case("PLAYFAIREXMABCDEFGHIKLMNOPQRSTUVWXYZ", "PLAYFIREXMBCDGHKNOQSTUVWZ")]
568    fn test_condense(#[case] in_str: &str, #[case] expected: &str) {
569        assert_eq!(condense(in_str), expected);
570    }
571
572    #[rstest]
573    #[case("AAA", "AXAXA")]
574    #[case("AAAA", "AXAXAXA")]
575    #[case("AAABRAACADAABRA", "AXAXABRAACADAXABRA")]
576    #[case("ARABESQUE", "ARABESQUE")]
577    #[case("LANNONCE", "LANXNONCE")]
578    #[case("PJRJJJJJJS", "PJRJJXJXJXJXJS")]
579    #[case("ABCDEFGHJJKLM", "ABCDEFGHJXJKLM")]
580    fn test_expand_insert(#[case] in_str: &str, #[case] expected: &str) {
581        assert_eq!(expand(in_str.as_bytes()), expected.as_bytes());
582    }
583
584    #[test]
585    fn test_insert() {
586        let a = [0, 1, 2, 3];
587        let b = [0, 1, 42, 2, 3];
588        assert_eq!(insert(&a, 42, 2), b);
589    }
590
591    #[rstest]
592    #[case("ARABESQUE", "ACKVRDLWBFMXEGNYSHOZQIP/UJT-")]
593    #[case("SUBWAY", "SCIOXUDJPZBEKQ/WFLR-AGMTYHNV")]
594    fn test_shuffle(#[case] key: &str, #[case] expected: &str) {
595        let res = shuffle(key, SC_ALPHABET);
596        assert_eq!(res, expected);
597    }
598
599    #[rstest]
600    #[case("ARABESQUE", "AHOVCJQXDKRYFMT/BIPWELSZGNU-")]
601    #[case("SUBWAY", "EKQWCIOU/AGMSYBHNTZDJPV-FLRX")]
602    fn test_transp_shuffle(#[case] key: &str, #[case] expected: &str) {
603        let res = transp_shuffle(key, SC_ALPHABET);
604        assert!(res.is_ok());
605        let res = res.unwrap();
606        assert_eq!(res, expected);
607    }
608
609    #[rstest]
610    #[case("ARABESQUE", vec![0, 6, 1, 2, 3, 7, 5, 8, 4])]
611    #[case("PJRJJJJJJS", vec![7, 0, 8, 1, 2, 3, 4, 5, 6, 9])]
612    #[case("AAABRAACADAABRA", vec![0, 1, 2, 9, 13, 3, 4, 11, 5, 12, 6, 7, 10, 14, 8])]
613    fn test_to_numeric(#[case] key: &str, #[case] expected: Vec<u8>) {
614        assert_eq!(to_numeric(key), expected);
615    }
616
617    #[rstest]
618    #[case(5, "ARABESQUE", "ARABE SQUE")]
619    #[case(4, "PJRJJJJJJS", "PJRJ JJJJ JS")]
620    #[case(5, "AAABRAACADAABRA", "AAABR AACAD AABRA")]
621    fn test_by_n(#[case] n: usize, #[case] in_str: &str, #[case] expected: &str) {
622        assert_eq!(by_n(in_str, n), expected);
623    }
624
625    #[rstest]
626    #[case("ARABESQUE", "ARABE SQUE")]
627    #[case("PJRJJJJJJS", "PJRJJ JJJJS")]
628    #[case("AAABRAACADAABRA", "AAABR AACAD AABRA")]
629    #[case("ABCDE", "ABCDE")]
630    #[case("ABCDEF", "ABCDE F")]
631    fn test_output_as_block(#[case] in_str: &str, #[case] expected: &str) {
632        assert_eq!(output_as_block(in_str), expected);
633    }
634
635    #[rstest]
636    #[case('Q', "ABCDEF", "ABCDEF")]
637    #[case('Q', "AABCDE", "AQABCDE")]
638    #[case('Q', "AAAAA", "AQAQAQAQA")]
639    #[case('Q', "CETOOT", "CETOQOT")]
640    #[case('Q', "CETOOTESTCHIFFREAVEC", "CETOQOTESTCHIFQFREAVEC")]
641    #[case('X', "ABCDEF", "ABCDEF")]
642    #[case('X', "AABCDE", "AXABCDE")]
643    #[case('X', "AAAAA", "AXAXAXAXA")]
644    #[case('X', "CETOOT", "CETOXOT")]
645    #[case('X', "CETOOTESTCHIFFREAVEC", "CETOXOTESTCHIFXFREAVEC")]
646    fn test_fix_double(#[case] fill: char, #[case] in_str: &str, #[case] expected: &str) {
647        assert_eq!(fix_double(in_str, fill), expected);
648    }
649
650    #[rstest]
651    #[case('Q', "ABCDEF", "ABCDEF")]
652    #[case('Q', "AABCDE", "AQABCDE")]
653    #[case('Q', "AAAAA", "AQAQAQAQA")] // AA -> AQ A, AA -> AQ A, AA -> AQ A
654    #[case('Q', "CETOOT", "CETOOT")]   // CE TO OT becomes CE TO QO T
655    #[case('Q', "HELLOWORLD", "HELQLOWORLD")] // LL on boundary becomes LQ
656    #[case('Q', "CETOOTEST", "CETOOTEST")] // OO is split across digrams (O|O), so no change
657    #[case('X', "ABCDEF", "ABCDEF")]
658    #[case('X', "AABCDE", "AXABCDE")]
659    #[case('X', "AAAAA", "AXAXAXAXA")]
660    #[case('X', "CETOOT", "CETOOT")]
661    #[case('X', "HELLOWORLD", "HELXLOWORLD")]
662    #[case('X', "CETOOTEST", "CETOOTEST")] // OO is split across digrams
663    fn test_fix_double_aligned(#[case] fill: char, #[case] in_str: &str, #[case] expected: &str) {
664        assert_eq!(fix_double_aligned(in_str, fill), expected);
665    }
666}