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!(
122                        f,
123                        "\n\x1b[1;36mhint:\x1b[0m check for missing or incorrect '=' characters at end of input"
124                    )?;
125                } else {
126                    writeln!(f, "error: invalid padding")?;
127                    write!(
128                        f,
129                        "\nhint: check for missing or incorrect '=' characters at end of input"
130                    )?;
131                }
132                Ok(())
133            }
134            DecodeError::InvalidLength {
135                actual,
136                expected,
137                hint,
138            } => {
139                if use_color {
140                    writeln!(f, "\x1b[1;31merror:\x1b[0m invalid length for decode",)?;
141                } else {
142                    writeln!(f, "error: invalid length for decode")?;
143                }
144                writeln!(f)?;
145                writeln!(f, "  input is {} characters, expected {}", actual, expected)?;
146                writeln!(f)?;
147                if use_color {
148                    write!(f, "\x1b[1;36mhint:\x1b[0m {}", hint)?;
149                } else {
150                    write!(f, "hint: {}", hint)?;
151                }
152                Ok(())
153            }
154        }
155    }
156}
157
158impl std::error::Error for DecodeError {}
159
160/// Check if colored output should be used
161fn should_use_color() -> bool {
162    // Respect NO_COLOR environment variable
163    if std::env::var("NO_COLOR").is_ok() {
164        return false;
165    }
166
167    // Check if stderr is a terminal
168    use std::io::IsTerminal;
169    std::io::stderr().is_terminal()
170}
171
172/// Error when a dictionary is not found
173#[derive(Debug)]
174pub struct DictionaryNotFoundError {
175    pub name: String,
176    pub suggestion: Option<String>,
177}
178
179impl DictionaryNotFoundError {
180    pub fn new(name: impl Into<String>) -> Self {
181        Self {
182            name: name.into(),
183            suggestion: None,
184        }
185    }
186
187    pub fn with_suggestion(name: impl Into<String>, suggestion: Option<String>) -> Self {
188        Self {
189            name: name.into(),
190            suggestion,
191        }
192    }
193
194    pub fn with_cause(name: impl Into<String>, cause: impl std::fmt::Display) -> Self {
195        Self {
196            name: name.into(),
197            suggestion: Some(format!("build failed: {}", cause)),
198        }
199    }
200}
201
202impl fmt::Display for DictionaryNotFoundError {
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        let use_color = should_use_color();
205
206        if use_color {
207            writeln!(
208                f,
209                "\x1b[1;31merror:\x1b[0m dictionary '{}' not found",
210                self.name
211            )?;
212        } else {
213            writeln!(f, "error: dictionary '{}' not found", self.name)?;
214        }
215
216        writeln!(f)?;
217
218        if let Some(suggestion) = &self.suggestion {
219            if use_color {
220                writeln!(f, "\x1b[1;36mhint:\x1b[0m did you mean '{}'?", suggestion)?;
221            } else {
222                writeln!(f, "hint: did you mean '{}'?", suggestion)?;
223            }
224        }
225
226        if use_color {
227            write!(
228                f,
229                "      run \x1b[1m`base-d config --dictionaries`\x1b[0m to see all dictionaries"
230            )?;
231        } else {
232            write!(
233                f,
234                "      run `base-d config --dictionaries` to see all dictionaries"
235            )?;
236        }
237
238        Ok(())
239    }
240}
241
242impl std::error::Error for DictionaryNotFoundError {}
243
244/// Calculate Levenshtein distance between two strings
245fn levenshtein_distance(s1: &str, s2: &str) -> usize {
246    let len1 = s1.chars().count();
247    let len2 = s2.chars().count();
248
249    if len1 == 0 {
250        return len2;
251    }
252    if len2 == 0 {
253        return len1;
254    }
255
256    let mut prev_row: Vec<usize> = (0..=len2).collect();
257    let mut curr_row = vec![0; len2 + 1];
258
259    for (i, c1) in s1.chars().enumerate() {
260        curr_row[0] = i + 1;
261
262        for (j, c2) in s2.chars().enumerate() {
263            let cost = if c1 == c2 { 0 } else { 1 };
264            curr_row[j + 1] = (curr_row[j] + 1)
265                .min(prev_row[j + 1] + 1)
266                .min(prev_row[j] + cost);
267        }
268
269        std::mem::swap(&mut prev_row, &mut curr_row);
270    }
271
272    prev_row[len2]
273}
274
275/// Find the closest matching dictionary name
276pub fn find_closest_dictionary(name: &str, available: &[String]) -> Option<String> {
277    if available.is_empty() {
278        return None;
279    }
280
281    let mut best_match = None;
282    let mut best_distance = usize::MAX;
283
284    for dict_name in available {
285        let distance = levenshtein_distance(name, dict_name);
286
287        // Only suggest if distance is reasonably small
288        // (e.g., 1-2 character typos for short names, up to 3 for longer names)
289        let threshold = if name.len() < 5 { 2 } else { 3 };
290
291        if distance < best_distance && distance <= threshold {
292            best_distance = distance;
293            best_match = Some(dict_name.clone());
294        }
295    }
296
297    best_match
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn test_levenshtein_distance() {
306        assert_eq!(levenshtein_distance("base64", "base64"), 0);
307        assert_eq!(levenshtein_distance("base64", "base32"), 2);
308        assert_eq!(levenshtein_distance("bas64", "base64"), 1);
309        assert_eq!(levenshtein_distance("", "base64"), 6);
310    }
311
312    #[test]
313    fn test_find_closest_dictionary() {
314        let dicts = vec![
315            "base64".to_string(),
316            "base32".to_string(),
317            "base16".to_string(),
318            "hex".to_string(),
319        ];
320
321        assert_eq!(
322            find_closest_dictionary("bas64", &dicts),
323            Some("base64".to_string())
324        );
325        assert_eq!(
326            find_closest_dictionary("base63", &dicts),
327            Some("base64".to_string())
328        );
329        assert_eq!(
330            find_closest_dictionary("hex_radix", &dicts),
331            None // too different
332        );
333    }
334
335    #[test]
336    fn test_error_display_no_color() {
337        // Unsafe: environment variable access (not thread-safe)
338        // TODO: Audit that the environment access only happens in single-threaded code.
339        unsafe {
340            std::env::set_var("NO_COLOR", "1");
341        }
342
343        let err = DecodeError::invalid_character('_', 12, "SGVsbG9faW52YWxpZA==", "A-Za-z0-9+/=");
344        let display = format!("{}", err);
345
346        assert!(display.contains("invalid character '_' at position 12"));
347        assert!(display.contains("SGVsbG9faW52YWxpZA=="));
348        assert!(display.contains("^"));
349        assert!(display.contains("hint:"));
350
351        // Unsafe: environment variable access (not thread-safe)
352        // TODO: Audit that the environment access only happens in single-threaded code.
353        unsafe {
354            std::env::remove_var("NO_COLOR");
355        }
356    }
357
358    #[test]
359    fn test_invalid_length_error() {
360        // Unsafe: environment variable access (not thread-safe)
361        // TODO: Audit that the environment access only happens in single-threaded code.
362        unsafe {
363            std::env::set_var("NO_COLOR", "1");
364        }
365
366        let err = DecodeError::invalid_length(
367            13,
368            "multiple of 4",
369            "add padding (=) or check for missing characters",
370        );
371        let display = format!("{}", err);
372
373        assert!(display.contains("invalid length"));
374        assert!(display.contains("13 characters"));
375        assert!(display.contains("multiple of 4"));
376        assert!(display.contains("add padding"));
377
378        // Unsafe: environment variable access (not thread-safe)
379        // TODO: Audit that the environment access only happens in single-threaded code.
380        unsafe {
381            std::env::remove_var("NO_COLOR");
382        }
383    }
384
385    #[test]
386    fn test_dictionary_not_found_error() {
387        // Unsafe: environment variable access (not thread-safe)
388        // TODO: Audit that the environment access only happens in single-threaded code.
389        unsafe {
390            std::env::set_var("NO_COLOR", "1");
391        }
392
393        let err = DictionaryNotFoundError::with_suggestion("bas64", Some("base64".to_string()));
394        let display = format!("{}", err);
395
396        assert!(display.contains("dictionary 'bas64' not found"));
397        assert!(display.contains("did you mean 'base64'?"));
398        assert!(display.contains("base-d config --dictionaries"));
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}