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/// Redact credentials from a string before it enters an error message.
20///
21/// Connection strings and driver errors can carry `scheme://user:password@host`.
22/// We collapse the userinfo to `***` so passwords (and usernames) never reach a
23/// log line or a surfaced diagnostic, while keeping the scheme/host for context.
24pub fn redact_credentials(input: &str) -> String {
25    // Match the `://[userinfo@]` segment of any URL-like token and drop userinfo.
26    let mut out = String::with_capacity(input.len());
27    let mut rest = input;
28    while let Some(scheme_idx) = rest.find("://") {
29        let after_scheme = scheme_idx + 3;
30        out.push_str(&rest[..after_scheme]);
31        let tail = &rest[after_scheme..];
32        // Userinfo, if present, ends at the first '@' before the next authority
33        // delimiter ('/', '?', '#', or whitespace).
34        let authority_end = tail
35            .find(['/', '?', '#', ' ', '\t', '\n'])
36            .unwrap_or(tail.len());
37        if let Some(at_rel) = tail[..authority_end].find('@') {
38            out.push_str("***");
39            rest = &tail[at_rel..]; // keep '@host...'
40        } else {
41            rest = tail;
42        }
43    }
44    out.push_str(rest);
45    out
46}
47
48/// Auto-retry configuration for error recovery
49#[derive(Debug, Clone)]
50pub struct RetryConfig {
51    pub max_attempts: usize,
52    pub enable_delimiter_detection: bool,
53    pub enable_encoding_detection: bool,
54    pub enable_flexible_parsing: bool,
55}
56
57impl Default for RetryConfig {
58    fn default() -> Self {
59        Self {
60            max_attempts: 3,
61            enable_delimiter_detection: true,
62            enable_encoding_detection: true,
63            enable_flexible_parsing: true,
64        }
65    }
66}
67
68/// Result of an auto-recovery attempt
69#[derive(Debug, Clone)]
70pub struct RecoveryAttempt {
71    pub attempt_number: usize,
72    pub strategy: RecoveryStrategy,
73    pub success: bool,
74    pub error_message: Option<String>,
75}
76
77/// Strategies for error recovery
78#[derive(Debug, Clone)]
79pub enum RecoveryStrategy {
80    DelimiterDetection { delimiter: char },
81    EncodingConversion { from: String, to: String },
82    FlexibleParsing,
83    ChunkSizeReduction { new_size: usize },
84    MemoryOptimization,
85}
86
87/// Enhanced error types with more descriptive messages for DataProfiler
88#[derive(Error, Debug, Clone)]
89pub enum DataProfilerError {
90    #[error("CSV parsing failed: {message}\nSuggestion: {suggestion}")]
91    CsvParsingError { message: String, suggestion: String },
92
93    #[error(
94        "File not found: {path}\nPlease check that the file exists and you have permission to read it"
95    )]
96    FileNotFound { path: String },
97
98    #[error(
99        "Unsupported file format: {format}\nSupported formats: {}",
100        supported_formats_hint()
101    )]
102    UnsupportedFormat { format: String },
103
104    #[error(
105        "Memory limit exceeded while processing large file\nTry using streaming mode or increase available memory"
106    )]
107    MemoryLimitExceeded,
108
109    #[error("Invalid configuration: {message}\n{suggestion}")]
110    InvalidConfiguration { message: String, suggestion: String },
111
112    #[error("Invalid semantic hint: {message}\n{suggestion}")]
113    InvalidSemanticHint { message: String, suggestion: String },
114
115    #[error(
116        "Data quality issue detected: {issue}\nImpact: {impact}\nRecommendation: {recommendation}"
117    )]
118    DataQualityIssue {
119        issue: String,
120        impact: String,
121        recommendation: String,
122    },
123
124    #[error(
125        "Streaming processing failed: {message}\nTry setting a smaller chunk size on the profiler builder (e.g. `.chunk_size(ChunkSize::Fixed(1000))`)"
126    )]
127    StreamingError { message: String },
128
129    #[error("SIMD acceleration not available: {reason}\nFalling back to standard processing")]
130    SimdUnavailable { reason: String },
131
132    #[error("Sampling error: {message}\n{suggestion}")]
133    SamplingError { message: String, suggestion: String },
134
135    #[error("I/O error: {message}\nCheck file permissions and disk space")]
136    IoError { message: String },
137
138    #[error(
139        "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"
140    )]
141    EncodingError {
142        path: String,
143        detail: String,
144        guess: String,
145    },
146
147    #[error("JSON parsing failed: {message}\nVerify JSON format and encoding")]
148    JsonParsingError { message: String },
149
150    #[error("Column analysis failed for '{column}': {reason}\n{suggestion}")]
151    ColumnAnalysisError {
152        column: String,
153        reason: String,
154        suggestion: String,
155    },
156
157    #[error(
158        "Recoverable error (attempt {attempt}/{max_attempts}): {message}\n{recovery_suggestion}"
159    )]
160    RecoverableError {
161        message: String,
162        recovery_suggestion: String,
163        attempt: usize,
164        max_attempts: usize,
165        recovery_attempts: Vec<RecoveryAttempt>,
166    },
167
168    #[error(
169        "Auto-recovery failed after {attempts} attempts\nLast strategy tried: {last_strategy}\nRecovery log: {recovery_log}"
170    )]
171    RecoveryFailed {
172        attempts: usize,
173        last_strategy: String,
174        recovery_log: String,
175        original_error: String,
176    },
177
178    #[error("Parquet processing failed: {message}")]
179    ParquetError { message: String },
180
181    #[error("Arrow processing failed: {message}")]
182    ArrowError { message: String },
183
184    #[error("Unsupported data source: {message}")]
185    UnsupportedDataSource { message: String },
186
187    #[error("All engines failed: {message}")]
188    AllEnginesFailed { message: String },
189
190    #[error("Metrics calculation failed: {message}")]
191    MetricsCalculationError { message: String },
192
193    #[error(
194        "Duplicate column name{plural}: {names}\nSource: {source_label}. Column names must be unique after normalization; rename or drop the duplicate column(s) before profiling."
195    )]
196    DuplicateColumnName {
197        names: String,
198        plural: String,
199        source_label: String,
200    },
201
202    #[error("Configuration validation failed: {message}")]
203    ConfigValidationError { message: String },
204
205    #[error("Database connection failed: {message}\n{suggestion}")]
206    DatabaseConnectionError { message: String, suggestion: String },
207
208    #[error("Database query failed: {message}")]
209    DatabaseQueryError { message: String },
210
211    #[error("Database configuration error: {message}")]
212    DatabaseConfigError { message: String },
213
214    #[error("Database feature not enabled: {message}\nRecompile with the appropriate feature flag")]
215    DatabaseFeatureDisabled { message: String },
216
217    #[error("SQL validation failed: {message}")]
218    SqlValidationError { message: String },
219
220    #[error("Database SSL/TLS error: {message}")]
221    DatabaseSslError { message: String },
222
223    #[error(
224        "Database retry exhausted: operation '{operation}' failed after {attempts} attempts\nLast error: {last_error}"
225    )]
226    DatabaseRetryExhausted {
227        operation: String,
228        attempts: u32,
229        last_error: String,
230    },
231}
232
233impl DataProfilerError {
234    /// Create a database connection error.
235    ///
236    /// The message is scrubbed of URL credentials before storage so a driver
237    /// error that echoes the connection string cannot leak the password.
238    pub fn database_connection(message: &str) -> Self {
239        let message = redact_credentials(message);
240        let m = message.to_lowercase();
241        let suggestion = if m.contains("refused") {
242            "Check that the database server is running and accepting connections."
243        } else if m.contains("timeout") {
244            "Increase the connection timeout or check network connectivity."
245        } else if m.contains("authentication") || m.contains("password") {
246            "Verify your credentials or use environment variables for authentication."
247        } else {
248            "Verify the connection string format and database server availability."
249        };
250        DataProfilerError::DatabaseConnectionError {
251            message,
252            suggestion: suggestion.to_string(),
253        }
254    }
255
256    /// Create a database query error.
257    ///
258    /// Redacts URL credentials that a driver error might surface; the raw SQL
259    /// text and bound parameter values are never embedded by callers.
260    pub fn database_query(message: &str) -> Self {
261        DataProfilerError::DatabaseQueryError {
262            message: redact_credentials(message),
263        }
264    }
265
266    /// Create a database config error
267    pub fn database_config(message: &str) -> Self {
268        DataProfilerError::DatabaseConfigError {
269            message: message.to_string(),
270        }
271    }
272
273    /// Create a feature-not-enabled error
274    pub fn database_feature_disabled(db_name: &str, feature: &str) -> Self {
275        DataProfilerError::DatabaseFeatureDisabled {
276            message: format!(
277                "{} support not compiled. Enable '{}' feature.",
278                db_name, feature
279            ),
280        }
281    }
282
283    /// Create a SQL validation error
284    pub fn sql_validation(message: &str) -> Self {
285        DataProfilerError::SqlValidationError {
286            message: message.to_string(),
287        }
288    }
289
290    /// Create a database SSL error
291    pub fn database_ssl(message: &str) -> Self {
292        DataProfilerError::DatabaseSslError {
293            message: message.to_string(),
294        }
295    }
296    /// Create a duplicate-column-name error from the already-collected list of
297    /// repeated names and a short transport label.
298    ///
299    /// Only the offending names and the source are embedded — never row values —
300    /// so the diagnostic cannot leak cell data.
301    pub fn duplicate_column_name(duplicates: &[String], source: &str) -> Self {
302        let plural = if duplicates.len() == 1 { "" } else { "s" };
303        let names = duplicates
304            .iter()
305            .map(|n| format!("'{}'", n))
306            .collect::<Vec<_>>()
307            .join(", ");
308        DataProfilerError::DuplicateColumnName {
309            names,
310            plural: plural.to_string(),
311            source_label: source.to_string(),
312        }
313    }
314
315    /// Create a CSV parsing error with helpful suggestions.
316    ///
317    /// `file_path` is `None` at boundaries that do not know the source (e.g. the
318    /// `From<csv::Error>` conversion); pass `Some(path)` whenever it is known so
319    /// the suggestion can name the offending file instead of a placeholder.
320    pub fn csv_parsing(original_error: &str, file_path: Option<&str>) -> Self {
321        let suggestion = if original_error.contains("field") && original_error.contains("record") {
322            let file_clause = match file_path {
323                Some(path) => format!("The CSV file '{}' has", path),
324                None => "This CSV file has".to_string(),
325            };
326            format!(
327                "{} 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.",
328                file_clause
329            )
330        } else if original_error.contains("UTF-8") {
331            "The file contains non-UTF-8 characters. Try converting it to UTF-8 encoding."
332                .to_string()
333        } else if original_error.contains("permission") {
334            "Check file permissions - you may not have read access to this file.".to_string()
335        } else {
336            "Try using a different CSV delimiter or check for data formatting issues.".to_string()
337        };
338
339        DataProfilerError::CsvParsingError {
340            message: original_error.to_string(),
341            suggestion,
342        }
343    }
344
345    /// Create a file not found error with path context
346    pub fn file_not_found<P: AsRef<str>>(path: P) -> Self {
347        DataProfilerError::FileNotFound {
348            path: path.as_ref().to_string(),
349        }
350    }
351
352    /// Create unsupported format error with format detection
353    pub fn unsupported_format(extension: &str) -> Self {
354        DataProfilerError::UnsupportedFormat {
355            format: extension.to_string(),
356        }
357    }
358
359    /// Create configuration error with specific suggestion
360    pub fn invalid_config(message: &str, suggestion: &str) -> Self {
361        DataProfilerError::InvalidConfiguration {
362            message: message.to_string(),
363            suggestion: suggestion.to_string(),
364        }
365    }
366
367    /// Create data quality issue with impact and recommendation
368    pub fn data_quality_issue(issue: &str, impact: &str, recommendation: &str) -> Self {
369        DataProfilerError::DataQualityIssue {
370            issue: issue.to_string(),
371            impact: impact.to_string(),
372            recommendation: recommendation.to_string(),
373        }
374    }
375
376    /// Create streaming error with context
377    pub fn streaming_error(message: &str) -> Self {
378        DataProfilerError::StreamingError {
379            message: message.to_string(),
380        }
381    }
382
383    /// Create SIMD error with fallback information
384    pub fn simd_unavailable(reason: &str) -> Self {
385        DataProfilerError::SimdUnavailable {
386            reason: reason.to_string(),
387        }
388    }
389
390    /// Create sampling error with suggestion
391    pub fn sampling_error(message: &str, suggestion: &str) -> Self {
392        DataProfilerError::SamplingError {
393            message: message.to_string(),
394            suggestion: suggestion.to_string(),
395        }
396    }
397
398    /// Create I/O error with context
399    pub fn io_error(original: &std::io::Error) -> Self {
400        DataProfilerError::IoError {
401            message: original.to_string(),
402        }
403    }
404
405    /// Create JSON parsing error
406    pub fn json_parsing_error(original: &str) -> Self {
407        DataProfilerError::JsonParsingError {
408            message: original.to_string(),
409        }
410    }
411
412    /// Create column analysis error with specific suggestion
413    pub fn column_analysis_error(column: &str, reason: &str, suggestion: &str) -> Self {
414        DataProfilerError::ColumnAnalysisError {
415            column: column.to_string(),
416            reason: reason.to_string(),
417            suggestion: suggestion.to_string(),
418        }
419    }
420
421    /// Create a recoverable error that can be auto-retried
422    pub fn recoverable_error(
423        message: &str,
424        recovery_suggestion: &str,
425        attempt: usize,
426        max_attempts: usize,
427    ) -> Self {
428        DataProfilerError::RecoverableError {
429            message: message.to_string(),
430            recovery_suggestion: recovery_suggestion.to_string(),
431            attempt,
432            max_attempts,
433            recovery_attempts: Vec::new(),
434        }
435    }
436
437    /// Create a recovery failed error with attempt log
438    pub fn recovery_failed(
439        attempts: usize,
440        last_strategy: &str,
441        recovery_log: &str,
442        original_error: &str,
443    ) -> Self {
444        DataProfilerError::RecoveryFailed {
445            attempts,
446            last_strategy: last_strategy.to_string(),
447            recovery_log: recovery_log.to_string(),
448            original_error: original_error.to_string(),
449        }
450    }
451
452    /// Add a recovery attempt to the error
453    pub fn add_recovery_attempt(&mut self, attempt: RecoveryAttempt) {
454        if let DataProfilerError::RecoverableError {
455            recovery_attempts, ..
456        } = self
457        {
458            recovery_attempts.push(attempt);
459        }
460    }
461
462    /// Check if error supports auto-recovery
463    pub fn supports_auto_recovery(&self) -> bool {
464        matches!(
465            self,
466            DataProfilerError::CsvParsingError { .. }
467                | DataProfilerError::JsonParsingError { .. }
468                | DataProfilerError::StreamingError { .. }
469                | DataProfilerError::MemoryLimitExceeded
470                | DataProfilerError::RecoverableError { .. }
471        )
472    }
473
474    /// Get suggested recovery strategies for this error
475    pub fn suggested_recovery_strategies(&self) -> Vec<RecoveryStrategy> {
476        match self {
477            DataProfilerError::CsvParsingError { .. } => vec![
478                RecoveryStrategy::DelimiterDetection { delimiter: ',' },
479                RecoveryStrategy::DelimiterDetection { delimiter: ';' },
480                RecoveryStrategy::DelimiterDetection { delimiter: '\t' },
481                RecoveryStrategy::DelimiterDetection { delimiter: '|' },
482                RecoveryStrategy::EncodingConversion {
483                    from: "latin1".to_string(),
484                    to: "utf8".to_string(),
485                },
486                RecoveryStrategy::FlexibleParsing,
487            ],
488            DataProfilerError::MemoryLimitExceeded => vec![
489                RecoveryStrategy::ChunkSizeReduction { new_size: 1000 },
490                RecoveryStrategy::MemoryOptimization,
491            ],
492            DataProfilerError::JsonParsingError { .. } => {
493                vec![RecoveryStrategy::EncodingConversion {
494                    from: "latin1".to_string(),
495                    to: "utf8".to_string(),
496                }]
497            }
498            DataProfilerError::StreamingError { .. } => vec![
499                RecoveryStrategy::ChunkSizeReduction { new_size: 500 },
500                RecoveryStrategy::MemoryOptimization,
501            ],
502            _ => vec![],
503        }
504    }
505
506    /// Check if this error is recoverable (can continue processing)
507    pub fn is_recoverable(&self) -> bool {
508        matches!(
509            self,
510            DataProfilerError::SimdUnavailable { .. }
511                | DataProfilerError::SamplingError { .. }
512                | DataProfilerError::DataQualityIssue { .. }
513                | DataProfilerError::RecoverableError { .. }
514        )
515    }
516
517    /// The actionable next step for this error, as structured context.
518    ///
519    /// Suggestions live in the variants and the `Display` string, but callers
520    /// that want to react programmatically (or render the hint separately from
521    /// the message) should read it here rather than parse the formatted output.
522    pub fn suggestion(&self) -> Option<String> {
523        match self {
524            DataProfilerError::CsvParsingError { suggestion, .. }
525            | DataProfilerError::InvalidConfiguration { suggestion, .. }
526            | DataProfilerError::InvalidSemanticHint { suggestion, .. }
527            | DataProfilerError::SamplingError { suggestion, .. }
528            | DataProfilerError::DatabaseConnectionError { suggestion, .. } => {
529                Some(suggestion.clone())
530            }
531            DataProfilerError::ColumnAnalysisError { suggestion, .. } => Some(suggestion.clone()),
532            DataProfilerError::FileNotFound { .. } => {
533                Some("Check that the file exists and you have permission to read it.".to_string())
534            }
535            DataProfilerError::UnsupportedFormat { .. } => Some(format!(
536                "This build reads {}. Convert the input to one of these formats, or rebuild with the feature for the format you need.",
537                supported_formats_hint()
538            )),
539            DataProfilerError::EncodingError { guess, .. } => Some(format!(
540                "Re-encode the file as UTF-8 (e.g. `iconv -f {guess} -t UTF-8`) and profile the result."
541            )),
542            DataProfilerError::MemoryLimitExceeded => {
543                Some("Use streaming mode or increase available memory.".to_string())
544            }
545            DataProfilerError::StreamingError { .. } => Some(
546                "Set a smaller chunk size on the profiler builder, e.g. `.chunk_size(ChunkSize::Fixed(1000))`."
547                    .to_string(),
548            ),
549            DataProfilerError::DataQualityIssue { recommendation, .. } => {
550                Some(recommendation.clone())
551            }
552            DataProfilerError::DatabaseFeatureDisabled { .. } => {
553                Some("Recompile with the appropriate database feature flag.".to_string())
554            }
555            _ => None,
556        }
557    }
558
559    /// Get error category for logging/metrics
560    pub fn category(&self) -> &'static str {
561        match self {
562            DataProfilerError::CsvParsingError { .. } => "csv_parsing",
563            DataProfilerError::FileNotFound { .. } => "file_not_found",
564            DataProfilerError::UnsupportedFormat { .. } => "unsupported_format",
565            DataProfilerError::EncodingError { .. } => "encoding",
566            DataProfilerError::MemoryLimitExceeded => "memory_limit",
567            DataProfilerError::InvalidConfiguration { .. } => "configuration",
568            DataProfilerError::InvalidSemanticHint { .. } => "semantic_hint",
569            DataProfilerError::DataQualityIssue { .. } => "data_quality",
570            DataProfilerError::StreamingError { .. } => "streaming",
571            DataProfilerError::SimdUnavailable { .. } => "simd",
572            DataProfilerError::SamplingError { .. } => "sampling",
573            DataProfilerError::IoError { .. } => "io",
574            DataProfilerError::JsonParsingError { .. } => "json_parsing",
575            DataProfilerError::ColumnAnalysisError { .. } => "column_analysis",
576            DataProfilerError::RecoverableError { .. } => "recoverable",
577            DataProfilerError::RecoveryFailed { .. } => "recovery_failed",
578            DataProfilerError::ParquetError { .. } => "parquet",
579            DataProfilerError::ArrowError { .. } => "arrow",
580            DataProfilerError::UnsupportedDataSource { .. } => "unsupported_data_source",
581            DataProfilerError::AllEnginesFailed { .. } => "all_engines_failed",
582            DataProfilerError::MetricsCalculationError { .. } => "metrics_calculation",
583            DataProfilerError::DuplicateColumnName { .. } => "duplicate_column_name",
584            DataProfilerError::ConfigValidationError { .. } => "config_validation",
585            DataProfilerError::DatabaseConnectionError { .. } => "database_connection",
586            DataProfilerError::DatabaseQueryError { .. } => "database_query",
587            DataProfilerError::DatabaseConfigError { .. } => "database_config",
588            DataProfilerError::DatabaseFeatureDisabled { .. } => "database_feature_disabled",
589            DataProfilerError::SqlValidationError { .. } => "sql_validation",
590            DataProfilerError::DatabaseSslError { .. } => "database_ssl",
591            DataProfilerError::DatabaseRetryExhausted { .. } => "database_retry_exhausted",
592        }
593    }
594}
595
596/// Convert from anyhow::Error to DataProfilerError with context.
597///
598/// These conversions run where the source path/operation is *not* known, so they
599/// never fabricate one: a path is only ever attached by the call sites that hold
600/// it (see [`DataProfilerError::csv_parsing`], `map_io_error`, and the facade's
601/// existence check). Preserving the original message keeps the source diagnostic.
602impl From<anyhow::Error> for DataProfilerError {
603    fn from(err: anyhow::Error) -> Self {
604        let error_str = err.to_string();
605
606        // Categorize by message only; do not invent a path we do not have.
607        if error_str.contains("CSV") {
608            DataProfilerError::CsvParsingError {
609                message: error_str,
610                suggestion: "Try using robust CSV parsing mode".to_string(),
611            }
612        } else if error_str.contains("JSON") {
613            DataProfilerError::JsonParsingError { message: error_str }
614        } else {
615            // Generic I/O error — the original message carries the detail
616            // (including "file not found" wording) without a fabricated path.
617            DataProfilerError::IoError { message: error_str }
618        }
619    }
620}
621
622/// Convert from std::io::Error to DataProfilerError.
623///
624/// Path-less by design: callers that know the file use `map_io_error` to produce
625/// a [`DataProfilerError::FileNotFound`] with the real path. Here we keep the OS
626/// message (which already names the condition) rather than guessing a path.
627impl From<std::io::Error> for DataProfilerError {
628    fn from(err: std::io::Error) -> Self {
629        match err.kind() {
630            std::io::ErrorKind::PermissionDenied => DataProfilerError::IoError {
631                message: "Permission denied - check file access rights".to_string(),
632            },
633            _ => DataProfilerError::IoError {
634                message: err.to_string(),
635            },
636        }
637    }
638}
639
640/// Convert from csv::Error to DataProfilerError with enhanced context.
641///
642/// The source path is unknown at this boundary, so the suggestion is written
643/// without a filename rather than the misleading `'unknown'` placeholder.
644impl From<csv::Error> for DataProfilerError {
645    fn from(err: csv::Error) -> Self {
646        DataProfilerError::csv_parsing(&err.to_string(), None)
647    }
648}
649
650/// Convert from arrow::error::ArrowError to DataProfilerError
651#[cfg(feature = "arrow")]
652impl From<arrow::error::ArrowError> for DataProfilerError {
653    fn from(err: arrow::error::ArrowError) -> Self {
654        DataProfilerError::ArrowError {
655            message: err.to_string(),
656        }
657    }
658}
659
660/// Convert from serde_json::Error to DataProfilerError
661impl From<serde_json::Error> for DataProfilerError {
662    fn from(err: serde_json::Error) -> Self {
663        DataProfilerError::JsonParsingError {
664            message: err.to_string(),
665        }
666    }
667}
668
669/// Convert from glob::PatternError to DataProfilerError
670impl From<glob::PatternError> for DataProfilerError {
671    fn from(err: glob::PatternError) -> Self {
672        DataProfilerError::InvalidConfiguration {
673            message: format!("Invalid glob pattern: {}", err),
674            suggestion: "Check the glob pattern syntax".to_string(),
675        }
676    }
677}
678
679/// Convert from toml::de::Error to DataProfilerError
680impl From<toml::de::Error> for DataProfilerError {
681    fn from(err: toml::de::Error) -> Self {
682        DataProfilerError::InvalidConfiguration {
683            message: format!("Failed to parse TOML configuration: {}", err),
684            suggestion: "Check your configuration file syntax".to_string(),
685        }
686    }
687}
688
689/// Convert from toml::ser::Error to DataProfilerError
690impl From<toml::ser::Error> for DataProfilerError {
691    fn from(err: toml::ser::Error) -> Self {
692        DataProfilerError::InvalidConfiguration {
693            message: format!("Failed to serialize configuration: {}", err),
694            suggestion: "Check configuration values for serialization issues".to_string(),
695        }
696    }
697}
698
699/// Auto-recovery manager for handling error recovery strategies
700pub struct AutoRecoveryManager {
701    config: RetryConfig,
702    recovery_log: Vec<RecoveryAttempt>,
703}
704
705impl AutoRecoveryManager {
706    pub fn new(config: RetryConfig) -> Self {
707        Self {
708            config,
709            recovery_log: Vec::new(),
710        }
711    }
712
713    /// Attempt auto-recovery for a given error
714    pub fn attempt_recovery<F, T>(
715        &mut self,
716        error: &DataProfilerError,
717        retry_fn: F,
718    ) -> Result<T, DataProfilerError>
719    where
720        F: Fn(RecoveryStrategy) -> Result<T, DataProfilerError>,
721    {
722        if !error.supports_auto_recovery() {
723            return Err(error.clone());
724        }
725
726        let strategies = error.suggested_recovery_strategies();
727        let mut last_error: DataProfilerError = error.clone();
728
729        for (attempt, strategy) in strategies.iter().enumerate() {
730            if attempt >= self.config.max_attempts {
731                break;
732            }
733
734            // Log attempt start
735            log::info!(
736                "Auto-recovery attempt {}/{}: {:?}",
737                attempt + 1,
738                self.config.max_attempts,
739                strategy
740            );
741
742            match retry_fn(strategy.clone()) {
743                Ok(result) => {
744                    let recovery_attempt = RecoveryAttempt {
745                        attempt_number: attempt + 1,
746                        strategy: strategy.clone(),
747                        success: true,
748                        error_message: None,
749                    };
750                    self.recovery_log.push(recovery_attempt);
751
752                    log::info!("Auto-recovery successful with strategy: {:?}", strategy);
753                    return Ok(result);
754                }
755                Err(err) => {
756                    let recovery_attempt = RecoveryAttempt {
757                        attempt_number: attempt + 1,
758                        strategy: strategy.clone(),
759                        success: false,
760                        error_message: Some(err.to_string()),
761                    };
762                    self.recovery_log.push(recovery_attempt);
763                    last_error = err;
764
765                    log::warn!("Auto-recovery attempt failed: {}", last_error);
766                }
767            }
768        }
769
770        // All recovery attempts failed
771        let recovery_log_text = self
772            .recovery_log
773            .iter()
774            .map(|attempt| {
775                format!(
776                    "Attempt {}: {:?} - {}",
777                    attempt.attempt_number,
778                    attempt.strategy,
779                    if attempt.success { "Success" } else { "Failed" }
780                )
781            })
782            .collect::<Vec<_>>()
783            .join("; ");
784
785        let last_strategy = self
786            .recovery_log
787            .last()
788            .map(|attempt| format!("{:?}", attempt.strategy))
789            .unwrap_or_else(|| "None".to_string());
790
791        Err(DataProfilerError::recovery_failed(
792            self.recovery_log.len(),
793            &last_strategy,
794            &recovery_log_text,
795            &last_error.to_string(),
796        ))
797    }
798
799    /// Get the recovery log
800    pub fn get_recovery_log(&self) -> &[RecoveryAttempt] {
801        &self.recovery_log
802    }
803
804    /// Clear the recovery log
805    pub fn clear_log(&mut self) {
806        self.recovery_log.clear();
807    }
808}
809
810impl Default for AutoRecoveryManager {
811    fn default() -> Self {
812        Self::new(RetryConfig::default())
813    }
814}
815
816#[cfg(test)]
817mod tests {
818    use super::*;
819
820    #[test]
821    fn test_error_categorization() {
822        let csv_error = DataProfilerError::csv_parsing("field count mismatch", Some("test.csv"));
823        assert_eq!(csv_error.category(), "csv_parsing");
824        assert!(!csv_error.is_recoverable());
825    }
826
827    #[test]
828    fn test_recoverable_errors() {
829        let simd_error = DataProfilerError::simd_unavailable("CPU doesn't support SIMD");
830        assert!(simd_error.is_recoverable());
831    }
832
833    #[test]
834    fn test_error_suggestions() {
835        let config_error = DataProfilerError::invalid_config(
836            "Invalid chunk size",
837            "Use a value between 1000 and 100000",
838        );
839
840        let error_string = config_error.to_string();
841        assert!(error_string.contains("Invalid chunk size"));
842        assert!(error_string.contains("Use a value between"));
843    }
844
845    #[test]
846    fn encoding_error_names_file_and_gives_reencode_advice() {
847        let err = DataProfilerError::EncodingError {
848            path: "sales.csv".to_string(),
849            detail: "first invalid UTF-8 byte at offset 12".to_string(),
850            guess: "windows-1252".to_string(),
851        };
852        assert_eq!(err.category(), "encoding");
853        let msg = err.to_string();
854        assert!(msg.contains("sales.csv"), "names the file: {msg}");
855        assert!(msg.contains("offset 12"), "reports the offset: {msg}");
856        assert!(
857            msg.to_lowercase().contains("utf-8"),
858            "mentions UTF-8: {msg}"
859        );
860        assert!(msg.contains("iconv"), "gives a re-encode command: {msg}");
861        // The old, misleading advice must not appear.
862        assert!(
863            !msg.to_lowercase().contains("check file permissions"),
864            "{msg}"
865        );
866        assert!(!msg.contains("All engines failed"), "{msg}");
867        assert!(err.suggestion().unwrap().contains("windows-1252"));
868    }
869
870    #[test]
871    fn io_conversion_never_fabricates_a_path() {
872        // A NotFound io::Error carries no path here; the conversion must not
873        // invent one (the `'unknown'` placeholder the contract work removed).
874        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
875        let err = DataProfilerError::from(io_err);
876        assert_eq!(err.category(), "io");
877        assert!(!err.to_string().contains("unknown"));
878    }
879
880    #[test]
881    fn csv_conversion_without_path_omits_the_filename() {
882        // The `From<csv::Error>` boundary does not know the source file, so the
883        // suggestion must not print `'unknown'` as if it were the filename.
884        let err = DataProfilerError::csv_parsing("record has 5 fields but 3 expected", None);
885        let msg = err.to_string();
886        assert!(!msg.contains("unknown"));
887        assert!(msg.contains("This CSV file has inconsistent column counts"));
888    }
889
890    #[test]
891    fn csv_conversion_with_path_names_the_file() {
892        let err =
893            DataProfilerError::csv_parsing("record has 5 fields but 3 expected", Some("data.csv"));
894        assert!(err.to_string().contains("The CSV file 'data.csv' has"));
895    }
896
897    #[test]
898    fn file_not_found_keeps_the_real_path() {
899        let err = DataProfilerError::file_not_found("/data/sales.csv");
900        assert_eq!(err.category(), "file_not_found");
901        assert!(err.to_string().contains("/data/sales.csv"));
902    }
903
904    #[test]
905    fn unsupported_format_advertises_only_buildable_formats() {
906        let err = DataProfilerError::unsupported_format("xlsx");
907        let msg = err.to_string();
908        assert!(msg.contains("CSV, JSON, JSONL"));
909        // Parquet is only claimed when the build can actually read it.
910        #[cfg(feature = "parquet")]
911        assert!(msg.contains("Parquet"));
912        #[cfg(not(feature = "parquet"))]
913        assert!(!msg.contains("Parquet"));
914    }
915
916    #[test]
917    fn suggestion_is_available_as_structured_context() {
918        let err = DataProfilerError::unsupported_format("xlsx");
919        assert!(err.suggestion().is_some());
920        let hint = DataProfilerError::simd_unavailable("no avx2");
921        assert!(hint.suggestion().is_none());
922    }
923
924    #[test]
925    fn redact_credentials_scrubs_userinfo() {
926        assert_eq!(
927            redact_credentials("postgresql://admin:s3cret@db.internal:5432/sales"),
928            "postgresql://***@db.internal:5432/sales"
929        );
930        // No userinfo → untouched.
931        assert_eq!(
932            redact_credentials("mysql://db.internal:3306/app"),
933            "mysql://db.internal:3306/app"
934        );
935        // Embedded in a driver error message.
936        let msg = "Failed to connect: error with url mysql://root:hunter2@localhost/db";
937        let redacted = redact_credentials(msg);
938        assert!(!redacted.contains("hunter2"));
939        assert!(redacted.contains("mysql://***@localhost/db"));
940    }
941
942    #[test]
943    fn database_connection_error_redacts_password() {
944        let err = DataProfilerError::database_connection(
945            "pool timed out connecting to postgres://user:topsecret@host/db",
946        );
947        assert!(!err.to_string().contains("topsecret"));
948    }
949}