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