Skip to main content

datui_lib/
error_display.rs

1//! User-facing error message formatting.
2//!
3//! Uses typed error matching (PolarsError variants, io::ErrorKind) rather than
4//! string parsing to produce actionable, implementation-agnostic messages.
5
6use polars::prelude::PolarsError;
7use std::io;
8use std::path::Path;
9
10/// Format a PolarsError as a user-facing message by matching on its variant.
11pub fn user_message_from_polars(err: &PolarsError) -> String {
12    use polars::prelude::PolarsError as PE;
13
14    match err {
15        PE::ColumnNotFound(msg) => format!(
16            "Column not found: {}. Check spelling and that the column exists.",
17            msg
18        ),
19        PE::Duplicate(msg) => format!(
20            "Duplicate column in result: {}. Use aliases to rename columns, e.g. `select my_date: timestamp.date`",
21            msg
22        ),
23        PE::IO { error, msg } => {
24            user_message_from_io(error.as_ref(), msg.as_ref().map(|m| m.as_ref()))
25        }
26        PE::NoData(msg) => format!("No data: {}", msg),
27        PE::SchemaMismatch(msg) => format!("Schema mismatch: {}", msg),
28        PE::ShapeMismatch(msg) => format!("Row shape mismatch: {}", msg),
29        PE::InvalidOperation(msg) => format!("Operation not allowed: {}", msg),
30        PE::OutOfBounds(msg) => format!("Index or row out of bounds: {}", msg),
31        PE::SchemaFieldNotFound(msg) => format!("Schema field not found: {}", msg),
32        PE::StructFieldNotFound(msg) => format!("Struct field not found: {}", msg),
33        PE::ComputeError(msg) => simplify_compute_message(msg),
34        PE::AssertionError(msg) => format!("Assertion failed: {}", msg),
35        PE::StringCacheMismatch(msg) => format!("String cache mismatch: {}", msg),
36        PE::SQLInterface(msg) | PE::SQLSyntax(msg) => msg.to_string(),
37        PE::Context { error, msg } => {
38            let inner = user_message_from_polars(error);
39            format!("{}: {}", msg, inner)
40        }
41        #[allow(unreachable_patterns)]
42        _ => err.to_string(),
43    }
44}
45
46/// Format an io::Error as a user-facing message by matching on ErrorKind.
47pub fn user_message_from_io(err: &io::Error, context: Option<&str>) -> String {
48    use std::io::ErrorKind;
49
50    let base: String = match err.kind() {
51        ErrorKind::NotFound => "File or directory not found.".to_string(),
52        ErrorKind::PermissionDenied => "Permission denied. Check read access.".to_string(),
53        ErrorKind::ConnectionRefused => "Connection refused.".to_string(),
54        ErrorKind::ConnectionReset => "Connection reset.".to_string(),
55        ErrorKind::InvalidData | ErrorKind::InvalidInput => {
56            "Invalid or corrupted data.".to_string()
57        }
58        ErrorKind::UnexpectedEof => "Unexpected end of file.".to_string(),
59        ErrorKind::WouldBlock => "Operation would block.".to_string(),
60        ErrorKind::Interrupted => "Operation interrupted.".to_string(),
61        ErrorKind::OutOfMemory => "Out of memory.".to_string(),
62        ErrorKind::Other => {
63            let msg = err.to_string();
64            if msg.contains("No space left") || msg.contains("space left") {
65                return "No space left on device. Free up disk space and try again.".to_string();
66            }
67            if msg.contains("Is a directory") {
68                return "Path is a directory, not a file.".to_string();
69            }
70            return if context.is_some() {
71                format!("I/O error: {}", msg)
72            } else {
73                msg
74            };
75        }
76        _ => err.to_string(),
77    };
78
79    if let Some(ctx) = context {
80        if !ctx.is_empty() {
81            format!("{} {}", base, ctx)
82        } else {
83            base
84        }
85    } else {
86        base
87    }
88}
89
90/// Classification for consumers (e.g. Python binding) that map to native exception types.
91/// Keeps error-handling logic in one place instead of duplicating in each binding.
92#[derive(Debug, Clone, Copy)]
93pub enum ErrorKindForPython {
94    FileNotFound,
95    PermissionDenied,
96    Other,
97}
98
99/// Classify a report and return a kind plus user-facing message. Used by the Python binding
100/// to raise FileNotFoundError, PermissionDenied, or RuntimeError without duplicating chain-walk logic.
101pub fn error_for_python(report: &color_eyre::eyre::Report) -> (ErrorKindForPython, String) {
102    use std::io::ErrorKind;
103    for cause in report.chain() {
104        if let Some(io_err) = cause.downcast_ref::<io::Error>() {
105            let kind = match io_err.kind() {
106                ErrorKind::NotFound => ErrorKindForPython::FileNotFound,
107                ErrorKind::PermissionDenied => ErrorKindForPython::PermissionDenied,
108                _ => ErrorKindForPython::Other,
109            };
110            let msg = io_err.to_string();
111            return (kind, msg);
112        }
113    }
114    let display = report.to_string();
115    let msg = display
116        .lines()
117        .next()
118        .map(str::trim)
119        .unwrap_or("An error occurred")
120        .to_string();
121    (ErrorKindForPython::Other, msg)
122}
123
124/// Format a color_eyre Report by downcasting to known error types.
125/// Walks the cause chain to find PolarsError or io::Error.
126pub fn user_message_from_report(report: &color_eyre::eyre::Report, path: Option<&Path>) -> String {
127    for cause in report.chain() {
128        if let Some(pe) = cause.downcast_ref::<PolarsError>() {
129            let msg = user_message_from_polars(pe);
130            return if let Some(p) = path {
131                format!("Failed to load {}: {}", p.display(), msg)
132            } else {
133                msg
134            };
135        }
136        if let Some(io_err) = cause.downcast_ref::<io::Error>() {
137            let msg = user_message_from_io(io_err, None);
138            return if let Some(p) = path {
139                format!("Failed to load {}: {}", p.display(), msg)
140            } else {
141                msg
142            };
143        }
144    }
145
146    // Fallback: use first line of display to avoid long tracebacks
147    let display = report.to_string();
148    let first_line = display.lines().next().unwrap_or("An error occurred");
149    let trimmed = first_line.trim();
150    if let Some(p) = path {
151        format!("Failed to load {}: {}", p.display(), trimmed)
152    } else {
153        trimmed.to_string()
154    }
155}
156
157/// Light cleanup for ComputeError messages: strip Polars-internal phrasing.
158fn simplify_compute_message(msg: &str) -> String {
159    if is_csv_parse_type_error(msg) {
160        return short_csv_parse_error_message(msg);
161    }
162    crate::query::sanitize_query_error(msg)
163}
164
165/// True if this looks like Polars' "could not parse X as dtype Y" / "invalid primitive value" CSV error.
166fn is_csv_parse_type_error(msg: &str) -> bool {
167    let m = msg.to_lowercase();
168    (m.contains("could not parse") && m.contains("as dtype"))
169        || m.contains("invalid primitive value")
170}
171
172/// Extract column name from Polars message like "at column 'name'" or "at column \"name\"".
173fn extract_csv_parse_column(msg: &str) -> Option<String> {
174    let m = msg.to_lowercase();
175    for (needle, quote) in [("at column '", '\''), ("at column \"", '"')] {
176        if let Some(start) = m.find(needle) {
177            let after = &msg[start + needle.len()..];
178            let end = after.find(quote)?;
179            let name = after[..end].trim();
180            if !name.is_empty() {
181                return Some(name.to_string());
182            }
183        }
184    }
185    None
186}
187
188fn short_csv_parse_error_message(raw: &str) -> String {
189    let col = extract_csv_parse_column(raw);
190    let first = match &col {
191        Some(c) => format!(
192            "CSV parse error in column '{}': a value didn't match the inferred type.",
193            c
194        ),
195        None => "CSV parse error: a value didn't match the inferred column type.".to_string(),
196    };
197    format!(
198        "{}\n\
199         Try: --infer-schema-length 1000\n\
200              --null-value <value>  (treat as null)\n\
201              --ignore-errors true  (skip bad rows)",
202        first
203    )
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_user_message_from_io_not_found() {
212        let err = io::Error::new(io::ErrorKind::NotFound, "No such file");
213        let msg = user_message_from_io(&err, None);
214        assert!(
215            msg.contains("not found"),
216            "expected 'not found', got: {}",
217            msg
218        );
219    }
220
221    #[test]
222    fn test_user_message_from_io_permission_denied() {
223        let err = io::Error::new(io::ErrorKind::PermissionDenied, "Permission denied");
224        let msg = user_message_from_io(&err, None);
225        assert!(
226            msg.to_lowercase().contains("permission"),
227            "expected 'permission', got: {}",
228            msg
229        );
230    }
231
232    #[test]
233    fn test_user_message_from_polars_column_not_found() {
234        use polars::prelude::PolarsError;
235        let err = PolarsError::ColumnNotFound("foo".into());
236        let msg = user_message_from_polars(&err);
237        assert!(msg.contains("foo"), "expected 'foo', got: {}", msg);
238        assert!(
239            msg.contains("Column not found"),
240            "expected column not found, got: {}",
241            msg
242        );
243    }
244
245    #[test]
246    fn test_user_message_from_polars_duplicate() {
247        use polars::prelude::PolarsError;
248        let err = PolarsError::Duplicate("bar".into());
249        let msg = user_message_from_polars(&err);
250        assert!(
251            msg.contains("Duplicate"),
252            "expected 'Duplicate', got: {}",
253            msg
254        );
255        assert!(msg.contains("alias"), "expected alias hint, got: {}", msg);
256    }
257
258    #[test]
259    fn test_simplify_compute_message_alias_hint() {
260        let raw = "projections contained duplicate: 'x'. Try renaming with .alias(\"name\")";
261        let msg = simplify_compute_message(raw);
262        assert!(
263            !msg.contains(".alias("),
264            "should strip .alias( hint: {}",
265            msg
266        );
267        assert!(
268            msg.contains("Use aliases"),
269            "expected alias suggestion: {}",
270            msg
271        );
272    }
273
274    #[test]
275    fn test_simplify_compute_message_csv_parse_error() {
276        let raw = "could not parse `N/A` as dtype `i64` at column 'column' (column number 1)\n\n\
277            The current offset in the file is 292 bytes.\n\n\
278            You might want to try: ...\n\
279            Original error: ```invalid primitive value found during CSV parsing```";
280        let msg = simplify_compute_message(raw);
281        assert!(
282            msg.contains("CSV parse error"),
283            "expected short CSV message: {}",
284            msg
285        );
286        assert!(
287            msg.contains("column 'column'"),
288            "expected offending column in message: {}",
289            msg
290        );
291        assert!(
292            msg.contains("--infer-schema-length"),
293            "expected CLI hint: {}",
294            msg
295        );
296        assert!(
297            msg.contains("--null-value"),
298            "expected null-value hint: {}",
299            msg
300        );
301        assert!(
302            !msg.contains("Original error"),
303            "should not regurgitate Polars: {}",
304            msg
305        );
306    }
307}