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 JSON parsing error with a caller-supplied message that still
566    /// retains `original` as its source.
567    ///
568    /// Used where the message adds context the decoder does not have (a record
569    /// position, a physical line number) and the decoder error is still worth
570    /// keeping underneath it.
571    pub fn json_parsing_with_source<E>(message: impl Into<String>, original: E) -> Self
572    where
573        E: std::error::Error + Send + Sync + 'static,
574    {
575        DataProfilerError::JsonParsingError {
576            message: message.into(),
577            source: Some(Box::new(original)),
578        }
579    }
580
581    /// Create an Arrow error with a caller-supplied message that still retains
582    /// `original` as its source.
583    pub fn arrow_with_source<E>(message: impl Into<String>, original: E) -> Self
584    where
585        E: std::error::Error + Send + Sync + 'static,
586    {
587        DataProfilerError::ArrowError {
588            message: message.into(),
589            source: Some(Box::new(original)),
590        }
591    }
592
593    /// Create a Parquet error with a caller-supplied message that still retains
594    /// `original` as its source.
595    pub fn parquet_with_source<E>(message: impl Into<String>, original: E) -> Self
596    where
597        E: std::error::Error + Send + Sync + 'static,
598    {
599        DataProfilerError::ParquetError {
600            message: message.into(),
601            source: Some(Box::new(original)),
602        }
603    }
604
605    /// Create an I/O error with a caller-supplied message that still retains
606    /// `original` as its source.
607    pub fn io_with_source<E>(message: impl Into<String>, original: E) -> Self
608    where
609        E: std::error::Error + Send + Sync + 'static,
610    {
611        DataProfilerError::IoError {
612            message: message.into(),
613            source: Some(Box::new(original)),
614        }
615    }
616
617    /// Create an Arrow error with no retained source.
618    pub fn arrow_error(message: &str) -> Self {
619        DataProfilerError::ArrowError {
620            message: message.to_string(),
621            source: None,
622        }
623    }
624
625    /// Create an Arrow error that retains `original` as its source.
626    pub fn arrow_error_from<E>(original: E) -> Self
627    where
628        E: std::error::Error + Send + Sync + 'static,
629    {
630        DataProfilerError::ArrowError {
631            message: original.to_string(),
632            source: Some(Box::new(original)),
633        }
634    }
635
636    /// Create a Parquet error with no retained source.
637    pub fn parquet_error(message: &str) -> Self {
638        DataProfilerError::ParquetError {
639            message: message.to_string(),
640            source: None,
641        }
642    }
643
644    /// Create a Parquet error that retains `original` as its source.
645    pub fn parquet_error_from<E>(original: E) -> Self
646    where
647        E: std::error::Error + Send + Sync + 'static,
648    {
649        DataProfilerError::ParquetError {
650            message: original.to_string(),
651            source: Some(Box::new(original)),
652        }
653    }
654
655    /// Create column analysis error with specific suggestion
656    pub fn column_analysis_error(column: &str, reason: &str, suggestion: &str) -> Self {
657        DataProfilerError::ColumnAnalysisError {
658            column: column.to_string(),
659            reason: reason.to_string(),
660            suggestion: suggestion.to_string(),
661        }
662    }
663
664    /// Create a recoverable error that can be auto-retried
665    pub fn recoverable_error(
666        message: &str,
667        recovery_suggestion: &str,
668        attempt: usize,
669        max_attempts: usize,
670    ) -> Self {
671        DataProfilerError::RecoverableError {
672            message: message.to_string(),
673            recovery_suggestion: recovery_suggestion.to_string(),
674            attempt,
675            max_attempts,
676            recovery_attempts: Vec::new(),
677        }
678    }
679
680    /// Create a recovery failed error with attempt log
681    pub fn recovery_failed(
682        attempts: usize,
683        last_strategy: &str,
684        recovery_log: &str,
685        original_error: &str,
686    ) -> Self {
687        DataProfilerError::RecoveryFailed {
688            attempts,
689            last_strategy: last_strategy.to_string(),
690            recovery_log: recovery_log.to_string(),
691            original_error: original_error.to_string(),
692        }
693    }
694
695    /// Add a recovery attempt to the error
696    pub fn add_recovery_attempt(&mut self, attempt: RecoveryAttempt) {
697        if let DataProfilerError::RecoverableError {
698            recovery_attempts, ..
699        } = self
700        {
701            recovery_attempts.push(attempt);
702        }
703    }
704
705    /// Check if error supports auto-recovery
706    pub fn supports_auto_recovery(&self) -> bool {
707        matches!(
708            self,
709            DataProfilerError::CsvParsingError { .. }
710                | DataProfilerError::JsonParsingError { .. }
711                | DataProfilerError::StreamingError { .. }
712                | DataProfilerError::MemoryLimitExceeded
713                | DataProfilerError::RecoverableError { .. }
714        )
715    }
716
717    /// Get suggested recovery strategies for this error
718    pub fn suggested_recovery_strategies(&self) -> Vec<RecoveryStrategy> {
719        match self {
720            DataProfilerError::CsvParsingError { .. } => vec![
721                RecoveryStrategy::DelimiterDetection { delimiter: ',' },
722                RecoveryStrategy::DelimiterDetection { delimiter: ';' },
723                RecoveryStrategy::DelimiterDetection { delimiter: '\t' },
724                RecoveryStrategy::DelimiterDetection { delimiter: '|' },
725                RecoveryStrategy::EncodingConversion {
726                    from: "latin1".to_string(),
727                    to: "utf8".to_string(),
728                },
729                RecoveryStrategy::FlexibleParsing,
730            ],
731            DataProfilerError::MemoryLimitExceeded => vec![
732                RecoveryStrategy::ChunkSizeReduction { new_size: 1000 },
733                RecoveryStrategy::MemoryOptimization,
734            ],
735            DataProfilerError::JsonParsingError { .. } => {
736                vec![RecoveryStrategy::EncodingConversion {
737                    from: "latin1".to_string(),
738                    to: "utf8".to_string(),
739                }]
740            }
741            DataProfilerError::StreamingError { .. } => vec![RecoveryStrategy::MemoryOptimization],
742            _ => vec![],
743        }
744    }
745
746    /// Check if this error is recoverable (can continue processing)
747    pub fn is_recoverable(&self) -> bool {
748        matches!(
749            self,
750            DataProfilerError::SimdUnavailable { .. }
751                | DataProfilerError::SamplingError { .. }
752                | DataProfilerError::DataQualityIssue { .. }
753                | DataProfilerError::RecoverableError { .. }
754        )
755    }
756
757    /// The actionable next step for this error, as structured context.
758    ///
759    /// Suggestions live in the variants and the `Display` string, but callers
760    /// that want to react programmatically (or render the hint separately from
761    /// the message) should read it here rather than parse the formatted output.
762    pub fn suggestion(&self) -> Option<String> {
763        match self {
764            DataProfilerError::CsvParsingError { suggestion, .. }
765            | DataProfilerError::InvalidConfiguration { suggestion, .. }
766            | DataProfilerError::InvalidSemanticHint { suggestion, .. }
767            | DataProfilerError::SamplingError { suggestion, .. }
768            | DataProfilerError::DatabaseConnectionError { suggestion, .. } => {
769                Some(suggestion.clone())
770            }
771            DataProfilerError::ColumnAnalysisError { suggestion, .. } => Some(suggestion.clone()),
772            DataProfilerError::FileNotFound { .. } => {
773                Some("Check that the file exists and you have permission to read it.".to_string())
774            }
775            DataProfilerError::UnsupportedFormat { .. } => Some(format!(
776                "This build reads {}. Convert the input to one of these formats, or rebuild with the feature for the format you need.",
777                supported_formats_hint()
778            )),
779            DataProfilerError::EncodingError { guess, .. } => Some(format!(
780                "Re-encode the file as UTF-8 (e.g. `iconv -f {guess} -t UTF-8`) and profile the result."
781            )),
782            DataProfilerError::MemoryLimitExceeded => {
783                Some("Use streaming mode or increase available memory.".to_string())
784            }
785            DataProfilerError::StreamingError { suggestion, .. } => {
786                if suggestion.is_empty() {
787                    None
788                } else {
789                    Some(suggestion.clone())
790                }
791            }
792            DataProfilerError::DataQualityIssue { recommendation, .. } => {
793                Some(recommendation.clone())
794            }
795            DataProfilerError::DatabaseFeatureDisabled { .. } => {
796                Some("Recompile with the appropriate database feature flag.".to_string())
797            }
798            _ => None,
799        }
800    }
801
802    /// Get error category for logging/metrics
803    pub fn category(&self) -> &'static str {
804        match self {
805            DataProfilerError::CsvParsingError { .. } => "csv_parsing",
806            DataProfilerError::FileNotFound { .. } => "file_not_found",
807            DataProfilerError::UnsupportedFormat { .. } => "unsupported_format",
808            DataProfilerError::EncodingError { .. } => "encoding",
809            DataProfilerError::MemoryLimitExceeded => "memory_limit",
810            DataProfilerError::InvalidConfiguration { .. } => "configuration",
811            DataProfilerError::InvalidSemanticHint { .. } => "semantic_hint",
812            DataProfilerError::DataQualityIssue { .. } => "data_quality",
813            DataProfilerError::StreamingError { .. } => "streaming",
814            DataProfilerError::SimdUnavailable { .. } => "simd",
815            DataProfilerError::SamplingError { .. } => "sampling",
816            DataProfilerError::IoError { .. } => "io",
817            DataProfilerError::JsonParsingError { .. } => "json_parsing",
818            DataProfilerError::ColumnAnalysisError { .. } => "column_analysis",
819            DataProfilerError::RecoverableError { .. } => "recoverable",
820            DataProfilerError::RecoveryFailed { .. } => "recovery_failed",
821            DataProfilerError::ParquetError { .. } => "parquet",
822            DataProfilerError::ArrowError { .. } => "arrow",
823            DataProfilerError::UnsupportedDataSource { .. } => "unsupported_data_source",
824            DataProfilerError::AllEnginesFailed { .. } => "all_engines_failed",
825            DataProfilerError::MetricsCalculationError { .. } => "metrics_calculation",
826            DataProfilerError::DuplicateColumnName { .. } => "duplicate_column_name",
827            DataProfilerError::ConfigValidationError { .. } => "config_validation",
828            DataProfilerError::DatabaseConnectionError { .. } => "database_connection",
829            DataProfilerError::DatabaseQueryError { .. } => "database_query",
830            DataProfilerError::DatabaseConfigError { .. } => "database_config",
831            DataProfilerError::DatabaseFeatureDisabled { .. } => "database_feature_disabled",
832            DataProfilerError::SqlValidationError { .. } => "sql_validation",
833            DataProfilerError::DatabaseSslError { .. } => "database_ssl",
834            DataProfilerError::DatabaseRetryExhausted { .. } => "database_retry_exhausted",
835        }
836    }
837}
838
839/// Convert from anyhow::Error to DataProfilerError with context.
840///
841/// These conversions run where the source path/operation is *not* known, so they
842/// never fabricate one: a path is only ever attached by the call sites that hold
843/// it (see [`DataProfilerError::csv_parsing`], `map_io_error`, and the facade's
844/// existence check). Preserving the original message keeps the source diagnostic.
845impl From<anyhow::Error> for DataProfilerError {
846    fn from(err: anyhow::Error) -> Self {
847        let error_str = err.to_string();
848
849        // The anyhow error is retained as the source in every branch, so a
850        // caller can still reach the concrete error underneath it via
851        // `source()` even though categorisation here is message-based.
852        let source: Option<ErrorSource> = Some(err.into());
853
854        // Categorize by message only; do not invent a path we do not have.
855        if error_str.contains("CSV") {
856            DataProfilerError::CsvParsingError {
857                message: error_str,
858                suggestion: "Try using robust CSV parsing mode".to_string(),
859                source,
860            }
861        } else if error_str.contains("JSON") {
862            DataProfilerError::JsonParsingError {
863                message: error_str,
864                source,
865            }
866        } else {
867            // Generic I/O error — the original message carries the detail
868            // (including "file not found" wording) without a fabricated path.
869            DataProfilerError::IoError {
870                message: error_str,
871                source,
872            }
873        }
874    }
875}
876
877/// Convert from std::io::Error to DataProfilerError.
878///
879/// Path-less by design: callers that know the file use `map_io_error` to produce
880/// a [`DataProfilerError::FileNotFound`] with the real path. Here we keep the OS
881/// message (which already names the condition) rather than guessing a path.
882impl From<std::io::Error> for DataProfilerError {
883    fn from(err: std::io::Error) -> Self {
884        // The message is still rewritten for PermissionDenied, but the original
885        // error is retained either way: `err.source()` now yields the
886        // `std::io::Error`, so a caller can match on `kind()` instead of
887        // pattern-matching the human-readable text.
888        let message = match err.kind() {
889            std::io::ErrorKind::PermissionDenied => {
890                "Permission denied - check file access rights".to_string()
891            }
892            _ => err.to_string(),
893        };
894        DataProfilerError::IoError {
895            message,
896            source: Some(Box::new(err)),
897        }
898    }
899}
900
901/// Convert from csv::Error to DataProfilerError with enhanced context.
902///
903/// The source path is unknown at this boundary, so the suggestion is written
904/// without a filename rather than the misleading `'unknown'` placeholder.
905impl From<csv::Error> for DataProfilerError {
906    fn from(err: csv::Error) -> Self {
907        DataProfilerError::csv_parsing_from(err, None)
908    }
909}
910
911/// Convert from arrow::error::ArrowError to DataProfilerError
912#[cfg(feature = "arrow")]
913impl From<arrow::error::ArrowError> for DataProfilerError {
914    fn from(err: arrow::error::ArrowError) -> Self {
915        DataProfilerError::arrow_error_from(err)
916    }
917}
918
919/// Convert from serde_json::Error to DataProfilerError
920impl From<serde_json::Error> for DataProfilerError {
921    fn from(err: serde_json::Error) -> Self {
922        DataProfilerError::json_parsing_from(err)
923    }
924}
925
926/// Convert from glob::PatternError to DataProfilerError
927impl From<glob::PatternError> for DataProfilerError {
928    fn from(err: glob::PatternError) -> Self {
929        let message = format!("Invalid glob pattern: {}", err);
930        DataProfilerError::invalid_config_with_source(message, "Check the glob pattern syntax", err)
931    }
932}
933
934/// Convert from toml::de::Error to DataProfilerError
935impl From<toml::de::Error> for DataProfilerError {
936    fn from(err: toml::de::Error) -> Self {
937        let message = format!("Failed to parse TOML configuration: {}", err);
938        DataProfilerError::invalid_config_with_source(
939            message,
940            "Check your configuration file syntax",
941            err,
942        )
943    }
944}
945
946/// Convert from toml::ser::Error to DataProfilerError
947impl From<toml::ser::Error> for DataProfilerError {
948    fn from(err: toml::ser::Error) -> Self {
949        let message = format!("Failed to serialize configuration: {}", err);
950        DataProfilerError::invalid_config_with_source(
951            message,
952            "Check configuration values for serialization issues",
953            err,
954        )
955    }
956}
957
958/// Auto-recovery manager for handling error recovery strategies
959pub struct AutoRecoveryManager {
960    config: RetryConfig,
961    recovery_log: Vec<RecoveryAttempt>,
962}
963
964impl AutoRecoveryManager {
965    pub fn new(config: RetryConfig) -> Self {
966        Self {
967            config,
968            recovery_log: Vec::new(),
969        }
970    }
971
972    /// Attempt auto-recovery for a given error
973    pub fn attempt_recovery<F, T>(
974        &mut self,
975        error: DataProfilerError,
976        retry_fn: F,
977    ) -> Result<T, DataProfilerError>
978    where
979        F: Fn(RecoveryStrategy) -> Result<T, DataProfilerError>,
980    {
981        if !error.supports_auto_recovery() {
982            return Err(error);
983        }
984
985        let strategies = error.suggested_recovery_strategies();
986        let mut last_error: DataProfilerError = error;
987
988        for (attempt, strategy) in strategies.iter().enumerate() {
989            if attempt >= self.config.max_attempts {
990                break;
991            }
992
993            // Log attempt start
994            log::info!(
995                "Auto-recovery attempt {}/{}: {:?}",
996                attempt + 1,
997                self.config.max_attempts,
998                strategy
999            );
1000
1001            match retry_fn(strategy.clone()) {
1002                Ok(result) => {
1003                    let recovery_attempt = RecoveryAttempt {
1004                        attempt_number: attempt + 1,
1005                        strategy: strategy.clone(),
1006                        success: true,
1007                        error_message: None,
1008                    };
1009                    self.recovery_log.push(recovery_attempt);
1010
1011                    log::info!("Auto-recovery successful with strategy: {:?}", strategy);
1012                    return Ok(result);
1013                }
1014                Err(err) => {
1015                    let recovery_attempt = RecoveryAttempt {
1016                        attempt_number: attempt + 1,
1017                        strategy: strategy.clone(),
1018                        success: false,
1019                        error_message: Some(err.to_string()),
1020                    };
1021                    self.recovery_log.push(recovery_attempt);
1022                    last_error = err;
1023
1024                    log::warn!("Auto-recovery attempt failed: {}", last_error);
1025                }
1026            }
1027        }
1028
1029        // All recovery attempts failed
1030        let recovery_log_text = self
1031            .recovery_log
1032            .iter()
1033            .map(|attempt| {
1034                format!(
1035                    "Attempt {}: {:?} - {}",
1036                    attempt.attempt_number,
1037                    attempt.strategy,
1038                    if attempt.success { "Success" } else { "Failed" }
1039                )
1040            })
1041            .collect::<Vec<_>>()
1042            .join("; ");
1043
1044        let last_strategy = self
1045            .recovery_log
1046            .last()
1047            .map(|attempt| format!("{:?}", attempt.strategy))
1048            .unwrap_or_else(|| "None".to_string());
1049
1050        Err(DataProfilerError::recovery_failed(
1051            self.recovery_log.len(),
1052            &last_strategy,
1053            &recovery_log_text,
1054            &last_error.to_string(),
1055        ))
1056    }
1057
1058    /// Get the recovery log
1059    pub fn get_recovery_log(&self) -> &[RecoveryAttempt] {
1060        &self.recovery_log
1061    }
1062
1063    /// Clear the recovery log
1064    pub fn clear_log(&mut self) {
1065        self.recovery_log.clear();
1066    }
1067}
1068
1069impl Default for AutoRecoveryManager {
1070    fn default() -> Self {
1071        Self::new(RetryConfig::default())
1072    }
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077    use super::*;
1078
1079    #[test]
1080    fn test_error_categorization() {
1081        let csv_error = DataProfilerError::csv_parsing("field count mismatch", Some("test.csv"));
1082        assert_eq!(csv_error.category(), "csv_parsing");
1083        assert!(!csv_error.is_recoverable());
1084    }
1085
1086    #[test]
1087    fn test_recoverable_errors() {
1088        let simd_error = DataProfilerError::simd_unavailable("CPU doesn't support SIMD");
1089        assert!(simd_error.is_recoverable());
1090    }
1091
1092    #[test]
1093    fn test_error_suggestions() {
1094        let config_error = DataProfilerError::invalid_config(
1095            "Invalid chunk size",
1096            "Use a value between 1000 and 100000",
1097        );
1098
1099        let error_string = config_error.to_string();
1100        assert!(error_string.contains("Invalid chunk size"));
1101        assert!(error_string.contains("Use a value between"));
1102    }
1103
1104    #[test]
1105    fn streaming_error_without_suggestion_prints_no_stale_advice() {
1106        let err = DataProfilerError::StreamingError {
1107            message: "Reader task panicked: boxed error".to_string(),
1108            suggestion: String::new(),
1109        };
1110        let msg = err.to_string();
1111        assert!(msg.contains("Reader task panicked"));
1112        assert!(!msg.contains("chunk size"), "stale remedy leaked: {msg}");
1113        assert!(err.suggestion().is_none());
1114        // No remedy means no line for one: an unconditional "\n{suggestion}"
1115        // leaves the message ending in a bare newline, which surfaces in Python
1116        // as a blank line under the error.
1117        assert!(
1118            !msg.ends_with('\n'),
1119            "message ends in a bare newline: {msg:?}"
1120        );
1121        assert!(
1122            !msg.contains('\n'),
1123            "empty remedy still opened a line: {msg:?}"
1124        );
1125    }
1126
1127    #[test]
1128    fn streaming_error_with_suggestion_prints_the_remedy_exactly_once() {
1129        let err = DataProfilerError::StreamingError {
1130            message: "Parquet schema inference requires random access".to_string(),
1131            suggestion: "Use infer_schema() with a file path instead".to_string(),
1132        };
1133        let msg = err.to_string();
1134        assert!(msg.contains("random access"), "{msg}");
1135        assert_eq!(
1136            msg.matches("Use infer_schema() with a file path instead")
1137                .count(),
1138            1,
1139            "remedy must appear exactly once: {msg}"
1140        );
1141        assert!(!msg.contains("chunk size"), "stale remedy leaked: {msg}");
1142        // A remedy still gets its own line, and only one.
1143        assert_eq!(
1144            msg.matches('\n').count(),
1145            1,
1146            "remedy is not on its own line: {msg:?}"
1147        );
1148        assert!(
1149            !msg.ends_with('\n'),
1150            "trailing newline after the remedy: {msg:?}"
1151        );
1152        assert_eq!(
1153            err.suggestion(),
1154            Some("Use infer_schema() with a file path instead".to_string())
1155        );
1156    }
1157
1158    #[test]
1159    fn encoding_error_names_file_and_gives_reencode_advice() {
1160        let err = DataProfilerError::EncodingError {
1161            path: "sales.csv".to_string(),
1162            detail: "first invalid UTF-8 byte at offset 12".to_string(),
1163            guess: "windows-1252".to_string(),
1164        };
1165        assert_eq!(err.category(), "encoding");
1166        let msg = err.to_string();
1167        assert!(msg.contains("sales.csv"), "names the file: {msg}");
1168        assert!(msg.contains("offset 12"), "reports the offset: {msg}");
1169        assert!(
1170            msg.to_lowercase().contains("utf-8"),
1171            "mentions UTF-8: {msg}"
1172        );
1173        assert!(msg.contains("iconv"), "gives a re-encode command: {msg}");
1174        // The old, misleading advice must not appear.
1175        assert!(
1176            !msg.to_lowercase().contains("check file permissions"),
1177            "{msg}"
1178        );
1179        assert!(!msg.contains("All engines failed"), "{msg}");
1180        assert!(err.suggestion().unwrap().contains("windows-1252"));
1181    }
1182
1183    #[test]
1184    fn io_conversion_never_fabricates_a_path() {
1185        // A NotFound io::Error carries no path here; the conversion must not
1186        // invent one (the `'unknown'` placeholder the contract work removed).
1187        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
1188        let err = DataProfilerError::from(io_err);
1189        assert_eq!(err.category(), "io");
1190        assert!(!err.to_string().contains("unknown"));
1191    }
1192
1193    #[test]
1194    fn csv_conversion_without_path_omits_the_filename() {
1195        // The `From<csv::Error>` boundary does not know the source file, so the
1196        // suggestion must not print `'unknown'` as if it were the filename.
1197        let err = DataProfilerError::csv_parsing("record has 5 fields but 3 expected", None);
1198        let msg = err.to_string();
1199        assert!(!msg.contains("unknown"));
1200        assert!(msg.contains("This CSV file has inconsistent column counts"));
1201    }
1202
1203    #[test]
1204    fn csv_conversion_with_path_names_the_file() {
1205        let err =
1206            DataProfilerError::csv_parsing("record has 5 fields but 3 expected", Some("data.csv"));
1207        assert!(err.to_string().contains("The CSV file 'data.csv' has"));
1208    }
1209
1210    #[test]
1211    fn file_not_found_keeps_the_real_path() {
1212        let err = DataProfilerError::file_not_found("/data/sales.csv");
1213        assert_eq!(err.category(), "file_not_found");
1214        assert!(err.to_string().contains("/data/sales.csv"));
1215    }
1216
1217    #[test]
1218    fn unsupported_format_advertises_only_buildable_formats() {
1219        let err = DataProfilerError::unsupported_format("xlsx");
1220        let msg = err.to_string();
1221        assert!(msg.contains("CSV, JSON, JSONL"));
1222        // Parquet is only claimed when the build can actually read it.
1223        #[cfg(feature = "parquet")]
1224        assert!(msg.contains("Parquet"));
1225        #[cfg(not(feature = "parquet"))]
1226        assert!(!msg.contains("Parquet"));
1227    }
1228
1229    #[test]
1230    fn suggestion_is_available_as_structured_context() {
1231        let err = DataProfilerError::unsupported_format("xlsx");
1232        assert!(err.suggestion().is_some());
1233        let hint = DataProfilerError::simd_unavailable("no avx2");
1234        assert!(hint.suggestion().is_none());
1235    }
1236
1237    #[test]
1238    fn redact_credentials_scrubs_userinfo() {
1239        assert_eq!(
1240            redact_credentials("postgresql://admin:s3cret@db.internal:5432/sales"),
1241            "postgresql://***@db.internal:5432/sales"
1242        );
1243        // No userinfo → untouched.
1244        assert_eq!(
1245            redact_credentials("mysql://db.internal:3306/app"),
1246            "mysql://db.internal:3306/app"
1247        );
1248        // Embedded in a driver error message.
1249        let msg = "Failed to connect: error with url mysql://root:hunter2@localhost/db";
1250        let redacted = redact_credentials(msg);
1251        assert!(!redacted.contains("hunter2"));
1252        assert!(redacted.contains("mysql://***@localhost/db"));
1253    }
1254
1255    #[test]
1256    fn database_connection_error_redacts_password() {
1257        let err = DataProfilerError::database_connection(
1258            "pool timed out connecting to postgres://user:topsecret@host/db",
1259        );
1260        assert!(!err.to_string().contains("topsecret"));
1261    }
1262
1263    // --- source chain (#447) ---------------------------------------------
1264    //
1265    // The point of retaining a source is that a caller can recover the
1266    // *concrete* originating error, not just a string that reads like it. Each
1267    // test below therefore downcasts, rather than comparing rendered text.
1268
1269    use std::error::Error as _;
1270
1271    #[test]
1272    fn io_error_retains_the_os_error_and_its_kind() {
1273        let original = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied by acl");
1274        let err = DataProfilerError::io_error(original);
1275
1276        let source = err.source().expect("io error must retain a source");
1277        let io: &std::io::Error = source
1278            .downcast_ref()
1279            .expect("source must downcast to the original std::io::Error");
1280        assert_eq!(io.kind(), std::io::ErrorKind::PermissionDenied);
1281    }
1282
1283    #[test]
1284    fn from_io_error_retains_the_kind_even_when_the_message_is_rewritten() {
1285        // PermissionDenied is the one branch that replaces the OS text, so it is
1286        // the case where a flattened source would lose the most.
1287        let original = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied by acl");
1288        let err: DataProfilerError = original.into();
1289
1290        assert!(err.to_string().contains("Permission denied"));
1291        let io: &std::io::Error = err
1292            .source()
1293            .and_then(|s| s.downcast_ref())
1294            .expect("rewritten message must still retain the io::Error");
1295        assert_eq!(io.kind(), std::io::ErrorKind::PermissionDenied);
1296        assert!(io.to_string().contains("denied by acl"));
1297    }
1298
1299    #[test]
1300    fn from_json_error_retains_line_and_column() {
1301        let original = serde_json::from_str::<serde_json::Value>("{oops}").unwrap_err();
1302        let (line, column) = (original.line(), original.column());
1303        let err: DataProfilerError = original.into();
1304
1305        let json: &serde_json::Error = err
1306            .source()
1307            .and_then(|s| s.downcast_ref())
1308            .expect("source must downcast to serde_json::Error");
1309        assert_eq!((json.line(), json.column()), (line, column));
1310    }
1311
1312    #[test]
1313    fn from_csv_error_retains_the_csv_error_kind() {
1314        let mut reader = csv::ReaderBuilder::new()
1315            .flexible(false)
1316            .from_reader("a,b\n1,2,3\n".as_bytes());
1317        let original = reader
1318            .records()
1319            .next()
1320            .expect("one record")
1321            .expect_err("ragged row must fail with flexible(false)");
1322        let err: DataProfilerError = original.into();
1323
1324        let csv: &csv::Error = err
1325            .source()
1326            .and_then(|s| s.downcast_ref())
1327            .expect("source must downcast to csv::Error");
1328        assert!(matches!(csv.kind(), csv::ErrorKind::UnequalLengths { .. }));
1329    }
1330
1331    // Gated exactly like the conversion it covers: without the feature there is
1332    // no `From<ArrowError>` impl to test.
1333    #[cfg(feature = "arrow")]
1334    #[test]
1335    fn from_arrow_error_retains_the_arrow_error() {
1336        let original = arrow::error::ArrowError::ComputeError("overflow in sum".to_string());
1337        let expected = original.to_string();
1338        let err: DataProfilerError = original.into();
1339
1340        assert!(matches!(err, DataProfilerError::ArrowError { .. }));
1341        let arrow_err: &arrow::error::ArrowError = err
1342            .source()
1343            .and_then(|s| s.downcast_ref())
1344            .expect("source must downcast to arrow::error::ArrowError");
1345        assert_eq!(arrow_err.to_string(), expected);
1346        assert!(matches!(
1347            arrow_err,
1348            arrow::error::ArrowError::ComputeError(_)
1349        ));
1350    }
1351
1352    // Parquet failures reach the user through `parquet_with_source`, which the
1353    // reader paths use; this covers that constructor's retention contract with
1354    // a stand-in error so the test needs no parquet dependency here.
1355    #[test]
1356    fn parquet_with_source_retains_the_reader_error() {
1357        let original = std::io::Error::new(std::io::ErrorKind::InvalidData, "Corrupt footer");
1358        let err =
1359            DataProfilerError::parquet_with_source("Failed to create Parquet reader", original);
1360
1361        assert!(err.to_string().contains("Failed to create Parquet reader"));
1362        let io: &std::io::Error = err
1363            .source()
1364            .and_then(|s| s.downcast_ref())
1365            .expect("parquet errors must retain the reader failure");
1366        assert_eq!(io.kind(), std::io::ErrorKind::InvalidData);
1367    }
1368
1369    #[test]
1370    fn from_toml_error_retains_the_parse_error() {
1371        let original = toml::from_str::<toml::Value>("key = [unclosed").unwrap_err();
1372        let expected = original.to_string();
1373        let err: DataProfilerError = original.into();
1374
1375        let toml_err: &toml::de::Error = err
1376            .source()
1377            .and_then(|s| s.downcast_ref())
1378            .expect("source must downcast to toml::de::Error");
1379        assert_eq!(toml_err.to_string(), expected);
1380    }
1381
1382    #[test]
1383    fn from_glob_error_retains_the_pattern_error() {
1384        let original = glob::Pattern::new("[").unwrap_err();
1385        let expected = original.to_string();
1386        let err: DataProfilerError = original.into();
1387
1388        let glob_err: &glob::PatternError = err
1389            .source()
1390            .and_then(|s| s.downcast_ref())
1391            .expect("source must downcast to glob::PatternError");
1392        assert_eq!(glob_err.to_string(), expected);
1393    }
1394
1395    #[test]
1396    fn errors_built_without_a_cause_report_no_source() {
1397        // "No source" must stay distinguishable from "source not retained":
1398        // these variants genuinely have no originating error underneath.
1399        assert!(
1400            DataProfilerError::io_message("disk budget exceeded")
1401                .source()
1402                .is_none()
1403        );
1404        assert!(
1405            DataProfilerError::json_parsing_error("not an object")
1406                .source()
1407                .is_none()
1408        );
1409        assert!(
1410            DataProfilerError::arrow_error("downcast failed")
1411                .source()
1412                .is_none()
1413        );
1414        assert!(
1415            DataProfilerError::file_not_found("/nope")
1416                .source()
1417                .is_none()
1418        );
1419    }
1420
1421    #[test]
1422    fn retaining_a_source_does_not_change_the_rendered_message() {
1423        // The #373 message contract is independent of the cause chain: the two
1424        // constructions must be indistinguishable in output.
1425        let original = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
1426        let text = original.to_string();
1427
1428        let with_source = DataProfilerError::io_error(original).to_string();
1429        let without_source = DataProfilerError::io_message(&text).to_string();
1430        assert_eq!(with_source, without_source);
1431    }
1432
1433    #[test]
1434    fn the_whole_chain_is_walkable_to_the_root() {
1435        let original = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "truncated");
1436        let err = DataProfilerError::io_with_source("reading chunk 4", original);
1437
1438        let mut depth = 0;
1439        let mut current: Option<&(dyn std::error::Error + 'static)> = Some(&err);
1440        while let Some(e) = current {
1441            current = e.source();
1442            depth += 1;
1443        }
1444        // Exactly two links: the DataProfilerError and the io::Error beneath it.
1445        // A wrapper type in between would make this 3 and break downcasting.
1446        assert_eq!(depth, 2);
1447    }
1448}