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 #[error("{}", .0.format_error())]
38 Formula(FormulaErrorContext),
39}
40
41#[derive(Debug, Clone)]
43pub struct FormulaErrorContext {
44 pub formula: String,
46 pub location: String,
48 pub error: String,
50 pub suggestion: Option<String>,
52 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 #[must_use]
82 pub fn find_similar(&self, target: &str) -> Option<String> {
83 let target_lower = target.to_lowercase();
84
85 for col in &self.available_columns {
87 if col.to_lowercase() == target_lower {
88 return Some(col.clone());
89 }
90 }
91
92 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 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 #[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#[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 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 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 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 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 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 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 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 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}