base_d/encoders/algorithms/
errors.rs

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