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    crate::query::sanitize_query_error(msg)
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn test_user_message_from_io_not_found() {
168        let err = io::Error::new(io::ErrorKind::NotFound, "No such file");
169        let msg = user_message_from_io(&err, None);
170        assert!(
171            msg.contains("not found"),
172            "expected 'not found', got: {}",
173            msg
174        );
175    }
176
177    #[test]
178    fn test_user_message_from_io_permission_denied() {
179        let err = io::Error::new(io::ErrorKind::PermissionDenied, "Permission denied");
180        let msg = user_message_from_io(&err, None);
181        assert!(
182            msg.to_lowercase().contains("permission"),
183            "expected 'permission', got: {}",
184            msg
185        );
186    }
187
188    #[test]
189    fn test_user_message_from_polars_column_not_found() {
190        use polars::prelude::PolarsError;
191        let err = PolarsError::ColumnNotFound("foo".into());
192        let msg = user_message_from_polars(&err);
193        assert!(msg.contains("foo"), "expected 'foo', got: {}", msg);
194        assert!(
195            msg.contains("Column not found"),
196            "expected column not found, got: {}",
197            msg
198        );
199    }
200
201    #[test]
202    fn test_user_message_from_polars_duplicate() {
203        use polars::prelude::PolarsError;
204        let err = PolarsError::Duplicate("bar".into());
205        let msg = user_message_from_polars(&err);
206        assert!(
207            msg.contains("Duplicate"),
208            "expected 'Duplicate', got: {}",
209            msg
210        );
211        assert!(msg.contains("alias"), "expected alias hint, got: {}", msg);
212    }
213
214    #[test]
215    fn test_simplify_compute_message_alias_hint() {
216        let raw = "projections contained duplicate: 'x'. Try renaming with .alias(\"name\")";
217        let msg = simplify_compute_message(raw);
218        assert!(
219            !msg.contains(".alias("),
220            "should strip .alias( hint: {}",
221            msg
222        );
223        assert!(
224            msg.contains("Use aliases"),
225            "expected alias suggestion: {}",
226            msg
227        );
228    }
229}