1use polars::prelude::PolarsError;
5use std::io;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug)]
11pub struct FileError {
12 path: PathBuf,
13 at: Option<(usize, usize)>,
15 what: String,
16 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 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
54pub fn file_message(path: &Path, what: &str) -> String {
58 located_message(Some(path), None, what)
59}
60
61fn 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
74pub 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 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
102pub 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 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
129pub 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
138const GONE_SINCE_OPENED: &str = "A file was removed or replaced after the dataset was opened";
140
141pub(crate) fn says_gone_since_opened(message: &str) -> bool {
144 message.contains(GONE_SINCE_OPENED)
145}
146
147#[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 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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct HttpGone {
218 pub cell: String,
220 pub message: String,
222}
223
224#[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#[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#[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
269pub 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
281pub fn user_message_from_polars(err: &PolarsError) -> String {
283 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
307fn rust_names_said_plainly(msg: &str) -> Option<String> {
310 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
346fn nothing_matched(msg: &str) -> Option<String> {
349 let (_, input) = msg.split_once("expanded paths were empty")?;
350 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
365fn 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#[derive(Debug, Clone, PartialEq, Eq)]
427pub struct ConversionFailure {
428 pub column: String,
429 pub to: String,
431 pub failed: usize,
432 pub checked: usize,
435 pub examples: Vec<String>,
437 pub parsing: bool,
439}
440
441pub 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
461fn 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 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 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
571fn 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
591pub 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
600const HELD: &str =
602 "is open in another program that does not allow reading it; close it there and reopen";
603
604pub 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
615fn says_held(text: &str) -> bool {
617 text.contains("(os error 32)") || text.contains("(os error 33)")
618}
619
620fn 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
642fn 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
650pub 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#[derive(Debug, Clone, Copy)]
704pub enum ErrorKindForPython {
705 FileNotFound,
706 PermissionDenied,
707 Other,
708}
709
710pub 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
739pub 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
772pub 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 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 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
808fn 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
823fn 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
836fn is_union_schema_error(msg: &str) -> bool {
840 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
860fn 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
883fn 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
891fn 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
898fn 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;