1use thiserror::Error;
2
3fn 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
19fn suggestion_line(suggestion: &str) -> String {
26 if suggestion.is_empty() {
27 String::new()
28 } else {
29 format!("\n{suggestion}")
30 }
31}
32
33pub fn redact_credentials(input: &str) -> String {
39 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 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..]; } else {
55 rest = tail;
56 }
57 }
58 out.push_str(rest);
59 out
60}
61
62#[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#[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#[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
101pub type ErrorSource = Box<dyn std::error::Error + Send + Sync + 'static>;
110
111#[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 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 pub fn database_query(message: &str) -> Self {
320 DataProfilerError::DatabaseQueryError {
321 message: redact_credentials(message),
322 }
323 }
324
325 pub fn database_config(message: &str) -> Self {
327 DataProfilerError::DatabaseConfigError {
328 message: message.to_string(),
329 }
330 }
331
332 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 pub fn sql_validation(message: &str) -> Self {
344 DataProfilerError::SqlValidationError {
345 message: message.to_string(),
346 }
347 }
348
349 pub fn database_ssl(message: &str) -> Self {
351 DataProfilerError::DatabaseSslError {
352 message: message.to_string(),
353 }
354 }
355 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 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 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 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 pub fn unsupported_format(extension: &str) -> Self {
430 DataProfilerError::UnsupportedFormat {
431 format: extension.to_string(),
432 }
433 }
434
435 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 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 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 pub fn streaming_error(message: &str) -> Self {
474 DataProfilerError::StreamingError {
475 message: message.to_string(),
476 suggestion: String::new(),
477 }
478 }
479
480 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 pub fn simd_unavailable(reason: &str) -> Self {
490 DataProfilerError::SimdUnavailable {
491 reason: reason.to_string(),
492 }
493 }
494
495 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 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 pub fn io_message(message: &str) -> Self {
520 DataProfilerError::IoError {
521 message: message.to_string(),
522 source: None,
523 }
524 }
525
526 pub fn json_parsing_error(original: &str) -> Self {
528 DataProfilerError::JsonParsingError {
529 message: original.to_string(),
530 source: None,
531 }
532 }
533
534 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 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 pub fn csv_structure(message: impl Into<String>, suggestion: impl Into<String>) -> Self {
568 DataProfilerError::CsvParsingError {
569 message: message.into(),
570 suggestion: suggestion.into(),
571 source: None,
572 }
573 }
574
575 pub fn json_parsing_with_source<E>(message: impl Into<String>, original: E) -> Self
582 where
583 E: std::error::Error + Send + Sync + 'static,
584 {
585 DataProfilerError::JsonParsingError {
586 message: message.into(),
587 source: Some(Box::new(original)),
588 }
589 }
590
591 pub fn arrow_with_source<E>(message: impl Into<String>, original: E) -> Self
594 where
595 E: std::error::Error + Send + Sync + 'static,
596 {
597 DataProfilerError::ArrowError {
598 message: message.into(),
599 source: Some(Box::new(original)),
600 }
601 }
602
603 pub fn parquet_with_source<E>(message: impl Into<String>, original: E) -> Self
606 where
607 E: std::error::Error + Send + Sync + 'static,
608 {
609 DataProfilerError::ParquetError {
610 message: message.into(),
611 source: Some(Box::new(original)),
612 }
613 }
614
615 pub fn io_with_source<E>(message: impl Into<String>, original: E) -> Self
618 where
619 E: std::error::Error + Send + Sync + 'static,
620 {
621 DataProfilerError::IoError {
622 message: message.into(),
623 source: Some(Box::new(original)),
624 }
625 }
626
627 pub fn arrow_error(message: &str) -> Self {
629 DataProfilerError::ArrowError {
630 message: message.to_string(),
631 source: None,
632 }
633 }
634
635 pub fn arrow_error_from<E>(original: E) -> Self
637 where
638 E: std::error::Error + Send + Sync + 'static,
639 {
640 DataProfilerError::ArrowError {
641 message: original.to_string(),
642 source: Some(Box::new(original)),
643 }
644 }
645
646 pub fn parquet_error(message: &str) -> Self {
648 DataProfilerError::ParquetError {
649 message: message.to_string(),
650 source: None,
651 }
652 }
653
654 pub fn parquet_error_from<E>(original: E) -> Self
656 where
657 E: std::error::Error + Send + Sync + 'static,
658 {
659 DataProfilerError::ParquetError {
660 message: original.to_string(),
661 source: Some(Box::new(original)),
662 }
663 }
664
665 pub fn column_analysis_error(column: &str, reason: &str, suggestion: &str) -> Self {
667 DataProfilerError::ColumnAnalysisError {
668 column: column.to_string(),
669 reason: reason.to_string(),
670 suggestion: suggestion.to_string(),
671 }
672 }
673
674 pub fn recoverable_error(
676 message: &str,
677 recovery_suggestion: &str,
678 attempt: usize,
679 max_attempts: usize,
680 ) -> Self {
681 DataProfilerError::RecoverableError {
682 message: message.to_string(),
683 recovery_suggestion: recovery_suggestion.to_string(),
684 attempt,
685 max_attempts,
686 recovery_attempts: Vec::new(),
687 }
688 }
689
690 pub fn recovery_failed(
692 attempts: usize,
693 last_strategy: &str,
694 recovery_log: &str,
695 original_error: &str,
696 ) -> Self {
697 DataProfilerError::RecoveryFailed {
698 attempts,
699 last_strategy: last_strategy.to_string(),
700 recovery_log: recovery_log.to_string(),
701 original_error: original_error.to_string(),
702 }
703 }
704
705 pub fn add_recovery_attempt(&mut self, attempt: RecoveryAttempt) {
707 if let DataProfilerError::RecoverableError {
708 recovery_attempts, ..
709 } = self
710 {
711 recovery_attempts.push(attempt);
712 }
713 }
714
715 pub fn supports_auto_recovery(&self) -> bool {
717 matches!(
718 self,
719 DataProfilerError::CsvParsingError { .. }
720 | DataProfilerError::JsonParsingError { .. }
721 | DataProfilerError::StreamingError { .. }
722 | DataProfilerError::MemoryLimitExceeded
723 | DataProfilerError::RecoverableError { .. }
724 )
725 }
726
727 pub fn suggested_recovery_strategies(&self) -> Vec<RecoveryStrategy> {
729 match self {
730 DataProfilerError::CsvParsingError { .. } => vec![
731 RecoveryStrategy::DelimiterDetection { delimiter: ',' },
732 RecoveryStrategy::DelimiterDetection { delimiter: ';' },
733 RecoveryStrategy::DelimiterDetection { delimiter: '\t' },
734 RecoveryStrategy::DelimiterDetection { delimiter: '|' },
735 RecoveryStrategy::EncodingConversion {
736 from: "latin1".to_string(),
737 to: "utf8".to_string(),
738 },
739 RecoveryStrategy::FlexibleParsing,
740 ],
741 DataProfilerError::MemoryLimitExceeded => vec![
742 RecoveryStrategy::ChunkSizeReduction { new_size: 1000 },
743 RecoveryStrategy::MemoryOptimization,
744 ],
745 DataProfilerError::JsonParsingError { .. } => {
746 vec![RecoveryStrategy::EncodingConversion {
747 from: "latin1".to_string(),
748 to: "utf8".to_string(),
749 }]
750 }
751 DataProfilerError::StreamingError { .. } => vec![RecoveryStrategy::MemoryOptimization],
752 _ => vec![],
753 }
754 }
755
756 pub fn is_recoverable(&self) -> bool {
758 matches!(
759 self,
760 DataProfilerError::SimdUnavailable { .. }
761 | DataProfilerError::SamplingError { .. }
762 | DataProfilerError::DataQualityIssue { .. }
763 | DataProfilerError::RecoverableError { .. }
764 )
765 }
766
767 pub fn suggestion(&self) -> Option<String> {
773 match self {
774 DataProfilerError::CsvParsingError { suggestion, .. }
775 | DataProfilerError::InvalidConfiguration { suggestion, .. }
776 | DataProfilerError::InvalidSemanticHint { suggestion, .. }
777 | DataProfilerError::SamplingError { suggestion, .. }
778 | DataProfilerError::DatabaseConnectionError { suggestion, .. } => {
779 Some(suggestion.clone())
780 }
781 DataProfilerError::ColumnAnalysisError { suggestion, .. } => Some(suggestion.clone()),
782 DataProfilerError::FileNotFound { .. } => {
783 Some("Check that the file exists and you have permission to read it.".to_string())
784 }
785 DataProfilerError::UnsupportedFormat { .. } => Some(format!(
786 "This build reads {}. Convert the input to one of these formats, or rebuild with the feature for the format you need.",
787 supported_formats_hint()
788 )),
789 DataProfilerError::EncodingError { guess, .. } => Some(format!(
790 "Re-encode the file as UTF-8 (e.g. `iconv -f {guess} -t UTF-8`) and profile the result."
791 )),
792 DataProfilerError::MemoryLimitExceeded => {
793 Some("Use streaming mode or increase available memory.".to_string())
794 }
795 DataProfilerError::StreamingError { suggestion, .. } => {
796 if suggestion.is_empty() {
797 None
798 } else {
799 Some(suggestion.clone())
800 }
801 }
802 DataProfilerError::DataQualityIssue { recommendation, .. } => {
803 Some(recommendation.clone())
804 }
805 DataProfilerError::DatabaseFeatureDisabled { .. } => {
806 Some("Recompile with the appropriate database feature flag.".to_string())
807 }
808 _ => None,
809 }
810 }
811
812 pub fn category(&self) -> &'static str {
814 match self {
815 DataProfilerError::CsvParsingError { .. } => "csv_parsing",
816 DataProfilerError::FileNotFound { .. } => "file_not_found",
817 DataProfilerError::UnsupportedFormat { .. } => "unsupported_format",
818 DataProfilerError::EncodingError { .. } => "encoding",
819 DataProfilerError::MemoryLimitExceeded => "memory_limit",
820 DataProfilerError::InvalidConfiguration { .. } => "configuration",
821 DataProfilerError::InvalidSemanticHint { .. } => "semantic_hint",
822 DataProfilerError::DataQualityIssue { .. } => "data_quality",
823 DataProfilerError::StreamingError { .. } => "streaming",
824 DataProfilerError::SimdUnavailable { .. } => "simd",
825 DataProfilerError::SamplingError { .. } => "sampling",
826 DataProfilerError::IoError { .. } => "io",
827 DataProfilerError::JsonParsingError { .. } => "json_parsing",
828 DataProfilerError::ColumnAnalysisError { .. } => "column_analysis",
829 DataProfilerError::RecoverableError { .. } => "recoverable",
830 DataProfilerError::RecoveryFailed { .. } => "recovery_failed",
831 DataProfilerError::ParquetError { .. } => "parquet",
832 DataProfilerError::ArrowError { .. } => "arrow",
833 DataProfilerError::UnsupportedDataSource { .. } => "unsupported_data_source",
834 DataProfilerError::AllEnginesFailed { .. } => "all_engines_failed",
835 DataProfilerError::MetricsCalculationError { .. } => "metrics_calculation",
836 DataProfilerError::DuplicateColumnName { .. } => "duplicate_column_name",
837 DataProfilerError::ConfigValidationError { .. } => "config_validation",
838 DataProfilerError::DatabaseConnectionError { .. } => "database_connection",
839 DataProfilerError::DatabaseQueryError { .. } => "database_query",
840 DataProfilerError::DatabaseConfigError { .. } => "database_config",
841 DataProfilerError::DatabaseFeatureDisabled { .. } => "database_feature_disabled",
842 DataProfilerError::SqlValidationError { .. } => "sql_validation",
843 DataProfilerError::DatabaseSslError { .. } => "database_ssl",
844 DataProfilerError::DatabaseRetryExhausted { .. } => "database_retry_exhausted",
845 }
846 }
847}
848
849impl From<anyhow::Error> for DataProfilerError {
856 fn from(err: anyhow::Error) -> Self {
857 let error_str = err.to_string();
858
859 let source: Option<ErrorSource> = Some(err.into());
863
864 if error_str.contains("CSV") {
866 DataProfilerError::CsvParsingError {
867 message: error_str,
868 suggestion: "Try using robust CSV parsing mode".to_string(),
869 source,
870 }
871 } else if error_str.contains("JSON") {
872 DataProfilerError::JsonParsingError {
873 message: error_str,
874 source,
875 }
876 } else {
877 DataProfilerError::IoError {
880 message: error_str,
881 source,
882 }
883 }
884 }
885}
886
887impl From<std::io::Error> for DataProfilerError {
893 fn from(err: std::io::Error) -> Self {
894 let message = match err.kind() {
899 std::io::ErrorKind::PermissionDenied => {
900 "Permission denied - check file access rights".to_string()
901 }
902 _ => err.to_string(),
903 };
904 DataProfilerError::IoError {
905 message,
906 source: Some(Box::new(err)),
907 }
908 }
909}
910
911impl From<csv::Error> for DataProfilerError {
916 fn from(err: csv::Error) -> Self {
917 DataProfilerError::csv_parsing_from(err, None)
918 }
919}
920
921#[cfg(feature = "arrow")]
923impl From<arrow::error::ArrowError> for DataProfilerError {
924 fn from(err: arrow::error::ArrowError) -> Self {
925 DataProfilerError::arrow_error_from(err)
926 }
927}
928
929impl From<serde_json::Error> for DataProfilerError {
931 fn from(err: serde_json::Error) -> Self {
932 DataProfilerError::json_parsing_from(err)
933 }
934}
935
936impl From<glob::PatternError> for DataProfilerError {
938 fn from(err: glob::PatternError) -> Self {
939 let message = format!("Invalid glob pattern: {}", err);
940 DataProfilerError::invalid_config_with_source(message, "Check the glob pattern syntax", err)
941 }
942}
943
944impl From<toml::de::Error> for DataProfilerError {
946 fn from(err: toml::de::Error) -> Self {
947 let message = format!("Failed to parse TOML configuration: {}", err);
948 DataProfilerError::invalid_config_with_source(
949 message,
950 "Check your configuration file syntax",
951 err,
952 )
953 }
954}
955
956impl From<toml::ser::Error> for DataProfilerError {
958 fn from(err: toml::ser::Error) -> Self {
959 let message = format!("Failed to serialize configuration: {}", err);
960 DataProfilerError::invalid_config_with_source(
961 message,
962 "Check configuration values for serialization issues",
963 err,
964 )
965 }
966}
967
968pub struct AutoRecoveryManager {
970 config: RetryConfig,
971 recovery_log: Vec<RecoveryAttempt>,
972}
973
974impl AutoRecoveryManager {
975 pub fn new(config: RetryConfig) -> Self {
976 Self {
977 config,
978 recovery_log: Vec::new(),
979 }
980 }
981
982 pub fn attempt_recovery<F, T>(
984 &mut self,
985 error: DataProfilerError,
986 retry_fn: F,
987 ) -> Result<T, DataProfilerError>
988 where
989 F: Fn(RecoveryStrategy) -> Result<T, DataProfilerError>,
990 {
991 if !error.supports_auto_recovery() {
992 return Err(error);
993 }
994
995 let strategies = error.suggested_recovery_strategies();
996 let mut last_error: DataProfilerError = error;
997
998 for (attempt, strategy) in strategies.iter().enumerate() {
999 if attempt >= self.config.max_attempts {
1000 break;
1001 }
1002
1003 log::info!(
1005 "Auto-recovery attempt {}/{}: {:?}",
1006 attempt + 1,
1007 self.config.max_attempts,
1008 strategy
1009 );
1010
1011 match retry_fn(strategy.clone()) {
1012 Ok(result) => {
1013 let recovery_attempt = RecoveryAttempt {
1014 attempt_number: attempt + 1,
1015 strategy: strategy.clone(),
1016 success: true,
1017 error_message: None,
1018 };
1019 self.recovery_log.push(recovery_attempt);
1020
1021 log::info!("Auto-recovery successful with strategy: {:?}", strategy);
1022 return Ok(result);
1023 }
1024 Err(err) => {
1025 let recovery_attempt = RecoveryAttempt {
1026 attempt_number: attempt + 1,
1027 strategy: strategy.clone(),
1028 success: false,
1029 error_message: Some(err.to_string()),
1030 };
1031 self.recovery_log.push(recovery_attempt);
1032 last_error = err;
1033
1034 log::warn!("Auto-recovery attempt failed: {}", last_error);
1035 }
1036 }
1037 }
1038
1039 let recovery_log_text = self
1041 .recovery_log
1042 .iter()
1043 .map(|attempt| {
1044 format!(
1045 "Attempt {}: {:?} - {}",
1046 attempt.attempt_number,
1047 attempt.strategy,
1048 if attempt.success { "Success" } else { "Failed" }
1049 )
1050 })
1051 .collect::<Vec<_>>()
1052 .join("; ");
1053
1054 let last_strategy = self
1055 .recovery_log
1056 .last()
1057 .map(|attempt| format!("{:?}", attempt.strategy))
1058 .unwrap_or_else(|| "None".to_string());
1059
1060 Err(DataProfilerError::recovery_failed(
1061 self.recovery_log.len(),
1062 &last_strategy,
1063 &recovery_log_text,
1064 &last_error.to_string(),
1065 ))
1066 }
1067
1068 pub fn get_recovery_log(&self) -> &[RecoveryAttempt] {
1070 &self.recovery_log
1071 }
1072
1073 pub fn clear_log(&mut self) {
1075 self.recovery_log.clear();
1076 }
1077}
1078
1079impl Default for AutoRecoveryManager {
1080 fn default() -> Self {
1081 Self::new(RetryConfig::default())
1082 }
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087 use super::*;
1088
1089 #[test]
1090 fn test_error_categorization() {
1091 let csv_error = DataProfilerError::csv_parsing("field count mismatch", Some("test.csv"));
1092 assert_eq!(csv_error.category(), "csv_parsing");
1093 assert!(!csv_error.is_recoverable());
1094 }
1095
1096 #[test]
1097 fn test_recoverable_errors() {
1098 let simd_error = DataProfilerError::simd_unavailable("CPU doesn't support SIMD");
1099 assert!(simd_error.is_recoverable());
1100 }
1101
1102 #[test]
1103 fn test_error_suggestions() {
1104 let config_error = DataProfilerError::invalid_config(
1105 "Invalid chunk size",
1106 "Use a value between 1000 and 100000",
1107 );
1108
1109 let error_string = config_error.to_string();
1110 assert!(error_string.contains("Invalid chunk size"));
1111 assert!(error_string.contains("Use a value between"));
1112 }
1113
1114 #[test]
1115 fn streaming_error_without_suggestion_prints_no_stale_advice() {
1116 let err = DataProfilerError::StreamingError {
1117 message: "Reader task panicked: boxed error".to_string(),
1118 suggestion: String::new(),
1119 };
1120 let msg = err.to_string();
1121 assert!(msg.contains("Reader task panicked"));
1122 assert!(!msg.contains("chunk size"), "stale remedy leaked: {msg}");
1123 assert!(err.suggestion().is_none());
1124 assert!(
1128 !msg.ends_with('\n'),
1129 "message ends in a bare newline: {msg:?}"
1130 );
1131 assert!(
1132 !msg.contains('\n'),
1133 "empty remedy still opened a line: {msg:?}"
1134 );
1135 }
1136
1137 #[test]
1138 fn streaming_error_with_suggestion_prints_the_remedy_exactly_once() {
1139 let err = DataProfilerError::StreamingError {
1140 message: "Parquet schema inference requires random access".to_string(),
1141 suggestion: "Use infer_schema() with a file path instead".to_string(),
1142 };
1143 let msg = err.to_string();
1144 assert!(msg.contains("random access"), "{msg}");
1145 assert_eq!(
1146 msg.matches("Use infer_schema() with a file path instead")
1147 .count(),
1148 1,
1149 "remedy must appear exactly once: {msg}"
1150 );
1151 assert!(!msg.contains("chunk size"), "stale remedy leaked: {msg}");
1152 assert_eq!(
1154 msg.matches('\n').count(),
1155 1,
1156 "remedy is not on its own line: {msg:?}"
1157 );
1158 assert!(
1159 !msg.ends_with('\n'),
1160 "trailing newline after the remedy: {msg:?}"
1161 );
1162 assert_eq!(
1163 err.suggestion(),
1164 Some("Use infer_schema() with a file path instead".to_string())
1165 );
1166 }
1167
1168 #[test]
1169 fn encoding_error_names_file_and_gives_reencode_advice() {
1170 let err = DataProfilerError::EncodingError {
1171 path: "sales.csv".to_string(),
1172 detail: "first invalid UTF-8 byte at offset 12".to_string(),
1173 guess: "windows-1252".to_string(),
1174 };
1175 assert_eq!(err.category(), "encoding");
1176 let msg = err.to_string();
1177 assert!(msg.contains("sales.csv"), "names the file: {msg}");
1178 assert!(msg.contains("offset 12"), "reports the offset: {msg}");
1179 assert!(
1180 msg.to_lowercase().contains("utf-8"),
1181 "mentions UTF-8: {msg}"
1182 );
1183 assert!(msg.contains("iconv"), "gives a re-encode command: {msg}");
1184 assert!(
1186 !msg.to_lowercase().contains("check file permissions"),
1187 "{msg}"
1188 );
1189 assert!(!msg.contains("All engines failed"), "{msg}");
1190 assert!(err.suggestion().unwrap().contains("windows-1252"));
1191 }
1192
1193 #[test]
1194 fn io_conversion_never_fabricates_a_path() {
1195 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
1198 let err = DataProfilerError::from(io_err);
1199 assert_eq!(err.category(), "io");
1200 assert!(!err.to_string().contains("unknown"));
1201 }
1202
1203 #[test]
1204 fn csv_conversion_without_path_omits_the_filename() {
1205 let err = DataProfilerError::csv_parsing("record has 5 fields but 3 expected", None);
1208 let msg = err.to_string();
1209 assert!(!msg.contains("unknown"));
1210 assert!(msg.contains("This CSV file has inconsistent column counts"));
1211 }
1212
1213 #[test]
1214 fn csv_conversion_with_path_names_the_file() {
1215 let err =
1216 DataProfilerError::csv_parsing("record has 5 fields but 3 expected", Some("data.csv"));
1217 assert!(err.to_string().contains("The CSV file 'data.csv' has"));
1218 }
1219
1220 #[test]
1221 fn file_not_found_keeps_the_real_path() {
1222 let err = DataProfilerError::file_not_found("/data/sales.csv");
1223 assert_eq!(err.category(), "file_not_found");
1224 assert!(err.to_string().contains("/data/sales.csv"));
1225 }
1226
1227 #[test]
1228 fn unsupported_format_advertises_only_buildable_formats() {
1229 let err = DataProfilerError::unsupported_format("xlsx");
1230 let msg = err.to_string();
1231 assert!(msg.contains("CSV, JSON, JSONL"));
1232 #[cfg(feature = "parquet")]
1234 assert!(msg.contains("Parquet"));
1235 #[cfg(not(feature = "parquet"))]
1236 assert!(!msg.contains("Parquet"));
1237 }
1238
1239 #[test]
1240 fn suggestion_is_available_as_structured_context() {
1241 let err = DataProfilerError::unsupported_format("xlsx");
1242 assert!(err.suggestion().is_some());
1243 let hint = DataProfilerError::simd_unavailable("no avx2");
1244 assert!(hint.suggestion().is_none());
1245 }
1246
1247 #[test]
1248 fn redact_credentials_scrubs_userinfo() {
1249 assert_eq!(
1250 redact_credentials("postgresql://admin:s3cret@db.internal:5432/sales"),
1251 "postgresql://***@db.internal:5432/sales"
1252 );
1253 assert_eq!(
1255 redact_credentials("mysql://db.internal:3306/app"),
1256 "mysql://db.internal:3306/app"
1257 );
1258 let msg = "Failed to connect: error with url mysql://root:hunter2@localhost/db";
1260 let redacted = redact_credentials(msg);
1261 assert!(!redacted.contains("hunter2"));
1262 assert!(redacted.contains("mysql://***@localhost/db"));
1263 }
1264
1265 #[test]
1266 fn database_connection_error_redacts_password() {
1267 let err = DataProfilerError::database_connection(
1268 "pool timed out connecting to postgres://user:topsecret@host/db",
1269 );
1270 assert!(!err.to_string().contains("topsecret"));
1271 }
1272
1273 use std::error::Error as _;
1280
1281 #[test]
1282 fn io_error_retains_the_os_error_and_its_kind() {
1283 let original = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied by acl");
1284 let err = DataProfilerError::io_error(original);
1285
1286 let source = err.source().expect("io error must retain a source");
1287 let io: &std::io::Error = source
1288 .downcast_ref()
1289 .expect("source must downcast to the original std::io::Error");
1290 assert_eq!(io.kind(), std::io::ErrorKind::PermissionDenied);
1291 }
1292
1293 #[test]
1294 fn from_io_error_retains_the_kind_even_when_the_message_is_rewritten() {
1295 let original = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied by acl");
1298 let err: DataProfilerError = original.into();
1299
1300 assert!(err.to_string().contains("Permission denied"));
1301 let io: &std::io::Error = err
1302 .source()
1303 .and_then(|s| s.downcast_ref())
1304 .expect("rewritten message must still retain the io::Error");
1305 assert_eq!(io.kind(), std::io::ErrorKind::PermissionDenied);
1306 assert!(io.to_string().contains("denied by acl"));
1307 }
1308
1309 #[test]
1310 fn from_json_error_retains_line_and_column() {
1311 let original = serde_json::from_str::<serde_json::Value>("{oops}").unwrap_err();
1312 let (line, column) = (original.line(), original.column());
1313 let err: DataProfilerError = original.into();
1314
1315 let json: &serde_json::Error = err
1316 .source()
1317 .and_then(|s| s.downcast_ref())
1318 .expect("source must downcast to serde_json::Error");
1319 assert_eq!((json.line(), json.column()), (line, column));
1320 }
1321
1322 #[test]
1323 fn from_csv_error_retains_the_csv_error_kind() {
1324 let mut reader = csv::ReaderBuilder::new()
1325 .flexible(false)
1326 .from_reader("a,b\n1,2,3\n".as_bytes());
1327 let original = reader
1328 .records()
1329 .next()
1330 .expect("one record")
1331 .expect_err("ragged row must fail with flexible(false)");
1332 let err: DataProfilerError = original.into();
1333
1334 let csv: &csv::Error = err
1335 .source()
1336 .and_then(|s| s.downcast_ref())
1337 .expect("source must downcast to csv::Error");
1338 assert!(matches!(csv.kind(), csv::ErrorKind::UnequalLengths { .. }));
1339 }
1340
1341 #[cfg(feature = "arrow")]
1344 #[test]
1345 fn from_arrow_error_retains_the_arrow_error() {
1346 let original = arrow::error::ArrowError::ComputeError("overflow in sum".to_string());
1347 let expected = original.to_string();
1348 let err: DataProfilerError = original.into();
1349
1350 assert!(matches!(err, DataProfilerError::ArrowError { .. }));
1351 let arrow_err: &arrow::error::ArrowError = err
1352 .source()
1353 .and_then(|s| s.downcast_ref())
1354 .expect("source must downcast to arrow::error::ArrowError");
1355 assert_eq!(arrow_err.to_string(), expected);
1356 assert!(matches!(
1357 arrow_err,
1358 arrow::error::ArrowError::ComputeError(_)
1359 ));
1360 }
1361
1362 #[test]
1366 fn parquet_with_source_retains_the_reader_error() {
1367 let original = std::io::Error::new(std::io::ErrorKind::InvalidData, "Corrupt footer");
1368 let err =
1369 DataProfilerError::parquet_with_source("Failed to create Parquet reader", original);
1370
1371 assert!(err.to_string().contains("Failed to create Parquet reader"));
1372 let io: &std::io::Error = err
1373 .source()
1374 .and_then(|s| s.downcast_ref())
1375 .expect("parquet errors must retain the reader failure");
1376 assert_eq!(io.kind(), std::io::ErrorKind::InvalidData);
1377 }
1378
1379 #[test]
1380 fn from_toml_error_retains_the_parse_error() {
1381 let original = toml::from_str::<toml::Value>("key = [unclosed").unwrap_err();
1382 let expected = original.to_string();
1383 let err: DataProfilerError = original.into();
1384
1385 let toml_err: &toml::de::Error = err
1386 .source()
1387 .and_then(|s| s.downcast_ref())
1388 .expect("source must downcast to toml::de::Error");
1389 assert_eq!(toml_err.to_string(), expected);
1390 }
1391
1392 #[test]
1393 fn from_glob_error_retains_the_pattern_error() {
1394 let original = glob::Pattern::new("[").unwrap_err();
1395 let expected = original.to_string();
1396 let err: DataProfilerError = original.into();
1397
1398 let glob_err: &glob::PatternError = err
1399 .source()
1400 .and_then(|s| s.downcast_ref())
1401 .expect("source must downcast to glob::PatternError");
1402 assert_eq!(glob_err.to_string(), expected);
1403 }
1404
1405 #[test]
1406 fn errors_built_without_a_cause_report_no_source() {
1407 assert!(
1410 DataProfilerError::io_message("disk budget exceeded")
1411 .source()
1412 .is_none()
1413 );
1414 assert!(
1415 DataProfilerError::json_parsing_error("not an object")
1416 .source()
1417 .is_none()
1418 );
1419 assert!(
1420 DataProfilerError::arrow_error("downcast failed")
1421 .source()
1422 .is_none()
1423 );
1424 assert!(
1425 DataProfilerError::file_not_found("/nope")
1426 .source()
1427 .is_none()
1428 );
1429 }
1430
1431 #[test]
1432 fn retaining_a_source_does_not_change_the_rendered_message() {
1433 let original = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
1436 let text = original.to_string();
1437
1438 let with_source = DataProfilerError::io_error(original).to_string();
1439 let without_source = DataProfilerError::io_message(&text).to_string();
1440 assert_eq!(with_source, without_source);
1441 }
1442
1443 #[test]
1444 fn the_whole_chain_is_walkable_to_the_root() {
1445 let original = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "truncated");
1446 let err = DataProfilerError::io_with_source("reading chunk 4", original);
1447
1448 let mut depth = 0;
1449 let mut current: Option<&(dyn std::error::Error + 'static)> = Some(&err);
1450 while let Some(e) = current {
1451 current = e.source();
1452 depth += 1;
1453 }
1454 assert_eq!(depth, 2);
1457 }
1458}