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
19pub fn redact_credentials(input: &str) -> String {
25 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 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..]; } else {
41 rest = tail;
42 }
43 }
44 out.push_str(rest);
45 out
46}
47
48#[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#[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#[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#[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 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 pub fn database_query(message: &str) -> Self {
261 DataProfilerError::DatabaseQueryError {
262 message: redact_credentials(message),
263 }
264 }
265
266 pub fn database_config(message: &str) -> Self {
268 DataProfilerError::DatabaseConfigError {
269 message: message.to_string(),
270 }
271 }
272
273 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 pub fn sql_validation(message: &str) -> Self {
285 DataProfilerError::SqlValidationError {
286 message: message.to_string(),
287 }
288 }
289
290 pub fn database_ssl(message: &str) -> Self {
292 DataProfilerError::DatabaseSslError {
293 message: message.to_string(),
294 }
295 }
296 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 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 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 pub fn unsupported_format(extension: &str) -> Self {
354 DataProfilerError::UnsupportedFormat {
355 format: extension.to_string(),
356 }
357 }
358
359 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 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 pub fn streaming_error(message: &str) -> Self {
378 DataProfilerError::StreamingError {
379 message: message.to_string(),
380 }
381 }
382
383 pub fn simd_unavailable(reason: &str) -> Self {
385 DataProfilerError::SimdUnavailable {
386 reason: reason.to_string(),
387 }
388 }
389
390 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 pub fn io_error(original: &std::io::Error) -> Self {
400 DataProfilerError::IoError {
401 message: original.to_string(),
402 }
403 }
404
405 pub fn json_parsing_error(original: &str) -> Self {
407 DataProfilerError::JsonParsingError {
408 message: original.to_string(),
409 }
410 }
411
412 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 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 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 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 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 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 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 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 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
596impl From<anyhow::Error> for DataProfilerError {
603 fn from(err: anyhow::Error) -> Self {
604 let error_str = err.to_string();
605
606 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 DataProfilerError::IoError { message: error_str }
618 }
619 }
620}
621
622impl 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
640impl From<csv::Error> for DataProfilerError {
645 fn from(err: csv::Error) -> Self {
646 DataProfilerError::csv_parsing(&err.to_string(), None)
647 }
648}
649
650#[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
660impl 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
669impl 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
679impl 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
689impl 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
699pub 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 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::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 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 pub fn get_recovery_log(&self) -> &[RecoveryAttempt] {
801 &self.recovery_log
802 }
803
804 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 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 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 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 #[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 assert_eq!(
932 redact_credentials("mysql://db.internal:3306/app"),
933 "mysql://db.internal:3306/app"
934 );
935 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}