Skip to main content

dataprof_core/
errors.rs

1use thiserror::Error;
2
3/// The file formats this build can actually read, reflecting compiled features.
4///
5/// The list must never over-claim: Parquet is only advertised when the `parquet`
6/// feature is enabled, so an "unsupported format" error cannot point the user at
7/// a format the installed build cannot open.
8fn supported_formats_hint() -> &'static str {
9    #[cfg(feature = "parquet")]
10    {
11        "CSV, JSON, JSONL, Parquet"
12    }
13    #[cfg(not(feature = "parquet"))]
14    {
15        "CSV, JSON, JSONL"
16    }
17}
18
19/// Render a remedy as its own line, or nothing at all when there is none.
20///
21/// Streaming failures are the one family where "no advice" is a real answer: a
22/// panicked task has no remedy the caller can act on, and #550 removed the
23/// hardcoded chunk-size line that used to fill the gap. Formatting the
24/// suggestion unconditionally would end those messages in a bare newline.
25fn suggestion_line(suggestion: &str) -> String {
26    if suggestion.is_empty() {
27        String::new()
28    } else {
29        format!("\n{suggestion}")
30    }
31}
32
33/// Redact credentials from a string before it enters an error message.
34///
35/// Connection strings and driver errors can carry `scheme://user:password@host`.
36/// We collapse the userinfo to `***` so passwords (and usernames) never reach a
37/// log line or a surfaced diagnostic, while keeping the scheme/host for context.
38pub fn redact_credentials(input: &str) -> String {
39    // Match the `://[userinfo@]` segment of any URL-like token and drop userinfo.
40    let mut out = String::with_capacity(input.len());
41    let mut rest = input;
42    while let Some(scheme_idx) = rest.find("://") {
43        let after_scheme = scheme_idx + 3;
44        out.push_str(&rest[..after_scheme]);
45        let tail = &rest[after_scheme..];
46        // Userinfo, if present, ends at the first '@' before the next authority
47        // delimiter ('/', '?', '#', or whitespace).
48        let authority_end = tail
49            .find(['/', '?', '#', ' ', '\t', '\n'])
50            .unwrap_or(tail.len());
51        if let Some(at_rel) = tail[..authority_end].find('@') {
52            out.push_str("***");
53            rest = &tail[at_rel..]; // keep '@host...'
54        } else {
55            rest = tail;
56        }
57    }
58    out.push_str(rest);
59    out
60}
61
62/// Auto-retry configuration for error recovery
63#[derive(Debug, Clone)]
64pub struct RetryConfig {
65    pub max_attempts: usize,
66    pub enable_delimiter_detection: bool,
67    pub enable_encoding_detection: bool,
68    pub enable_flexible_parsing: bool,
69}
70
71impl Default for RetryConfig {
72    fn default() -> Self {
73        Self {
74            max_attempts: 3,
75            enable_delimiter_detection: true,
76            enable_encoding_detection: true,
77            enable_flexible_parsing: true,
78        }
79    }
80}
81
82/// Result of an auto-recovery attempt
83#[derive(Debug, Clone)]
84pub struct RecoveryAttempt {
85    pub attempt_number: usize,
86    pub strategy: RecoveryStrategy,
87    pub success: bool,
88    pub error_message: Option<String>,
89}
90
91/// Strategies for error recovery
92#[derive(Debug, Clone)]
93pub enum RecoveryStrategy {
94    DelimiterDetection { delimiter: char },
95    EncodingConversion { from: String, to: String },
96    FlexibleParsing,
97    ChunkSizeReduction { new_size: usize },
98    MemoryOptimization,
99}
100
101/// Boxed originating error retained by the variants that can carry a cause.
102///
103/// `Box` rather than `Arc` is deliberate. `thiserror` renders a boxed source
104/// through `&**b`, so [`std::error::Error::source`] hands back the *concrete*
105/// error and `downcast_ref::<std::io::Error>()` succeeds. Behind an `Arc` the
106/// chain instead yields the `Arc`: the message still reads correctly while
107/// every downcast returns `None`, which is the exact capability this type
108/// exists to provide.
109pub type ErrorSource = Box<dyn std::error::Error + Send + Sync + 'static>;
110
111/// Enhanced error types with more descriptive messages for DataProfiler
112///
113/// Not `Clone`: a retained [`ErrorSource`] is a boxed trait object, and the
114/// originating errors (`std::io::Error`, `csv::Error`, ...) are not cloneable
115/// themselves. Cloning an error was only ever used by the recovery manager,
116/// which now takes ownership instead.
117#[derive(Error, Debug)]
118pub enum DataProfilerError {
119    #[error("CSV parsing failed: {message}\nSuggestion: {suggestion}")]
120    #[non_exhaustive]
121    CsvParsingError {
122        message: String,
123        suggestion: String,
124        #[source]
125        source: Option<ErrorSource>,
126    },
127
128    #[error(
129        "File not found: {path}\nPlease check that the file exists and you have permission to read it"
130    )]
131    FileNotFound { path: String },
132
133    #[error(
134        "Unsupported file format: {format}\nSupported formats: {}",
135        supported_formats_hint()
136    )]
137    UnsupportedFormat { format: String },
138
139    #[error(
140        "Memory limit exceeded while processing large file\nTry using streaming mode or increase available memory"
141    )]
142    MemoryLimitExceeded,
143
144    #[error("Invalid configuration: {message}\n{suggestion}")]
145    #[non_exhaustive]
146    InvalidConfiguration {
147        message: String,
148        suggestion: String,
149        #[source]
150        source: Option<ErrorSource>,
151    },
152
153    #[error("Invalid semantic hint: {message}\n{suggestion}")]
154    InvalidSemanticHint { message: String, suggestion: String },
155
156    #[error(
157        "Data quality issue detected: {issue}\nImpact: {impact}\nRecommendation: {recommendation}"
158    )]
159    DataQualityIssue {
160        issue: String,
161        impact: String,
162        recommendation: String,
163    },
164
165    #[error("Streaming processing failed: {message}{}", suggestion_line(.suggestion))]
166    StreamingError { message: String, suggestion: String },
167
168    #[error("SIMD acceleration not available: {reason}\nFalling back to standard processing")]
169    SimdUnavailable { reason: String },
170
171    #[error("Sampling error: {message}\n{suggestion}")]
172    SamplingError { message: String, suggestion: String },
173
174    #[error("I/O error: {message}\nCheck file permissions and disk space")]
175    #[non_exhaustive]
176    IoError {
177        message: String,
178        #[source]
179        source: Option<ErrorSource>,
180    },
181
182    #[error(
183        "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"
184    )]
185    EncodingError {
186        path: String,
187        detail: String,
188        guess: String,
189    },
190
191    #[error("JSON parsing failed: {message}\nVerify JSON format and encoding")]
192    #[non_exhaustive]
193    JsonParsingError {
194        message: String,
195        #[source]
196        source: Option<ErrorSource>,
197    },
198
199    #[error("Column analysis failed for '{column}': {reason}\n{suggestion}")]
200    ColumnAnalysisError {
201        column: String,
202        reason: String,
203        suggestion: String,
204    },
205
206    #[error(
207        "Recoverable error (attempt {attempt}/{max_attempts}): {message}\n{recovery_suggestion}"
208    )]
209    RecoverableError {
210        message: String,
211        recovery_suggestion: String,
212        attempt: usize,
213        max_attempts: usize,
214        recovery_attempts: Vec<RecoveryAttempt>,
215    },
216
217    #[error(
218        "Auto-recovery failed after {attempts} attempts\nLast strategy tried: {last_strategy}\nRecovery log: {recovery_log}"
219    )]
220    RecoveryFailed {
221        attempts: usize,
222        last_strategy: String,
223        recovery_log: String,
224        original_error: String,
225    },
226
227    #[error("Parquet processing failed: {message}")]
228    #[non_exhaustive]
229    ParquetError {
230        message: String,
231        #[source]
232        source: Option<ErrorSource>,
233    },
234
235    #[error("Arrow processing failed: {message}")]
236    #[non_exhaustive]
237    ArrowError {
238        message: String,
239        #[source]
240        source: Option<ErrorSource>,
241    },
242
243    #[error("Unsupported data source: {message}")]
244    UnsupportedDataSource { message: String },
245
246    #[error("All engines failed: {message}")]
247    AllEnginesFailed { message: String },
248
249    #[error("Metrics calculation failed: {message}")]
250    MetricsCalculationError { message: String },
251
252    #[error(
253        "Duplicate column name{plural}: {names}\nSource: {source_label}. Column names must be unique after normalization; rename or drop the duplicate column(s) before profiling."
254    )]
255    DuplicateColumnName {
256        names: String,
257        plural: String,
258        source_label: String,
259    },
260
261    #[error("Configuration validation failed: {message}")]
262    ConfigValidationError { message: String },
263
264    #[error("Database connection failed: {message}\n{suggestion}")]
265    DatabaseConnectionError { message: String, suggestion: String },
266
267    #[error("Database query failed: {message}")]
268    DatabaseQueryError { message: String },
269
270    #[error("Database configuration error: {message}")]
271    DatabaseConfigError { message: String },
272
273    #[error("Database feature not enabled: {message}\nRecompile with the appropriate feature flag")]
274    DatabaseFeatureDisabled { message: String },
275
276    #[error("SQL validation failed: {message}")]
277    SqlValidationError { message: String },
278
279    #[error("Database SSL/TLS error: {message}")]
280    DatabaseSslError { message: String },
281
282    #[error(
283        "Database retry exhausted: operation '{operation}' failed after {attempts} attempts\nLast error: {last_error}"
284    )]
285    DatabaseRetryExhausted {
286        operation: String,
287        attempts: u32,
288        last_error: String,
289    },
290}
291
292impl DataProfilerError {
293    /// Create a database connection error.
294    ///
295    /// The message is scrubbed of URL credentials before storage so a driver
296    /// error that echoes the connection string cannot leak the password.
297    pub fn database_connection(message: &str) -> Self {
298        let message = redact_credentials(message);
299        let m = message.to_lowercase();
300        let suggestion = if m.contains("refused") {
301            "Check that the database server is running and accepting connections."
302        } else if m.contains("timeout") {
303            "Increase the connection timeout or check network connectivity."
304        } else if m.contains("authentication") || m.contains("password") {
305            "Verify your credentials or use environment variables for authentication."
306        } else {
307            "Verify the connection string format and database server availability."
308        };
309        DataProfilerError::DatabaseConnectionError {
310            message,
311            suggestion: suggestion.to_string(),
312        }
313    }
314
315    /// Create a database query error.
316    ///
317    /// Redacts URL credentials that a driver error might surface; the raw SQL
318    /// text and bound parameter values are never embedded by callers.
319    pub fn database_query(message: &str) -> Self {
320        DataProfilerError::DatabaseQueryError {
321            message: redact_credentials(message),
322        }
323    }
324
325    /// Create a database config error
326    pub fn database_config(message: &str) -> Self {
327        DataProfilerError::DatabaseConfigError {
328            message: message.to_string(),
329        }
330    }
331
332    /// Create a feature-not-enabled error
333    pub fn database_feature_disabled(db_name: &str, feature: &str) -> Self {
334        DataProfilerError::DatabaseFeatureDisabled {
335            message: format!(
336                "{} support not compiled. Enable '{}' feature.",
337                db_name, feature
338            ),
339        }
340    }
341
342    /// Create a SQL validation error
343    pub fn sql_validation(message: &str) -> Self {
344        DataProfilerError::SqlValidationError {
345            message: message.to_string(),
346        }
347    }
348
349    /// Create a database SSL error
350    pub fn database_ssl(message: &str) -> Self {
351        DataProfilerError::DatabaseSslError {
352            message: message.to_string(),
353        }
354    }
355    /// Create a duplicate-column-name error from the already-collected list of
356    /// repeated names and a short transport label.
357    ///
358    /// Only the offending names and the source are embedded — never row values —
359    /// so the diagnostic cannot leak cell data.
360    pub fn duplicate_column_name(duplicates: &[String], source: &str) -> Self {
361        let plural = if duplicates.len() == 1 { "" } else { "s" };
362        let names = duplicates
363            .iter()
364            .map(|n| format!("'{}'", n))
365            .collect::<Vec<_>>()
366            .join(", ");
367        DataProfilerError::DuplicateColumnName {
368            names,
369            plural: plural.to_string(),
370            source_label: source.to_string(),
371        }
372    }
373
374    /// Create a CSV parsing error with helpful suggestions.
375    ///
376    /// `file_path` is `None` at boundaries that do not know the source (e.g. the
377    /// `From<csv::Error>` conversion); pass `Some(path)` whenever it is known so
378    /// the suggestion can name the offending file instead of a placeholder.
379    pub fn csv_parsing(original_error: &str, file_path: Option<&str>) -> Self {
380        let suggestion = if original_error.contains("field") && original_error.contains("record") {
381            let file_clause = match file_path {
382                Some(path) => format!("The CSV file '{}' has", path),
383                None => "This CSV file has".to_string(),
384            };
385            format!(
386                "{} 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.",
387                file_clause
388            )
389        } else if original_error.contains("UTF-8") {
390            "The file contains non-UTF-8 characters. Try converting it to UTF-8 encoding."
391                .to_string()
392        } else if original_error.contains("permission") {
393            "Check file permissions - you may not have read access to this file.".to_string()
394        } else {
395            "Try using a different CSV delimiter or check for data formatting issues.".to_string()
396        };
397
398        DataProfilerError::CsvParsingError {
399            message: original_error.to_string(),
400            suggestion,
401            source: None,
402        }
403    }
404
405    /// Create a CSV parsing error that retains `original` as its source.
406    ///
407    /// The suggestion is derived from the same message text as
408    /// [`DataProfilerError::csv_parsing`], so the two forms render identically;
409    /// only the cause chain differs.
410    pub fn csv_parsing_from<E>(original: E, file_path: Option<&str>) -> Self
411    where
412        E: std::error::Error + Send + Sync + 'static,
413    {
414        let mut err = Self::csv_parsing(&original.to_string(), file_path);
415        if let DataProfilerError::CsvParsingError { source, .. } = &mut err {
416            *source = Some(Box::new(original));
417        }
418        err
419    }
420
421    /// Create a file not found error with path context
422    pub fn file_not_found<P: AsRef<str>>(path: P) -> Self {
423        DataProfilerError::FileNotFound {
424            path: path.as_ref().to_string(),
425        }
426    }
427
428    /// Create unsupported format error with format detection
429    pub fn unsupported_format(extension: &str) -> Self {
430        DataProfilerError::UnsupportedFormat {
431            format: extension.to_string(),
432        }
433    }
434
435    /// Create configuration error with specific suggestion
436    pub fn invalid_config(message: &str, suggestion: &str) -> Self {
437        DataProfilerError::InvalidConfiguration {
438            message: message.to_string(),
439            suggestion: suggestion.to_string(),
440            source: None,
441        }
442    }
443
444    /// Create a configuration error that retains `original` as its source.
445    ///
446    /// Used by the TOML and glob conversions, where the underlying parser
447    /// reports a position worth reaching programmatically.
448    pub fn invalid_config_with_source<E>(
449        message: impl Into<String>,
450        suggestion: impl Into<String>,
451        original: E,
452    ) -> Self
453    where
454        E: std::error::Error + Send + Sync + 'static,
455    {
456        DataProfilerError::InvalidConfiguration {
457            message: message.into(),
458            suggestion: suggestion.into(),
459            source: Some(Box::new(original)),
460        }
461    }
462
463    /// Create data quality issue with impact and recommendation
464    pub fn data_quality_issue(issue: &str, impact: &str, recommendation: &str) -> Self {
465        DataProfilerError::DataQualityIssue {
466            issue: issue.to_string(),
467            impact: impact.to_string(),
468            recommendation: recommendation.to_string(),
469        }
470    }
471
472    /// Create streaming error with context
473    pub fn streaming_error(message: &str) -> Self {
474        DataProfilerError::StreamingError {
475            message: message.to_string(),
476            suggestion: String::new(),
477        }
478    }
479
480    /// Create streaming error with context and a suggested remedy
481    pub fn streaming_error_with_suggestion(message: &str, suggestion: &str) -> Self {
482        DataProfilerError::StreamingError {
483            message: message.to_string(),
484            suggestion: suggestion.to_string(),
485        }
486    }
487
488    /// Create SIMD error with fallback information
489    pub fn simd_unavailable(reason: &str) -> Self {
490        DataProfilerError::SimdUnavailable {
491            reason: reason.to_string(),
492        }
493    }
494
495    /// Create sampling error with suggestion
496    pub fn sampling_error(message: &str, suggestion: &str) -> Self {
497        DataProfilerError::SamplingError {
498            message: message.to_string(),
499            suggestion: suggestion.to_string(),
500        }
501    }
502
503    /// Create an I/O error that retains `original` as its source.
504    ///
505    /// Takes the error by value: `std::io::Error` is not cloneable, so a
506    /// borrowed one could only ever be stringified, which is what this issue
507    /// set out to stop. Use `map_err(DataProfilerError::io_error)` at call
508    /// sites.
509    pub fn io_error(original: std::io::Error) -> Self {
510        DataProfilerError::IoError {
511            message: original.to_string(),
512            source: Some(Box::new(original)),
513        }
514    }
515
516    /// Create an I/O error from a message, for callers with no originating
517    /// error to retain (a condition detected by dataprof itself rather than
518    /// reported by the OS).
519    pub fn io_message(message: &str) -> Self {
520        DataProfilerError::IoError {
521            message: message.to_string(),
522            source: None,
523        }
524    }
525
526    /// Create a JSON parsing error with no retained source.
527    pub fn json_parsing_error(original: &str) -> Self {
528        DataProfilerError::JsonParsingError {
529            message: original.to_string(),
530            source: None,
531        }
532    }
533
534    /// Create a JSON parsing error that retains `original` as its source.
535    pub fn json_parsing_from<E>(original: E) -> Self
536    where
537        E: std::error::Error + Send + Sync + 'static,
538    {
539        DataProfilerError::JsonParsingError {
540            message: original.to_string(),
541            source: Some(Box::new(original)),
542        }
543    }
544
545    /// Create a CSV parsing error with a caller-supplied message and
546    /// suggestion that still retains `original` as its source.
547    ///
548    /// For callers whose suggestion is specific to their own context and so
549    /// cannot come from [`DataProfilerError::csv_parsing`]'s message analysis.
550    pub fn csv_parsing_with_source<E>(
551        message: impl Into<String>,
552        suggestion: impl Into<String>,
553        original: E,
554    ) -> Self
555    where
556        E: std::error::Error + Send + Sync + 'static,
557    {
558        DataProfilerError::CsvParsingError {
559            message: message.into(),
560            suggestion: suggestion.into(),
561            source: Some(Box::new(original)),
562        }
563    }
564
565    /// Create a CSV parsing error for a structural problem the parser itself
566    /// accepted, so there is no underlying error to keep as its source.
567    pub fn csv_structure(message: impl Into<String>, suggestion: impl Into<String>) -> Self {
568        DataProfilerError::CsvParsingError {
569            message: message.into(),
570            suggestion: suggestion.into(),
571            source: None,
572        }
573    }
574
575    /// Create a JSON parsing error with a caller-supplied message that still
576    /// retains `original` as its source.
577    ///
578    /// Used where the message adds context the decoder does not have (a record
579    /// position, a physical line number) and the decoder error is still worth
580    /// keeping underneath it.
581    pub fn json_parsing_with_source<E>(message: impl Into<String>, original: E) -> Self
582    where
583        E: std::error::Error + Send + Sync + 'static,
584    {
585        DataProfilerError::JsonParsingError {
586            message: message.into(),
587            source: Some(Box::new(original)),
588        }
589    }
590
591    /// Create an Arrow error with a caller-supplied message that still retains
592    /// `original` as its source.
593    pub fn arrow_with_source<E>(message: impl Into<String>, original: E) -> Self
594    where
595        E: std::error::Error + Send + Sync + 'static,
596    {
597        DataProfilerError::ArrowError {
598            message: message.into(),
599            source: Some(Box::new(original)),
600        }
601    }
602
603    /// Create a Parquet error with a caller-supplied message that still retains
604    /// `original` as its source.
605    pub fn parquet_with_source<E>(message: impl Into<String>, original: E) -> Self
606    where
607        E: std::error::Error + Send + Sync + 'static,
608    {
609        DataProfilerError::ParquetError {
610            message: message.into(),
611            source: Some(Box::new(original)),
612        }
613    }
614
615    /// Create an I/O error with a caller-supplied message that still retains
616    /// `original` as its source.
617    pub fn io_with_source<E>(message: impl Into<String>, original: E) -> Self
618    where
619        E: std::error::Error + Send + Sync + 'static,
620    {
621        DataProfilerError::IoError {
622            message: message.into(),
623            source: Some(Box::new(original)),
624        }
625    }
626
627    /// Create an Arrow error with no retained source.
628    pub fn arrow_error(message: &str) -> Self {
629        DataProfilerError::ArrowError {
630            message: message.to_string(),
631            source: None,
632        }
633    }
634
635    /// Create an Arrow error that retains `original` as its source.
636    pub fn arrow_error_from<E>(original: E) -> Self
637    where
638        E: std::error::Error + Send + Sync + 'static,
639    {
640        DataProfilerError::ArrowError {
641            message: original.to_string(),
642            source: Some(Box::new(original)),
643        }
644    }
645
646    /// Create a Parquet error with no retained source.
647    pub fn parquet_error(message: &str) -> Self {
648        DataProfilerError::ParquetError {
649            message: message.to_string(),
650            source: None,
651        }
652    }
653
654    /// Create a Parquet error that retains `original` as its source.
655    pub fn parquet_error_from<E>(original: E) -> Self
656    where
657        E: std::error::Error + Send + Sync + 'static,
658    {
659        DataProfilerError::ParquetError {
660            message: original.to_string(),
661            source: Some(Box::new(original)),
662        }
663    }
664
665    /// Create column analysis error with specific suggestion
666    pub fn column_analysis_error(column: &str, reason: &str, suggestion: &str) -> Self {
667        DataProfilerError::ColumnAnalysisError {
668            column: column.to_string(),
669            reason: reason.to_string(),
670            suggestion: suggestion.to_string(),
671        }
672    }
673
674    /// Create a recoverable error that can be auto-retried
675    pub fn recoverable_error(
676        message: &str,
677        recovery_suggestion: &str,
678        attempt: usize,
679        max_attempts: usize,
680    ) -> Self {
681        DataProfilerError::RecoverableError {
682            message: message.to_string(),
683            recovery_suggestion: recovery_suggestion.to_string(),
684            attempt,
685            max_attempts,
686            recovery_attempts: Vec::new(),
687        }
688    }
689
690    /// Create a recovery failed error with attempt log
691    pub fn recovery_failed(
692        attempts: usize,
693        last_strategy: &str,
694        recovery_log: &str,
695        original_error: &str,
696    ) -> Self {
697        DataProfilerError::RecoveryFailed {
698            attempts,
699            last_strategy: last_strategy.to_string(),
700            recovery_log: recovery_log.to_string(),
701            original_error: original_error.to_string(),
702        }
703    }
704
705    /// Add a recovery attempt to the error
706    pub fn add_recovery_attempt(&mut self, attempt: RecoveryAttempt) {
707        if let DataProfilerError::RecoverableError {
708            recovery_attempts, ..
709        } = self
710        {
711            recovery_attempts.push(attempt);
712        }
713    }
714
715    /// Check if error supports auto-recovery
716    pub fn supports_auto_recovery(&self) -> bool {
717        matches!(
718            self,
719            DataProfilerError::CsvParsingError { .. }
720                | DataProfilerError::JsonParsingError { .. }
721                | DataProfilerError::StreamingError { .. }
722                | DataProfilerError::MemoryLimitExceeded
723                | DataProfilerError::RecoverableError { .. }
724        )
725    }
726
727    /// Get suggested recovery strategies for this error
728    pub fn suggested_recovery_strategies(&self) -> Vec<RecoveryStrategy> {
729        match self {
730            DataProfilerError::CsvParsingError { .. } => vec![
731                RecoveryStrategy::DelimiterDetection { delimiter: ',' },
732                RecoveryStrategy::DelimiterDetection { delimiter: ';' },
733                RecoveryStrategy::DelimiterDetection { delimiter: '\t' },
734                RecoveryStrategy::DelimiterDetection { delimiter: '|' },
735                RecoveryStrategy::EncodingConversion {
736                    from: "latin1".to_string(),
737                    to: "utf8".to_string(),
738                },
739                RecoveryStrategy::FlexibleParsing,
740            ],
741            DataProfilerError::MemoryLimitExceeded => vec![
742                RecoveryStrategy::ChunkSizeReduction { new_size: 1000 },
743                RecoveryStrategy::MemoryOptimization,
744            ],
745            DataProfilerError::JsonParsingError { .. } => {
746                vec![RecoveryStrategy::EncodingConversion {
747                    from: "latin1".to_string(),
748                    to: "utf8".to_string(),
749                }]
750            }
751            DataProfilerError::StreamingError { .. } => vec![RecoveryStrategy::MemoryOptimization],
752            _ => vec![],
753        }
754    }
755
756    /// Check if this error is recoverable (can continue processing)
757    pub fn is_recoverable(&self) -> bool {
758        matches!(
759            self,
760            DataProfilerError::SimdUnavailable { .. }
761                | DataProfilerError::SamplingError { .. }
762                | DataProfilerError::DataQualityIssue { .. }
763                | DataProfilerError::RecoverableError { .. }
764        )
765    }
766
767    /// The actionable next step for this error, as structured context.
768    ///
769    /// Suggestions live in the variants and the `Display` string, but callers
770    /// that want to react programmatically (or render the hint separately from
771    /// the message) should read it here rather than parse the formatted output.
772    pub fn suggestion(&self) -> Option<String> {
773        match self {
774            DataProfilerError::CsvParsingError { suggestion, .. }
775            | DataProfilerError::InvalidConfiguration { suggestion, .. }
776            | DataProfilerError::InvalidSemanticHint { suggestion, .. }
777            | DataProfilerError::SamplingError { suggestion, .. }
778            | DataProfilerError::DatabaseConnectionError { suggestion, .. } => {
779                Some(suggestion.clone())
780            }
781            DataProfilerError::ColumnAnalysisError { suggestion, .. } => Some(suggestion.clone()),
782            DataProfilerError::FileNotFound { .. } => {
783                Some("Check that the file exists and you have permission to read it.".to_string())
784            }
785            DataProfilerError::UnsupportedFormat { .. } => Some(format!(
786                "This build reads {}. Convert the input to one of these formats, or rebuild with the feature for the format you need.",
787                supported_formats_hint()
788            )),
789            DataProfilerError::EncodingError { guess, .. } => Some(format!(
790                "Re-encode the file as UTF-8 (e.g. `iconv -f {guess} -t UTF-8`) and profile the result."
791            )),
792            DataProfilerError::MemoryLimitExceeded => {
793                Some("Use streaming mode or increase available memory.".to_string())
794            }
795            DataProfilerError::StreamingError { suggestion, .. } => {
796                if suggestion.is_empty() {
797                    None
798                } else {
799                    Some(suggestion.clone())
800                }
801            }
802            DataProfilerError::DataQualityIssue { recommendation, .. } => {
803                Some(recommendation.clone())
804            }
805            DataProfilerError::DatabaseFeatureDisabled { .. } => {
806                Some("Recompile with the appropriate database feature flag.".to_string())
807            }
808            _ => None,
809        }
810    }
811
812    /// Get error category for logging/metrics
813    pub fn category(&self) -> &'static str {
814        match self {
815            DataProfilerError::CsvParsingError { .. } => "csv_parsing",
816            DataProfilerError::FileNotFound { .. } => "file_not_found",
817            DataProfilerError::UnsupportedFormat { .. } => "unsupported_format",
818            DataProfilerError::EncodingError { .. } => "encoding",
819            DataProfilerError::MemoryLimitExceeded => "memory_limit",
820            DataProfilerError::InvalidConfiguration { .. } => "configuration",
821            DataProfilerError::InvalidSemanticHint { .. } => "semantic_hint",
822            DataProfilerError::DataQualityIssue { .. } => "data_quality",
823            DataProfilerError::StreamingError { .. } => "streaming",
824            DataProfilerError::SimdUnavailable { .. } => "simd",
825            DataProfilerError::SamplingError { .. } => "sampling",
826            DataProfilerError::IoError { .. } => "io",
827            DataProfilerError::JsonParsingError { .. } => "json_parsing",
828            DataProfilerError::ColumnAnalysisError { .. } => "column_analysis",
829            DataProfilerError::RecoverableError { .. } => "recoverable",
830            DataProfilerError::RecoveryFailed { .. } => "recovery_failed",
831            DataProfilerError::ParquetError { .. } => "parquet",
832            DataProfilerError::ArrowError { .. } => "arrow",
833            DataProfilerError::UnsupportedDataSource { .. } => "unsupported_data_source",
834            DataProfilerError::AllEnginesFailed { .. } => "all_engines_failed",
835            DataProfilerError::MetricsCalculationError { .. } => "metrics_calculation",
836            DataProfilerError::DuplicateColumnName { .. } => "duplicate_column_name",
837            DataProfilerError::ConfigValidationError { .. } => "config_validation",
838            DataProfilerError::DatabaseConnectionError { .. } => "database_connection",
839            DataProfilerError::DatabaseQueryError { .. } => "database_query",
840            DataProfilerError::DatabaseConfigError { .. } => "database_config",
841            DataProfilerError::DatabaseFeatureDisabled { .. } => "database_feature_disabled",
842            DataProfilerError::SqlValidationError { .. } => "sql_validation",
843            DataProfilerError::DatabaseSslError { .. } => "database_ssl",
844            DataProfilerError::DatabaseRetryExhausted { .. } => "database_retry_exhausted",
845        }
846    }
847}
848
849/// Convert from anyhow::Error to DataProfilerError with context.
850///
851/// These conversions run where the source path/operation is *not* known, so they
852/// never fabricate one: a path is only ever attached by the call sites that hold
853/// it (see [`DataProfilerError::csv_parsing`], `map_io_error`, and the facade's
854/// existence check). Preserving the original message keeps the source diagnostic.
855impl From<anyhow::Error> for DataProfilerError {
856    fn from(err: anyhow::Error) -> Self {
857        let error_str = err.to_string();
858
859        // The anyhow error is retained as the source in every branch, so a
860        // caller can still reach the concrete error underneath it via
861        // `source()` even though categorisation here is message-based.
862        let source: Option<ErrorSource> = Some(err.into());
863
864        // Categorize by message only; do not invent a path we do not have.
865        if error_str.contains("CSV") {
866            DataProfilerError::CsvParsingError {
867                message: error_str,
868                suggestion: "Try using robust CSV parsing mode".to_string(),
869                source,
870            }
871        } else if error_str.contains("JSON") {
872            DataProfilerError::JsonParsingError {
873                message: error_str,
874                source,
875            }
876        } else {
877            // Generic I/O error — the original message carries the detail
878            // (including "file not found" wording) without a fabricated path.
879            DataProfilerError::IoError {
880                message: error_str,
881                source,
882            }
883        }
884    }
885}
886
887/// Convert from std::io::Error to DataProfilerError.
888///
889/// Path-less by design: callers that know the file use `map_io_error` to produce
890/// a [`DataProfilerError::FileNotFound`] with the real path. Here we keep the OS
891/// message (which already names the condition) rather than guessing a path.
892impl From<std::io::Error> for DataProfilerError {
893    fn from(err: std::io::Error) -> Self {
894        // The message is still rewritten for PermissionDenied, but the original
895        // error is retained either way: `err.source()` now yields the
896        // `std::io::Error`, so a caller can match on `kind()` instead of
897        // pattern-matching the human-readable text.
898        let message = match err.kind() {
899            std::io::ErrorKind::PermissionDenied => {
900                "Permission denied - check file access rights".to_string()
901            }
902            _ => err.to_string(),
903        };
904        DataProfilerError::IoError {
905            message,
906            source: Some(Box::new(err)),
907        }
908    }
909}
910
911/// Convert from csv::Error to DataProfilerError with enhanced context.
912///
913/// The source path is unknown at this boundary, so the suggestion is written
914/// without a filename rather than the misleading `'unknown'` placeholder.
915impl From<csv::Error> for DataProfilerError {
916    fn from(err: csv::Error) -> Self {
917        DataProfilerError::csv_parsing_from(err, None)
918    }
919}
920
921/// Convert from arrow::error::ArrowError to DataProfilerError
922#[cfg(feature = "arrow")]
923impl From<arrow::error::ArrowError> for DataProfilerError {
924    fn from(err: arrow::error::ArrowError) -> Self {
925        DataProfilerError::arrow_error_from(err)
926    }
927}
928
929/// Convert from serde_json::Error to DataProfilerError
930impl From<serde_json::Error> for DataProfilerError {
931    fn from(err: serde_json::Error) -> Self {
932        DataProfilerError::json_parsing_from(err)
933    }
934}
935
936/// Convert from glob::PatternError to DataProfilerError
937impl From<glob::PatternError> for DataProfilerError {
938    fn from(err: glob::PatternError) -> Self {
939        let message = format!("Invalid glob pattern: {}", err);
940        DataProfilerError::invalid_config_with_source(message, "Check the glob pattern syntax", err)
941    }
942}
943
944/// Convert from toml::de::Error to DataProfilerError
945impl From<toml::de::Error> for DataProfilerError {
946    fn from(err: toml::de::Error) -> Self {
947        let message = format!("Failed to parse TOML configuration: {}", err);
948        DataProfilerError::invalid_config_with_source(
949            message,
950            "Check your configuration file syntax",
951            err,
952        )
953    }
954}
955
956/// Convert from toml::ser::Error to DataProfilerError
957impl From<toml::ser::Error> for DataProfilerError {
958    fn from(err: toml::ser::Error) -> Self {
959        let message = format!("Failed to serialize configuration: {}", err);
960        DataProfilerError::invalid_config_with_source(
961            message,
962            "Check configuration values for serialization issues",
963            err,
964        )
965    }
966}
967
968/// Auto-recovery manager for handling error recovery strategies
969pub struct AutoRecoveryManager {
970    config: RetryConfig,
971    recovery_log: Vec<RecoveryAttempt>,
972}
973
974impl AutoRecoveryManager {
975    pub fn new(config: RetryConfig) -> Self {
976        Self {
977            config,
978            recovery_log: Vec::new(),
979        }
980    }
981
982    /// Attempt auto-recovery for a given error
983    pub fn attempt_recovery<F, T>(
984        &mut self,
985        error: DataProfilerError,
986        retry_fn: F,
987    ) -> Result<T, DataProfilerError>
988    where
989        F: Fn(RecoveryStrategy) -> Result<T, DataProfilerError>,
990    {
991        if !error.supports_auto_recovery() {
992            return Err(error);
993        }
994
995        let strategies = error.suggested_recovery_strategies();
996        let mut last_error: DataProfilerError = error;
997
998        for (attempt, strategy) in strategies.iter().enumerate() {
999            if attempt >= self.config.max_attempts {
1000                break;
1001            }
1002
1003            // Log attempt start
1004            log::info!(
1005                "Auto-recovery attempt {}/{}: {:?}",
1006                attempt + 1,
1007                self.config.max_attempts,
1008                strategy
1009            );
1010
1011            match retry_fn(strategy.clone()) {
1012                Ok(result) => {
1013                    let recovery_attempt = RecoveryAttempt {
1014                        attempt_number: attempt + 1,
1015                        strategy: strategy.clone(),
1016                        success: true,
1017                        error_message: None,
1018                    };
1019                    self.recovery_log.push(recovery_attempt);
1020
1021                    log::info!("Auto-recovery successful with strategy: {:?}", strategy);
1022                    return Ok(result);
1023                }
1024                Err(err) => {
1025                    let recovery_attempt = RecoveryAttempt {
1026                        attempt_number: attempt + 1,
1027                        strategy: strategy.clone(),
1028                        success: false,
1029                        error_message: Some(err.to_string()),
1030                    };
1031                    self.recovery_log.push(recovery_attempt);
1032                    last_error = err;
1033
1034                    log::warn!("Auto-recovery attempt failed: {}", last_error);
1035                }
1036            }
1037        }
1038
1039        // All recovery attempts failed
1040        let recovery_log_text = self
1041            .recovery_log
1042            .iter()
1043            .map(|attempt| {
1044                format!(
1045                    "Attempt {}: {:?} - {}",
1046                    attempt.attempt_number,
1047                    attempt.strategy,
1048                    if attempt.success { "Success" } else { "Failed" }
1049                )
1050            })
1051            .collect::<Vec<_>>()
1052            .join("; ");
1053
1054        let last_strategy = self
1055            .recovery_log
1056            .last()
1057            .map(|attempt| format!("{:?}", attempt.strategy))
1058            .unwrap_or_else(|| "None".to_string());
1059
1060        Err(DataProfilerError::recovery_failed(
1061            self.recovery_log.len(),
1062            &last_strategy,
1063            &recovery_log_text,
1064            &last_error.to_string(),
1065        ))
1066    }
1067
1068    /// Get the recovery log
1069    pub fn get_recovery_log(&self) -> &[RecoveryAttempt] {
1070        &self.recovery_log
1071    }
1072
1073    /// Clear the recovery log
1074    pub fn clear_log(&mut self) {
1075        self.recovery_log.clear();
1076    }
1077}
1078
1079impl Default for AutoRecoveryManager {
1080    fn default() -> Self {
1081        Self::new(RetryConfig::default())
1082    }
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087    use super::*;
1088
1089    #[test]
1090    fn test_error_categorization() {
1091        let csv_error = DataProfilerError::csv_parsing("field count mismatch", Some("test.csv"));
1092        assert_eq!(csv_error.category(), "csv_parsing");
1093        assert!(!csv_error.is_recoverable());
1094    }
1095
1096    #[test]
1097    fn test_recoverable_errors() {
1098        let simd_error = DataProfilerError::simd_unavailable("CPU doesn't support SIMD");
1099        assert!(simd_error.is_recoverable());
1100    }
1101
1102    #[test]
1103    fn test_error_suggestions() {
1104        let config_error = DataProfilerError::invalid_config(
1105            "Invalid chunk size",
1106            "Use a value between 1000 and 100000",
1107        );
1108
1109        let error_string = config_error.to_string();
1110        assert!(error_string.contains("Invalid chunk size"));
1111        assert!(error_string.contains("Use a value between"));
1112    }
1113
1114    #[test]
1115    fn streaming_error_without_suggestion_prints_no_stale_advice() {
1116        let err = DataProfilerError::StreamingError {
1117            message: "Reader task panicked: boxed error".to_string(),
1118            suggestion: String::new(),
1119        };
1120        let msg = err.to_string();
1121        assert!(msg.contains("Reader task panicked"));
1122        assert!(!msg.contains("chunk size"), "stale remedy leaked: {msg}");
1123        assert!(err.suggestion().is_none());
1124        // No remedy means no line for one: an unconditional "\n{suggestion}"
1125        // leaves the message ending in a bare newline, which surfaces in Python
1126        // as a blank line under the error.
1127        assert!(
1128            !msg.ends_with('\n'),
1129            "message ends in a bare newline: {msg:?}"
1130        );
1131        assert!(
1132            !msg.contains('\n'),
1133            "empty remedy still opened a line: {msg:?}"
1134        );
1135    }
1136
1137    #[test]
1138    fn streaming_error_with_suggestion_prints_the_remedy_exactly_once() {
1139        let err = DataProfilerError::StreamingError {
1140            message: "Parquet schema inference requires random access".to_string(),
1141            suggestion: "Use infer_schema() with a file path instead".to_string(),
1142        };
1143        let msg = err.to_string();
1144        assert!(msg.contains("random access"), "{msg}");
1145        assert_eq!(
1146            msg.matches("Use infer_schema() with a file path instead")
1147                .count(),
1148            1,
1149            "remedy must appear exactly once: {msg}"
1150        );
1151        assert!(!msg.contains("chunk size"), "stale remedy leaked: {msg}");
1152        // A remedy still gets its own line, and only one.
1153        assert_eq!(
1154            msg.matches('\n').count(),
1155            1,
1156            "remedy is not on its own line: {msg:?}"
1157        );
1158        assert!(
1159            !msg.ends_with('\n'),
1160            "trailing newline after the remedy: {msg:?}"
1161        );
1162        assert_eq!(
1163            err.suggestion(),
1164            Some("Use infer_schema() with a file path instead".to_string())
1165        );
1166    }
1167
1168    #[test]
1169    fn encoding_error_names_file_and_gives_reencode_advice() {
1170        let err = DataProfilerError::EncodingError {
1171            path: "sales.csv".to_string(),
1172            detail: "first invalid UTF-8 byte at offset 12".to_string(),
1173            guess: "windows-1252".to_string(),
1174        };
1175        assert_eq!(err.category(), "encoding");
1176        let msg = err.to_string();
1177        assert!(msg.contains("sales.csv"), "names the file: {msg}");
1178        assert!(msg.contains("offset 12"), "reports the offset: {msg}");
1179        assert!(
1180            msg.to_lowercase().contains("utf-8"),
1181            "mentions UTF-8: {msg}"
1182        );
1183        assert!(msg.contains("iconv"), "gives a re-encode command: {msg}");
1184        // The old, misleading advice must not appear.
1185        assert!(
1186            !msg.to_lowercase().contains("check file permissions"),
1187            "{msg}"
1188        );
1189        assert!(!msg.contains("All engines failed"), "{msg}");
1190        assert!(err.suggestion().unwrap().contains("windows-1252"));
1191    }
1192
1193    #[test]
1194    fn io_conversion_never_fabricates_a_path() {
1195        // A NotFound io::Error carries no path here; the conversion must not
1196        // invent one (the `'unknown'` placeholder the contract work removed).
1197        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
1198        let err = DataProfilerError::from(io_err);
1199        assert_eq!(err.category(), "io");
1200        assert!(!err.to_string().contains("unknown"));
1201    }
1202
1203    #[test]
1204    fn csv_conversion_without_path_omits_the_filename() {
1205        // The `From<csv::Error>` boundary does not know the source file, so the
1206        // suggestion must not print `'unknown'` as if it were the filename.
1207        let err = DataProfilerError::csv_parsing("record has 5 fields but 3 expected", None);
1208        let msg = err.to_string();
1209        assert!(!msg.contains("unknown"));
1210        assert!(msg.contains("This CSV file has inconsistent column counts"));
1211    }
1212
1213    #[test]
1214    fn csv_conversion_with_path_names_the_file() {
1215        let err =
1216            DataProfilerError::csv_parsing("record has 5 fields but 3 expected", Some("data.csv"));
1217        assert!(err.to_string().contains("The CSV file 'data.csv' has"));
1218    }
1219
1220    #[test]
1221    fn file_not_found_keeps_the_real_path() {
1222        let err = DataProfilerError::file_not_found("/data/sales.csv");
1223        assert_eq!(err.category(), "file_not_found");
1224        assert!(err.to_string().contains("/data/sales.csv"));
1225    }
1226
1227    #[test]
1228    fn unsupported_format_advertises_only_buildable_formats() {
1229        let err = DataProfilerError::unsupported_format("xlsx");
1230        let msg = err.to_string();
1231        assert!(msg.contains("CSV, JSON, JSONL"));
1232        // Parquet is only claimed when the build can actually read it.
1233        #[cfg(feature = "parquet")]
1234        assert!(msg.contains("Parquet"));
1235        #[cfg(not(feature = "parquet"))]
1236        assert!(!msg.contains("Parquet"));
1237    }
1238
1239    #[test]
1240    fn suggestion_is_available_as_structured_context() {
1241        let err = DataProfilerError::unsupported_format("xlsx");
1242        assert!(err.suggestion().is_some());
1243        let hint = DataProfilerError::simd_unavailable("no avx2");
1244        assert!(hint.suggestion().is_none());
1245    }
1246
1247    #[test]
1248    fn redact_credentials_scrubs_userinfo() {
1249        assert_eq!(
1250            redact_credentials("postgresql://admin:s3cret@db.internal:5432/sales"),
1251            "postgresql://***@db.internal:5432/sales"
1252        );
1253        // No userinfo → untouched.
1254        assert_eq!(
1255            redact_credentials("mysql://db.internal:3306/app"),
1256            "mysql://db.internal:3306/app"
1257        );
1258        // Embedded in a driver error message.
1259        let msg = "Failed to connect: error with url mysql://root:hunter2@localhost/db";
1260        let redacted = redact_credentials(msg);
1261        assert!(!redacted.contains("hunter2"));
1262        assert!(redacted.contains("mysql://***@localhost/db"));
1263    }
1264
1265    #[test]
1266    fn database_connection_error_redacts_password() {
1267        let err = DataProfilerError::database_connection(
1268            "pool timed out connecting to postgres://user:topsecret@host/db",
1269        );
1270        assert!(!err.to_string().contains("topsecret"));
1271    }
1272
1273    // --- source chain (#447) ---------------------------------------------
1274    //
1275    // The point of retaining a source is that a caller can recover the
1276    // *concrete* originating error, not just a string that reads like it. Each
1277    // test below therefore downcasts, rather than comparing rendered text.
1278
1279    use std::error::Error as _;
1280
1281    #[test]
1282    fn io_error_retains_the_os_error_and_its_kind() {
1283        let original = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied by acl");
1284        let err = DataProfilerError::io_error(original);
1285
1286        let source = err.source().expect("io error must retain a source");
1287        let io: &std::io::Error = source
1288            .downcast_ref()
1289            .expect("source must downcast to the original std::io::Error");
1290        assert_eq!(io.kind(), std::io::ErrorKind::PermissionDenied);
1291    }
1292
1293    #[test]
1294    fn from_io_error_retains_the_kind_even_when_the_message_is_rewritten() {
1295        // PermissionDenied is the one branch that replaces the OS text, so it is
1296        // the case where a flattened source would lose the most.
1297        let original = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied by acl");
1298        let err: DataProfilerError = original.into();
1299
1300        assert!(err.to_string().contains("Permission denied"));
1301        let io: &std::io::Error = err
1302            .source()
1303            .and_then(|s| s.downcast_ref())
1304            .expect("rewritten message must still retain the io::Error");
1305        assert_eq!(io.kind(), std::io::ErrorKind::PermissionDenied);
1306        assert!(io.to_string().contains("denied by acl"));
1307    }
1308
1309    #[test]
1310    fn from_json_error_retains_line_and_column() {
1311        let original = serde_json::from_str::<serde_json::Value>("{oops}").unwrap_err();
1312        let (line, column) = (original.line(), original.column());
1313        let err: DataProfilerError = original.into();
1314
1315        let json: &serde_json::Error = err
1316            .source()
1317            .and_then(|s| s.downcast_ref())
1318            .expect("source must downcast to serde_json::Error");
1319        assert_eq!((json.line(), json.column()), (line, column));
1320    }
1321
1322    #[test]
1323    fn from_csv_error_retains_the_csv_error_kind() {
1324        let mut reader = csv::ReaderBuilder::new()
1325            .flexible(false)
1326            .from_reader("a,b\n1,2,3\n".as_bytes());
1327        let original = reader
1328            .records()
1329            .next()
1330            .expect("one record")
1331            .expect_err("ragged row must fail with flexible(false)");
1332        let err: DataProfilerError = original.into();
1333
1334        let csv: &csv::Error = err
1335            .source()
1336            .and_then(|s| s.downcast_ref())
1337            .expect("source must downcast to csv::Error");
1338        assert!(matches!(csv.kind(), csv::ErrorKind::UnequalLengths { .. }));
1339    }
1340
1341    // Gated exactly like the conversion it covers: without the feature there is
1342    // no `From<ArrowError>` impl to test.
1343    #[cfg(feature = "arrow")]
1344    #[test]
1345    fn from_arrow_error_retains_the_arrow_error() {
1346        let original = arrow::error::ArrowError::ComputeError("overflow in sum".to_string());
1347        let expected = original.to_string();
1348        let err: DataProfilerError = original.into();
1349
1350        assert!(matches!(err, DataProfilerError::ArrowError { .. }));
1351        let arrow_err: &arrow::error::ArrowError = err
1352            .source()
1353            .and_then(|s| s.downcast_ref())
1354            .expect("source must downcast to arrow::error::ArrowError");
1355        assert_eq!(arrow_err.to_string(), expected);
1356        assert!(matches!(
1357            arrow_err,
1358            arrow::error::ArrowError::ComputeError(_)
1359        ));
1360    }
1361
1362    // Parquet failures reach the user through `parquet_with_source`, which the
1363    // reader paths use; this covers that constructor's retention contract with
1364    // a stand-in error so the test needs no parquet dependency here.
1365    #[test]
1366    fn parquet_with_source_retains_the_reader_error() {
1367        let original = std::io::Error::new(std::io::ErrorKind::InvalidData, "Corrupt footer");
1368        let err =
1369            DataProfilerError::parquet_with_source("Failed to create Parquet reader", original);
1370
1371        assert!(err.to_string().contains("Failed to create Parquet reader"));
1372        let io: &std::io::Error = err
1373            .source()
1374            .and_then(|s| s.downcast_ref())
1375            .expect("parquet errors must retain the reader failure");
1376        assert_eq!(io.kind(), std::io::ErrorKind::InvalidData);
1377    }
1378
1379    #[test]
1380    fn from_toml_error_retains_the_parse_error() {
1381        let original = toml::from_str::<toml::Value>("key = [unclosed").unwrap_err();
1382        let expected = original.to_string();
1383        let err: DataProfilerError = original.into();
1384
1385        let toml_err: &toml::de::Error = err
1386            .source()
1387            .and_then(|s| s.downcast_ref())
1388            .expect("source must downcast to toml::de::Error");
1389        assert_eq!(toml_err.to_string(), expected);
1390    }
1391
1392    #[test]
1393    fn from_glob_error_retains_the_pattern_error() {
1394        let original = glob::Pattern::new("[").unwrap_err();
1395        let expected = original.to_string();
1396        let err: DataProfilerError = original.into();
1397
1398        let glob_err: &glob::PatternError = err
1399            .source()
1400            .and_then(|s| s.downcast_ref())
1401            .expect("source must downcast to glob::PatternError");
1402        assert_eq!(glob_err.to_string(), expected);
1403    }
1404
1405    #[test]
1406    fn errors_built_without_a_cause_report_no_source() {
1407        // "No source" must stay distinguishable from "source not retained":
1408        // these variants genuinely have no originating error underneath.
1409        assert!(
1410            DataProfilerError::io_message("disk budget exceeded")
1411                .source()
1412                .is_none()
1413        );
1414        assert!(
1415            DataProfilerError::json_parsing_error("not an object")
1416                .source()
1417                .is_none()
1418        );
1419        assert!(
1420            DataProfilerError::arrow_error("downcast failed")
1421                .source()
1422                .is_none()
1423        );
1424        assert!(
1425            DataProfilerError::file_not_found("/nope")
1426                .source()
1427                .is_none()
1428        );
1429    }
1430
1431    #[test]
1432    fn retaining_a_source_does_not_change_the_rendered_message() {
1433        // The #373 message contract is independent of the cause chain: the two
1434        // constructions must be indistinguishable in output.
1435        let original = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
1436        let text = original.to_string();
1437
1438        let with_source = DataProfilerError::io_error(original).to_string();
1439        let without_source = DataProfilerError::io_message(&text).to_string();
1440        assert_eq!(with_source, without_source);
1441    }
1442
1443    #[test]
1444    fn the_whole_chain_is_walkable_to_the_root() {
1445        let original = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "truncated");
1446        let err = DataProfilerError::io_with_source("reading chunk 4", original);
1447
1448        let mut depth = 0;
1449        let mut current: Option<&(dyn std::error::Error + 'static)> = Some(&err);
1450        while let Some(e) = current {
1451            current = e.source();
1452            depth += 1;
1453        }
1454        // Exactly two links: the DataProfilerError and the io::Error beneath it.
1455        // A wrapper type in between would make this 3 and break downcasting.
1456        assert_eq!(depth, 2);
1457    }
1458}