Skip to main content

datui_lib/
error_display.rs

1//! User-facing error messages, matched on types (PolarsError variants, io::ErrorKind)
2//! rather than strings.
3
4use polars::prelude::PolarsError;
5use std::io;
6use std::path::{Path, PathBuf};
7
8/// An error reading a file, said as every reader error is:
9/// `"<path>": <what went wrong>. <what to do>.` ([`file_message`]).
10#[derive(Debug)]
11pub struct FileError {
12    path: PathBuf,
13    /// Line and column, one-based, when the problem is at one place in the file's text.
14    at: Option<(usize, usize)>,
15    what: String,
16    /// What it was told, so a cause (a missing file) is still found under it.
17    source: Option<color_eyre::eyre::Report>,
18}
19
20impl FileError {
21    pub fn new(path: &Path, what: impl Into<String>) -> Self {
22        Self {
23            path: path.to_path_buf(),
24            at: None,
25            what: what.into(),
26            source: None,
27        }
28    }
29
30    /// The problem at `line`:`column` (one-based; line 0 is nowhere in particular),
31    /// said as a compiler does: `"spec.toml":3:7: …`.
32    pub fn at(path: &Path, line: usize, column: usize, what: impl Into<String>) -> Self {
33        Self {
34            at: (line > 0).then_some((line, column)),
35            ..Self::new(path, what)
36        }
37    }
38}
39
40impl std::fmt::Display for FileError {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.write_str(&located_message(Some(&self.path), self.at, &self.what))
43    }
44}
45
46impl std::error::Error for FileError {
47    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
48        self.source
49            .as_ref()
50            .map(|e| &**e as &(dyn std::error::Error + 'static))
51    }
52}
53
54/// `what` went wrong reading `path`, in the one shape reader errors take:
55/// `"<path>": <what went wrong>. <what to do>.` Sentence case, its first line ended
56/// with a full stop, the file named once.
57pub fn file_message(path: &Path, what: &str) -> String {
58    located_message(Some(path), None, what)
59}
60
61/// Whether `what` starts by naming a file, as [`file_message`] does: `"<path>": ` or
62/// `"<path>":3:7: `. A shard's error said under its dataset, or a spec's under the
63/// file it was to read, keeps the file it names.
64fn names_a_file(what: &str) -> bool {
65    let Some(quoted) = what.strip_prefix('"') else {
66        return false;
67    };
68    let Some((_, after)) = quoted.split_once("\":") else {
69        return false;
70    };
71    after.starts_with(' ') || after.starts_with(|c: char| c.is_ascii_digit())
72}
73
74/// [`file_message`], with the line and column the problem is at, when it is at one
75/// place: `"spec.toml":3:7: <what went wrong>.` Without a path, the location and the
76/// sentence alone.
77pub fn located_message(path: Option<&Path>, at: Option<(usize, usize)>, what: &str) -> String {
78    let mut said = String::with_capacity(what.len() + 16);
79    let mut what = what.trim();
80    if let Some(path) = path {
81        if names_a_file(what) {
82            return what.to_string();
83        }
84        let named = path.display().to_string();
85        // A message that already names the file, as a path does, is not named twice.
86        what = what.strip_prefix(&format!("{named}: ")).unwrap_or(what);
87        said.push('"');
88        said.push_str(&named);
89        said.push('"');
90        said.push(':');
91        if at.is_none() {
92            said.push(' ');
93        }
94    }
95    if let Some((line, column)) = at {
96        said.push_str(&format!("{line}:{column}: "));
97    }
98    said.push_str(&sentence(what));
99    said
100}
101
102/// `what` as a sentence: its first letter capitalized and its first line ended with a
103/// full stop. The lines after it are kept as they are.
104pub fn sentence(what: &str) -> String {
105    let what = what.trim();
106    let (first, rest) = what.split_once('\n').unwrap_or((what, ""));
107    let first = first.trim_end();
108    let mut said = String::with_capacity(what.len() + 1);
109    let mut chars = first.chars();
110    if let Some(c) = chars.next() {
111        // A key or a name is written as the user wrote it, to be searched for.
112        if starts_with_a_key(first) {
113            said.push(c);
114        } else {
115            said.extend(c.to_uppercase());
116        }
117        said.push_str(chars.as_str());
118    }
119    if !first.ends_with(['.', '?', '!']) {
120        said.push('.');
121    }
122    if !rest.is_empty() {
123        said.push('\n');
124        said.push_str(rest);
125    }
126    said
127}
128
129/// Whether `what` starts with a key, a path or a name rather than a word: its first
130/// word followed by `:` (`type: expected …`), or holding `.`, `_`, `=`, a digit, a
131/// quote or a backtick (`tags.nine`, `"day"`). [`sentence`] leaves it as written.
132pub fn starts_with_a_key(what: &str) -> bool {
133    let word = what.split_whitespace().next().unwrap_or_default();
134    word.ends_with(':')
135        || word.contains(|c: char| matches!(c, '.' | '_' | '=' | '"' | '`') || c.is_ascii_digit())
136}
137
138/// How a read says a file the open listed is gone; see [`says_gone_since_opened`].
139const GONE_SINCE_OPENED: &str = "A file was removed or replaced after the dataset was opened";
140
141/// Whether a read's message says a file the open listed is gone, which a reopen
142/// mends: the app offers one.
143pub(crate) fn says_gone_since_opened(message: &str) -> bool {
144    message.contains(GONE_SINCE_OPENED)
145}
146
147/// A file the open listed that the store no longer has: its publisher removed or
148/// replaced it (NOAA rewrites a year's partition under new names). The scan read the
149/// listing once, so only a reopen sees the new files.
150#[cfg(feature = "cloud")]
151fn gone_since_opened(err: &io::Error) -> Option<String> {
152    let store = err
153        .get_ref()?
154        .downcast_ref::<polars::io::cloud::PolarsObjectStoreError>()?;
155    let object_store::Error::NotFound { path, .. } = &store.source else {
156        return None;
157    };
158    // Named by its whole URL: the store's base URL is whichever path first built the
159    // store Polars caches per bucket, so only its scheme and bucket say this dataset's.
160    let base = store.base_url.as_str();
161    let (scheme, rest) = base.split_once("://")?;
162    let bucket = rest.split('/').next().unwrap_or(rest);
163    let file = format!("{scheme}://{bucket}/{}", path.trim_start_matches('/'));
164    Some(gone_since_opened_message(&file))
165}
166
167/// What a read says of `file`, listed by the open and gone or changed since.
168#[cfg(feature = "cloud")]
169pub(crate) fn gone_since_opened_message(file: &str) -> String {
170    format!("{GONE_SINCE_OPENED}: {file}. Reopen the dataset to read the current files.")
171}
172
173/// What an object store said about a file in it: a missing object, refused access, or
174/// the store's own words, each with what to check.
175#[cfg(feature = "cloud")]
176pub fn store_message(err: &object_store::Error) -> String {
177    use object_store::Error as E;
178    match err {
179        E::NotFound { .. } => "No object there. Check the URL.".to_string(),
180        E::PermissionDenied { .. } => "Access denied. Check the credentials.".to_string(),
181        E::Unauthenticated { .. } => {
182            "The store did not accept the credentials. Check them.".to_string()
183        }
184        e => format!(
185            "Could not read it: {}. Check the credentials and the URL.",
186            e.to_string().trim_end_matches('.')
187        ),
188    }
189}
190
191/// What an HTTP request for `url` came to, when it failed: the server's answer, or
192/// that there was none, naming the host either way.
193#[cfg(any(feature = "http", feature = "cloud"))]
194pub fn http_message(url: &str, err: &ureq::Error) -> String {
195    let host = url_host(url);
196    match err {
197        ureq::Error::StatusCode(code @ (404 | 410)) => {
198            format!("The server at {host} returned {code}: the file may have moved.")
199        }
200        ureq::Error::StatusCode(code @ (401 | 403)) => {
201            format!("The server at {host} refused it ({code}). Check the URL and its access.")
202        }
203        ureq::Error::StatusCode(code @ 500..=599) => {
204            format!("The server at {host} returned {code}: try again later.")
205        }
206        ureq::Error::StatusCode(code) => format!("The server at {host} returned {code}."),
207        _ if http_unanswered(err) => format!("No answer from {host}."),
208        e => format!(
209            "Could not read it: {}.",
210            e.to_string().trim_end_matches('.')
211        ),
212    }
213}
214
215/// An HTTP(S) file a request settled cannot be had.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct HttpGone {
218    /// What its home row says in place of a size: `HTTP 404`, or `no answer`.
219    pub cell: String,
220    /// The sentence: [`http_message`].
221    pub message: String,
222}
223
224/// `err` as [`HttpGone`] when it settles that `url` cannot be had: the file is not
225/// there, or no server answered. `None` for what an open might still get past, such as
226/// a refused HEAD or a server error.
227#[cfg(any(feature = "http", feature = "cloud"))]
228pub fn http_gone(url: &str, err: &ureq::Error) -> Option<HttpGone> {
229    let cell = match err {
230        ureq::Error::StatusCode(code @ (404 | 410)) => format!("HTTP {code}"),
231        _ if http_unanswered(err) => "no answer".to_string(),
232        _ => return None,
233    };
234    Some(HttpGone {
235        cell,
236        message: http_message(url, err),
237    })
238}
239
240/// No server answered: its name did not resolve, nothing listened, or it said nothing
241/// in time.
242#[cfg(any(feature = "http", feature = "cloud"))]
243fn http_unanswered(err: &ureq::Error) -> bool {
244    matches!(
245        err,
246        ureq::Error::HostNotFound
247            | ureq::Error::ConnectionFailed
248            | ureq::Error::Timeout(_)
249            | ureq::Error::Io(_)
250    )
251}
252
253/// The host of `url` for a message, with its port when it names one; the URL itself
254/// when it has no host.
255#[cfg(any(feature = "http", feature = "cloud"))]
256fn url_host(url: &str) -> String {
257    url::Url::parse(url)
258        .ok()
259        .and_then(|u| {
260            let host = u.host_str()?.to_string();
261            Some(match u.port() {
262                Some(port) => format!("{host}:{port}"),
263                None => host,
264            })
265        })
266        .unwrap_or_else(|| url.to_string())
267}
268
269/// `err`, from reading `path`, named by it ([`FileError`]) unless it already is.
270pub fn in_file(path: &Path, err: color_eyre::eyre::Report) -> color_eyre::eyre::Report {
271    if err.downcast_ref::<FileError>().is_some() {
272        return err;
273    }
274    let what = report_message(cfg!(windows), &err, None);
275    color_eyre::eyre::Report::new(FileError {
276        source: Some(err),
277        ..FileError::new(path, what)
278    })
279}
280
281/// Format a PolarsError as a user-facing message by matching on its variant.
282pub fn user_message_from_polars(err: &PolarsError) -> String {
283    // Polars' words, then tidying every message needs: the query plan cut off, and a union
284    // schema clash said as a directory problem. Around the match, so no arm skips it.
285    let said = polars_words(err);
286    if is_union_schema_error(&said) {
287        return union_schema_message(&said);
288    }
289    if let Some(differ) = files_columns_differ(&said) {
290        return differ;
291    }
292    if let Some(none) = nothing_matched(&said) {
293        return none;
294    }
295    let said = without_the_query_plan(&said);
296    let (first, rest) = said.split_once('\n').unwrap_or((&said, ""));
297    match rust_names_said_plainly(first) {
298        Some(plain) if rest.is_empty() => plain,
299        Some(plain) => format!("{plain}\n{rest}"),
300        None => said,
301    }
302}
303
304/// A file reader's words that are a Rust name (`Out-of-spec: InvalidFooter`,
305/// `OutOfSpec`, `InvalidUtf8 at character 0`), said in English.
306fn rust_names_said_plainly(msg: &str) -> Option<String> {
307    // `InvalidFooter` as `invalid footer`.
308    let words = |name: &str| {
309        let mut out = String::new();
310        for (i, c) in name.chars().enumerate() {
311            if c.is_uppercase() && i > 0 {
312                out.push(' ');
313            }
314            out.extend(c.to_lowercase());
315        }
316        out
317    };
318    let msg = msg.trim();
319    let is_name = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric());
320    let damaged = |what: Option<String>| {
321        let what = what.map(|w| format!(" ({w})")).unwrap_or_default();
322        format!(
323            "The file is not laid out as its format says{what}: it is damaged, or another \
324             format. --format names the format to read it as."
325        )
326    };
327    if msg == "OutOfSpec" {
328        return Some(damaged(None));
329    }
330    const SPEC: &str = "out-of-spec: ";
331    if let Some(name) = msg
332        .get(..SPEC.len())
333        .filter(|p| p.eq_ignore_ascii_case(SPEC))
334        .map(|_| &msg[SPEC.len()..])
335        && is_name(name)
336    {
337        return Some(damaged(Some(words(name))));
338    }
339    let at = msg.strip_prefix("InvalidUtf8")?;
340    Some(format!("The file is not UTF-8 text{at}."))
341}
342
343/// A scan whose path expanded to no files, said plainly instead of Polars' internals. A
344/// local path is a pattern only when no file has its name (`source::expands_as_glob`).
345fn nothing_matched(msg: &str) -> Option<String> {
346    let (_, input) = msg.split_once("expanded paths were empty")?;
347    // The paths are Debug-printed, `paths: [PlRefPath { inner: "…" }]`: look inside
348    // the quotes, past the list's own brackets.
349    let pattern = input.contains("glob: true")
350        && input.split("inner: \"").skip(1).any(|p| {
351            p.split('"')
352                .next()
353                .is_some_and(|p| p.contains(['*', '?', '[']))
354        });
355    Some(if pattern {
356        "No files match this pattern.".to_string()
357    } else {
358        "No files found there.".to_string()
359    })
360}
361
362fn polars_words(err: &PolarsError) -> String {
363    use polars::prelude::PolarsError as PE;
364
365    match err {
366        PE::ColumnNotFound(msg) => format!(
367            "Column not found: {}. Check spelling and that the column exists.",
368            msg
369        ),
370        PE::Duplicate(msg) => format!(
371            "Duplicate column in result: {}. Use aliases to rename columns, e.g. `select my_date: timestamp.date`",
372            msg
373        ),
374        PE::IO { error, msg } => {
375            user_message_from_io(error.as_ref(), msg.as_ref().map(|m| m.as_ref()))
376        }
377        PE::NoData(msg) => format!("No data: {}", msg),
378        PE::SchemaMismatch(msg) => format!("Schema mismatch: {}", msg),
379        PE::ShapeMismatch(msg) => format!("Row shape mismatch: {}", msg),
380        PE::InvalidOperation(msg) => format!("Operation not allowed: {}", msg),
381        PE::OutOfBounds(msg) => format!("Index or row out of bounds: {}", msg),
382        PE::SchemaFieldNotFound(msg) => format!("Schema field not found: {}", msg),
383        PE::StructFieldNotFound(msg) => format!("Struct field not found: {}", msg),
384        PE::ComputeError(msg) => simplify_compute_message(msg),
385        PE::AssertionError(msg) => format!("Assertion failed: {}", msg),
386        PE::StringCacheMismatch(msg) => format!("String cache mismatch: {}", msg),
387        PE::SQLInterface(msg) | PE::SQLSyntax(msg) => msg.to_string(),
388        PE::Context { error, msg } => {
389            let inner = user_message_from_polars(error);
390            format!("{}: {}", msg, inner)
391        }
392        #[allow(unreachable_patterns)]
393        _ => err.to_string(),
394    }
395}
396
397/// Values that would not convert, from a strict CAST or a STRPTIME, as the failed
398/// run reported them: Polars counts the failures in the batch it was converting and
399/// quotes a few.
400#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct ConversionFailure {
402    pub column: String,
403    /// The target type as Polars spells it: `i32`, `date`, `datetime[μs]`.
404    pub to: String,
405    pub failed: usize,
406    /// Values in the batch the failures were counted in. The whole column only when
407    /// it fit in one batch.
408    pub checked: usize,
409    /// Distinct offending values, at most three, without their quotes.
410    pub examples: Vec<String>,
411    /// Parsed with a format (STRPTIME) rather than cast.
412    pub parsing: bool,
413}
414
415/// The conversion that failed, if that is what `err` is.
416pub fn conversion_failure(err: &PolarsError) -> Option<ConversionFailure> {
417    let mut parsing = false;
418    let mut err = err;
419    loop {
420        match err {
421            PolarsError::ExprContext { error, expr } => {
422                parsing |= expr.contains("strptime") || expr.contains("to_date");
423                err = error;
424            }
425            PolarsError::Context { error, .. } => err = error,
426            _ => break,
427        }
428    }
429    let PolarsError::InvalidOperation(msg) = err else {
430        return None;
431    };
432    parse_conversion(msg, parsing)
433}
434
435/// `conversion from `str` to `i32` failed in column 'FT' for 8 out of 380 values:
436/// ["n/a", "n/a", … "n/a"]`, the shape `handle_casting_failures` writes.
437fn parse_conversion(msg: &str, parsing: bool) -> Option<ConversionFailure> {
438    let rest = msg.strip_prefix("conversion from `")?;
439    let (_, rest) = rest.split_once("` to `")?;
440    let (to, rest) = rest.split_once("` failed in column '")?;
441    let (column, rest) = rest.split_once("' for ")?;
442    let (failed, rest) = rest.split_once(" out of ")?;
443    let (checked, rest) = rest.split_once(" values: ")?;
444    let list = rest.lines().next().unwrap_or("");
445    let list = list.strip_prefix('[').unwrap_or(list);
446    let list = list.strip_suffix(']').unwrap_or(list);
447    let mut examples: Vec<String> = Vec::new();
448    for item in list.split(", ") {
449        // The truncation marker Polars puts before the last value.
450        let item = item.trim_start_matches('…').trim();
451        let item = item
452            .strip_prefix('"')
453            .and_then(|i| i.strip_suffix('"'))
454            .unwrap_or(item);
455        if !item.is_empty() && !examples.iter().any(|e| e == item) && examples.len() < 3 {
456            examples.push(item.to_string());
457        }
458    }
459    Some(ConversionFailure {
460        column: column.to_string(),
461        to: to.to_string(),
462        failed: failed.trim().parse().ok()?,
463        checked: checked.trim().parse().ok()?,
464        examples,
465        parsing,
466    })
467}
468
469impl ConversionFailure {
470    /// What a SQL writer can act on: the column, how many values, a few, and SQL to get past
471    /// them. `rows` is `df`'s height if known; "N of M" only when Polars checked all,
472    /// otherwise a lower bound from the batch it stopped in.
473    pub fn sql_message(&self, rows: Option<usize>) -> String {
474        let column = crate::query::sql_assist::sql_name(&self.column);
475        let exact = rows == Some(self.checked);
476        let one = !exact && self.failed == 1;
477        let temporal = self.to == "date" || self.to == "time" || self.to.starts_with("datetime");
478        let (many, single) = if temporal && self.parsing {
479            ("do not match the format", "does not match the format")
480        } else if self.to == "date" {
481            (
482                "are not dates written YYYY-MM-DD",
483                "is not a date written YYYY-MM-DD",
484            )
485        } else if self.to == "time" {
486            (
487                "are not times written HH:MM:SS",
488                "is not a time written HH:MM:SS",
489            )
490        } else if temporal {
491            (
492                "are not timestamps written YYYY-MM-DD HH:MM:SS",
493                "is not a timestamp written YYYY-MM-DD HH:MM:SS",
494            )
495        } else if self.to.starts_with('i') || self.to.starts_with('u') {
496            ("are not whole numbers", "is not a whole number")
497        } else if self.to.starts_with('f') || self.to.starts_with("decimal") {
498            ("are not numbers", "is not a number")
499        } else if self.to == "bool" {
500            ("are not true or false", "is not true or false")
501        } else {
502            ("cannot be converted", "cannot be converted")
503        };
504        let quoted: Vec<String> = self.examples.iter().map(|e| format!("\"{e}\"")).collect();
505        let such_as = match quoted.as_slice() {
506            [] => String::new(),
507            [one] => format!(", such as {one}"),
508            [init @ .., last] => format!(", such as {} and {last}", init.join(", ")),
509        };
510        let lead = if exact {
511            format!(
512                "{column}: {} of {} values {many}{such_as}.",
513                crate::numfmt::group_chrome(self.failed),
514                crate::numfmt::group_chrome(self.checked)
515            )
516        } else if one {
517            format!("At least 1 value in {column} {single}{such_as}.")
518        } else {
519            format!(
520                "At least {} values in {column} {many}{such_as}.",
521                crate::numfmt::group_chrome(self.failed)
522            )
523        };
524        let hint = if temporal && self.parsing {
525            format!(
526                "Try: a format that fits them all, or trim the text first with \
527                 SUBSTR({column}, 1, n) or REPLACE({column}, 'text', '')."
528            )
529        } else if temporal {
530            format!(
531                "Try: STRPTIME({column}, '%d/%m/%Y') with the format the values are \
532                 written in."
533            )
534        } else {
535            let sql_type = sql_type_for(&self.to);
536            format!(
537                "Try: TRY_CAST({column} AS {sql_type}) to read them as null, or clean \
538                 the text first with REPLACE or SUBSTR."
539            )
540        };
541        format!("{lead}\n{hint}")
542    }
543}
544
545/// The SQL type name for a Polars one, for a TRY_CAST suggestion.
546fn sql_type_for(polars: &str) -> &'static str {
547    match polars {
548        "i8" => "TINYINT",
549        "i16" => "SMALLINT",
550        "i32" => "INT",
551        "i64" => "BIGINT",
552        "i128" => "HUGEINT",
553        "u8" => "UTINYINT",
554        "u16" => "USMALLINT",
555        "u32" => "UINTEGER",
556        "u64" => "UBIGINT",
557        "f32" => "REAL",
558        "f64" => "DOUBLE",
559        "bool" => "BOOLEAN",
560        t if t.starts_with("decimal") => "DECIMAL",
561        _ => "VARCHAR",
562    }
563}
564
565/// A SQL error as the query prompt shows it: a failed conversion in datui's words,
566/// anything else as Polars says it.
567pub fn sql_error_message(err: &PolarsError, rows: Option<usize>) -> String {
568    match conversion_failure(err) {
569        Some(failure) => failure.sql_message(rows),
570        None => user_message_from_polars(err),
571    }
572}
573
574/// What a file another program holds says after its name.
575const HELD: &str =
576    "is open in another program that does not allow reading it; close it there and reopen";
577
578/// Whether `err` is Windows refusing a file another program holds: a sharing violation
579/// (32, e.g. a spreadsheet open) or locked region (33). Polars rewraps opens keeping
580/// the OS text but not the code, so the text is read too. Other OSes: never.
581pub fn held_by_another_program(err: &io::Error) -> bool {
582    held_on(cfg!(windows), err)
583}
584
585fn held_on(windows: bool, err: &io::Error) -> bool {
586    windows && (matches!(err.raw_os_error(), Some(32 | 33)) || says_held(&err.to_string()))
587}
588
589/// Whether an error's text carries the code of a file another program holds.
590fn says_held(text: &str) -> bool {
591    text.contains("(os error 32)") || text.contains("(os error 33)")
592}
593
594/// Whether anything in `report` is a file another program holds: an `io::Error`, one
595/// inside a Polars error, or, on `windows`, the text of one an error turned into words.
596fn report_held(windows: bool, report: &color_eyre::eyre::Report) -> bool {
597    fn polars_held(windows: bool, err: &PolarsError) -> bool {
598        match err {
599            PolarsError::IO { error, .. } => held_on(windows, error),
600            PolarsError::Context { error, .. } => polars_held(windows, error),
601            _ => false,
602        }
603    }
604    windows
605        && report.chain().any(|cause| {
606            cause
607                .downcast_ref::<io::Error>()
608                .is_some_and(|e| held_on(windows, e))
609                || cause
610                    .downcast_ref::<PolarsError>()
611                    .is_some_and(|e| polars_held(windows, e))
612                || says_held(&cause.to_string())
613        })
614}
615
616/// What a file another program holds says, named by `path` when it is known.
617fn held_message(path: Option<&Path>) -> String {
618    match path {
619        Some(path) => file_message(path, &format!("the file {HELD}")),
620        None => format!("The file {HELD}."),
621    }
622}
623
624/// Format an io::Error as a user-facing message by matching on ErrorKind.
625pub fn user_message_from_io(err: &io::Error, context: Option<&str>) -> String {
626    use std::io::ErrorKind;
627
628    if held_by_another_program(err) {
629        return held_message(None);
630    }
631    #[cfg(feature = "cloud")]
632    if let Some(gone) = gone_since_opened(err) {
633        return gone;
634    }
635    let base: String = match err.kind() {
636        ErrorKind::NotFound => "File or directory not found.".to_string(),
637        ErrorKind::PermissionDenied => "Permission denied. Check read access.".to_string(),
638        ErrorKind::ConnectionRefused => "Connection refused.".to_string(),
639        ErrorKind::ConnectionReset => "Connection reset.".to_string(),
640        ErrorKind::InvalidData | ErrorKind::InvalidInput => {
641            "Invalid or corrupted data.".to_string()
642        }
643        ErrorKind::UnexpectedEof => "Unexpected end of file.".to_string(),
644        ErrorKind::WouldBlock => "Operation would block.".to_string(),
645        ErrorKind::Interrupted => "Operation interrupted.".to_string(),
646        ErrorKind::OutOfMemory => "Out of memory.".to_string(),
647        ErrorKind::Other => {
648            let msg = err.to_string();
649            if msg.contains("No space left") || msg.contains("space left") {
650                return "No space left on device. Free up disk space and try again.".to_string();
651            }
652            if msg.contains("Is a directory") {
653                return "Path is a directory, not a file.".to_string();
654            }
655            return if context.is_some() {
656                format!("I/O error: {}", msg)
657            } else {
658                msg
659            };
660        }
661        _ => err.to_string(),
662    };
663
664    if let Some(ctx) = context {
665        if !ctx.is_empty() {
666            format!("{} {}", base, ctx)
667        } else {
668            base
669        }
670    } else {
671        base
672    }
673}
674
675/// Classification for consumers (e.g. Python binding) that map to native exception types.
676/// Keeps error-handling logic in one place instead of duplicating in each binding.
677#[derive(Debug, Clone, Copy)]
678pub enum ErrorKindForPython {
679    FileNotFound,
680    PermissionDenied,
681    Other,
682}
683
684/// Classify a report and return a kind plus user-facing message. Used by the Python binding
685/// to raise FileNotFoundError, PermissionDenied, or RuntimeError without duplicating chain-walk logic.
686pub fn error_for_python(report: &color_eyre::eyre::Report) -> (ErrorKindForPython, String) {
687    use std::io::ErrorKind;
688    let named = report.downcast_ref::<FileError>().map(ToString::to_string);
689    for cause in report.chain() {
690        if let Some(io_err) = cause.downcast_ref::<io::Error>() {
691            let kind = match io_err.kind() {
692                ErrorKind::NotFound => ErrorKindForPython::FileNotFound,
693                ErrorKind::PermissionDenied => ErrorKindForPython::PermissionDenied,
694                _ => ErrorKindForPython::Other,
695            };
696            let msg = named.unwrap_or_else(|| io_err.to_string());
697            return (kind, msg);
698        }
699    }
700    if let Some(named) = named {
701        return (ErrorKindForPython::Other, named);
702    }
703    let display = report.to_string();
704    let msg = display
705        .lines()
706        .next()
707        .map(str::trim)
708        .unwrap_or("An error occurred")
709        .to_string();
710    (ErrorKindForPython::Other, msg)
711}
712
713/// `message` with `file` (a temp copy datui made: a download, a decompressed CSV)
714/// renamed `source`, what the user opened. Only whole mentions are replaced (no path
715/// character on either side, though a sentence's full stop may follow), so longer paths
716/// sharing the name are untouched.
717pub fn named_by_source(message: &str, file: &Path, source: &Path) -> String {
718    let file = file.to_string_lossy();
719    if file.is_empty() {
720        return message.to_string();
721    }
722    let source = source.to_string_lossy();
723    let in_a_name = |c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '~');
724    let in_a_path = |c: char| in_a_name(c) || matches!(c, '.' | '/' | '\\');
725    let mut named = String::with_capacity(message.len());
726    let mut copied = 0;
727    for (at, _) in message.match_indices(file.as_ref()) {
728        let end = at + file.len();
729        let mut after = message[end..].chars();
730        let whole = !message[..at].chars().next_back().is_some_and(in_a_path)
731            && match after.next() {
732                None => true,
733                Some('.') => !after.next().is_some_and(in_a_path),
734                Some(c) => !in_a_path(c),
735            };
736        if whole {
737            named.push_str(&message[copied..at]);
738            named.push_str(&source);
739            copied = end;
740        }
741    }
742    named.push_str(&message[copied..]);
743    named
744}
745
746/// Format a color_eyre Report by downcasting to known error types.
747/// Walks the cause chain to find PolarsError or io::Error.
748pub fn user_message_from_report(report: &color_eyre::eyre::Report, path: Option<&Path>) -> String {
749    report_message(cfg!(windows), report, path)
750}
751
752fn report_message(windows: bool, report: &color_eyre::eyre::Report, path: Option<&Path>) -> String {
753    // A reader's error names the file it was reading, which may be one of several.
754    if let Some(named) = report.downcast_ref::<FileError>() {
755        return named.to_string();
756    }
757    if report_held(windows, report) {
758        return held_message(path);
759    }
760    let named = |msg: String| match path {
761        Some(p) => file_message(p, &msg),
762        None => msg,
763    };
764    for cause in report.chain() {
765        if let Some(named_file) = cause.downcast_ref::<FileError>() {
766            return named_file.to_string();
767        }
768        if let Some(pe) = cause.downcast_ref::<PolarsError>() {
769            return named(user_message_from_polars(pe));
770        }
771        if let Some(io_err) = cause.downcast_ref::<io::Error>() {
772            return named(user_message_from_io(io_err, None));
773        }
774    }
775
776    // Fallback: use first line of display to avoid long tracebacks
777    let display = report.to_string();
778    let first_line = display.lines().next().unwrap_or("An error occurred").trim();
779    named(rust_names_said_plainly(first_line).unwrap_or_else(|| first_line.to_string()))
780}
781
782/// Polars' words without the appended query plan (`Resolved plan until failure:` and
783/// the `FAILED HERE` fragment), keeping only the file the scan stopped at, which the
784/// plan names and the message often does not.
785fn without_the_query_plan(msg: &str) -> String {
786    let Some(cut) = msg.find("Resolved plan until failure:") else {
787        return msg.to_string();
788    };
789    let (said, plan) = msg.split_at(cut);
790    let said = said.trim_end();
791    match file_in_plan(plan) {
792        Some(file) => format!("{said}\nIt stopped at {file}."),
793        None => said.to_string(),
794    }
795}
796
797/// The path in the failing plan node's scan (`Csv SCAN [/data/one.csv]`): the one under
798/// the marker, not the plan's first scan, which in a union or join is often not the
799/// failing file.
800fn file_in_plan(plan: &str) -> Option<&str> {
801    const MARKER: &str = "FAILED HERE";
802    let from = plan.find(MARKER).map_or(0, |at| at + MARKER.len());
803    let at = plan[from..].find(" SCAN [")? + from + " SCAN [".len();
804    let rest = &plan[at..];
805    let end = rest.find(']')?;
806    let file = rest[..end].trim();
807    (!file.is_empty()).then_some(file)
808}
809
810/// Files that could not be stacked into one table, said as a directory rather than two
811/// unlabeled full schemas. The reader already unions by name and widens types, so
812/// what remains needs which file and what to do.
813fn is_union_schema_error(msg: &str) -> bool {
814    // Only a multi-file read's phrase: `unable to vstack` also comes from single-file
815    // buffer stitching and DQ segments, where advising "open one file" would hide the
816    // cause.
817    msg.contains("'union'/'concat' inputs should all have the same schema")
818}
819
820fn union_schema_message(msg: &str) -> String {
821    let mut said = "These files cannot be read as one table: they disagree on a column \
822                    in a way datui cannot reconcile by widening its type."
823        .to_string();
824    if let Some(file) = file_in_plan(msg) {
825        said.push_str(&format!("\nIt stopped at {file}."));
826    }
827    said.push_str(
828        "\nTry: open one file on its own, or --format / --infer-rows to settle \
829         the types.",
830    );
831    said
832}
833
834/// Files whose columns could not be lined up, said as files. Polars' `schema names
835/// differ: got 39, expected 25` counts names, often a headerless file's first row read
836/// as names.
837fn files_columns_differ(msg: &str) -> Option<String> {
838    let how = if let Some(rest) = msg.split("schema names differ: got ").nth(1) {
839        let (got, expected) = rest.split_once(", expected ")?;
840        let expected = expected.lines().next().unwrap_or(expected).trim();
841        format!(
842            "one has a column named \"{}\" where another has \"{expected}\"",
843            got.trim()
844        )
845    } else if msg.contains("schema lengths differ") {
846        "they have different numbers of columns".to_string()
847    } else {
848        return None;
849    };
850    Some(format!(
851        "These files cannot be read as one table: their columns differ — {how}.\n\
852         Try: open one file on its own. If the files have no header row, --no-header \
853         reads their columns by position."
854    ))
855}
856
857/// Light cleanup for ComputeError messages: strip Polars-internal phrasing.
858fn simplify_compute_message(msg: &str) -> String {
859    if is_csv_parse_type_error(msg) {
860        return short_csv_parse_error_message(msg);
861    }
862    crate::query::sanitize_query_error(msg)
863}
864
865/// True if this looks like Polars' "could not parse X as dtype Y" / "invalid primitive value" CSV error.
866fn is_csv_parse_type_error(msg: &str) -> bool {
867    let m = msg.to_lowercase();
868    (m.contains("could not parse") && m.contains("as dtype"))
869        || m.contains("invalid primitive value")
870}
871
872/// Extract column name from Polars message like "at column 'name'" or "at column \"name\"".
873fn extract_csv_parse_column(msg: &str) -> Option<String> {
874    let m = msg.to_lowercase();
875    for (needle, quote) in [("at column '", '\''), ("at column \"", '"')] {
876        if let Some(start) = m.find(needle) {
877            let after = &msg[start + needle.len()..];
878            let end = after.find(quote)?;
879            let name = after[..end].trim();
880            if !name.is_empty() {
881                return Some(name.to_string());
882            }
883        }
884    }
885    None
886}
887
888fn short_csv_parse_error_message(raw: &str) -> String {
889    let col = extract_csv_parse_column(raw);
890    let first = match &col {
891        Some(c) => format!(
892            "CSV parse error in column \"{}\": a value didn't match the inferred type.",
893            c
894        ),
895        None => "CSV parse error: a value didn't match the inferred column type.".to_string(),
896    };
897    format!(
898        "{}\n\
899         Try: --infer-rows 1000\n\
900              --null <value>  (treat as null)\n\
901              --ignore-errors  (skip bad rows)",
902        first
903    )
904}
905
906#[cfg(test)]
907mod tests;