Skip to main content

dataprof_core/
errors.rs

1use crate::binary_input::BinarySignature;
2use thiserror::Error;
3
4/// The file formats this build can actually read, reflecting compiled features.
5///
6/// The list must never over-claim: Parquet is only advertised when the `parquet`
7/// feature is enabled, so an "unsupported format" error cannot point the user at
8/// a format the installed build cannot open.
9fn supported_formats_hint() -> &'static str {
10    #[cfg(feature = "parquet")]
11    {
12        "CSV, JSON, JSONL, Parquet"
13    }
14    #[cfg(not(feature = "parquet"))]
15    {
16        "CSV, JSON, JSONL"
17    }
18}
19
20/// Render a remedy as its own line, or nothing at all when there is none.
21///
22/// Streaming failures are the one family where "no advice" is a real answer: a
23/// panicked task has no remedy the caller can act on, and #550 removed the
24/// hardcoded chunk-size line that used to fill the gap. Formatting the
25/// suggestion unconditionally would end those messages in a bare newline.
26fn suggestion_line(suggestion: &str) -> String {
27    if suggestion.is_empty() {
28        String::new()
29    } else {
30        format!("\n{suggestion}")
31    }
32}
33
34/// Redact credentials from a string before it enters an error message.
35///
36/// Connection strings and driver errors can carry `scheme://user:password@host`.
37/// We collapse the userinfo to `***` so passwords (and usernames) never reach a
38/// log line or a surfaced diagnostic, while keeping the scheme/host for context.
39pub fn redact_credentials(input: &str) -> String {
40    // Match the `://[userinfo@]` segment of any URL-like token and drop userinfo.
41    let mut out = String::with_capacity(input.len());
42    let mut rest = input;
43    while let Some(scheme_idx) = rest.find("://") {
44        let after_scheme = scheme_idx + 3;
45        out.push_str(&rest[..after_scheme]);
46        let tail = &rest[after_scheme..];
47        // Userinfo, if present, ends at the first '@' before the next authority
48        // delimiter ('/', '?', '#', or whitespace).
49        let authority_end = tail
50            .find(['/', '?', '#', ' ', '\t', '\n'])
51            .unwrap_or(tail.len());
52        if let Some(at_rel) = tail[..authority_end].find('@') {
53            out.push_str("***");
54            rest = &tail[at_rel..]; // keep '@host...'
55        } else {
56            rest = tail;
57        }
58    }
59    out.push_str(rest);
60    out
61}
62
63/// Boxed originating error retained by the variants that can carry a cause.
64///
65/// `Box` rather than `Arc` is deliberate. `thiserror` renders a boxed source
66/// through `&**b`, so [`std::error::Error::source`] hands back the *concrete*
67/// error and `downcast_ref::<std::io::Error>()` succeeds. Behind an `Arc` the
68/// chain instead yields the `Arc`: the message still reads correctly while
69/// every downcast returns `None`, which is the exact capability this type
70/// exists to provide.
71pub type ErrorSource = Box<dyn std::error::Error + Send + Sync + 'static>;
72
73/// Enhanced error types with more descriptive messages for DataProfiler
74///
75/// Not `Clone`: a retained [`ErrorSource`] is a boxed trait object, and the
76/// originating errors (`std::io::Error`, `csv::Error`, ...) are not cloneable
77/// themselves.
78#[derive(Error, Debug)]
79pub enum DataProfilerError {
80    #[error("CSV parsing failed: {message}\nSuggestion: {suggestion}")]
81    #[non_exhaustive]
82    CsvParsingError {
83        message: String,
84        suggestion: String,
85        #[source]
86        source: Option<ErrorSource>,
87    },
88
89    #[error(
90        "File not found: {path}\nPlease check that the file exists and you have permission to read it"
91    )]
92    FileNotFound { path: String },
93
94    #[error(
95        "Unsupported file format: {format}\nSupported formats: {}",
96        supported_formats_hint()
97    )]
98    UnsupportedFormat { format: String },
99
100    #[error("Invalid configuration: {message}\n{suggestion}")]
101    #[non_exhaustive]
102    InvalidConfiguration {
103        message: String,
104        suggestion: String,
105        #[source]
106        source: Option<ErrorSource>,
107    },
108
109    #[error("Invalid semantic hint: {message}\n{suggestion}")]
110    InvalidSemanticHint { message: String, suggestion: String },
111
112    #[error("Streaming processing failed: {message}{}", suggestion_line(.suggestion))]
113    #[non_exhaustive]
114    StreamingError {
115        message: String,
116        suggestion: String,
117        #[source]
118        source: Option<ErrorSource>,
119    },
120
121    #[error("I/O error: {message}\nCheck file permissions and disk space")]
122    #[non_exhaustive]
123    IoError {
124        message: String,
125        #[source]
126        source: Option<ErrorSource>,
127    },
128
129    #[error(
130        "Non-UTF-8 input in {path}: {detail}\nRe-encode the file as UTF-8 (e.g. `iconv -f {guess} -t UTF-8 '{path}'`) and profile the result"
131    )]
132    EncodingError {
133        path: String,
134        detail: String,
135        guess: String,
136    },
137
138    /// A file whose signature bytes name a binary format (gzip, zstd, zip,
139    /// Parquet) was about to reach a text reader (#893). Built with
140    /// [`DataProfilerError::binary_input`].
141    #[error("Binary input in {path}: the file is {detected}, not text\n{suggestion}")]
142    #[non_exhaustive]
143    BinaryInput {
144        path: String,
145        /// What the signature says the file is, e.g. `gzip-compressed`.
146        detected: String,
147        suggestion: String,
148    },
149
150    /// A remote object changed between the ranged reads of one profile, so
151    /// the bytes read do not all come from one version of it (#865). Built
152    /// with [`DataProfilerError::source_changed`].
153    #[error("Source changed during the read: {path}: {detail}\n{suggestion}")]
154    #[non_exhaustive]
155    SourceChanged {
156        path: String,
157        /// What showed the change, e.g. the server answering 412 to `If-Match`.
158        detail: String,
159        suggestion: String,
160    },
161
162    #[error("JSON parsing failed: {message}\nVerify JSON format and encoding")]
163    #[non_exhaustive]
164    JsonParsingError {
165        message: String,
166        #[source]
167        source: Option<ErrorSource>,
168    },
169
170    #[error("Parquet processing failed: {message}")]
171    #[non_exhaustive]
172    ParquetError {
173        message: String,
174        #[source]
175        source: Option<ErrorSource>,
176    },
177
178    #[error("Arrow processing failed: {message}")]
179    #[non_exhaustive]
180    ArrowError {
181        message: String,
182        #[source]
183        source: Option<ErrorSource>,
184    },
185
186    #[error("Unsupported data source: {message}")]
187    UnsupportedDataSource { message: String },
188
189    #[error("All engines failed: {message}")]
190    AllEnginesFailed { message: String },
191
192    #[error("Metrics calculation failed: {message}")]
193    MetricsCalculationError { message: String },
194
195    #[error(
196        "Duplicate column name{plural}: {names}\nSource: {source_label}. Column names must be unique after normalization; rename or drop the duplicate column(s) before profiling."
197    )]
198    DuplicateColumnName {
199        names: String,
200        plural: String,
201        source_label: String,
202    },
203
204    #[error("Configuration validation failed: {message}")]
205    ConfigValidationError { message: String },
206
207    #[error("Database connection failed: {message}\n{suggestion}")]
208    DatabaseConnectionError { message: String, suggestion: String },
209
210    #[error("Database query failed: {message}")]
211    DatabaseQueryError { message: String },
212
213    #[error("Database configuration error: {message}")]
214    DatabaseConfigError { message: String },
215
216    #[error("Database feature not enabled: {message}\nRecompile with the appropriate feature flag")]
217    DatabaseFeatureDisabled { message: String },
218
219    #[error("SQL validation failed: {message}")]
220    SqlValidationError { message: String },
221
222    #[error("Database SSL/TLS error: {message}")]
223    DatabaseSslError { message: String },
224}
225
226impl DataProfilerError {
227    /// Create a database connection error.
228    ///
229    /// The message is scrubbed of URL credentials before storage so a driver
230    /// error that echoes the connection string cannot leak the password.
231    pub fn database_connection(message: &str) -> Self {
232        let message = redact_credentials(message);
233        let m = message.to_lowercase();
234        let suggestion = if m.contains("refused") {
235            "Check that the database server is running and accepting connections."
236        } else if m.contains("timeout") {
237            "Increase the connection timeout or check network connectivity."
238        } else if m.contains("authentication") || m.contains("password") {
239            "Verify your credentials or use environment variables for authentication."
240        } else {
241            "Verify the connection string format and database server availability."
242        };
243        DataProfilerError::DatabaseConnectionError {
244            message,
245            suggestion: suggestion.to_string(),
246        }
247    }
248
249    /// Create a database query error.
250    ///
251    /// Redacts URL credentials that a driver error might surface; the raw SQL
252    /// text and bound parameter values are never embedded by callers.
253    pub fn database_query(message: &str) -> Self {
254        DataProfilerError::DatabaseQueryError {
255            message: redact_credentials(message),
256        }
257    }
258
259    /// Create a database config error
260    pub fn database_config(message: &str) -> Self {
261        DataProfilerError::DatabaseConfigError {
262            message: message.to_string(),
263        }
264    }
265
266    /// Create a feature-not-enabled error
267    pub fn database_feature_disabled(db_name: &str, feature: &str) -> Self {
268        DataProfilerError::DatabaseFeatureDisabled {
269            message: format!(
270                "{} support not compiled. Enable '{}' feature.",
271                db_name, feature
272            ),
273        }
274    }
275
276    /// Create a SQL validation error
277    pub fn sql_validation(message: &str) -> Self {
278        DataProfilerError::SqlValidationError {
279            message: message.to_string(),
280        }
281    }
282
283    /// Create a database SSL error
284    pub fn database_ssl(message: &str) -> Self {
285        DataProfilerError::DatabaseSslError {
286            message: message.to_string(),
287        }
288    }
289    /// Create a duplicate-column-name error from the already-collected list of
290    /// repeated names and a short transport label.
291    ///
292    /// Only the offending names and the source are embedded — never row values —
293    /// so the diagnostic cannot leak cell data.
294    pub fn duplicate_column_name(duplicates: &[String], source: &str) -> Self {
295        let plural = if duplicates.len() == 1 { "" } else { "s" };
296        let names = duplicates
297            .iter()
298            .map(|n| format!("'{}'", n))
299            .collect::<Vec<_>>()
300            .join(", ");
301        DataProfilerError::DuplicateColumnName {
302            names,
303            plural: plural.to_string(),
304            source_label: source.to_string(),
305        }
306    }
307
308    /// Create a CSV parsing error with helpful suggestions.
309    ///
310    /// `file_path` is `None` at boundaries that do not know the source (e.g. the
311    /// `From<csv::Error>` conversion); pass `Some(path)` whenever it is known so
312    /// the suggestion can name the offending file instead of a placeholder.
313    pub fn csv_parsing(original_error: &str, file_path: Option<&str>) -> Self {
314        let suggestion = if original_error.contains("field") && original_error.contains("record") {
315            let file_clause = match file_path {
316                Some(path) => format!("The CSV file '{}' has", path),
317                None => "This CSV file has".to_string(),
318            };
319            format!(
320                "{} inconsistent column counts. This often happens with:\n  • Text fields containing commas without proper quoting\n  • Mixed line endings (Windows/Unix)\n  • Embedded newlines in data\n\n  dataprof will attempt to parse it with flexible mode automatically.",
321                file_clause
322            )
323        } else if original_error.contains("UTF-8") {
324            "The file contains non-UTF-8 characters. Try converting it to UTF-8 encoding."
325                .to_string()
326        } else if original_error.contains("permission") {
327            "Check file permissions - you may not have read access to this file.".to_string()
328        } else {
329            "Try using a different CSV delimiter or check for data formatting issues.".to_string()
330        };
331
332        DataProfilerError::CsvParsingError {
333            message: original_error.to_string(),
334            suggestion,
335            source: None,
336        }
337    }
338
339    /// Create a CSV parsing error that retains `original` as its source.
340    ///
341    /// The suggestion is derived from the same message text as
342    /// [`DataProfilerError::csv_parsing`], so the two forms render identically;
343    /// only the cause chain differs.
344    pub fn csv_parsing_from<E>(original: E, file_path: Option<&str>) -> Self
345    where
346        E: std::error::Error + Send + Sync + 'static,
347    {
348        let mut err = Self::csv_parsing(&original.to_string(), file_path);
349        if let DataProfilerError::CsvParsingError { source, .. } = &mut err {
350            *source = Some(Box::new(original));
351        }
352        err
353    }
354
355    /// Create a file not found error with path context
356    pub fn file_not_found<P: AsRef<str>>(path: P) -> Self {
357        DataProfilerError::FileNotFound {
358            path: path.as_ref().to_string(),
359        }
360    }
361
362    /// A file at `path` whose bytes carry `signature`, refused before a text
363    /// reader parses it.
364    pub fn binary_input(path: &std::path::Path, signature: BinarySignature) -> Self {
365        let path = path.display().to_string();
366        DataProfilerError::BinaryInput {
367            detected: signature.description().to_string(),
368            suggestion: signature.suggestion(&path),
369            path,
370        }
371    }
372
373    /// The object at `path` changed while it was being read; `detail` says
374    /// how that showed.
375    pub fn source_changed(path: &str, detail: impl Into<String>) -> Self {
376        DataProfilerError::SourceChanged {
377            path: path.to_string(),
378            detail: detail.into(),
379            suggestion: "Profile it again once writes to it have finished, or profile a copy that is not being rewritten.".to_string(),
380        }
381    }
382
383    /// Create unsupported format error with format detection
384    pub fn unsupported_format(extension: &str) -> Self {
385        DataProfilerError::UnsupportedFormat {
386            format: extension.to_string(),
387        }
388    }
389
390    /// Create configuration error with specific suggestion
391    pub fn invalid_config(message: &str, suggestion: &str) -> Self {
392        DataProfilerError::InvalidConfiguration {
393            message: message.to_string(),
394            suggestion: suggestion.to_string(),
395            source: None,
396        }
397    }
398
399    /// Create a configuration error that retains `original` as its source.
400    ///
401    /// Used by the TOML and glob conversions, where the underlying parser
402    /// reports a position worth reaching programmatically.
403    pub fn invalid_config_with_source<E>(
404        message: impl Into<String>,
405        suggestion: impl Into<String>,
406        original: E,
407    ) -> Self
408    where
409        E: std::error::Error + Send + Sync + 'static,
410    {
411        DataProfilerError::InvalidConfiguration {
412            message: message.into(),
413            suggestion: suggestion.into(),
414            source: Some(Box::new(original)),
415        }
416    }
417
418    /// Create streaming error with context
419    pub fn streaming_error(message: &str) -> Self {
420        Self::streaming_with_suggestion(message, "")
421    }
422
423    /// Create a streaming error with an actionable suggestion and no source.
424    ///
425    /// An empty suggestion means there is no remedy to offer the caller.
426    pub fn streaming_with_suggestion(
427        message: impl Into<String>,
428        suggestion: impl Into<String>,
429    ) -> Self {
430        DataProfilerError::StreamingError {
431            message: message.into(),
432            suggestion: suggestion.into(),
433            source: None,
434        }
435    }
436
437    /// Create a streaming error that retains `original` without changing its
438    /// contextual message, suggestion, or streaming category.
439    pub fn streaming_with_source<E>(
440        message: impl Into<String>,
441        suggestion: impl Into<String>,
442        original: E,
443    ) -> Self
444    where
445        E: std::error::Error + Send + Sync + 'static,
446    {
447        DataProfilerError::StreamingError {
448            message: message.into(),
449            suggestion: suggestion.into(),
450            source: Some(Box::new(original)),
451        }
452    }
453
454    /// Create an I/O error that retains `original` as its source.
455    ///
456    /// Takes the error by value: `std::io::Error` is not cloneable, so a
457    /// borrowed one could only ever be stringified, which is what this issue
458    /// set out to stop. Use `map_err(DataProfilerError::io_error)` at call
459    /// sites.
460    pub fn io_error(original: std::io::Error) -> Self {
461        DataProfilerError::IoError {
462            message: original.to_string(),
463            source: Some(Box::new(original)),
464        }
465    }
466
467    /// Create an I/O error from a message, for callers with no originating
468    /// error to retain (a condition detected by dataprof itself rather than
469    /// reported by the OS).
470    pub fn io_message(message: &str) -> Self {
471        DataProfilerError::IoError {
472            message: message.to_string(),
473            source: None,
474        }
475    }
476
477    /// Create a JSON parsing error with no retained source.
478    pub fn json_parsing_error(original: &str) -> Self {
479        DataProfilerError::JsonParsingError {
480            message: original.to_string(),
481            source: None,
482        }
483    }
484
485    /// Create a JSON parsing error that retains `original` as its source.
486    pub fn json_parsing_from<E>(original: E) -> Self
487    where
488        E: std::error::Error + Send + Sync + 'static,
489    {
490        DataProfilerError::JsonParsingError {
491            message: original.to_string(),
492            source: Some(Box::new(original)),
493        }
494    }
495
496    /// Create a CSV parsing error with a caller-supplied message and
497    /// suggestion that still retains `original` as its source.
498    ///
499    /// For callers whose suggestion is specific to their own context and so
500    /// cannot come from [`DataProfilerError::csv_parsing`]'s message analysis.
501    pub fn csv_parsing_with_source<E>(
502        message: impl Into<String>,
503        suggestion: impl Into<String>,
504        original: E,
505    ) -> Self
506    where
507        E: std::error::Error + Send + Sync + 'static,
508    {
509        DataProfilerError::CsvParsingError {
510            message: message.into(),
511            suggestion: suggestion.into(),
512            source: Some(Box::new(original)),
513        }
514    }
515
516    /// Create a CSV parsing error for a structural problem the parser itself
517    /// accepted, so there is no underlying error to keep as its source.
518    pub fn csv_structure(message: impl Into<String>, suggestion: impl Into<String>) -> Self {
519        DataProfilerError::CsvParsingError {
520            message: message.into(),
521            suggestion: suggestion.into(),
522            source: None,
523        }
524    }
525
526    /// Create a JSON parsing error with a caller-supplied message that still
527    /// retains `original` as its source.
528    ///
529    /// Used where the message adds context the decoder does not have (a record
530    /// position, a physical line number) and the decoder error is still worth
531    /// keeping underneath it.
532    pub fn json_parsing_with_source<E>(message: impl Into<String>, original: E) -> Self
533    where
534        E: std::error::Error + Send + Sync + 'static,
535    {
536        DataProfilerError::JsonParsingError {
537            message: message.into(),
538            source: Some(Box::new(original)),
539        }
540    }
541
542    /// Create an Arrow error with a caller-supplied message that still retains
543    /// `original` as its source.
544    pub fn arrow_with_source<E>(message: impl Into<String>, original: E) -> Self
545    where
546        E: std::error::Error + Send + Sync + 'static,
547    {
548        DataProfilerError::ArrowError {
549            message: message.into(),
550            source: Some(Box::new(original)),
551        }
552    }
553
554    /// Create a Parquet error with a caller-supplied message that still retains
555    /// `original` as its source.
556    pub fn parquet_with_source<E>(message: impl Into<String>, original: E) -> Self
557    where
558        E: std::error::Error + Send + Sync + 'static,
559    {
560        DataProfilerError::ParquetError {
561            message: message.into(),
562            source: Some(Box::new(original)),
563        }
564    }
565
566    /// Create an I/O error with a caller-supplied message that still retains
567    /// `original` as its source.
568    pub fn io_with_source<E>(message: impl Into<String>, original: E) -> Self
569    where
570        E: std::error::Error + Send + Sync + 'static,
571    {
572        DataProfilerError::IoError {
573            message: message.into(),
574            source: Some(Box::new(original)),
575        }
576    }
577
578    /// Create an Arrow error with no retained source.
579    pub fn arrow_error(message: &str) -> Self {
580        DataProfilerError::ArrowError {
581            message: message.to_string(),
582            source: None,
583        }
584    }
585
586    /// Create an Arrow error that retains `original` as its source.
587    pub fn arrow_error_from<E>(original: E) -> Self
588    where
589        E: std::error::Error + Send + Sync + 'static,
590    {
591        DataProfilerError::ArrowError {
592            message: original.to_string(),
593            source: Some(Box::new(original)),
594        }
595    }
596
597    /// Create a Parquet error with no retained source.
598    pub fn parquet_error(message: &str) -> Self {
599        DataProfilerError::ParquetError {
600            message: message.to_string(),
601            source: None,
602        }
603    }
604
605    /// The actionable next step for this error, as structured context.
606    ///
607    /// Suggestions live in the variants and the `Display` string, but callers
608    /// that want to react programmatically (or render the hint separately from
609    /// the message) should read it here rather than parse the formatted output.
610    pub fn suggestion(&self) -> Option<String> {
611        match self {
612            DataProfilerError::CsvParsingError { suggestion, .. }
613            | DataProfilerError::InvalidConfiguration { suggestion, .. }
614            | DataProfilerError::InvalidSemanticHint { suggestion, .. }
615            | DataProfilerError::BinaryInput { suggestion, .. }
616            | DataProfilerError::SourceChanged { suggestion, .. }
617            | DataProfilerError::DatabaseConnectionError { suggestion, .. } => {
618                Some(suggestion.clone())
619            }
620            DataProfilerError::FileNotFound { .. } => {
621                Some("Check that the file exists and you have permission to read it.".to_string())
622            }
623            DataProfilerError::UnsupportedFormat { .. } => Some(format!(
624                "This build reads {}. Convert the input to one of these formats, or rebuild with the feature for the format you need.",
625                supported_formats_hint()
626            )),
627            DataProfilerError::EncodingError { guess, .. } => Some(format!(
628                "Re-encode the file as UTF-8 (e.g. `iconv -f {guess} -t UTF-8`) and profile the result."
629            )),
630            DataProfilerError::StreamingError { suggestion, .. } => {
631                if suggestion.is_empty() {
632                    None
633                } else {
634                    Some(suggestion.clone())
635                }
636            }
637            DataProfilerError::DatabaseFeatureDisabled { .. } => {
638                Some("Recompile with the appropriate database feature flag.".to_string())
639            }
640            _ => None,
641        }
642    }
643
644    /// Get error category for logging/metrics
645    pub fn category(&self) -> &'static str {
646        match self {
647            DataProfilerError::CsvParsingError { .. } => "csv_parsing",
648            DataProfilerError::FileNotFound { .. } => "file_not_found",
649            DataProfilerError::UnsupportedFormat { .. } => "unsupported_format",
650            DataProfilerError::EncodingError { .. } => "encoding",
651            DataProfilerError::BinaryInput { .. } => "binary_input",
652            DataProfilerError::SourceChanged { .. } => "source_changed",
653            DataProfilerError::InvalidConfiguration { .. } => "configuration",
654            DataProfilerError::InvalidSemanticHint { .. } => "semantic_hint",
655            DataProfilerError::StreamingError { .. } => "streaming",
656            DataProfilerError::IoError { .. } => "io",
657            DataProfilerError::JsonParsingError { .. } => "json_parsing",
658            DataProfilerError::ParquetError { .. } => "parquet",
659            DataProfilerError::ArrowError { .. } => "arrow",
660            DataProfilerError::UnsupportedDataSource { .. } => "unsupported_data_source",
661            DataProfilerError::AllEnginesFailed { .. } => "all_engines_failed",
662            DataProfilerError::MetricsCalculationError { .. } => "metrics_calculation",
663            DataProfilerError::DuplicateColumnName { .. } => "duplicate_column_name",
664            DataProfilerError::ConfigValidationError { .. } => "config_validation",
665            DataProfilerError::DatabaseConnectionError { .. } => "database_connection",
666            DataProfilerError::DatabaseQueryError { .. } => "database_query",
667            DataProfilerError::DatabaseConfigError { .. } => "database_config",
668            DataProfilerError::DatabaseFeatureDisabled { .. } => "database_feature_disabled",
669            DataProfilerError::SqlValidationError { .. } => "sql_validation",
670            DataProfilerError::DatabaseSslError { .. } => "database_ssl",
671        }
672    }
673}
674
675/// Convert from anyhow::Error to DataProfilerError with context.
676///
677/// These conversions run where the source path/operation is *not* known, so they
678/// never fabricate one: a path is only ever attached by the call sites that hold
679/// it (see [`DataProfilerError::csv_parsing`], `map_io_error`, and the facade's
680/// existence check). Preserving the original message keeps the source diagnostic.
681impl From<anyhow::Error> for DataProfilerError {
682    fn from(err: anyhow::Error) -> Self {
683        let error_str = err.to_string();
684
685        // The anyhow error is retained as the source in every branch, so a
686        // caller can still reach the concrete error underneath it via
687        // `source()` even though categorisation here is message-based.
688        let source: Option<ErrorSource> = Some(err.into());
689
690        // Categorize by message only; do not invent a path we do not have.
691        if error_str.contains("CSV") {
692            DataProfilerError::CsvParsingError {
693                message: error_str,
694                suggestion: "Try using robust CSV parsing mode".to_string(),
695                source,
696            }
697        } else if error_str.contains("JSON") {
698            DataProfilerError::JsonParsingError {
699                message: error_str,
700                source,
701            }
702        } else {
703            // Generic I/O error — the original message carries the detail
704            // (including "file not found" wording) without a fabricated path.
705            DataProfilerError::IoError {
706                message: error_str,
707                source,
708            }
709        }
710    }
711}
712
713/// Convert from std::io::Error to DataProfilerError.
714///
715/// Path-less by design: callers that know the file use `map_io_error` to produce
716/// a [`DataProfilerError::FileNotFound`] with the real path. Here we keep the OS
717/// message (which already names the condition) rather than guessing a path.
718impl From<std::io::Error> for DataProfilerError {
719    fn from(err: std::io::Error) -> Self {
720        // The message is still rewritten for PermissionDenied, but the original
721        // error is retained either way: `err.source()` now yields the
722        // `std::io::Error`, so a caller can match on `kind()` instead of
723        // pattern-matching the human-readable text.
724        let message = match err.kind() {
725            std::io::ErrorKind::PermissionDenied => {
726                "Permission denied - check file access rights".to_string()
727            }
728            _ => err.to_string(),
729        };
730        DataProfilerError::IoError {
731            message,
732            source: Some(Box::new(err)),
733        }
734    }
735}
736
737/// Convert from csv::Error to DataProfilerError with enhanced context.
738///
739/// The source path is unknown at this boundary, so the suggestion is written
740/// without a filename rather than the misleading `'unknown'` placeholder.
741impl From<csv::Error> for DataProfilerError {
742    fn from(err: csv::Error) -> Self {
743        DataProfilerError::csv_parsing_from(err, None)
744    }
745}
746
747/// Convert from arrow::error::ArrowError to DataProfilerError
748#[cfg(feature = "arrow")]
749impl From<arrow::error::ArrowError> for DataProfilerError {
750    fn from(err: arrow::error::ArrowError) -> Self {
751        DataProfilerError::arrow_error_from(err)
752    }
753}
754
755/// Convert from serde_json::Error to DataProfilerError
756impl From<serde_json::Error> for DataProfilerError {
757    fn from(err: serde_json::Error) -> Self {
758        DataProfilerError::json_parsing_from(err)
759    }
760}
761
762#[cfg(test)]
763mod tests {
764    use super::*;
765
766    #[test]
767    fn test_error_categorization() {
768        let csv_error = DataProfilerError::csv_parsing("field count mismatch", Some("test.csv"));
769        assert_eq!(csv_error.category(), "csv_parsing");
770    }
771
772    #[test]
773    fn test_error_suggestions() {
774        let config_error = DataProfilerError::invalid_config(
775            "Invalid chunk size",
776            "Use a value between 1000 and 100000",
777        );
778
779        let error_string = config_error.to_string();
780        assert!(error_string.contains("Invalid chunk size"));
781        assert!(error_string.contains("Use a value between"));
782    }
783
784    #[test]
785    fn streaming_error_without_suggestion_prints_no_stale_advice() {
786        let err = DataProfilerError::streaming_error("Reader task panicked: boxed error");
787        let msg = err.to_string();
788        assert!(msg.contains("Reader task panicked"));
789        assert!(!msg.contains("chunk size"), "stale remedy leaked: {msg}");
790        assert!(err.suggestion().is_none());
791        assert!(std::error::Error::source(&err).is_none());
792        // No remedy means no line for one: an unconditional "\n{suggestion}"
793        // leaves the message ending in a bare newline, which surfaces in Python
794        // as a blank line under the error.
795        assert!(
796            !msg.ends_with('\n'),
797            "message ends in a bare newline: {msg:?}"
798        );
799        assert!(
800            !msg.contains('\n'),
801            "empty remedy still opened a line: {msg:?}"
802        );
803    }
804
805    #[test]
806    fn streaming_error_with_suggestion_prints_the_remedy_exactly_once() {
807        let err = DataProfilerError::streaming_with_suggestion(
808            "Parquet schema inference requires random access",
809            "Use infer_schema() with a file path instead",
810        );
811        let msg = err.to_string();
812        assert!(msg.contains("random access"), "{msg}");
813        assert_eq!(
814            msg.matches("Use infer_schema() with a file path instead")
815                .count(),
816            1,
817            "remedy must appear exactly once: {msg}"
818        );
819        assert!(!msg.contains("chunk size"), "stale remedy leaked: {msg}");
820        // A remedy still gets its own line, and only one.
821        assert_eq!(
822            msg.matches('\n').count(),
823            1,
824            "remedy is not on its own line: {msg:?}"
825        );
826        assert!(
827            !msg.ends_with('\n'),
828            "trailing newline after the remedy: {msg:?}"
829        );
830        assert_eq!(
831            err.suggestion(),
832            Some("Use infer_schema() with a file path instead".to_string())
833        );
834    }
835
836    #[test]
837    fn encoding_error_names_file_and_gives_reencode_advice() {
838        let err = DataProfilerError::EncodingError {
839            path: "sales.csv".to_string(),
840            detail: "first invalid UTF-8 byte at offset 12".to_string(),
841            guess: "windows-1252".to_string(),
842        };
843        assert_eq!(err.category(), "encoding");
844        let msg = err.to_string();
845        assert!(msg.contains("sales.csv"), "names the file: {msg}");
846        assert!(msg.contains("offset 12"), "reports the offset: {msg}");
847        assert!(
848            msg.to_lowercase().contains("utf-8"),
849            "mentions UTF-8: {msg}"
850        );
851        assert!(msg.contains("iconv"), "gives a re-encode command: {msg}");
852        // The old, misleading advice must not appear.
853        assert!(
854            !msg.to_lowercase().contains("check file permissions"),
855            "{msg}"
856        );
857        assert!(!msg.contains("All engines failed"), "{msg}");
858        assert!(err.suggestion().unwrap().contains("windows-1252"));
859    }
860
861    #[test]
862    fn io_conversion_never_fabricates_a_path() {
863        // A NotFound io::Error carries no path here; the conversion must not
864        // invent one (the `'unknown'` placeholder the contract work removed).
865        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
866        let err = DataProfilerError::from(io_err);
867        assert_eq!(err.category(), "io");
868        assert!(!err.to_string().contains("unknown"));
869    }
870
871    #[test]
872    fn csv_conversion_without_path_omits_the_filename() {
873        // The `From<csv::Error>` boundary does not know the source file, so the
874        // suggestion must not print `'unknown'` as if it were the filename.
875        let err = DataProfilerError::csv_parsing("record has 5 fields but 3 expected", None);
876        let msg = err.to_string();
877        assert!(!msg.contains("unknown"));
878        assert!(msg.contains("This CSV file has inconsistent column counts"));
879    }
880
881    #[test]
882    fn csv_conversion_with_path_names_the_file() {
883        let err =
884            DataProfilerError::csv_parsing("record has 5 fields but 3 expected", Some("data.csv"));
885        assert!(err.to_string().contains("The CSV file 'data.csv' has"));
886    }
887
888    #[test]
889    fn file_not_found_keeps_the_real_path() {
890        let err = DataProfilerError::file_not_found("/data/sales.csv");
891        assert_eq!(err.category(), "file_not_found");
892        assert!(err.to_string().contains("/data/sales.csv"));
893    }
894
895    #[test]
896    fn unsupported_format_advertises_only_buildable_formats() {
897        let err = DataProfilerError::unsupported_format("xlsx");
898        let msg = err.to_string();
899        assert!(msg.contains("CSV, JSON, JSONL"));
900        // Parquet is only claimed when the build can actually read it.
901        #[cfg(feature = "parquet")]
902        assert!(msg.contains("Parquet"));
903        #[cfg(not(feature = "parquet"))]
904        assert!(!msg.contains("Parquet"));
905    }
906
907    #[test]
908    fn suggestion_is_available_as_structured_context() {
909        let err = DataProfilerError::unsupported_format("xlsx");
910        assert!(err.suggestion().is_some());
911        let hint = DataProfilerError::streaming_error("reader task panicked");
912        assert!(hint.suggestion().is_none());
913    }
914
915    #[test]
916    fn redact_credentials_scrubs_userinfo() {
917        assert_eq!(
918            redact_credentials("postgresql://admin:s3cret@db.internal:5432/sales"),
919            "postgresql://***@db.internal:5432/sales"
920        );
921        // No userinfo → untouched.
922        assert_eq!(
923            redact_credentials("mysql://db.internal:3306/app"),
924            "mysql://db.internal:3306/app"
925        );
926        // Embedded in a driver error message.
927        let msg = "Failed to connect: error with url mysql://root:hunter2@localhost/db";
928        let redacted = redact_credentials(msg);
929        assert!(!redacted.contains("hunter2"));
930        assert!(redacted.contains("mysql://***@localhost/db"));
931    }
932
933    #[test]
934    fn database_connection_error_redacts_password() {
935        let err = DataProfilerError::database_connection(
936            "pool timed out connecting to postgres://user:topsecret@host/db",
937        );
938        assert!(!err.to_string().contains("topsecret"));
939    }
940
941    // --- source chain (#447) ---------------------------------------------
942    //
943    // The point of retaining a source is that a caller can recover the
944    // *concrete* originating error, not just a string that reads like it. Each
945    // test below therefore downcasts, rather than comparing rendered text.
946
947    use std::error::Error as _;
948
949    #[test]
950    fn io_error_retains_the_os_error_and_its_kind() {
951        let original = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied by acl");
952        let err = DataProfilerError::io_error(original);
953
954        let source = err.source().expect("io error must retain a source");
955        let io: &std::io::Error = source
956            .downcast_ref()
957            .expect("source must downcast to the original std::io::Error");
958        assert_eq!(io.kind(), std::io::ErrorKind::PermissionDenied);
959    }
960
961    #[test]
962    fn from_io_error_retains_the_kind_even_when_the_message_is_rewritten() {
963        // PermissionDenied is the one branch that replaces the OS text, so it is
964        // the case where a flattened source would lose the most.
965        let original = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied by acl");
966        let err: DataProfilerError = original.into();
967
968        assert!(err.to_string().contains("Permission denied"));
969        let io: &std::io::Error = err
970            .source()
971            .and_then(|s| s.downcast_ref())
972            .expect("rewritten message must still retain the io::Error");
973        assert_eq!(io.kind(), std::io::ErrorKind::PermissionDenied);
974        assert!(io.to_string().contains("denied by acl"));
975    }
976
977    #[test]
978    fn from_json_error_retains_line_and_column() {
979        let original = serde_json::from_str::<serde_json::Value>("{oops}").unwrap_err();
980        let (line, column) = (original.line(), original.column());
981        let err: DataProfilerError = original.into();
982
983        let json: &serde_json::Error = err
984            .source()
985            .and_then(|s| s.downcast_ref())
986            .expect("source must downcast to serde_json::Error");
987        assert_eq!((json.line(), json.column()), (line, column));
988    }
989
990    #[test]
991    fn from_csv_error_retains_the_csv_error_kind() {
992        let mut reader = csv::ReaderBuilder::new()
993            .flexible(false)
994            .from_reader("a,b\n1,2,3\n".as_bytes());
995        let original = reader
996            .records()
997            .next()
998            .expect("one record")
999            .expect_err("ragged row must fail with flexible(false)");
1000        let err: DataProfilerError = original.into();
1001
1002        let csv: &csv::Error = err
1003            .source()
1004            .and_then(|s| s.downcast_ref())
1005            .expect("source must downcast to csv::Error");
1006        assert!(matches!(csv.kind(), csv::ErrorKind::UnequalLengths { .. }));
1007    }
1008
1009    // Gated exactly like the conversion it covers: without the feature there is
1010    // no `From<ArrowError>` impl to test.
1011    #[cfg(feature = "arrow")]
1012    #[test]
1013    fn from_arrow_error_retains_the_arrow_error() {
1014        let original = arrow::error::ArrowError::ComputeError("overflow in sum".to_string());
1015        let expected = original.to_string();
1016        let err: DataProfilerError = original.into();
1017
1018        assert!(matches!(err, DataProfilerError::ArrowError { .. }));
1019        let arrow_err: &arrow::error::ArrowError = err
1020            .source()
1021            .and_then(|s| s.downcast_ref())
1022            .expect("source must downcast to arrow::error::ArrowError");
1023        assert_eq!(arrow_err.to_string(), expected);
1024        assert!(matches!(
1025            arrow_err,
1026            arrow::error::ArrowError::ComputeError(_)
1027        ));
1028    }
1029
1030    // Parquet failures reach the user through `parquet_with_source`, which the
1031    // reader paths use; this covers that constructor's retention contract with
1032    // a stand-in error so the test needs no parquet dependency here.
1033    #[test]
1034    fn parquet_with_source_retains_the_reader_error() {
1035        let original = std::io::Error::new(std::io::ErrorKind::InvalidData, "Corrupt footer");
1036        let err =
1037            DataProfilerError::parquet_with_source("Failed to create Parquet reader", original);
1038
1039        assert!(err.to_string().contains("Failed to create Parquet reader"));
1040        let io: &std::io::Error = err
1041            .source()
1042            .and_then(|s| s.downcast_ref())
1043            .expect("parquet errors must retain the reader failure");
1044        assert_eq!(io.kind(), std::io::ErrorKind::InvalidData);
1045    }
1046
1047    #[test]
1048    fn errors_built_without_a_cause_report_no_source() {
1049        // "No source" must stay distinguishable from "source not retained":
1050        // these variants genuinely have no originating error underneath.
1051        assert!(
1052            DataProfilerError::io_message("disk budget exceeded")
1053                .source()
1054                .is_none()
1055        );
1056        assert!(
1057            DataProfilerError::json_parsing_error("not an object")
1058                .source()
1059                .is_none()
1060        );
1061        assert!(
1062            DataProfilerError::arrow_error("downcast failed")
1063                .source()
1064                .is_none()
1065        );
1066        assert!(
1067            DataProfilerError::file_not_found("/nope")
1068                .source()
1069                .is_none()
1070        );
1071    }
1072
1073    #[test]
1074    fn retaining_a_source_does_not_change_the_rendered_message() {
1075        // The #373 message contract is independent of the cause chain: the two
1076        // constructions must be indistinguishable in output.
1077        let original = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
1078        let text = original.to_string();
1079
1080        let with_source = DataProfilerError::io_error(original).to_string();
1081        let without_source = DataProfilerError::io_message(&text).to_string();
1082        assert_eq!(with_source, without_source);
1083    }
1084
1085    #[test]
1086    fn the_whole_chain_is_walkable_to_the_root() {
1087        let original = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "truncated");
1088        let err = DataProfilerError::io_with_source("reading chunk 4", original);
1089
1090        let mut depth = 0;
1091        let mut current: Option<&(dyn std::error::Error + 'static)> = Some(&err);
1092        while let Some(e) = current {
1093            current = e.source();
1094            depth += 1;
1095        }
1096        // Exactly two links: the DataProfilerError and the io::Error beneath it.
1097        // A wrapper type in between would make this 3 and break downcasting.
1098        assert_eq!(depth, 2);
1099    }
1100}