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
138#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct HttpGone {
183 pub cell: String,
185 pub message: String,
187}
188
189#[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#[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#[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
234pub 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
246pub fn user_message_from_polars(err: &PolarsError) -> String {
248 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
269fn rust_names_said_plainly(msg: &str) -> Option<String> {
272 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
308fn nothing_matched(msg: &str) -> Option<String> {
311 let (_, input) = msg.split_once("expanded paths were empty")?;
312 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#[derive(Debug, Clone, PartialEq, Eq)]
366pub struct ConversionFailure {
367 pub column: String,
368 pub to: String,
370 pub failed: usize,
371 pub checked: usize,
374 pub examples: Vec<String>,
376 pub parsing: bool,
378}
379
380pub 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
400fn 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 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 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
510fn 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
530pub 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
539const HELD: &str =
541 "is open in another program that does not allow reading it; close it there and reopen";
542
543pub 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
554fn says_held(text: &str) -> bool {
556 text.contains("(os error 32)") || text.contains("(os error 33)")
557}
558
559fn 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
581fn 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
589pub 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#[derive(Debug, Clone, Copy)]
639pub enum ErrorKindForPython {
640 FileNotFound,
641 PermissionDenied,
642 Other,
643}
644
645pub 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
674pub 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
707pub 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 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 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
743fn 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
758fn 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
771fn is_union_schema_error(msg: &str) -> bool {
775 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
795fn 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
818fn 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
826fn 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
833fn 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;