Skip to main content

rite_model/
paper32.rs

1//! Paper32: bytes as rows of base-32 characters a person writes down and
2//! types back, with parity that repairs a slip and refuses a row it cannot
3//! repair.
4//!
5//! The alphabet is Crockford's base 32, `0-9` and the letters without
6//! `I`, `L`, `O` and `U`, one character per five bits. The decoder takes
7//! either case and reads `O` as `0` and `I` or `L` as `1`, since those are
8//! what a hand writes for them; a `?` is a character the person could not
9//! read, an erasure, and so is a `U`, which stands for one character but
10//! not which. Spaces and separators between groups are ignored; any other
11//! character is refused where it stands.
12//!
13//! The characters go in rows of 32: 28 of data and 4 of parity, the last
14//! row shorter and still ending in its 4 parity characters. Each row is a
15//! Reed-Solomon codeword over GF(32): the data characters fix a polynomial
16//! of degree below their count, evaluated at the first points of a fixed
17//! order of the 32 field elements, and the parity characters are that
18//! polynomial at the last four points. Any four characters of a row
19//! determine the other 28, so a row repairs one wrong character or two
20//! unreadable ones and still has two characters of parity to check the
21//! repair against. Beyond that it is refused by name: a row that cannot
22//! be trusted is one the person reads again, not one the code guesses.
23//!
24//! A correct sheet decodes without any of this: drop the last four
25//! characters of each row and read the rest as base 32.
26
27use std::fmt;
28
29use zeroize::Zeroizing;
30
31/// The alphabet, in five-bit order.
32const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
33
34/// Data characters in a full row.
35pub const ROW_DATA: usize = 28;
36
37/// Parity characters closing every row.
38pub const ROW_PARITY: usize = 4;
39
40/// Characters in a full row.
41pub const ROW_LEN: usize = ROW_DATA + ROW_PARITY;
42
43/// Unreadable characters a row may recover, keeping two characters of
44/// parity to check the recovery. One wrong character is the other case.
45const MAX_ERASURES: usize = 2;
46
47// ── GF(32) ──────────────────────────────────────────────────────────────────
48
49/// The field polynomial, x^5 + x^2 + 1.
50const POLY: u8 = 0b10_0101;
51
52/// Multiply in GF(32).
53const fn gf_mul(a: u8, b: u8) -> u8 {
54    let mut a = a & 31;
55    let mut b = b & 31;
56    let mut product = 0u8;
57    while b != 0 {
58        if b & 1 == 1 {
59            product ^= a;
60        }
61        b >>= 1;
62        a <<= 1;
63        if a & 0b10_0000 != 0 {
64            a ^= POLY;
65        }
66    }
67    product & 31
68}
69
70/// The inverses in GF(32), found once by looking: the field is small.
71#[allow(clippy::indexing_slicing)] // `a` is below 32 by the loop's bound
72const INVERSES: [u8; 32] = {
73    let mut table = [0u8; 32];
74    let mut a = 1u8;
75    while a < 32 {
76        let mut x = 1u8;
77        while x < 32 {
78            if gf_mul(a, x) == 1 {
79                table[a as usize] = x;
80            }
81            x += 1;
82        }
83        a += 1;
84    }
85    table
86};
87
88/// The inverse in GF(32); zero has none and gives zero.
89fn gf_inv(a: u8) -> u8 {
90    INVERSES.get(usize::from(a & 31)).copied().unwrap_or(0)
91}
92
93/// The value at `x` of the polynomial through `points`, by Lagrange.
94fn interpolate(points: &[(u8, u8)], x: u8) -> u8 {
95    let mut result = 0u8;
96    for (i, &(xi, yi)) in points.iter().enumerate() {
97        let mut basis = 1u8;
98        for (j, &(xj, _)) in points.iter().enumerate() {
99            if i != j {
100                basis = gf_mul(basis, gf_mul(x ^ xj, gf_inv(xi ^ xj)));
101            }
102        }
103        result ^= gf_mul(yi, basis);
104    }
105    result
106}
107
108// ── rows of paper32 ────────────────────────────────────────────────────────────────
109
110/// The evaluation point of position `i` in a row: the field element `i`,
111/// data at 0 to 27, parity at 28 to 31.
112fn point(position: usize) -> u8 {
113    u8::try_from(position & 31).unwrap_or(0)
114}
115
116/// The parity of `data`, the row's polynomial at the four parity points.
117fn parity(data: &[u8]) -> [u8; ROW_PARITY] {
118    let points: Vec<(u8, u8)> = data
119        .iter()
120        .enumerate()
121        .map(|(i, &d)| (point(i), d))
122        .collect();
123    let mut out = [0u8; ROW_PARITY];
124    for (k, slot) in out.iter_mut().enumerate() {
125        *slot = interpolate(&points, point(ROW_DATA.saturating_add(k)));
126    }
127    out
128}
129
130/// Five-bit values of `bytes`, the last padded with zero bits.
131fn to_symbols(bytes: &[u8]) -> Vec<u8> {
132    let mut out = Vec::with_capacity(bytes.len().saturating_mul(8).div_ceil(5));
133    let mut acc: u32 = 0;
134    let mut bits: u32 = 0;
135    for &b in bytes {
136        acc = (acc << 8) | u32::from(b);
137        bits = bits.saturating_add(8);
138        while bits >= 5 {
139            bits = bits.saturating_sub(5);
140            out.push(u8::try_from((acc >> bits) & 31).unwrap_or(0));
141        }
142    }
143    if bits > 0 {
144        out.push(u8::try_from((acc << (5u32.saturating_sub(bits))) & 31).unwrap_or(0));
145    }
146    out
147}
148
149/// Bytes from five-bit values; the padding must be shorter than a
150/// character and zero.
151fn from_symbols(symbols: &[u8]) -> Option<Vec<u8>> {
152    let mut out = Vec::with_capacity(symbols.len().saturating_mul(5) / 8);
153    let mut acc: u32 = 0;
154    let mut bits: u32 = 0;
155    for &s in symbols {
156        acc = (acc << 5) | u32::from(s & 31);
157        bits = bits.saturating_add(5);
158        while bits >= 8 {
159            bits = bits.saturating_sub(8);
160            out.push(u8::try_from((acc >> bits) & 0xff).unwrap_or(0));
161        }
162    }
163    if bits >= 5 || (acc << (8u32.saturating_sub(bits))) & 0xff != 0 {
164        return None;
165    }
166    Some(out)
167}
168
169/// Write bytes as rows, one string with no separators. Grouping for the
170/// eye is the caller's, since it does not take part in the encoding.
171///
172/// Four bytes are seven characters of data, the last padded with zero
173/// bits, and four of parity:
174///
175/// ```
176/// use rite_model::paper32;
177///
178/// assert_eq!(paper32::encode(b"rite"), "E9MQ8S8PH8B");
179/// ```
180pub fn encode(bytes: &[u8]) -> String {
181    let symbols = Zeroizing::new(to_symbols(bytes));
182    let mut out = String::with_capacity(symbols.len().saturating_add(ROW_LEN).saturating_mul(2));
183    for data in symbols.chunks(ROW_DATA) {
184        for &s in data {
185            out.push(letter(s));
186        }
187        for p in parity(data) {
188            out.push(letter(p));
189        }
190    }
191    out
192}
193
194fn letter(symbol: u8) -> char {
195    ALPHABET
196        .get(usize::from(symbol & 31))
197        .map_or('0', |&b| char::from(b))
198}
199
200/// What one character of the text is.
201enum Read {
202    /// A separator between groups: nothing.
203    Separator,
204    /// A character the person could not read.
205    Erasure,
206    /// A character of the alphabet, or one usually written for it.
207    Value(u8),
208    /// A character with no place here.
209    Unknown,
210}
211
212fn read(c: char) -> Read {
213    let c = c.to_ascii_uppercase();
214    match c {
215        ' ' | '-' | '|' | '\t' | '\n' | '\r' | '.' => Read::Separator,
216        // U is outside the alphabet but stands for one character, most
217        // often a V: unreadable, so the row still recovers it.
218        '?' | 'U' => Read::Erasure,
219        'O' => Read::Value(0),
220        'I' | 'L' => Read::Value(1),
221        _ => ALPHABET
222            .iter()
223            .position(|&x| char::from(x) == c)
224            .and_then(|p| u8::try_from(p).ok())
225            .map_or(Read::Unknown, Read::Value),
226    }
227}
228
229/// Read rows back. The result carries the bytes and what was repaired,
230/// so the person can be told which rows needed it.
231///
232/// # Errors
233///
234/// A character outside the alphabet, a row that cannot be repaired (more
235/// than one wrong character or two unreadable ones), a shape that is not
236/// rows, or a length that is not bytes, each named.
237///
238/// Case, separators and the letters a hand writes for digits are read
239/// through; a wrong character is repaired and its row named:
240///
241/// ```
242/// use rite_model::paper32;
243///
244/// let clean = paper32::decode("e9mq 8s8 | ph8b").unwrap();
245/// assert_eq!(*clean.bytes, *b"rite");
246/// assert!(clean.repaired.is_empty());
247///
248/// // The fifth character miscopied: still "rite", with row 1 named.
249/// let slipped = paper32::decode("E9MQ9S8PH8B").unwrap();
250/// assert_eq!(*slipped.bytes, *b"rite");
251/// assert_eq!(slipped.repaired[0].row, 1);
252/// ```
253pub fn decode(text: &str) -> Result<Decoded, RowError> {
254    let mut symbols: Vec<Option<u8>> = Vec::with_capacity(text.len());
255    for (position, c) in text.chars().enumerate() {
256        match read(c) {
257            Read::Separator => {}
258            Read::Erasure => symbols.push(None),
259            Read::Value(v) => symbols.push(Some(v)),
260            Read::Unknown => return Err(RowError::Character { position }),
261        }
262    }
263    if symbols.is_empty() {
264        return Err(RowError::Empty);
265    }
266    let tail = symbols.len() % ROW_LEN;
267    if tail != 0 && tail <= ROW_PARITY {
268        return Err(RowError::Shape {
269            characters: symbols.len(),
270        });
271    }
272
273    let mut data = Zeroizing::new(Vec::with_capacity(symbols.len()));
274    let mut repaired = Vec::new();
275    for (index, row) in symbols.chunks(ROW_LEN).enumerate() {
276        let row_number = index.saturating_add(1);
277        let (fixed, repair) = repair_row(row).ok_or(RowError::Row {
278            row: row_number,
279            unreadable: row.iter().filter(|s| s.is_none()).count(),
280        })?;
281        if let Some(repair) = repair {
282            repaired.push(Repair {
283                row: row_number,
284                kind: repair,
285            });
286        }
287        data.extend_from_slice(
288            fixed
289                .get(..row.len().saturating_sub(ROW_PARITY))
290                .unwrap_or(&[]),
291        );
292    }
293    let bytes = from_symbols(&data).ok_or(RowError::Padding)?;
294    Ok(Decoded {
295        bytes: Zeroizing::new(bytes),
296        repaired,
297    })
298}
299
300/// One row as typed, read on its own, for a frontend that takes a value a
301/// row at a time and says at once which row needs another look. [`decode`]
302/// reads the whole value; this checks a row the same way. `row` is the
303/// row's number from 1, for the repair and the error to name.
304///
305/// # Errors
306///
307/// A character outside the alphabet, a row too short or too long to be
308/// one, or a row that cannot be repaired.
309///
310/// Two characters the person could not read, typed as `?` or `U`, are
311/// recovered:
312///
313/// ```
314/// use rite_model::paper32;
315///
316/// let row = paper32::read_row("E9?Q8S8PU8B", 1).unwrap();
317/// assert_eq!(row.text.as_str(), "E9MQ8S8PH8B");
318/// assert!(row.repair.is_some());
319/// ```
320pub fn read_row(text: &str, row: usize) -> Result<RowRead, RowError> {
321    let mut symbols: Vec<Option<u8>> = Vec::with_capacity(ROW_LEN);
322    for (position, c) in text.chars().enumerate() {
323        match read(c) {
324            Read::Separator => {}
325            Read::Erasure => symbols.push(None),
326            Read::Value(v) => symbols.push(Some(v)),
327            Read::Unknown => return Err(RowError::Character { position }),
328        }
329    }
330    if symbols.is_empty() {
331        return Err(RowError::Empty);
332    }
333    if symbols.len() <= ROW_PARITY || symbols.len() > ROW_LEN {
334        return Err(RowError::Shape {
335            characters: symbols.len(),
336        });
337    }
338    let (fixed, repair) = repair_row(&symbols).ok_or(RowError::Row {
339        row,
340        unreadable: symbols.iter().filter(|s| s.is_none()).count(),
341    })?;
342    Ok(RowRead {
343        text: Zeroizing::new(fixed.iter().map(|&s| letter(s)).collect()),
344        repair: repair.map(|kind| Repair { row, kind }),
345    })
346}
347
348/// What [`read_row`] read.
349#[derive(Debug)]
350pub struct RowRead {
351    /// The row as it should read, in the alphabet's own characters.
352    pub text: Zeroizing<String>,
353    /// The repair it took, if any.
354    pub repair: Option<Repair>,
355}
356
357/// The row as it should read, and what it took: nothing, or one repair.
358/// `None` when no single repair makes it check.
359fn repair_row(row: &[Option<u8>]) -> Option<(Vec<u8>, Option<RepairKind>)> {
360    let erased: Vec<usize> = row
361        .iter()
362        .enumerate()
363        .filter_map(|(i, s)| s.is_none().then_some(i))
364        .collect();
365    if erased.len() > MAX_ERASURES {
366        return None;
367    }
368    let data_len = row.len().saturating_sub(ROW_PARITY);
369
370    // Fill the erasures from the readable characters, then check.
371    let filled = fill(row, &erased, data_len)?;
372    if checks(&filled, data_len) {
373        let repair = (!erased.is_empty()).then_some(RepairKind::Unreadable {
374            count: erased.len(),
375        });
376        return Some((filled, repair));
377    }
378    if !erased.is_empty() {
379        // An unreadable character and a wrong one are more than the row
380        // can vouch for.
381        return None;
382    }
383
384    // One wrong character: the one position that, treated as unreadable,
385    // gives a row that checks. Two positions would mean the row is
386    // ambiguous, which a single wrong character never is.
387    let mut found: Option<(usize, Vec<u8>)> = None;
388    for position in 0..row.len() {
389        let candidate = fill(row, &[position], data_len)?;
390        if checks(&candidate, data_len) && candidate.get(position) != row.get(position)?.as_ref() {
391            if found.is_some() {
392                return None;
393            }
394            found = Some((position, candidate));
395        }
396    }
397    let (position, fixed) = found?;
398    Some((fixed, Some(RepairKind::Wrong { position })))
399}
400
401/// The row with the given positions recomputed from the others: every
402/// readable character is a point of the row's polynomial, and any
403/// `data_len` of them determine it.
404fn fill(row: &[Option<u8>], holes: &[usize], data_len: usize) -> Option<Vec<u8>> {
405    let known: Vec<(u8, u8)> = row
406        .iter()
407        .enumerate()
408        .filter(|(i, s)| s.is_some() && !holes.contains(i))
409        .filter_map(|(i, s)| s.map(|v| (row_point(i, row.len()), v)))
410        .take(data_len)
411        .collect();
412    if known.len() < data_len {
413        return None;
414    }
415    let mut out = Vec::with_capacity(row.len());
416    for (i, s) in row.iter().enumerate() {
417        match s {
418            Some(v) if !holes.contains(&i) => out.push(*v),
419            _ => out.push(interpolate(&known, row_point(i, row.len()))),
420        }
421    }
422    Some(out)
423}
424
425/// The evaluation point of position `i` in a row of `len` characters: the
426/// data points from 0, the parity points from 28, whatever the row's
427/// length.
428fn row_point(i: usize, len: usize) -> u8 {
429    let data_len = len.saturating_sub(ROW_PARITY);
430    if i < data_len {
431        point(i)
432    } else {
433        point(ROW_DATA.saturating_add(i.saturating_sub(data_len)))
434    }
435}
436
437/// Whether the row's parity is the parity of its data.
438fn checks(row: &[u8], data_len: usize) -> bool {
439    let (data, given) = row.split_at(data_len.min(row.len()));
440    parity(data) == given
441}
442
443/// What [`decode`] read.
444#[derive(Debug)]
445pub struct Decoded {
446    /// The bytes, wiped when dropped.
447    pub bytes: Zeroizing<Vec<u8>>,
448    /// The rows that needed repair, in order.
449    pub repaired: Vec<Repair>,
450}
451
452/// A repair made to one row.
453#[derive(Debug, Clone, PartialEq, Eq)]
454pub struct Repair {
455    /// The row, from 1.
456    pub row: usize,
457    /// What was repaired.
458    pub kind: RepairKind,
459}
460
461/// What a row needed.
462#[derive(Debug, Clone, PartialEq, Eq)]
463#[non_exhaustive]
464pub enum RepairKind {
465    /// One character was wrong, at this position in the row, from 0.
466    Wrong {
467        /// Position in the row.
468        position: usize,
469    },
470    /// This many characters were unreadable and recovered.
471    Unreadable {
472        /// How many.
473        count: usize,
474    },
475}
476
477impl fmt::Display for Repair {
478    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
479        match self.kind {
480            RepairKind::Wrong { position } => write!(
481                f,
482                "row {}: character {} was wrong and has been corrected; check it against the sheet",
483                self.row,
484                position.saturating_add(1)
485            ),
486            RepairKind::Unreadable { count } => write!(
487                f,
488                "row {}: {count} unreadable character{} recovered",
489                self.row,
490                if count == 1 { "" } else { "s" }
491            ),
492        }
493    }
494}
495
496/// Why rows did not read back.
497#[derive(Debug, Clone, PartialEq, Eq)]
498#[non_exhaustive]
499pub enum RowError {
500    /// Nothing but separators.
501    Empty,
502    /// Not a digit, a letter, a `?` or a separator, at this position in the
503    /// text.
504    Character {
505        /// Zero-based position in the text as given.
506        position: usize,
507    },
508    /// The character count is not rows: a last row must hold at least one
509    /// data character before its four of parity.
510    Shape {
511        /// Characters read.
512        characters: usize,
513    },
514    /// A row with more wrong or unreadable characters than it can repair.
515    Row {
516        /// The row, from 1.
517        row: usize,
518        /// Characters in it marked unreadable.
519        unreadable: usize,
520    },
521    /// The data does not end on a whole byte.
522    Padding,
523}
524
525impl fmt::Display for RowError {
526    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
527        match self {
528            Self::Empty => write!(f, "nothing to read"),
529            Self::Character { position } => write!(
530                f,
531                "character {} is not a digit or a letter; write ? for one that cannot be read",
532                position.saturating_add(1)
533            ),
534            Self::Shape { characters } => write!(
535                f,
536                "{characters} characters do not make rows of 32; a last row has at least 5"
537            ),
538            Self::Row { row, unreadable } => match unreadable {
539                0 => write!(
540                    f,
541                    "row {row} does not check and has more than one wrong character; read it \
542                     again from the sheet"
543                ),
544                1 | 2 => write!(
545                    f,
546                    "row {row} has {unreadable} unreadable and still does not check, so another \
547                     character is wrong; a row recovers two unreadable or one wrong, not both; \
548                     read it again from the sheet"
549                ),
550                _ => write!(
551                    f,
552                    "row {row} has {unreadable} unreadable characters and recovers at most two; \
553                     read it again from the sheet"
554                ),
555            },
556            Self::Padding => write!(f, "the characters do not end on a whole byte"),
557        }
558    }
559}
560
561impl std::error::Error for RowError {}
562
563#[cfg(test)]
564#[allow(clippy::indexing_slicing, clippy::arithmetic_side_effects)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn the_field_has_inverses_and_the_polynomial_is_primitive() {
570        for a in 1..32u8 {
571            assert_eq!(gf_mul(a, gf_inv(a)), 1, "{a}");
572        }
573        // 2 generates the multiplicative group: 31 distinct powers.
574        let mut seen = std::collections::BTreeSet::new();
575        let mut x = 1u8;
576        for _ in 0..31 {
577            seen.insert(x);
578            x = gf_mul(x, 2);
579        }
580        assert_eq!(seen.len(), 31);
581        assert_eq!(x, 1);
582    }
583
584    #[test]
585    fn a_row_is_its_data_and_four_parity_characters() {
586        let text = encode(&[0u8; 35]);
587        assert_eq!(text.len(), 64);
588        assert_eq!(&text[..28], "0".repeat(28));
589        assert_eq!(&text[28..32], "0000");
590        let text = encode(&[0xFF; 35]);
591        assert_eq!(text.len(), 64);
592        assert!(text.chars().all(|c| ALPHABET.contains(&(c as u8))));
593    }
594
595    #[test]
596    fn bytes_round_trip_at_every_length() {
597        for len in 0..=100usize {
598            let bytes: Vec<u8> = (0..len)
599                .map(|i| u8::try_from(i.wrapping_mul(53).wrapping_add(7) & 0xff).unwrap())
600                .collect();
601            let text = encode(&bytes);
602            let rows = len.saturating_mul(8).div_ceil(5).div_ceil(ROW_DATA);
603            assert_eq!(
604                text.len(),
605                len.saturating_mul(8).div_ceil(5) + rows * ROW_PARITY,
606                "{len}"
607            );
608            if len == 0 {
609                assert_eq!(decode(&text).unwrap_err(), RowError::Empty);
610                continue;
611            }
612            let decoded = decode(&text).unwrap_or_else(|e| panic!("{len}: {e}"));
613            assert_eq!(*decoded.bytes, bytes, "{len}");
614            assert!(decoded.repaired.is_empty());
615        }
616    }
617
618    #[test]
619    fn case_confusions_and_separators_are_read_through() {
620        let bytes = b"a value to write";
621        let text = encode(bytes);
622        let grouped: String = text
623            .to_ascii_lowercase()
624            .as_bytes()
625            .chunks(4)
626            .map(|g| std::str::from_utf8(g).unwrap())
627            .collect::<Vec<_>>()
628            .join(" ");
629        assert_eq!(*decode(&grouped).unwrap().bytes, bytes);
630        // O for 0, I and L for 1.
631        let confused = text.replace('0', "O").replace('1', "l");
632        assert_eq!(*decode(&confused).unwrap().bytes, bytes);
633        // U is read as unreadable and recovered.
634        let mut chars: Vec<char> = text.chars().collect();
635        chars[0] = 'u';
636        let with_u: String = chars.iter().collect();
637        let decoded = decode(&with_u).unwrap();
638        assert_eq!(*decoded.bytes, bytes);
639        assert_eq!(
640            decoded.repaired,
641            vec![Repair {
642                row: 1,
643                kind: RepairKind::Unreadable { count: 1 }
644            }]
645        );
646        // Any other character is refused where it stands.
647        chars[0] = '#';
648        let outside: String = chars.iter().collect();
649        assert_eq!(
650            decode(&outside).unwrap_err(),
651            RowError::Character { position: 0 }
652        );
653    }
654
655    #[test]
656    fn one_wrong_character_anywhere_in_a_row_is_corrected_and_named() {
657        let bytes = [0x5A; 35];
658        let text = encode(&bytes);
659        let chars: Vec<char> = text.chars().collect();
660        for position in 0..chars.len() {
661            let mut wrong = chars.clone();
662            wrong[position] = if wrong[position] == 'Q' { 'R' } else { 'Q' };
663            let wrong: String = wrong.into_iter().collect();
664            let decoded = decode(&wrong).unwrap_or_else(|e| panic!("position {position}: {e}"));
665            assert_eq!(*decoded.bytes, bytes, "position {position}");
666            assert_eq!(
667                decoded.repaired,
668                [Repair {
669                    row: position / ROW_LEN + 1,
670                    kind: RepairKind::Wrong {
671                        position: position % ROW_LEN
672                    }
673                }],
674                "position {position}"
675            );
676        }
677    }
678
679    #[test]
680    fn two_unreadable_characters_in_a_row_are_recovered() {
681        let bytes = b"thirty-two bytes of some secret!";
682        let text = encode(bytes);
683        let mut chars: Vec<char> = text.chars().collect();
684        chars[3] = '?';
685        chars[30] = '?';
686        chars[40] = '?';
687        let smudged: String = chars.iter().collect();
688        let decoded = decode(&smudged).unwrap();
689        assert_eq!(*decoded.bytes, bytes);
690        assert_eq!(decoded.repaired.len(), 2);
691        assert_eq!(
692            decoded.repaired[0].to_string(),
693            "row 1: 2 unreadable characters recovered"
694        );
695        assert_eq!(
696            decoded.repaired[1],
697            Repair {
698                row: 2,
699                kind: RepairKind::Unreadable { count: 1 }
700            }
701        );
702    }
703
704    #[test]
705    fn a_row_beyond_repair_is_refused_by_name() {
706        let text = encode(&[0x33; 35]);
707        let mut chars: Vec<char> = text.chars().collect();
708        // Two wrong characters in row 2.
709        chars[35] = if chars[35] == 'A' { 'B' } else { 'A' };
710        chars[50] = if chars[50] == 'A' { 'B' } else { 'A' };
711        let wrong: String = chars.iter().collect();
712        assert_eq!(
713            decode(&wrong).unwrap_err(),
714            RowError::Row {
715                row: 2,
716                unreadable: 0
717            }
718        );
719        // Three unreadable in row 1.
720        let mut chars: Vec<char> = text.chars().collect();
721        chars[0] = '?';
722        chars[1] = '?';
723        chars[2] = '?';
724        let smudged: String = chars.iter().collect();
725        assert_eq!(
726            decode(&smudged).unwrap_err(),
727            RowError::Row {
728                row: 1,
729                unreadable: 3
730            }
731        );
732        // One wrong and one unreadable in a row.
733        let mut chars: Vec<char> = text.chars().collect();
734        chars[5] = '?';
735        chars[9] = if chars[9] == 'A' { 'B' } else { 'A' };
736        let mixed: String = chars.iter().collect();
737        let err = decode(&mixed).unwrap_err();
738        assert_eq!(
739            err,
740            RowError::Row {
741                row: 1,
742                unreadable: 1
743            }
744        );
745        assert!(err.to_string().contains("not both"));
746    }
747
748    #[test]
749    fn a_shape_that_is_not_rows_is_refused() {
750        assert_eq!(
751            decode("ABCD").unwrap_err(),
752            RowError::Shape { characters: 4 }
753        );
754        assert_eq!(
755            decode(&"A".repeat(34)).unwrap_err(),
756            RowError::Shape { characters: 34 }
757        );
758        assert_eq!(decode(" - ").unwrap_err(), RowError::Empty);
759    }
760}