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    if let Some(mixed) = extensions_differ(&said) {
296        return mixed;
297    }
298    let said = without_the_query_plan(&said);
299    let (first, rest) = said.split_once('\n').unwrap_or((&said, ""));
300    match rust_names_said_plainly(first) {
301        Some(plain) if rest.is_empty() => plain,
302        Some(plain) => format!("{plain}\n{rest}"),
303        None => said,
304    }
305}
306
307/// A file reader's words that are a Rust name (`Out-of-spec: InvalidFooter`,
308/// `OutOfSpec`, `InvalidUtf8 at character 0`), said in English.
309fn rust_names_said_plainly(msg: &str) -> Option<String> {
310    // `InvalidFooter` as `invalid footer`.
311    let words = |name: &str| {
312        let mut out = String::new();
313        for (i, c) in name.chars().enumerate() {
314            if c.is_uppercase() && i > 0 {
315                out.push(' ');
316            }
317            out.extend(c.to_lowercase());
318        }
319        out
320    };
321    let msg = msg.trim();
322    let is_name = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric());
323    let damaged = |what: Option<String>| {
324        let what = what.map(|w| format!(" ({w})")).unwrap_or_default();
325        format!(
326            "The file is not laid out as its format says{what}: it is damaged, or another \
327             format. --format names the format to read it as."
328        )
329    };
330    if msg == "OutOfSpec" {
331        return Some(damaged(None));
332    }
333    const SPEC: &str = "out-of-spec: ";
334    if let Some(name) = msg
335        .get(..SPEC.len())
336        .filter(|p| p.eq_ignore_ascii_case(SPEC))
337        .map(|_| &msg[SPEC.len()..])
338        && is_name(name)
339    {
340        return Some(damaged(Some(words(name))));
341    }
342    let at = msg.strip_prefix("InvalidUtf8")?;
343    Some(format!("The file is not UTF-8 text{at}."))
344}
345
346/// A scan whose path expanded to no files, said plainly instead of Polars' internals. A
347/// local path is a pattern only when no file has its name (`source::expands_as_glob`).
348fn nothing_matched(msg: &str) -> Option<String> {
349    let (_, input) = msg.split_once("expanded paths were empty")?;
350    // The paths are Debug-printed, `paths: [PlRefPath { inner: "…" }]`: look inside
351    // the quotes, past the list's own brackets.
352    let pattern = input.contains("glob: true")
353        && input.split("inner: \"").skip(1).any(|p| {
354            p.split('"')
355                .next()
356                .is_some_and(|p| p.contains(['*', '?', '[']))
357        });
358    Some(if pattern {
359        "No files match this pattern.".to_string()
360    } else {
361        "No files found there.".to_string()
362    })
363}
364
365/// A directory scan Polars refused because the files under it differ in extension,
366/// said with the two kinds it met and what to open instead. The caller names the
367/// directory.
368fn extensions_differ(msg: &str) -> Option<String> {
369    let (_, paths) = msg.split_once("directory contained paths with different file extensions")?;
370    let (_, rest) = paths.split_once("first path: ")?;
371    let (first, rest) = rest.split_once(", second path: ")?;
372    let second = rest.split(". Please use").next().unwrap_or(rest);
373    let extension = |path: &str| {
374        let name = path.trim().rsplit(['/', '\\']).next().unwrap_or(path);
375        match name.rsplit_once('.') {
376            Some((stem, ext)) if !stem.is_empty() => format!(".{ext}"),
377            _ => "no extension".to_string(),
378        }
379    };
380    Some(format!(
381        "The files below it are not all one kind ({} and {}). Open a file or directory \
382         inside it, or name the files with a pattern such as dir/**/*.parquet.",
383        extension(first),
384        extension(second)
385    ))
386}
387
388fn polars_words(err: &PolarsError) -> String {
389    use polars::prelude::PolarsError as PE;
390
391    match err {
392        PE::ColumnNotFound(msg) => format!(
393            "Column not found: {}. Check spelling and that the column exists.",
394            msg
395        ),
396        PE::Duplicate(msg) => format!(
397            "Duplicate column in result: {}. Use aliases to rename columns, e.g. `select my_date: timestamp.date`",
398            msg
399        ),
400        PE::IO { error, msg } => {
401            user_message_from_io(error.as_ref(), msg.as_ref().map(|m| m.as_ref()))
402        }
403        PE::NoData(msg) => format!("No data: {}", msg),
404        PE::SchemaMismatch(msg) => format!("Schema mismatch: {}", msg),
405        PE::ShapeMismatch(msg) => format!("Row shape mismatch: {}", msg),
406        PE::InvalidOperation(msg) => format!("Operation not allowed: {}", msg),
407        PE::OutOfBounds(msg) => format!("Index or row out of bounds: {}", msg),
408        PE::SchemaFieldNotFound(msg) => format!("Schema field not found: {}", msg),
409        PE::StructFieldNotFound(msg) => format!("Struct field not found: {}", msg),
410        PE::ComputeError(msg) => simplify_compute_message(msg),
411        PE::AssertionError(msg) => format!("Assertion failed: {}", msg),
412        PE::StringCacheMismatch(msg) => format!("String cache mismatch: {}", msg),
413        PE::SQLInterface(msg) | PE::SQLSyntax(msg) => msg.to_string(),
414        PE::Context { error, msg } => {
415            let inner = user_message_from_polars(error);
416            format!("{}: {}", msg, inner)
417        }
418        #[allow(unreachable_patterns)]
419        _ => err.to_string(),
420    }
421}
422
423/// Values that would not convert, from a strict CAST or a STRPTIME, as the failed
424/// run reported them: Polars counts the failures in the batch it was converting and
425/// quotes a few.
426#[derive(Debug, Clone, PartialEq, Eq)]
427pub struct ConversionFailure {
428    pub column: String,
429    /// The target type as Polars spells it: `i32`, `date`, `datetime[μs]`.
430    pub to: String,
431    pub failed: usize,
432    /// Values in the batch the failures were counted in. The whole column only when
433    /// it fit in one batch.
434    pub checked: usize,
435    /// Distinct offending values, at most three, without their quotes.
436    pub examples: Vec<String>,
437    /// Parsed with a format (STRPTIME) rather than cast.
438    pub parsing: bool,
439}
440
441/// The conversion that failed, if that is what `err` is.
442pub fn conversion_failure(err: &PolarsError) -> Option<ConversionFailure> {
443    let mut parsing = false;
444    let mut err = err;
445    loop {
446        match err {
447            PolarsError::ExprContext { error, expr } => {
448                parsing |= expr.contains("strptime") || expr.contains("to_date");
449                err = error;
450            }
451            PolarsError::Context { error, .. } => err = error,
452            _ => break,
453        }
454    }
455    let PolarsError::InvalidOperation(msg) = err else {
456        return None;
457    };
458    parse_conversion(msg, parsing)
459}
460
461/// `conversion from `str` to `i32` failed in column 'FT' for 8 out of 380 values:
462/// ["n/a", "n/a", … "n/a"]`, the shape `handle_casting_failures` writes.
463fn parse_conversion(msg: &str, parsing: bool) -> Option<ConversionFailure> {
464    let rest = msg.strip_prefix("conversion from `")?;
465    let (_, rest) = rest.split_once("` to `")?;
466    let (to, rest) = rest.split_once("` failed in column '")?;
467    let (column, rest) = rest.split_once("' for ")?;
468    let (failed, rest) = rest.split_once(" out of ")?;
469    let (checked, rest) = rest.split_once(" values: ")?;
470    let list = rest.lines().next().unwrap_or("");
471    let list = list.strip_prefix('[').unwrap_or(list);
472    let list = list.strip_suffix(']').unwrap_or(list);
473    let mut examples: Vec<String> = Vec::new();
474    for item in list.split(", ") {
475        // The truncation marker Polars puts before the last value.
476        let item = item.trim_start_matches('…').trim();
477        let item = item
478            .strip_prefix('"')
479            .and_then(|i| i.strip_suffix('"'))
480            .unwrap_or(item);
481        if !item.is_empty() && !examples.iter().any(|e| e == item) && examples.len() < 3 {
482            examples.push(item.to_string());
483        }
484    }
485    Some(ConversionFailure {
486        column: column.to_string(),
487        to: to.to_string(),
488        failed: failed.trim().parse().ok()?,
489        checked: checked.trim().parse().ok()?,
490        examples,
491        parsing,
492    })
493}
494
495impl ConversionFailure {
496    /// What a SQL writer can act on: the column, how many values, a few, and SQL to get past
497    /// them. `rows` is `df`'s height if known; "N of M" only when Polars checked all,
498    /// otherwise a lower bound from the batch it stopped in.
499    pub fn sql_message(&self, rows: Option<usize>) -> String {
500        let column = crate::query::sql_assist::sql_name(&self.column);
501        let exact = rows == Some(self.checked);
502        let one = !exact && self.failed == 1;
503        let temporal = self.to == "date" || self.to == "time" || self.to.starts_with("datetime");
504        let (many, single) = if temporal && self.parsing {
505            ("do not match the format", "does not match the format")
506        } else if self.to == "date" {
507            (
508                "are not dates written YYYY-MM-DD",
509                "is not a date written YYYY-MM-DD",
510            )
511        } else if self.to == "time" {
512            (
513                "are not times written HH:MM:SS",
514                "is not a time written HH:MM:SS",
515            )
516        } else if temporal {
517            (
518                "are not timestamps written YYYY-MM-DD HH:MM:SS",
519                "is not a timestamp written YYYY-MM-DD HH:MM:SS",
520            )
521        } else if self.to.starts_with('i') || self.to.starts_with('u') {
522            ("are not whole numbers", "is not a whole number")
523        } else if self.to.starts_with('f') || self.to.starts_with("decimal") {
524            ("are not numbers", "is not a number")
525        } else if self.to == "bool" {
526            ("are not true or false", "is not true or false")
527        } else {
528            ("cannot be converted", "cannot be converted")
529        };
530        let quoted: Vec<String> = self.examples.iter().map(|e| format!("\"{e}\"")).collect();
531        let such_as = match quoted.as_slice() {
532            [] => String::new(),
533            [one] => format!(", such as {one}"),
534            [init @ .., last] => format!(", such as {} and {last}", init.join(", ")),
535        };
536        let lead = if exact {
537            format!(
538                "{column}: {} of {} values {many}{such_as}.",
539                crate::numfmt::group_chrome(self.failed),
540                crate::numfmt::group_chrome(self.checked)
541            )
542        } else if one {
543            format!("At least 1 value in {column} {single}{such_as}.")
544        } else {
545            format!(
546                "At least {} values in {column} {many}{such_as}.",
547                crate::numfmt::group_chrome(self.failed)
548            )
549        };
550        let hint = if temporal && self.parsing {
551            format!(
552                "Try: a format that fits them all, or trim the text first with \
553                 SUBSTR({column}, 1, n) or REPLACE({column}, 'text', '')."
554            )
555        } else if temporal {
556            format!(
557                "Try: STRPTIME({column}, '%d/%m/%Y') with the format the values are \
558                 written in."
559            )
560        } else {
561            let sql_type = sql_type_for(&self.to);
562            format!(
563                "Try: TRY_CAST({column} AS {sql_type}) to read them as null, or clean \
564                 the text first with REPLACE or SUBSTR."
565            )
566        };
567        format!("{lead}\n{hint}")
568    }
569}
570
571/// The SQL type name for a Polars one, for a TRY_CAST suggestion.
572fn sql_type_for(polars: &str) -> &'static str {
573    match polars {
574        "i8" => "TINYINT",
575        "i16" => "SMALLINT",
576        "i32" => "INT",
577        "i64" => "BIGINT",
578        "i128" => "HUGEINT",
579        "u8" => "UTINYINT",
580        "u16" => "USMALLINT",
581        "u32" => "UINTEGER",
582        "u64" => "UBIGINT",
583        "f32" => "REAL",
584        "f64" => "DOUBLE",
585        "bool" => "BOOLEAN",
586        t if t.starts_with("decimal") => "DECIMAL",
587        _ => "VARCHAR",
588    }
589}
590
591/// A SQL error as the query prompt shows it: a failed conversion in datui's words,
592/// anything else as Polars says it.
593pub fn sql_error_message(err: &PolarsError, rows: Option<usize>) -> String {
594    match conversion_failure(err) {
595        Some(failure) => failure.sql_message(rows),
596        None => user_message_from_polars(err),
597    }
598}
599
600/// What a file another program holds says after its name.
601const HELD: &str =
602    "is open in another program that does not allow reading it; close it there and reopen";
603
604/// Whether `err` is Windows refusing a file another program holds: a sharing violation
605/// (32, e.g. a spreadsheet open) or locked region (33). Polars rewraps opens keeping
606/// the OS text but not the code, so the text is read too. Other OSes: never.
607pub fn held_by_another_program(err: &io::Error) -> bool {
608    held_on(cfg!(windows), err)
609}
610
611fn held_on(windows: bool, err: &io::Error) -> bool {
612    windows && (matches!(err.raw_os_error(), Some(32 | 33)) || says_held(&err.to_string()))
613}
614
615/// Whether an error's text carries the code of a file another program holds.
616fn says_held(text: &str) -> bool {
617    text.contains("(os error 32)") || text.contains("(os error 33)")
618}
619
620/// Whether anything in `report` is a file another program holds: an `io::Error`, one
621/// inside a Polars error, or, on `windows`, the text of one an error turned into words.
622fn report_held(windows: bool, report: &color_eyre::eyre::Report) -> bool {
623    fn polars_held(windows: bool, err: &PolarsError) -> bool {
624        match err {
625            PolarsError::IO { error, .. } => held_on(windows, error),
626            PolarsError::Context { error, .. } => polars_held(windows, error),
627            _ => false,
628        }
629    }
630    windows
631        && report.chain().any(|cause| {
632            cause
633                .downcast_ref::<io::Error>()
634                .is_some_and(|e| held_on(windows, e))
635                || cause
636                    .downcast_ref::<PolarsError>()
637                    .is_some_and(|e| polars_held(windows, e))
638                || says_held(&cause.to_string())
639        })
640}
641
642/// What a file another program holds says, named by `path` when it is known.
643fn held_message(path: Option<&Path>) -> String {
644    match path {
645        Some(path) => file_message(path, &format!("the file {HELD}")),
646        None => format!("The file {HELD}."),
647    }
648}
649
650/// Format an io::Error as a user-facing message by matching on ErrorKind.
651pub fn user_message_from_io(err: &io::Error, context: Option<&str>) -> String {
652    use std::io::ErrorKind;
653
654    if held_by_another_program(err) {
655        return held_message(None);
656    }
657    #[cfg(feature = "cloud")]
658    if let Some(gone) = gone_since_opened(err) {
659        return gone;
660    }
661    let base: String = match err.kind() {
662        ErrorKind::NotFound => "File or directory not found.".to_string(),
663        ErrorKind::PermissionDenied => "Permission denied. Check read access.".to_string(),
664        ErrorKind::ConnectionRefused => "Connection refused.".to_string(),
665        ErrorKind::ConnectionReset => "Connection reset.".to_string(),
666        ErrorKind::InvalidData | ErrorKind::InvalidInput => {
667            "Invalid or corrupted data.".to_string()
668        }
669        ErrorKind::UnexpectedEof => "Unexpected end of file.".to_string(),
670        ErrorKind::WouldBlock => "Operation would block.".to_string(),
671        ErrorKind::Interrupted => "Operation interrupted.".to_string(),
672        ErrorKind::OutOfMemory => "Out of memory.".to_string(),
673        ErrorKind::Other => {
674            let msg = err.to_string();
675            if msg.contains("No space left") || msg.contains("space left") {
676                return "No space left on device. Free up disk space and try again.".to_string();
677            }
678            if msg.contains("Is a directory") {
679                return "Path is a directory, not a file.".to_string();
680            }
681            return if context.is_some() {
682                format!("I/O error: {}", msg)
683            } else {
684                msg
685            };
686        }
687        _ => err.to_string(),
688    };
689
690    if let Some(ctx) = context {
691        if !ctx.is_empty() {
692            format!("{} {}", base, ctx)
693        } else {
694            base
695        }
696    } else {
697        base
698    }
699}
700
701/// Classification for consumers (e.g. Python binding) that map to native exception types.
702/// Keeps error-handling logic in one place instead of duplicating in each binding.
703#[derive(Debug, Clone, Copy)]
704pub enum ErrorKindForPython {
705    FileNotFound,
706    PermissionDenied,
707    Other,
708}
709
710/// Classify a report and return a kind plus user-facing message. Used by the Python binding
711/// to raise FileNotFoundError, PermissionDenied, or RuntimeError without duplicating chain-walk logic.
712pub fn error_for_python(report: &color_eyre::eyre::Report) -> (ErrorKindForPython, String) {
713    use std::io::ErrorKind;
714    let named = report.downcast_ref::<FileError>().map(ToString::to_string);
715    for cause in report.chain() {
716        if let Some(io_err) = cause.downcast_ref::<io::Error>() {
717            let kind = match io_err.kind() {
718                ErrorKind::NotFound => ErrorKindForPython::FileNotFound,
719                ErrorKind::PermissionDenied => ErrorKindForPython::PermissionDenied,
720                _ => ErrorKindForPython::Other,
721            };
722            let msg = named.unwrap_or_else(|| io_err.to_string());
723            return (kind, msg);
724        }
725    }
726    if let Some(named) = named {
727        return (ErrorKindForPython::Other, named);
728    }
729    let display = report.to_string();
730    let msg = display
731        .lines()
732        .next()
733        .map(str::trim)
734        .unwrap_or("An error occurred")
735        .to_string();
736    (ErrorKindForPython::Other, msg)
737}
738
739/// `message` with `file` (a temp copy datui made: a download, a decompressed CSV)
740/// renamed `source`, what the user opened. Only whole mentions are replaced (no path
741/// character on either side, though a sentence's full stop may follow), so longer paths
742/// sharing the name are untouched.
743pub fn named_by_source(message: &str, file: &Path, source: &Path) -> String {
744    let file = file.to_string_lossy();
745    if file.is_empty() {
746        return message.to_string();
747    }
748    let source = source.to_string_lossy();
749    let in_a_name = |c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '~');
750    let in_a_path = |c: char| in_a_name(c) || matches!(c, '.' | '/' | '\\');
751    let mut named = String::with_capacity(message.len());
752    let mut copied = 0;
753    for (at, _) in message.match_indices(file.as_ref()) {
754        let end = at + file.len();
755        let mut after = message[end..].chars();
756        let whole = !message[..at].chars().next_back().is_some_and(in_a_path)
757            && match after.next() {
758                None => true,
759                Some('.') => !after.next().is_some_and(in_a_path),
760                Some(c) => !in_a_path(c),
761            };
762        if whole {
763            named.push_str(&message[copied..at]);
764            named.push_str(&source);
765            copied = end;
766        }
767    }
768    named.push_str(&message[copied..]);
769    named
770}
771
772/// Format a color_eyre Report by downcasting to known error types.
773/// Walks the cause chain to find PolarsError or io::Error.
774pub fn user_message_from_report(report: &color_eyre::eyre::Report, path: Option<&Path>) -> String {
775    report_message(cfg!(windows), report, path)
776}
777
778fn report_message(windows: bool, report: &color_eyre::eyre::Report, path: Option<&Path>) -> String {
779    // A reader's error names the file it was reading, which may be one of several.
780    if let Some(named) = report.downcast_ref::<FileError>() {
781        return named.to_string();
782    }
783    if report_held(windows, report) {
784        return held_message(path);
785    }
786    let named = |msg: String| match path {
787        Some(p) => file_message(p, &msg),
788        None => msg,
789    };
790    for cause in report.chain() {
791        if let Some(named_file) = cause.downcast_ref::<FileError>() {
792            return named_file.to_string();
793        }
794        if let Some(pe) = cause.downcast_ref::<PolarsError>() {
795            return named(user_message_from_polars(pe));
796        }
797        if let Some(io_err) = cause.downcast_ref::<io::Error>() {
798            return named(user_message_from_io(io_err, None));
799        }
800    }
801
802    // Fallback: use first line of display to avoid long tracebacks
803    let display = report.to_string();
804    let first_line = display.lines().next().unwrap_or("An error occurred").trim();
805    named(rust_names_said_plainly(first_line).unwrap_or_else(|| first_line.to_string()))
806}
807
808/// Polars' words without the appended query plan (`Resolved plan until failure:` and
809/// the `FAILED HERE` fragment), keeping only the file the scan stopped at, which the
810/// plan names and the message often does not.
811fn without_the_query_plan(msg: &str) -> String {
812    let Some(cut) = msg.find("Resolved plan until failure:") else {
813        return msg.to_string();
814    };
815    let (said, plan) = msg.split_at(cut);
816    let said = said.trim_end();
817    match file_in_plan(plan) {
818        Some(file) => format!("{said}\nIt stopped at {file}."),
819        None => said.to_string(),
820    }
821}
822
823/// The path in the failing plan node's scan (`Csv SCAN [/data/one.csv]`): the one under
824/// the marker, not the plan's first scan, which in a union or join is often not the
825/// failing file.
826fn file_in_plan(plan: &str) -> Option<&str> {
827    const MARKER: &str = "FAILED HERE";
828    let from = plan.find(MARKER).map_or(0, |at| at + MARKER.len());
829    let at = plan[from..].find(" SCAN [")? + from + " SCAN [".len();
830    let rest = &plan[at..];
831    let end = rest.find(']')?;
832    let file = rest[..end].trim();
833    (!file.is_empty()).then_some(file)
834}
835
836/// Files that could not be stacked into one table, said as a directory rather than two
837/// unlabeled full schemas. The reader already unions by name and widens types, so
838/// what remains needs which file and what to do.
839fn is_union_schema_error(msg: &str) -> bool {
840    // Only a multi-file read's phrase: `unable to vstack` also comes from single-file
841    // buffer stitching and DQ segments, where advising "open one file" would hide the
842    // cause.
843    msg.contains("'union'/'concat' inputs should all have the same schema")
844}
845
846fn union_schema_message(msg: &str) -> String {
847    let mut said = "These files cannot be read as one table: they disagree on a column \
848                    in a way datui cannot reconcile by widening its type."
849        .to_string();
850    if let Some(file) = file_in_plan(msg) {
851        said.push_str(&format!("\nIt stopped at {file}."));
852    }
853    said.push_str(
854        "\nTry: open one file on its own, or --format / --infer-rows to settle \
855         the types.",
856    );
857    said
858}
859
860/// Files whose columns could not be lined up, said as files. Polars' `schema names
861/// differ: got 39, expected 25` counts names, often a headerless file's first row read
862/// as names.
863fn files_columns_differ(msg: &str) -> Option<String> {
864    let how = if let Some(rest) = msg.split("schema names differ: got ").nth(1) {
865        let (got, expected) = rest.split_once(", expected ")?;
866        let expected = expected.lines().next().unwrap_or(expected).trim();
867        format!(
868            "one has a column named \"{}\" where another has \"{expected}\"",
869            got.trim()
870        )
871    } else if msg.contains("schema lengths differ") {
872        "they have different numbers of columns".to_string()
873    } else {
874        return None;
875    };
876    Some(format!(
877        "These files cannot be read as one table: their columns differ — {how}.\n\
878         Try: open one file on its own. If the files have no header row, --no-header \
879         reads their columns by position."
880    ))
881}
882
883/// Light cleanup for ComputeError messages: strip Polars-internal phrasing.
884fn simplify_compute_message(msg: &str) -> String {
885    if is_csv_parse_type_error(msg) {
886        return short_csv_parse_error_message(msg);
887    }
888    crate::query::sanitize_query_error(msg)
889}
890
891/// True if this looks like Polars' "could not parse X as dtype Y" / "invalid primitive value" CSV error.
892fn is_csv_parse_type_error(msg: &str) -> bool {
893    let m = msg.to_lowercase();
894    (m.contains("could not parse") && m.contains("as dtype"))
895        || m.contains("invalid primitive value")
896}
897
898/// Extract column name from Polars message like "at column 'name'" or "at column \"name\"".
899fn extract_csv_parse_column(msg: &str) -> Option<String> {
900    let m = msg.to_lowercase();
901    for (needle, quote) in [("at column '", '\''), ("at column \"", '"')] {
902        if let Some(start) = m.find(needle) {
903            let after = &msg[start + needle.len()..];
904            let end = after.find(quote)?;
905            let name = after[..end].trim();
906            if !name.is_empty() {
907                return Some(name.to_string());
908            }
909        }
910    }
911    None
912}
913
914fn short_csv_parse_error_message(raw: &str) -> String {
915    let col = extract_csv_parse_column(raw);
916    let first = match &col {
917        Some(c) => format!(
918            "CSV parse error in column \"{}\": a value didn't match the inferred type.",
919            c
920        ),
921        None => "CSV parse error: a value didn't match the inferred column type.".to_string(),
922    };
923    format!(
924        "{}\n\
925         Try: --infer-rows 1000\n\
926              --null <value>  (treat as null)\n\
927              --ignore-errors  (skip bad rows)",
928        first
929    )
930}
931
932#[cfg(test)]
933mod tests;