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 let said = without_the_query_plan(&said);
296 let (first, rest) = said.split_once('\n').unwrap_or((&said, ""));
297 match rust_names_said_plainly(first) {
298 Some(plain) if rest.is_empty() => plain,
299 Some(plain) => format!("{plain}\n{rest}"),
300 None => said,
301 }
302}
303
304fn rust_names_said_plainly(msg: &str) -> Option<String> {
307 let words = |name: &str| {
309 let mut out = String::new();
310 for (i, c) in name.chars().enumerate() {
311 if c.is_uppercase() && i > 0 {
312 out.push(' ');
313 }
314 out.extend(c.to_lowercase());
315 }
316 out
317 };
318 let msg = msg.trim();
319 let is_name = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric());
320 let damaged = |what: Option<String>| {
321 let what = what.map(|w| format!(" ({w})")).unwrap_or_default();
322 format!(
323 "The file is not laid out as its format says{what}: it is damaged, or another \
324 format. --format names the format to read it as."
325 )
326 };
327 if msg == "OutOfSpec" {
328 return Some(damaged(None));
329 }
330 const SPEC: &str = "out-of-spec: ";
331 if let Some(name) = msg
332 .get(..SPEC.len())
333 .filter(|p| p.eq_ignore_ascii_case(SPEC))
334 .map(|_| &msg[SPEC.len()..])
335 && is_name(name)
336 {
337 return Some(damaged(Some(words(name))));
338 }
339 let at = msg.strip_prefix("InvalidUtf8")?;
340 Some(format!("The file is not UTF-8 text{at}."))
341}
342
343fn nothing_matched(msg: &str) -> Option<String> {
346 let (_, input) = msg.split_once("expanded paths were empty")?;
347 let pattern = input.contains("glob: true")
350 && input.split("inner: \"").skip(1).any(|p| {
351 p.split('"')
352 .next()
353 .is_some_and(|p| p.contains(['*', '?', '[']))
354 });
355 Some(if pattern {
356 "No files match this pattern.".to_string()
357 } else {
358 "No files found there.".to_string()
359 })
360}
361
362fn polars_words(err: &PolarsError) -> String {
363 use polars::prelude::PolarsError as PE;
364
365 match err {
366 PE::ColumnNotFound(msg) => format!(
367 "Column not found: {}. Check spelling and that the column exists.",
368 msg
369 ),
370 PE::Duplicate(msg) => format!(
371 "Duplicate column in result: {}. Use aliases to rename columns, e.g. `select my_date: timestamp.date`",
372 msg
373 ),
374 PE::IO { error, msg } => {
375 user_message_from_io(error.as_ref(), msg.as_ref().map(|m| m.as_ref()))
376 }
377 PE::NoData(msg) => format!("No data: {}", msg),
378 PE::SchemaMismatch(msg) => format!("Schema mismatch: {}", msg),
379 PE::ShapeMismatch(msg) => format!("Row shape mismatch: {}", msg),
380 PE::InvalidOperation(msg) => format!("Operation not allowed: {}", msg),
381 PE::OutOfBounds(msg) => format!("Index or row out of bounds: {}", msg),
382 PE::SchemaFieldNotFound(msg) => format!("Schema field not found: {}", msg),
383 PE::StructFieldNotFound(msg) => format!("Struct field not found: {}", msg),
384 PE::ComputeError(msg) => simplify_compute_message(msg),
385 PE::AssertionError(msg) => format!("Assertion failed: {}", msg),
386 PE::StringCacheMismatch(msg) => format!("String cache mismatch: {}", msg),
387 PE::SQLInterface(msg) | PE::SQLSyntax(msg) => msg.to_string(),
388 PE::Context { error, msg } => {
389 let inner = user_message_from_polars(error);
390 format!("{}: {}", msg, inner)
391 }
392 #[allow(unreachable_patterns)]
393 _ => err.to_string(),
394 }
395}
396
397#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct ConversionFailure {
402 pub column: String,
403 pub to: String,
405 pub failed: usize,
406 pub checked: usize,
409 pub examples: Vec<String>,
411 pub parsing: bool,
413}
414
415pub fn conversion_failure(err: &PolarsError) -> Option<ConversionFailure> {
417 let mut parsing = false;
418 let mut err = err;
419 loop {
420 match err {
421 PolarsError::ExprContext { error, expr } => {
422 parsing |= expr.contains("strptime") || expr.contains("to_date");
423 err = error;
424 }
425 PolarsError::Context { error, .. } => err = error,
426 _ => break,
427 }
428 }
429 let PolarsError::InvalidOperation(msg) = err else {
430 return None;
431 };
432 parse_conversion(msg, parsing)
433}
434
435fn parse_conversion(msg: &str, parsing: bool) -> Option<ConversionFailure> {
438 let rest = msg.strip_prefix("conversion from `")?;
439 let (_, rest) = rest.split_once("` to `")?;
440 let (to, rest) = rest.split_once("` failed in column '")?;
441 let (column, rest) = rest.split_once("' for ")?;
442 let (failed, rest) = rest.split_once(" out of ")?;
443 let (checked, rest) = rest.split_once(" values: ")?;
444 let list = rest.lines().next().unwrap_or("");
445 let list = list.strip_prefix('[').unwrap_or(list);
446 let list = list.strip_suffix(']').unwrap_or(list);
447 let mut examples: Vec<String> = Vec::new();
448 for item in list.split(", ") {
449 let item = item.trim_start_matches('…').trim();
451 let item = item
452 .strip_prefix('"')
453 .and_then(|i| i.strip_suffix('"'))
454 .unwrap_or(item);
455 if !item.is_empty() && !examples.iter().any(|e| e == item) && examples.len() < 3 {
456 examples.push(item.to_string());
457 }
458 }
459 Some(ConversionFailure {
460 column: column.to_string(),
461 to: to.to_string(),
462 failed: failed.trim().parse().ok()?,
463 checked: checked.trim().parse().ok()?,
464 examples,
465 parsing,
466 })
467}
468
469impl ConversionFailure {
470 pub fn sql_message(&self, rows: Option<usize>) -> String {
474 let column = crate::query::sql_assist::sql_name(&self.column);
475 let exact = rows == Some(self.checked);
476 let one = !exact && self.failed == 1;
477 let temporal = self.to == "date" || self.to == "time" || self.to.starts_with("datetime");
478 let (many, single) = if temporal && self.parsing {
479 ("do not match the format", "does not match the format")
480 } else if self.to == "date" {
481 (
482 "are not dates written YYYY-MM-DD",
483 "is not a date written YYYY-MM-DD",
484 )
485 } else if self.to == "time" {
486 (
487 "are not times written HH:MM:SS",
488 "is not a time written HH:MM:SS",
489 )
490 } else if temporal {
491 (
492 "are not timestamps written YYYY-MM-DD HH:MM:SS",
493 "is not a timestamp written YYYY-MM-DD HH:MM:SS",
494 )
495 } else if self.to.starts_with('i') || self.to.starts_with('u') {
496 ("are not whole numbers", "is not a whole number")
497 } else if self.to.starts_with('f') || self.to.starts_with("decimal") {
498 ("are not numbers", "is not a number")
499 } else if self.to == "bool" {
500 ("are not true or false", "is not true or false")
501 } else {
502 ("cannot be converted", "cannot be converted")
503 };
504 let quoted: Vec<String> = self.examples.iter().map(|e| format!("\"{e}\"")).collect();
505 let such_as = match quoted.as_slice() {
506 [] => String::new(),
507 [one] => format!(", such as {one}"),
508 [init @ .., last] => format!(", such as {} and {last}", init.join(", ")),
509 };
510 let lead = if exact {
511 format!(
512 "{column}: {} of {} values {many}{such_as}.",
513 crate::numfmt::group_chrome(self.failed),
514 crate::numfmt::group_chrome(self.checked)
515 )
516 } else if one {
517 format!("At least 1 value in {column} {single}{such_as}.")
518 } else {
519 format!(
520 "At least {} values in {column} {many}{such_as}.",
521 crate::numfmt::group_chrome(self.failed)
522 )
523 };
524 let hint = if temporal && self.parsing {
525 format!(
526 "Try: a format that fits them all, or trim the text first with \
527 SUBSTR({column}, 1, n) or REPLACE({column}, 'text', '')."
528 )
529 } else if temporal {
530 format!(
531 "Try: STRPTIME({column}, '%d/%m/%Y') with the format the values are \
532 written in."
533 )
534 } else {
535 let sql_type = sql_type_for(&self.to);
536 format!(
537 "Try: TRY_CAST({column} AS {sql_type}) to read them as null, or clean \
538 the text first with REPLACE or SUBSTR."
539 )
540 };
541 format!("{lead}\n{hint}")
542 }
543}
544
545fn sql_type_for(polars: &str) -> &'static str {
547 match polars {
548 "i8" => "TINYINT",
549 "i16" => "SMALLINT",
550 "i32" => "INT",
551 "i64" => "BIGINT",
552 "i128" => "HUGEINT",
553 "u8" => "UTINYINT",
554 "u16" => "USMALLINT",
555 "u32" => "UINTEGER",
556 "u64" => "UBIGINT",
557 "f32" => "REAL",
558 "f64" => "DOUBLE",
559 "bool" => "BOOLEAN",
560 t if t.starts_with("decimal") => "DECIMAL",
561 _ => "VARCHAR",
562 }
563}
564
565pub fn sql_error_message(err: &PolarsError, rows: Option<usize>) -> String {
568 match conversion_failure(err) {
569 Some(failure) => failure.sql_message(rows),
570 None => user_message_from_polars(err),
571 }
572}
573
574const HELD: &str =
576 "is open in another program that does not allow reading it; close it there and reopen";
577
578pub fn held_by_another_program(err: &io::Error) -> bool {
582 held_on(cfg!(windows), err)
583}
584
585fn held_on(windows: bool, err: &io::Error) -> bool {
586 windows && (matches!(err.raw_os_error(), Some(32 | 33)) || says_held(&err.to_string()))
587}
588
589fn says_held(text: &str) -> bool {
591 text.contains("(os error 32)") || text.contains("(os error 33)")
592}
593
594fn report_held(windows: bool, report: &color_eyre::eyre::Report) -> bool {
597 fn polars_held(windows: bool, err: &PolarsError) -> bool {
598 match err {
599 PolarsError::IO { error, .. } => held_on(windows, error),
600 PolarsError::Context { error, .. } => polars_held(windows, error),
601 _ => false,
602 }
603 }
604 windows
605 && report.chain().any(|cause| {
606 cause
607 .downcast_ref::<io::Error>()
608 .is_some_and(|e| held_on(windows, e))
609 || cause
610 .downcast_ref::<PolarsError>()
611 .is_some_and(|e| polars_held(windows, e))
612 || says_held(&cause.to_string())
613 })
614}
615
616fn held_message(path: Option<&Path>) -> String {
618 match path {
619 Some(path) => file_message(path, &format!("the file {HELD}")),
620 None => format!("The file {HELD}."),
621 }
622}
623
624pub fn user_message_from_io(err: &io::Error, context: Option<&str>) -> String {
626 use std::io::ErrorKind;
627
628 if held_by_another_program(err) {
629 return held_message(None);
630 }
631 #[cfg(feature = "cloud")]
632 if let Some(gone) = gone_since_opened(err) {
633 return gone;
634 }
635 let base: String = match err.kind() {
636 ErrorKind::NotFound => "File or directory not found.".to_string(),
637 ErrorKind::PermissionDenied => "Permission denied. Check read access.".to_string(),
638 ErrorKind::ConnectionRefused => "Connection refused.".to_string(),
639 ErrorKind::ConnectionReset => "Connection reset.".to_string(),
640 ErrorKind::InvalidData | ErrorKind::InvalidInput => {
641 "Invalid or corrupted data.".to_string()
642 }
643 ErrorKind::UnexpectedEof => "Unexpected end of file.".to_string(),
644 ErrorKind::WouldBlock => "Operation would block.".to_string(),
645 ErrorKind::Interrupted => "Operation interrupted.".to_string(),
646 ErrorKind::OutOfMemory => "Out of memory.".to_string(),
647 ErrorKind::Other => {
648 let msg = err.to_string();
649 if msg.contains("No space left") || msg.contains("space left") {
650 return "No space left on device. Free up disk space and try again.".to_string();
651 }
652 if msg.contains("Is a directory") {
653 return "Path is a directory, not a file.".to_string();
654 }
655 return if context.is_some() {
656 format!("I/O error: {}", msg)
657 } else {
658 msg
659 };
660 }
661 _ => err.to_string(),
662 };
663
664 if let Some(ctx) = context {
665 if !ctx.is_empty() {
666 format!("{} {}", base, ctx)
667 } else {
668 base
669 }
670 } else {
671 base
672 }
673}
674
675#[derive(Debug, Clone, Copy)]
678pub enum ErrorKindForPython {
679 FileNotFound,
680 PermissionDenied,
681 Other,
682}
683
684pub fn error_for_python(report: &color_eyre::eyre::Report) -> (ErrorKindForPython, String) {
687 use std::io::ErrorKind;
688 let named = report.downcast_ref::<FileError>().map(ToString::to_string);
689 for cause in report.chain() {
690 if let Some(io_err) = cause.downcast_ref::<io::Error>() {
691 let kind = match io_err.kind() {
692 ErrorKind::NotFound => ErrorKindForPython::FileNotFound,
693 ErrorKind::PermissionDenied => ErrorKindForPython::PermissionDenied,
694 _ => ErrorKindForPython::Other,
695 };
696 let msg = named.unwrap_or_else(|| io_err.to_string());
697 return (kind, msg);
698 }
699 }
700 if let Some(named) = named {
701 return (ErrorKindForPython::Other, named);
702 }
703 let display = report.to_string();
704 let msg = display
705 .lines()
706 .next()
707 .map(str::trim)
708 .unwrap_or("An error occurred")
709 .to_string();
710 (ErrorKindForPython::Other, msg)
711}
712
713pub fn named_by_source(message: &str, file: &Path, source: &Path) -> String {
718 let file = file.to_string_lossy();
719 if file.is_empty() {
720 return message.to_string();
721 }
722 let source = source.to_string_lossy();
723 let in_a_name = |c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '~');
724 let in_a_path = |c: char| in_a_name(c) || matches!(c, '.' | '/' | '\\');
725 let mut named = String::with_capacity(message.len());
726 let mut copied = 0;
727 for (at, _) in message.match_indices(file.as_ref()) {
728 let end = at + file.len();
729 let mut after = message[end..].chars();
730 let whole = !message[..at].chars().next_back().is_some_and(in_a_path)
731 && match after.next() {
732 None => true,
733 Some('.') => !after.next().is_some_and(in_a_path),
734 Some(c) => !in_a_path(c),
735 };
736 if whole {
737 named.push_str(&message[copied..at]);
738 named.push_str(&source);
739 copied = end;
740 }
741 }
742 named.push_str(&message[copied..]);
743 named
744}
745
746pub fn user_message_from_report(report: &color_eyre::eyre::Report, path: Option<&Path>) -> String {
749 report_message(cfg!(windows), report, path)
750}
751
752fn report_message(windows: bool, report: &color_eyre::eyre::Report, path: Option<&Path>) -> String {
753 if let Some(named) = report.downcast_ref::<FileError>() {
755 return named.to_string();
756 }
757 if report_held(windows, report) {
758 return held_message(path);
759 }
760 let named = |msg: String| match path {
761 Some(p) => file_message(p, &msg),
762 None => msg,
763 };
764 for cause in report.chain() {
765 if let Some(named_file) = cause.downcast_ref::<FileError>() {
766 return named_file.to_string();
767 }
768 if let Some(pe) = cause.downcast_ref::<PolarsError>() {
769 return named(user_message_from_polars(pe));
770 }
771 if let Some(io_err) = cause.downcast_ref::<io::Error>() {
772 return named(user_message_from_io(io_err, None));
773 }
774 }
775
776 let display = report.to_string();
778 let first_line = display.lines().next().unwrap_or("An error occurred").trim();
779 named(rust_names_said_plainly(first_line).unwrap_or_else(|| first_line.to_string()))
780}
781
782fn without_the_query_plan(msg: &str) -> String {
786 let Some(cut) = msg.find("Resolved plan until failure:") else {
787 return msg.to_string();
788 };
789 let (said, plan) = msg.split_at(cut);
790 let said = said.trim_end();
791 match file_in_plan(plan) {
792 Some(file) => format!("{said}\nIt stopped at {file}."),
793 None => said.to_string(),
794 }
795}
796
797fn file_in_plan(plan: &str) -> Option<&str> {
801 const MARKER: &str = "FAILED HERE";
802 let from = plan.find(MARKER).map_or(0, |at| at + MARKER.len());
803 let at = plan[from..].find(" SCAN [")? + from + " SCAN [".len();
804 let rest = &plan[at..];
805 let end = rest.find(']')?;
806 let file = rest[..end].trim();
807 (!file.is_empty()).then_some(file)
808}
809
810fn is_union_schema_error(msg: &str) -> bool {
814 msg.contains("'union'/'concat' inputs should all have the same schema")
818}
819
820fn union_schema_message(msg: &str) -> String {
821 let mut said = "These files cannot be read as one table: they disagree on a column \
822 in a way datui cannot reconcile by widening its type."
823 .to_string();
824 if let Some(file) = file_in_plan(msg) {
825 said.push_str(&format!("\nIt stopped at {file}."));
826 }
827 said.push_str(
828 "\nTry: open one file on its own, or --format / --infer-rows to settle \
829 the types.",
830 );
831 said
832}
833
834fn files_columns_differ(msg: &str) -> Option<String> {
838 let how = if let Some(rest) = msg.split("schema names differ: got ").nth(1) {
839 let (got, expected) = rest.split_once(", expected ")?;
840 let expected = expected.lines().next().unwrap_or(expected).trim();
841 format!(
842 "one has a column named \"{}\" where another has \"{expected}\"",
843 got.trim()
844 )
845 } else if msg.contains("schema lengths differ") {
846 "they have different numbers of columns".to_string()
847 } else {
848 return None;
849 };
850 Some(format!(
851 "These files cannot be read as one table: their columns differ — {how}.\n\
852 Try: open one file on its own. If the files have no header row, --no-header \
853 reads their columns by position."
854 ))
855}
856
857fn simplify_compute_message(msg: &str) -> String {
859 if is_csv_parse_type_error(msg) {
860 return short_csv_parse_error_message(msg);
861 }
862 crate::query::sanitize_query_error(msg)
863}
864
865fn is_csv_parse_type_error(msg: &str) -> bool {
867 let m = msg.to_lowercase();
868 (m.contains("could not parse") && m.contains("as dtype"))
869 || m.contains("invalid primitive value")
870}
871
872fn extract_csv_parse_column(msg: &str) -> Option<String> {
874 let m = msg.to_lowercase();
875 for (needle, quote) in [("at column '", '\''), ("at column \"", '"')] {
876 if let Some(start) = m.find(needle) {
877 let after = &msg[start + needle.len()..];
878 let end = after.find(quote)?;
879 let name = after[..end].trim();
880 if !name.is_empty() {
881 return Some(name.to_string());
882 }
883 }
884 }
885 None
886}
887
888fn short_csv_parse_error_message(raw: &str) -> String {
889 let col = extract_csv_parse_column(raw);
890 let first = match &col {
891 Some(c) => format!(
892 "CSV parse error in column \"{}\": a value didn't match the inferred type.",
893 c
894 ),
895 None => "CSV parse error: a value didn't match the inferred column type.".to_string(),
896 };
897 format!(
898 "{}\n\
899 Try: --infer-rows 1000\n\
900 --null <value> (treat as null)\n\
901 --ignore-errors (skip bad rows)",
902 first
903 )
904}
905
906#[cfg(test)]
907mod tests;