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