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 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 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 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 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 pub fn arrow_error(message: &str) -> Self {
619 DataProfilerError::ArrowError {
620 message: message.to_string(),
621 source: None,
622 }
623 }
624
625 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 pub fn parquet_error(message: &str) -> Self {
638 DataProfilerError::ParquetError {
639 message: message.to_string(),
640 source: None,
641 }
642 }
643
644 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 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 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 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 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 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 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 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 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 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
839impl From<anyhow::Error> for DataProfilerError {
846 fn from(err: anyhow::Error) -> Self {
847 let error_str = err.to_string();
848
849 let source: Option<ErrorSource> = Some(err.into());
853
854 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 DataProfilerError::IoError {
870 message: error_str,
871 source,
872 }
873 }
874 }
875}
876
877impl From<std::io::Error> for DataProfilerError {
883 fn from(err: std::io::Error) -> Self {
884 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
901impl From<csv::Error> for DataProfilerError {
906 fn from(err: csv::Error) -> Self {
907 DataProfilerError::csv_parsing_from(err, None)
908 }
909}
910
911#[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
919impl From<serde_json::Error> for DataProfilerError {
921 fn from(err: serde_json::Error) -> Self {
922 DataProfilerError::json_parsing_from(err)
923 }
924}
925
926impl 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
934impl 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
946impl 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
958pub 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 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::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 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 pub fn get_recovery_log(&self) -> &[RecoveryAttempt] {
1060 &self.recovery_log
1061 }
1062
1063 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 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 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 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 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 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 #[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 assert_eq!(
1245 redact_credentials("mysql://db.internal:3306/app"),
1246 "mysql://db.internal:3306/app"
1247 );
1248 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 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 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 #[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 #[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 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 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 assert_eq!(depth, 2);
1447 }
1448}