Skip to main content

base_d/encoders/algorithms/
errors.rs

1use std::fmt;
2
3/// Errors that can occur during encoding.
4#[derive(Debug, PartialEq, Eq)]
5pub enum EncodeError {
6    /// A byte mapped to an invalid Unicode codepoint during ByteRange encoding
7    InvalidCodepoint {
8        codepoint: u32,
9        start_codepoint: u32,
10        byte: u8,
11    },
12}
13
14impl fmt::Display for EncodeError {
15    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
16        match self {
17            EncodeError::InvalidCodepoint {
18                codepoint,
19                start_codepoint,
20                byte,
21            } => {
22                write!(
23                    f,
24                    "ByteRange encoding produced invalid codepoint U+{:04X} \
25                     (start_codepoint=U+{:04X}, byte=0x{:02X}). \
26                     This dictionary should have been rejected at construction time.",
27                    codepoint, start_codepoint, byte
28                )
29            }
30        }
31    }
32}
33
34impl std::error::Error for EncodeError {}
35
36/// Errors that can occur during decoding.
37#[derive(Debug, PartialEq, Eq)]
38pub enum DecodeError {
39    /// The input contains a character not in the dictionary
40    InvalidCharacter {
41        char: char,
42        position: usize,
43        input: String,
44        valid_chars: String,
45    },
46    /// The input contains a word not in the word dictionary
47    InvalidWord {
48        word: String,
49        position: usize,
50        input: String,
51    },
52    /// The input string is empty
53    EmptyInput,
54    /// The padding is malformed or incorrect
55    InvalidPadding,
56    /// Invalid length for the encoding format
57    InvalidLength {
58        actual: usize,
59        expected: String,
60        hint: String,
61    },
62}
63
64impl DecodeError {
65    /// Create an InvalidCharacter error with context
66    pub fn invalid_character(c: char, position: usize, input: &str, valid_chars: &str) -> Self {
67        // Truncate long inputs
68        let display_input = if input.len() > 60 {
69            format!("{}...", &input[..60])
70        } else {
71            input.to_string()
72        };
73
74        DecodeError::InvalidCharacter {
75            char: c,
76            position,
77            input: display_input,
78            valid_chars: valid_chars.to_string(),
79        }
80    }
81
82    /// Create an InvalidLength error
83    pub fn invalid_length(
84        actual: usize,
85        expected: impl Into<String>,
86        hint: impl Into<String>,
87    ) -> Self {
88        DecodeError::InvalidLength {
89            actual,
90            expected: expected.into(),
91            hint: hint.into(),
92        }
93    }
94
95    /// Create an InvalidWord error for word-based decoding
96    pub fn invalid_word(word: &str, position: usize, input: &str) -> Self {
97        // Truncate long inputs
98        let display_input = if input.len() > 80 {
99            format!("{}...", &input[..80])
100        } else {
101            input.to_string()
102        };
103
104        DecodeError::InvalidWord {
105            word: word.to_string(),
106            position,
107            input: display_input,
108        }
109    }
110}
111
112impl fmt::Display for DecodeError {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        let use_color = should_use_color();
115
116        match self {
117            DecodeError::InvalidCharacter {
118                char: c,
119                position,
120                input,
121                valid_chars,
122            } => {
123                // Error header
124                if use_color {
125                    writeln!(
126                        f,
127                        "\x1b[1;31merror:\x1b[0m invalid character '{}' at position {}",
128                        c, position
129                    )?;
130                } else {
131                    writeln!(
132                        f,
133                        "error: invalid character '{}' at position {}",
134                        c, position
135                    )?;
136                }
137                writeln!(f)?;
138
139                // Show input with caret pointing at error position
140                // Need to account for multi-byte UTF-8 characters
141                let char_position = input.chars().take(*position).count();
142                writeln!(f, "  {}", input)?;
143                write!(f, "  {}", " ".repeat(char_position))?;
144                if use_color {
145                    writeln!(f, "\x1b[1;31m^\x1b[0m")?;
146                } else {
147                    writeln!(f, "^")?;
148                }
149                writeln!(f)?;
150
151                // Hint with valid characters (truncate if too long)
152                let hint_chars = if valid_chars.len() > 80 {
153                    format!("{}...", &valid_chars[..80])
154                } else {
155                    valid_chars.clone()
156                };
157
158                if use_color {
159                    write!(f, "\x1b[1;36mhint:\x1b[0m valid characters: {}", hint_chars)?;
160                } else {
161                    write!(f, "hint: valid characters: {}", hint_chars)?;
162                }
163                Ok(())
164            }
165            DecodeError::InvalidWord {
166                word,
167                position,
168                input,
169            } => {
170                if use_color {
171                    writeln!(
172                        f,
173                        "\x1b[1;31merror:\x1b[0m unknown word '{}' at position {}",
174                        word, position
175                    )?;
176                } else {
177                    writeln!(f, "error: unknown word '{}' at position {}", word, position)?;
178                }
179                writeln!(f)?;
180                writeln!(f, "  {}", input)?;
181                writeln!(f)?;
182                if use_color {
183                    write!(
184                        f,
185                        "\x1b[1;36mhint:\x1b[0m check spelling or verify word is in dictionary"
186                    )?;
187                } else {
188                    write!(f, "hint: check spelling or verify word is in dictionary")?;
189                }
190                Ok(())
191            }
192            DecodeError::EmptyInput => {
193                if use_color {
194                    write!(f, "\x1b[1;31merror:\x1b[0m cannot decode empty input")?;
195                } else {
196                    write!(f, "error: cannot decode empty input")?;
197                }
198                Ok(())
199            }
200            DecodeError::InvalidPadding => {
201                if use_color {
202                    writeln!(f, "\x1b[1;31merror:\x1b[0m invalid padding")?;
203                    write!(
204                        f,
205                        "\n\x1b[1;36mhint:\x1b[0m check for missing or incorrect '=' characters at end of input"
206                    )?;
207                } else {
208                    writeln!(f, "error: invalid padding")?;
209                    write!(
210                        f,
211                        "\nhint: check for missing or incorrect '=' characters at end of input"
212                    )?;
213                }
214                Ok(())
215            }
216            DecodeError::InvalidLength {
217                actual,
218                expected,
219                hint,
220            } => {
221                if use_color {
222                    writeln!(f, "\x1b[1;31merror:\x1b[0m invalid length for decode",)?;
223                } else {
224                    writeln!(f, "error: invalid length for decode")?;
225                }
226                writeln!(f)?;
227                writeln!(f, "  input is {} characters, expected {}", actual, expected)?;
228                writeln!(f)?;
229                if use_color {
230                    write!(f, "\x1b[1;36mhint:\x1b[0m {}", hint)?;
231                } else {
232                    write!(f, "hint: {}", hint)?;
233                }
234                Ok(())
235            }
236        }
237    }
238}
239
240impl std::error::Error for DecodeError {}
241
242/// Check if colored output should be used
243fn should_use_color() -> bool {
244    // Respect NO_COLOR environment variable
245    if std::env::var("NO_COLOR").is_ok() {
246        return false;
247    }
248
249    // Check if stderr is a terminal
250    use std::io::IsTerminal;
251    std::io::stderr().is_terminal()
252}
253
254/// Error when a dictionary is not found
255#[derive(Debug)]
256pub struct DictionaryNotFoundError {
257    pub name: String,
258    pub suggestion: Option<String>,
259}
260
261impl DictionaryNotFoundError {
262    pub fn new(name: impl Into<String>) -> Self {
263        Self {
264            name: name.into(),
265            suggestion: None,
266        }
267    }
268
269    pub fn with_suggestion(name: impl Into<String>, suggestion: Option<String>) -> Self {
270        Self {
271            name: name.into(),
272            suggestion,
273        }
274    }
275
276    pub fn with_cause(name: impl Into<String>, cause: impl std::fmt::Display) -> Self {
277        Self {
278            name: name.into(),
279            suggestion: Some(format!("build failed: {}", cause)),
280        }
281    }
282}
283
284impl fmt::Display for DictionaryNotFoundError {
285    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
286        let use_color = should_use_color();
287
288        if use_color {
289            writeln!(
290                f,
291                "\x1b[1;31merror:\x1b[0m dictionary '{}' not found",
292                self.name
293            )?;
294        } else {
295            writeln!(f, "error: dictionary '{}' not found", self.name)?;
296        }
297
298        writeln!(f)?;
299
300        if let Some(suggestion) = &self.suggestion {
301            if use_color {
302                writeln!(f, "\x1b[1;36mhint:\x1b[0m did you mean '{}'?", suggestion)?;
303            } else {
304                writeln!(f, "hint: did you mean '{}'?", suggestion)?;
305            }
306        }
307
308        if use_color {
309            write!(
310                f,
311                "      run \x1b[1m`base-d config --dictionaries`\x1b[0m to see all dictionaries"
312            )?;
313        } else {
314            write!(
315                f,
316                "      run `base-d config --dictionaries` to see all dictionaries"
317            )?;
318        }
319
320        Ok(())
321    }
322}
323
324impl std::error::Error for DictionaryNotFoundError {}
325
326/// Calculate Levenshtein distance between two strings
327fn levenshtein_distance(s1: &str, s2: &str) -> usize {
328    let len1 = s1.chars().count();
329    let len2 = s2.chars().count();
330
331    if len1 == 0 {
332        return len2;
333    }
334    if len2 == 0 {
335        return len1;
336    }
337
338    let mut prev_row: Vec<usize> = (0..=len2).collect();
339    let mut curr_row = vec![0; len2 + 1];
340
341    for (i, c1) in s1.chars().enumerate() {
342        curr_row[0] = i + 1;
343
344        for (j, c2) in s2.chars().enumerate() {
345            let cost = if c1 == c2 { 0 } else { 1 };
346            curr_row[j + 1] = (curr_row[j] + 1)
347                .min(prev_row[j + 1] + 1)
348                .min(prev_row[j] + cost);
349        }
350
351        std::mem::swap(&mut prev_row, &mut curr_row);
352    }
353
354    prev_row[len2]
355}
356
357/// Find the closest matching dictionary name
358pub fn find_closest_dictionary(name: &str, available: &[String]) -> Option<String> {
359    if available.is_empty() {
360        return None;
361    }
362
363    let mut best_match = None;
364    let mut best_distance = usize::MAX;
365
366    for dict_name in available {
367        let distance = levenshtein_distance(name, dict_name);
368
369        // Only suggest if distance is reasonably small
370        // (e.g., 1-2 character typos for short names, up to 3 for longer names)
371        let threshold = if name.len() < 5 { 2 } else { 3 };
372
373        if distance < best_distance && distance <= threshold {
374            best_distance = distance;
375            best_match = Some(dict_name.clone());
376        }
377    }
378
379    best_match
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn test_levenshtein_distance() {
388        assert_eq!(levenshtein_distance("base64", "base64"), 0);
389        assert_eq!(levenshtein_distance("base64", "base32"), 2);
390        assert_eq!(levenshtein_distance("bas64", "base64"), 1);
391        assert_eq!(levenshtein_distance("", "base64"), 6);
392    }
393
394    #[test]
395    fn test_find_closest_dictionary() {
396        let dicts = vec![
397            "base64".to_string(),
398            "base32".to_string(),
399            "base16".to_string(),
400            "hex".to_string(),
401        ];
402
403        assert_eq!(
404            find_closest_dictionary("bas64", &dicts),
405            Some("base64".to_string())
406        );
407        assert_eq!(
408            find_closest_dictionary("base63", &dicts),
409            Some("base64".to_string())
410        );
411        assert_eq!(
412            find_closest_dictionary("hex_radix", &dicts),
413            None // too different
414        );
415    }
416
417    #[test]
418    fn test_error_display_no_color() {
419        // Unsafe: environment variable access (not thread-safe)
420        // TODO: Audit that the environment access only happens in single-threaded code.
421        unsafe {
422            std::env::set_var("NO_COLOR", "1");
423        }
424
425        let err = DecodeError::invalid_character('_', 12, "SGVsbG9faW52YWxpZA==", "A-Za-z0-9+/=");
426        let display = format!("{}", err);
427
428        assert!(display.contains("invalid character '_' at position 12"));
429        assert!(display.contains("SGVsbG9faW52YWxpZA=="));
430        assert!(display.contains("^"));
431        assert!(display.contains("hint:"));
432
433        // Unsafe: environment variable access (not thread-safe)
434        // TODO: Audit that the environment access only happens in single-threaded code.
435        unsafe {
436            std::env::remove_var("NO_COLOR");
437        }
438    }
439
440    #[test]
441    fn test_invalid_length_error() {
442        // Unsafe: environment variable access (not thread-safe)
443        // TODO: Audit that the environment access only happens in single-threaded code.
444        unsafe {
445            std::env::set_var("NO_COLOR", "1");
446        }
447
448        let err = DecodeError::invalid_length(
449            13,
450            "multiple of 4",
451            "add padding (=) or check for missing characters",
452        );
453        let display = format!("{}", err);
454
455        assert!(display.contains("invalid length"));
456        assert!(display.contains("13 characters"));
457        assert!(display.contains("multiple of 4"));
458        assert!(display.contains("add padding"));
459
460        // Unsafe: environment variable access (not thread-safe)
461        // TODO: Audit that the environment access only happens in single-threaded code.
462        unsafe {
463            std::env::remove_var("NO_COLOR");
464        }
465    }
466
467    #[test]
468    fn test_dictionary_not_found_error() {
469        // Unsafe: environment variable access (not thread-safe)
470        // TODO: Audit that the environment access only happens in single-threaded code.
471        unsafe {
472            std::env::set_var("NO_COLOR", "1");
473        }
474
475        let err = DictionaryNotFoundError::with_suggestion("bas64", Some("base64".to_string()));
476        let display = format!("{}", err);
477
478        assert!(display.contains("dictionary 'bas64' not found"));
479        assert!(display.contains("did you mean 'base64'?"));
480        assert!(display.contains("base-d config --dictionaries"));
481
482        // Unsafe: environment variable access (not thread-safe)
483        // TODO: Audit that the environment access only happens in single-threaded code.
484        unsafe {
485            std::env::remove_var("NO_COLOR");
486        }
487    }
488}