Skip to main content

mollendorff_forge/
error.rs

1use std::fmt::Write;
2
3use thiserror::Error;
4
5pub type ForgeResult<T> = Result<T, ForgeError>;
6
7#[derive(Error, Debug)]
8pub enum ForgeError {
9    #[error("IO error: {0}")]
10    Io(#[from] std::io::Error),
11
12    #[error("YAML parsing error: {0}")]
13    Yaml(#[from] serde_yaml_ng::Error),
14
15    #[error("Parse error: {0}")]
16    Parse(String),
17
18    #[error("Formula evaluation error: {0}")]
19    Eval(String),
20
21    #[error("Circular dependency detected: {0}")]
22    CircularDependency(String),
23
24    #[error("Validation error: {0}")]
25    Validation(String),
26
27    #[error("Excel export error: {0}")]
28    Export(String),
29
30    #[error("Excel import error: {0}")]
31    Import(String),
32
33    #[error("IO error: {0}")]
34    IO(String),
35
36    /// Rich formula error with context (v4.1.0)
37    #[error("{}", .0.format_error())]
38    Formula(FormulaErrorContext),
39}
40
41/// Rich error context for formula evaluation failures (v4.1.0)
42#[derive(Debug, Clone)]
43pub struct FormulaErrorContext {
44    /// The original formula that failed
45    pub formula: String,
46    /// Location: table.column or scalar name
47    pub location: String,
48    /// What went wrong
49    pub error: String,
50    /// Suggestion for fixing (optional)
51    pub suggestion: Option<String>,
52    /// Available columns in context (for "did you mean?" suggestions)
53    pub available_columns: Vec<String>,
54}
55
56impl FormulaErrorContext {
57    #[must_use]
58    pub fn new(formula: &str, location: &str, error: &str) -> Self {
59        Self {
60            formula: formula.to_string(),
61            location: location.to_string(),
62            error: error.to_string(),
63            suggestion: None,
64            available_columns: Vec::new(),
65        }
66    }
67
68    #[must_use]
69    pub fn with_suggestion(mut self, suggestion: &str) -> Self {
70        self.suggestion = Some(suggestion.to_string());
71        self
72    }
73
74    #[must_use]
75    pub fn with_available_columns(mut self, columns: Vec<String>) -> Self {
76        self.available_columns = columns;
77        self
78    }
79
80    /// Find similar column names for "did you mean?" suggestions
81    #[must_use]
82    pub fn find_similar(&self, target: &str) -> Option<String> {
83        let target_lower = target.to_lowercase();
84
85        // Exact match (case-insensitive)
86        for col in &self.available_columns {
87            if col.to_lowercase() == target_lower {
88                return Some(col.clone());
89            }
90        }
91
92        // Prefix match
93        for col in &self.available_columns {
94            if col.to_lowercase().starts_with(&target_lower)
95                || target_lower.starts_with(&col.to_lowercase())
96            {
97                return Some(col.clone());
98            }
99        }
100
101        // Contains match
102        for col in &self.available_columns {
103            if col.to_lowercase().contains(&target_lower)
104                || target_lower.contains(&col.to_lowercase())
105            {
106                return Some(col.clone());
107            }
108        }
109
110        None
111    }
112
113    /// Format the error message with context
114    #[must_use]
115    pub fn format_error(&self) -> String {
116        let mut msg = format!(
117            "Formula error in '{}':\n  Formula: {}\n  Error: {}",
118            self.location, self.formula, self.error
119        );
120
121        if let Some(ref suggestion) = self.suggestion {
122            let _ = write!(msg, "\n  Suggestion: {suggestion}");
123        }
124
125        if !self.available_columns.is_empty() && self.available_columns.len() <= 10 {
126            let _ = write!(
127                msg,
128                "\n  Available columns: {}",
129                self.available_columns.join(", ")
130            );
131        }
132
133        msg
134    }
135}
136
137/// Helper to create formula errors with context
138#[must_use]
139pub fn formula_error(
140    formula: &str,
141    location: &str,
142    error: &str,
143    suggestion: Option<&str>,
144) -> ForgeError {
145    let mut ctx = FormulaErrorContext::new(formula, location, error);
146    if let Some(s) = suggestion {
147        ctx = ctx.with_suggestion(s);
148    }
149    ForgeError::Formula(ctx)
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_formula_error_context_new() {
158        let ctx = FormulaErrorContext::new("=SUM(a)", "test.col", "undefined reference");
159        assert_eq!(ctx.formula, "=SUM(a)");
160        assert_eq!(ctx.location, "test.col");
161        assert_eq!(ctx.error, "undefined reference");
162        assert!(ctx.suggestion.is_none());
163        assert!(ctx.available_columns.is_empty());
164    }
165
166    #[test]
167    fn test_formula_error_context_with_suggestion() {
168        let ctx =
169            FormulaErrorContext::new("=SUM(a)", "test.col", "error").with_suggestion("use SUM(b)");
170        assert_eq!(ctx.suggestion, Some("use SUM(b)".to_string()));
171    }
172
173    #[test]
174    fn test_formula_error_context_with_available_columns() {
175        let ctx = FormulaErrorContext::new("=SUM(a)", "test.col", "error")
176            .with_available_columns(vec!["col1".to_string(), "col2".to_string()]);
177        assert_eq!(ctx.available_columns, vec!["col1", "col2"]);
178    }
179
180    #[test]
181    fn test_formula_error_context_find_similar_exact() {
182        let ctx = FormulaErrorContext::new("=SUM(a)", "test.col", "error")
183            .with_available_columns(vec!["Revenue".to_string(), "Costs".to_string()]);
184
185        // Case-insensitive exact match
186        assert_eq!(ctx.find_similar("revenue"), Some("Revenue".to_string()));
187        assert_eq!(ctx.find_similar("COSTS"), Some("Costs".to_string()));
188    }
189
190    #[test]
191    fn test_formula_error_context_find_similar_prefix() {
192        let ctx = FormulaErrorContext::new("=SUM(a)", "test.col", "error")
193            .with_available_columns(vec!["revenue_total".to_string()]);
194
195        // Prefix match
196        assert_eq!(
197            ctx.find_similar("revenue"),
198            Some("revenue_total".to_string())
199        );
200    }
201
202    #[test]
203    fn test_formula_error_context_find_similar_contains() {
204        let ctx = FormulaErrorContext::new("=SUM(a)", "test.col", "error")
205            .with_available_columns(vec!["total_revenue_ytd".to_string()]);
206
207        // Contains match
208        assert_eq!(
209            ctx.find_similar("revenue"),
210            Some("total_revenue_ytd".to_string())
211        );
212    }
213
214    #[test]
215    fn test_formula_error_context_find_similar_none() {
216        let ctx = FormulaErrorContext::new("=SUM(a)", "test.col", "error")
217            .with_available_columns(vec!["col1".to_string(), "col2".to_string()]);
218
219        // No match
220        assert_eq!(ctx.find_similar("xyz"), None);
221    }
222
223    #[test]
224    fn test_formula_error_context_format_error_basic() {
225        let ctx = FormulaErrorContext::new("=SUM(a)", "test.col", "undefined reference");
226        let msg = ctx.format_error();
227
228        assert!(msg.contains("test.col"));
229        assert!(msg.contains("=SUM(a)"));
230        assert!(msg.contains("undefined reference"));
231        assert!(!msg.contains("Suggestion"));
232        assert!(!msg.contains("Available"));
233    }
234
235    #[test]
236    fn test_formula_error_context_format_error_with_suggestion() {
237        let ctx = FormulaErrorContext::new("=SUM(a)", "test.col", "error")
238            .with_suggestion("Try using SUM(b)");
239        let msg = ctx.format_error();
240
241        assert!(msg.contains("Suggestion: Try using SUM(b)"));
242    }
243
244    #[test]
245    fn test_formula_error_context_format_error_with_columns() {
246        let ctx = FormulaErrorContext::new("=SUM(a)", "test.col", "error")
247            .with_available_columns(vec!["col1".to_string(), "col2".to_string()]);
248        let msg = ctx.format_error();
249
250        assert!(msg.contains("Available columns: col1, col2"));
251    }
252
253    #[test]
254    fn test_formula_error_context_format_error_many_columns() {
255        // More than 10 columns should not be shown
256        let ctx = FormulaErrorContext::new("=SUM(a)", "test.col", "error")
257            .with_available_columns((0..15).map(|i| format!("col{i}")).collect::<Vec<_>>());
258        let msg = ctx.format_error();
259
260        // Should NOT show columns when there are too many
261        assert!(!msg.contains("Available columns"));
262    }
263
264    #[test]
265    fn test_formula_error_helper() {
266        let err = formula_error("=SUM(a)", "test.col", "error", Some("fix it"));
267        if let ForgeError::Formula(ctx) = err {
268            assert_eq!(ctx.formula, "=SUM(a)");
269            assert_eq!(ctx.suggestion, Some("fix it".to_string()));
270        } else {
271            panic!("Expected ForgeError::Formula");
272        }
273    }
274
275    #[test]
276    fn test_formula_error_helper_no_suggestion() {
277        let err = formula_error("=SUM(a)", "test.col", "error", None);
278        if let ForgeError::Formula(ctx) = err {
279            assert!(ctx.suggestion.is_none());
280        } else {
281            panic!("Expected ForgeError::Formula");
282        }
283    }
284
285    #[test]
286    fn test_forge_error_display() {
287        // Test Display implementation for each variant
288        let io_err = ForgeError::IO("file not found".to_string());
289        assert!(io_err.to_string().contains("file not found"));
290
291        let parse_err = ForgeError::Parse("invalid syntax".to_string());
292        assert!(parse_err.to_string().contains("invalid syntax"));
293
294        let eval_err = ForgeError::Eval("division by zero".to_string());
295        assert!(eval_err.to_string().contains("division by zero"));
296
297        let circular_err = ForgeError::CircularDependency("A -> B -> A".to_string());
298        assert!(circular_err.to_string().contains("A -> B -> A"));
299
300        let validation_err = ForgeError::Validation("schema mismatch".to_string());
301        assert!(validation_err.to_string().contains("schema mismatch"));
302
303        let export_err = ForgeError::Export("xlsx write failed".to_string());
304        assert!(export_err.to_string().contains("xlsx write failed"));
305
306        let import_err = ForgeError::Import("xlsx read failed".to_string());
307        assert!(import_err.to_string().contains("xlsx read failed"));
308    }
309
310    #[test]
311    fn test_forge_error_formula_display() {
312        let ctx = FormulaErrorContext::new("=SUM(a)", "test.col", "undefined reference")
313            .with_suggestion("use SUM(b)");
314        let err = ForgeError::Formula(ctx);
315        let msg = err.to_string();
316
317        assert!(msg.contains("test.col"));
318        assert!(msg.contains("=SUM(a)"));
319        assert!(msg.contains("undefined reference"));
320        assert!(msg.contains("use SUM(b)"));
321    }
322
323    #[test]
324    fn test_forge_error_from_io_error() {
325        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
326        let forge_err: ForgeError = io_err.into();
327        assert!(matches!(forge_err, ForgeError::Io(_)));
328    }
329
330    #[test]
331    fn test_forge_error_from_yaml_error() {
332        // Create an invalid YAML to trigger a parse error
333        let invalid_yaml = ":\n  : invalid";
334        let yaml_result: Result<serde_yaml_ng::Value, _> = serde_yaml_ng::from_str(invalid_yaml);
335        assert!(yaml_result.is_err());
336
337        let yaml_err = yaml_result.unwrap_err();
338        let forge_err: ForgeError = yaml_err.into();
339        assert!(matches!(forge_err, ForgeError::Yaml(_)));
340        assert!(forge_err.to_string().contains("YAML parsing error"));
341    }
342}