1use crate::binary_input::BinarySignature;
2use thiserror::Error;
3
4fn supported_formats_hint() -> &'static str {
10 #[cfg(feature = "parquet")]
11 {
12 "CSV, JSON, JSONL, Parquet"
13 }
14 #[cfg(not(feature = "parquet"))]
15 {
16 "CSV, JSON, JSONL"
17 }
18}
19
20fn suggestion_line(suggestion: &str) -> String {
27 if suggestion.is_empty() {
28 String::new()
29 } else {
30 format!("\n{suggestion}")
31 }
32}
33
34pub fn redact_credentials(input: &str) -> String {
40 let mut out = String::with_capacity(input.len());
42 let mut rest = input;
43 while let Some(scheme_idx) = rest.find("://") {
44 let after_scheme = scheme_idx + 3;
45 out.push_str(&rest[..after_scheme]);
46 let tail = &rest[after_scheme..];
47 let authority_end = tail
50 .find(['/', '?', '#', ' ', '\t', '\n'])
51 .unwrap_or(tail.len());
52 if let Some(at_rel) = tail[..authority_end].find('@') {
53 out.push_str("***");
54 rest = &tail[at_rel..]; } else {
56 rest = tail;
57 }
58 }
59 out.push_str(rest);
60 out
61}
62
63pub type ErrorSource = Box<dyn std::error::Error + Send + Sync + 'static>;
72
73#[derive(Error, Debug)]
79pub enum DataProfilerError {
80 #[error("CSV parsing failed: {message}\nSuggestion: {suggestion}")]
81 #[non_exhaustive]
82 CsvParsingError {
83 message: String,
84 suggestion: String,
85 #[source]
86 source: Option<ErrorSource>,
87 },
88
89 #[error(
90 "File not found: {path}\nPlease check that the file exists and you have permission to read it"
91 )]
92 FileNotFound { path: String },
93
94 #[error(
95 "Unsupported file format: {format}\nSupported formats: {}",
96 supported_formats_hint()
97 )]
98 UnsupportedFormat { format: String },
99
100 #[error("Invalid configuration: {message}\n{suggestion}")]
101 #[non_exhaustive]
102 InvalidConfiguration {
103 message: String,
104 suggestion: String,
105 #[source]
106 source: Option<ErrorSource>,
107 },
108
109 #[error("Invalid semantic hint: {message}\n{suggestion}")]
110 InvalidSemanticHint { message: String, suggestion: String },
111
112 #[error("Streaming processing failed: {message}{}", suggestion_line(.suggestion))]
113 #[non_exhaustive]
114 StreamingError {
115 message: String,
116 suggestion: String,
117 #[source]
118 source: Option<ErrorSource>,
119 },
120
121 #[error("I/O error: {message}\nCheck file permissions and disk space")]
122 #[non_exhaustive]
123 IoError {
124 message: String,
125 #[source]
126 source: Option<ErrorSource>,
127 },
128
129 #[error(
130 "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"
131 )]
132 EncodingError {
133 path: String,
134 detail: String,
135 guess: String,
136 },
137
138 #[error("Binary input in {path}: the file is {detected}, not text\n{suggestion}")]
142 #[non_exhaustive]
143 BinaryInput {
144 path: String,
145 detected: String,
147 suggestion: String,
148 },
149
150 #[error("Source changed during the read: {path}: {detail}\n{suggestion}")]
154 #[non_exhaustive]
155 SourceChanged {
156 path: String,
157 detail: String,
159 suggestion: String,
160 },
161
162 #[error("JSON parsing failed: {message}\nVerify JSON format and encoding")]
163 #[non_exhaustive]
164 JsonParsingError {
165 message: String,
166 #[source]
167 source: Option<ErrorSource>,
168 },
169
170 #[error("Parquet processing failed: {message}")]
171 #[non_exhaustive]
172 ParquetError {
173 message: String,
174 #[source]
175 source: Option<ErrorSource>,
176 },
177
178 #[error("Arrow processing failed: {message}")]
179 #[non_exhaustive]
180 ArrowError {
181 message: String,
182 #[source]
183 source: Option<ErrorSource>,
184 },
185
186 #[error("Unsupported data source: {message}")]
187 UnsupportedDataSource { message: String },
188
189 #[error("All engines failed: {message}")]
190 AllEnginesFailed { message: String },
191
192 #[error("Metrics calculation failed: {message}")]
193 MetricsCalculationError { message: String },
194
195 #[error(
196 "Duplicate column name{plural}: {names}\nSource: {source_label}. Column names must be unique after normalization; rename or drop the duplicate column(s) before profiling."
197 )]
198 DuplicateColumnName {
199 names: String,
200 plural: String,
201 source_label: String,
202 },
203
204 #[error("Configuration validation failed: {message}")]
205 ConfigValidationError { message: String },
206
207 #[error("Database connection failed: {message}\n{suggestion}")]
208 DatabaseConnectionError { message: String, suggestion: String },
209
210 #[error("Database query failed: {message}")]
211 DatabaseQueryError { message: String },
212
213 #[error("Database configuration error: {message}")]
214 DatabaseConfigError { message: String },
215
216 #[error("Database feature not enabled: {message}\nRecompile with the appropriate feature flag")]
217 DatabaseFeatureDisabled { message: String },
218
219 #[error("SQL validation failed: {message}")]
220 SqlValidationError { message: String },
221
222 #[error("Database SSL/TLS error: {message}")]
223 DatabaseSslError { message: String },
224}
225
226impl DataProfilerError {
227 pub fn database_connection(message: &str) -> Self {
232 let message = redact_credentials(message);
233 let m = message.to_lowercase();
234 let suggestion = if m.contains("refused") {
235 "Check that the database server is running and accepting connections."
236 } else if m.contains("timeout") {
237 "Increase the connection timeout or check network connectivity."
238 } else if m.contains("authentication") || m.contains("password") {
239 "Verify your credentials or use environment variables for authentication."
240 } else {
241 "Verify the connection string format and database server availability."
242 };
243 DataProfilerError::DatabaseConnectionError {
244 message,
245 suggestion: suggestion.to_string(),
246 }
247 }
248
249 pub fn database_query(message: &str) -> Self {
254 DataProfilerError::DatabaseQueryError {
255 message: redact_credentials(message),
256 }
257 }
258
259 pub fn database_config(message: &str) -> Self {
261 DataProfilerError::DatabaseConfigError {
262 message: message.to_string(),
263 }
264 }
265
266 pub fn database_feature_disabled(db_name: &str, feature: &str) -> Self {
268 DataProfilerError::DatabaseFeatureDisabled {
269 message: format!(
270 "{} support not compiled. Enable '{}' feature.",
271 db_name, feature
272 ),
273 }
274 }
275
276 pub fn sql_validation(message: &str) -> Self {
278 DataProfilerError::SqlValidationError {
279 message: message.to_string(),
280 }
281 }
282
283 pub fn database_ssl(message: &str) -> Self {
285 DataProfilerError::DatabaseSslError {
286 message: message.to_string(),
287 }
288 }
289 pub fn duplicate_column_name(duplicates: &[String], source: &str) -> Self {
295 let plural = if duplicates.len() == 1 { "" } else { "s" };
296 let names = duplicates
297 .iter()
298 .map(|n| format!("'{}'", n))
299 .collect::<Vec<_>>()
300 .join(", ");
301 DataProfilerError::DuplicateColumnName {
302 names,
303 plural: plural.to_string(),
304 source_label: source.to_string(),
305 }
306 }
307
308 pub fn csv_parsing(original_error: &str, file_path: Option<&str>) -> Self {
314 let suggestion = if original_error.contains("field") && original_error.contains("record") {
315 let file_clause = match file_path {
316 Some(path) => format!("The CSV file '{}' has", path),
317 None => "This CSV file has".to_string(),
318 };
319 format!(
320 "{} 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.",
321 file_clause
322 )
323 } else if original_error.contains("UTF-8") {
324 "The file contains non-UTF-8 characters. Try converting it to UTF-8 encoding."
325 .to_string()
326 } else if original_error.contains("permission") {
327 "Check file permissions - you may not have read access to this file.".to_string()
328 } else {
329 "Try using a different CSV delimiter or check for data formatting issues.".to_string()
330 };
331
332 DataProfilerError::CsvParsingError {
333 message: original_error.to_string(),
334 suggestion,
335 source: None,
336 }
337 }
338
339 pub fn csv_parsing_from<E>(original: E, file_path: Option<&str>) -> Self
345 where
346 E: std::error::Error + Send + Sync + 'static,
347 {
348 let mut err = Self::csv_parsing(&original.to_string(), file_path);
349 if let DataProfilerError::CsvParsingError { source, .. } = &mut err {
350 *source = Some(Box::new(original));
351 }
352 err
353 }
354
355 pub fn file_not_found<P: AsRef<str>>(path: P) -> Self {
357 DataProfilerError::FileNotFound {
358 path: path.as_ref().to_string(),
359 }
360 }
361
362 pub fn binary_input(path: &std::path::Path, signature: BinarySignature) -> Self {
365 let path = path.display().to_string();
366 DataProfilerError::BinaryInput {
367 detected: signature.description().to_string(),
368 suggestion: signature.suggestion(&path),
369 path,
370 }
371 }
372
373 pub fn source_changed(path: &str, detail: impl Into<String>) -> Self {
376 DataProfilerError::SourceChanged {
377 path: path.to_string(),
378 detail: detail.into(),
379 suggestion: "Profile it again once writes to it have finished, or profile a copy that is not being rewritten.".to_string(),
380 }
381 }
382
383 pub fn unsupported_format(extension: &str) -> Self {
385 DataProfilerError::UnsupportedFormat {
386 format: extension.to_string(),
387 }
388 }
389
390 pub fn invalid_config(message: &str, suggestion: &str) -> Self {
392 DataProfilerError::InvalidConfiguration {
393 message: message.to_string(),
394 suggestion: suggestion.to_string(),
395 source: None,
396 }
397 }
398
399 pub fn invalid_config_with_source<E>(
404 message: impl Into<String>,
405 suggestion: impl Into<String>,
406 original: E,
407 ) -> Self
408 where
409 E: std::error::Error + Send + Sync + 'static,
410 {
411 DataProfilerError::InvalidConfiguration {
412 message: message.into(),
413 suggestion: suggestion.into(),
414 source: Some(Box::new(original)),
415 }
416 }
417
418 pub fn streaming_error(message: &str) -> Self {
420 Self::streaming_with_suggestion(message, "")
421 }
422
423 pub fn streaming_with_suggestion(
427 message: impl Into<String>,
428 suggestion: impl Into<String>,
429 ) -> Self {
430 DataProfilerError::StreamingError {
431 message: message.into(),
432 suggestion: suggestion.into(),
433 source: None,
434 }
435 }
436
437 pub fn streaming_with_source<E>(
440 message: impl Into<String>,
441 suggestion: impl Into<String>,
442 original: E,
443 ) -> Self
444 where
445 E: std::error::Error + Send + Sync + 'static,
446 {
447 DataProfilerError::StreamingError {
448 message: message.into(),
449 suggestion: suggestion.into(),
450 source: Some(Box::new(original)),
451 }
452 }
453
454 pub fn io_error(original: std::io::Error) -> Self {
461 DataProfilerError::IoError {
462 message: original.to_string(),
463 source: Some(Box::new(original)),
464 }
465 }
466
467 pub fn io_message(message: &str) -> Self {
471 DataProfilerError::IoError {
472 message: message.to_string(),
473 source: None,
474 }
475 }
476
477 pub fn json_parsing_error(original: &str) -> Self {
479 DataProfilerError::JsonParsingError {
480 message: original.to_string(),
481 source: None,
482 }
483 }
484
485 pub fn json_parsing_from<E>(original: E) -> Self
487 where
488 E: std::error::Error + Send + Sync + 'static,
489 {
490 DataProfilerError::JsonParsingError {
491 message: original.to_string(),
492 source: Some(Box::new(original)),
493 }
494 }
495
496 pub fn csv_parsing_with_source<E>(
502 message: impl Into<String>,
503 suggestion: impl Into<String>,
504 original: E,
505 ) -> Self
506 where
507 E: std::error::Error + Send + Sync + 'static,
508 {
509 DataProfilerError::CsvParsingError {
510 message: message.into(),
511 suggestion: suggestion.into(),
512 source: Some(Box::new(original)),
513 }
514 }
515
516 pub fn csv_structure(message: impl Into<String>, suggestion: impl Into<String>) -> Self {
519 DataProfilerError::CsvParsingError {
520 message: message.into(),
521 suggestion: suggestion.into(),
522 source: None,
523 }
524 }
525
526 pub fn json_parsing_with_source<E>(message: impl Into<String>, original: E) -> Self
533 where
534 E: std::error::Error + Send + Sync + 'static,
535 {
536 DataProfilerError::JsonParsingError {
537 message: message.into(),
538 source: Some(Box::new(original)),
539 }
540 }
541
542 pub fn arrow_with_source<E>(message: impl Into<String>, original: E) -> Self
545 where
546 E: std::error::Error + Send + Sync + 'static,
547 {
548 DataProfilerError::ArrowError {
549 message: message.into(),
550 source: Some(Box::new(original)),
551 }
552 }
553
554 pub fn parquet_with_source<E>(message: impl Into<String>, original: E) -> Self
557 where
558 E: std::error::Error + Send + Sync + 'static,
559 {
560 DataProfilerError::ParquetError {
561 message: message.into(),
562 source: Some(Box::new(original)),
563 }
564 }
565
566 pub fn io_with_source<E>(message: impl Into<String>, original: E) -> Self
569 where
570 E: std::error::Error + Send + Sync + 'static,
571 {
572 DataProfilerError::IoError {
573 message: message.into(),
574 source: Some(Box::new(original)),
575 }
576 }
577
578 pub fn arrow_error(message: &str) -> Self {
580 DataProfilerError::ArrowError {
581 message: message.to_string(),
582 source: None,
583 }
584 }
585
586 pub fn arrow_error_from<E>(original: E) -> Self
588 where
589 E: std::error::Error + Send + Sync + 'static,
590 {
591 DataProfilerError::ArrowError {
592 message: original.to_string(),
593 source: Some(Box::new(original)),
594 }
595 }
596
597 pub fn parquet_error(message: &str) -> Self {
599 DataProfilerError::ParquetError {
600 message: message.to_string(),
601 source: None,
602 }
603 }
604
605 pub fn suggestion(&self) -> Option<String> {
611 match self {
612 DataProfilerError::CsvParsingError { suggestion, .. }
613 | DataProfilerError::InvalidConfiguration { suggestion, .. }
614 | DataProfilerError::InvalidSemanticHint { suggestion, .. }
615 | DataProfilerError::BinaryInput { suggestion, .. }
616 | DataProfilerError::SourceChanged { suggestion, .. }
617 | DataProfilerError::DatabaseConnectionError { suggestion, .. } => {
618 Some(suggestion.clone())
619 }
620 DataProfilerError::FileNotFound { .. } => {
621 Some("Check that the file exists and you have permission to read it.".to_string())
622 }
623 DataProfilerError::UnsupportedFormat { .. } => Some(format!(
624 "This build reads {}. Convert the input to one of these formats, or rebuild with the feature for the format you need.",
625 supported_formats_hint()
626 )),
627 DataProfilerError::EncodingError { guess, .. } => Some(format!(
628 "Re-encode the file as UTF-8 (e.g. `iconv -f {guess} -t UTF-8`) and profile the result."
629 )),
630 DataProfilerError::StreamingError { suggestion, .. } => {
631 if suggestion.is_empty() {
632 None
633 } else {
634 Some(suggestion.clone())
635 }
636 }
637 DataProfilerError::DatabaseFeatureDisabled { .. } => {
638 Some("Recompile with the appropriate database feature flag.".to_string())
639 }
640 _ => None,
641 }
642 }
643
644 pub fn category(&self) -> &'static str {
646 match self {
647 DataProfilerError::CsvParsingError { .. } => "csv_parsing",
648 DataProfilerError::FileNotFound { .. } => "file_not_found",
649 DataProfilerError::UnsupportedFormat { .. } => "unsupported_format",
650 DataProfilerError::EncodingError { .. } => "encoding",
651 DataProfilerError::BinaryInput { .. } => "binary_input",
652 DataProfilerError::SourceChanged { .. } => "source_changed",
653 DataProfilerError::InvalidConfiguration { .. } => "configuration",
654 DataProfilerError::InvalidSemanticHint { .. } => "semantic_hint",
655 DataProfilerError::StreamingError { .. } => "streaming",
656 DataProfilerError::IoError { .. } => "io",
657 DataProfilerError::JsonParsingError { .. } => "json_parsing",
658 DataProfilerError::ParquetError { .. } => "parquet",
659 DataProfilerError::ArrowError { .. } => "arrow",
660 DataProfilerError::UnsupportedDataSource { .. } => "unsupported_data_source",
661 DataProfilerError::AllEnginesFailed { .. } => "all_engines_failed",
662 DataProfilerError::MetricsCalculationError { .. } => "metrics_calculation",
663 DataProfilerError::DuplicateColumnName { .. } => "duplicate_column_name",
664 DataProfilerError::ConfigValidationError { .. } => "config_validation",
665 DataProfilerError::DatabaseConnectionError { .. } => "database_connection",
666 DataProfilerError::DatabaseQueryError { .. } => "database_query",
667 DataProfilerError::DatabaseConfigError { .. } => "database_config",
668 DataProfilerError::DatabaseFeatureDisabled { .. } => "database_feature_disabled",
669 DataProfilerError::SqlValidationError { .. } => "sql_validation",
670 DataProfilerError::DatabaseSslError { .. } => "database_ssl",
671 }
672 }
673}
674
675impl From<anyhow::Error> for DataProfilerError {
682 fn from(err: anyhow::Error) -> Self {
683 let error_str = err.to_string();
684
685 let source: Option<ErrorSource> = Some(err.into());
689
690 if error_str.contains("CSV") {
692 DataProfilerError::CsvParsingError {
693 message: error_str,
694 suggestion: "Try using robust CSV parsing mode".to_string(),
695 source,
696 }
697 } else if error_str.contains("JSON") {
698 DataProfilerError::JsonParsingError {
699 message: error_str,
700 source,
701 }
702 } else {
703 DataProfilerError::IoError {
706 message: error_str,
707 source,
708 }
709 }
710 }
711}
712
713impl From<std::io::Error> for DataProfilerError {
719 fn from(err: std::io::Error) -> Self {
720 let message = match err.kind() {
725 std::io::ErrorKind::PermissionDenied => {
726 "Permission denied - check file access rights".to_string()
727 }
728 _ => err.to_string(),
729 };
730 DataProfilerError::IoError {
731 message,
732 source: Some(Box::new(err)),
733 }
734 }
735}
736
737impl From<csv::Error> for DataProfilerError {
742 fn from(err: csv::Error) -> Self {
743 DataProfilerError::csv_parsing_from(err, None)
744 }
745}
746
747#[cfg(feature = "arrow")]
749impl From<arrow::error::ArrowError> for DataProfilerError {
750 fn from(err: arrow::error::ArrowError) -> Self {
751 DataProfilerError::arrow_error_from(err)
752 }
753}
754
755impl From<serde_json::Error> for DataProfilerError {
757 fn from(err: serde_json::Error) -> Self {
758 DataProfilerError::json_parsing_from(err)
759 }
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765
766 #[test]
767 fn test_error_categorization() {
768 let csv_error = DataProfilerError::csv_parsing("field count mismatch", Some("test.csv"));
769 assert_eq!(csv_error.category(), "csv_parsing");
770 }
771
772 #[test]
773 fn test_error_suggestions() {
774 let config_error = DataProfilerError::invalid_config(
775 "Invalid chunk size",
776 "Use a value between 1000 and 100000",
777 );
778
779 let error_string = config_error.to_string();
780 assert!(error_string.contains("Invalid chunk size"));
781 assert!(error_string.contains("Use a value between"));
782 }
783
784 #[test]
785 fn streaming_error_without_suggestion_prints_no_stale_advice() {
786 let err = DataProfilerError::streaming_error("Reader task panicked: boxed error");
787 let msg = err.to_string();
788 assert!(msg.contains("Reader task panicked"));
789 assert!(!msg.contains("chunk size"), "stale remedy leaked: {msg}");
790 assert!(err.suggestion().is_none());
791 assert!(std::error::Error::source(&err).is_none());
792 assert!(
796 !msg.ends_with('\n'),
797 "message ends in a bare newline: {msg:?}"
798 );
799 assert!(
800 !msg.contains('\n'),
801 "empty remedy still opened a line: {msg:?}"
802 );
803 }
804
805 #[test]
806 fn streaming_error_with_suggestion_prints_the_remedy_exactly_once() {
807 let err = DataProfilerError::streaming_with_suggestion(
808 "Parquet schema inference requires random access",
809 "Use infer_schema() with a file path instead",
810 );
811 let msg = err.to_string();
812 assert!(msg.contains("random access"), "{msg}");
813 assert_eq!(
814 msg.matches("Use infer_schema() with a file path instead")
815 .count(),
816 1,
817 "remedy must appear exactly once: {msg}"
818 );
819 assert!(!msg.contains("chunk size"), "stale remedy leaked: {msg}");
820 assert_eq!(
822 msg.matches('\n').count(),
823 1,
824 "remedy is not on its own line: {msg:?}"
825 );
826 assert!(
827 !msg.ends_with('\n'),
828 "trailing newline after the remedy: {msg:?}"
829 );
830 assert_eq!(
831 err.suggestion(),
832 Some("Use infer_schema() with a file path instead".to_string())
833 );
834 }
835
836 #[test]
837 fn encoding_error_names_file_and_gives_reencode_advice() {
838 let err = DataProfilerError::EncodingError {
839 path: "sales.csv".to_string(),
840 detail: "first invalid UTF-8 byte at offset 12".to_string(),
841 guess: "windows-1252".to_string(),
842 };
843 assert_eq!(err.category(), "encoding");
844 let msg = err.to_string();
845 assert!(msg.contains("sales.csv"), "names the file: {msg}");
846 assert!(msg.contains("offset 12"), "reports the offset: {msg}");
847 assert!(
848 msg.to_lowercase().contains("utf-8"),
849 "mentions UTF-8: {msg}"
850 );
851 assert!(msg.contains("iconv"), "gives a re-encode command: {msg}");
852 assert!(
854 !msg.to_lowercase().contains("check file permissions"),
855 "{msg}"
856 );
857 assert!(!msg.contains("All engines failed"), "{msg}");
858 assert!(err.suggestion().unwrap().contains("windows-1252"));
859 }
860
861 #[test]
862 fn io_conversion_never_fabricates_a_path() {
863 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
866 let err = DataProfilerError::from(io_err);
867 assert_eq!(err.category(), "io");
868 assert!(!err.to_string().contains("unknown"));
869 }
870
871 #[test]
872 fn csv_conversion_without_path_omits_the_filename() {
873 let err = DataProfilerError::csv_parsing("record has 5 fields but 3 expected", None);
876 let msg = err.to_string();
877 assert!(!msg.contains("unknown"));
878 assert!(msg.contains("This CSV file has inconsistent column counts"));
879 }
880
881 #[test]
882 fn csv_conversion_with_path_names_the_file() {
883 let err =
884 DataProfilerError::csv_parsing("record has 5 fields but 3 expected", Some("data.csv"));
885 assert!(err.to_string().contains("The CSV file 'data.csv' has"));
886 }
887
888 #[test]
889 fn file_not_found_keeps_the_real_path() {
890 let err = DataProfilerError::file_not_found("/data/sales.csv");
891 assert_eq!(err.category(), "file_not_found");
892 assert!(err.to_string().contains("/data/sales.csv"));
893 }
894
895 #[test]
896 fn unsupported_format_advertises_only_buildable_formats() {
897 let err = DataProfilerError::unsupported_format("xlsx");
898 let msg = err.to_string();
899 assert!(msg.contains("CSV, JSON, JSONL"));
900 #[cfg(feature = "parquet")]
902 assert!(msg.contains("Parquet"));
903 #[cfg(not(feature = "parquet"))]
904 assert!(!msg.contains("Parquet"));
905 }
906
907 #[test]
908 fn suggestion_is_available_as_structured_context() {
909 let err = DataProfilerError::unsupported_format("xlsx");
910 assert!(err.suggestion().is_some());
911 let hint = DataProfilerError::streaming_error("reader task panicked");
912 assert!(hint.suggestion().is_none());
913 }
914
915 #[test]
916 fn redact_credentials_scrubs_userinfo() {
917 assert_eq!(
918 redact_credentials("postgresql://admin:s3cret@db.internal:5432/sales"),
919 "postgresql://***@db.internal:5432/sales"
920 );
921 assert_eq!(
923 redact_credentials("mysql://db.internal:3306/app"),
924 "mysql://db.internal:3306/app"
925 );
926 let msg = "Failed to connect: error with url mysql://root:hunter2@localhost/db";
928 let redacted = redact_credentials(msg);
929 assert!(!redacted.contains("hunter2"));
930 assert!(redacted.contains("mysql://***@localhost/db"));
931 }
932
933 #[test]
934 fn database_connection_error_redacts_password() {
935 let err = DataProfilerError::database_connection(
936 "pool timed out connecting to postgres://user:topsecret@host/db",
937 );
938 assert!(!err.to_string().contains("topsecret"));
939 }
940
941 use std::error::Error as _;
948
949 #[test]
950 fn io_error_retains_the_os_error_and_its_kind() {
951 let original = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied by acl");
952 let err = DataProfilerError::io_error(original);
953
954 let source = err.source().expect("io error must retain a source");
955 let io: &std::io::Error = source
956 .downcast_ref()
957 .expect("source must downcast to the original std::io::Error");
958 assert_eq!(io.kind(), std::io::ErrorKind::PermissionDenied);
959 }
960
961 #[test]
962 fn from_io_error_retains_the_kind_even_when_the_message_is_rewritten() {
963 let original = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied by acl");
966 let err: DataProfilerError = original.into();
967
968 assert!(err.to_string().contains("Permission denied"));
969 let io: &std::io::Error = err
970 .source()
971 .and_then(|s| s.downcast_ref())
972 .expect("rewritten message must still retain the io::Error");
973 assert_eq!(io.kind(), std::io::ErrorKind::PermissionDenied);
974 assert!(io.to_string().contains("denied by acl"));
975 }
976
977 #[test]
978 fn from_json_error_retains_line_and_column() {
979 let original = serde_json::from_str::<serde_json::Value>("{oops}").unwrap_err();
980 let (line, column) = (original.line(), original.column());
981 let err: DataProfilerError = original.into();
982
983 let json: &serde_json::Error = err
984 .source()
985 .and_then(|s| s.downcast_ref())
986 .expect("source must downcast to serde_json::Error");
987 assert_eq!((json.line(), json.column()), (line, column));
988 }
989
990 #[test]
991 fn from_csv_error_retains_the_csv_error_kind() {
992 let mut reader = csv::ReaderBuilder::new()
993 .flexible(false)
994 .from_reader("a,b\n1,2,3\n".as_bytes());
995 let original = reader
996 .records()
997 .next()
998 .expect("one record")
999 .expect_err("ragged row must fail with flexible(false)");
1000 let err: DataProfilerError = original.into();
1001
1002 let csv: &csv::Error = err
1003 .source()
1004 .and_then(|s| s.downcast_ref())
1005 .expect("source must downcast to csv::Error");
1006 assert!(matches!(csv.kind(), csv::ErrorKind::UnequalLengths { .. }));
1007 }
1008
1009 #[cfg(feature = "arrow")]
1012 #[test]
1013 fn from_arrow_error_retains_the_arrow_error() {
1014 let original = arrow::error::ArrowError::ComputeError("overflow in sum".to_string());
1015 let expected = original.to_string();
1016 let err: DataProfilerError = original.into();
1017
1018 assert!(matches!(err, DataProfilerError::ArrowError { .. }));
1019 let arrow_err: &arrow::error::ArrowError = err
1020 .source()
1021 .and_then(|s| s.downcast_ref())
1022 .expect("source must downcast to arrow::error::ArrowError");
1023 assert_eq!(arrow_err.to_string(), expected);
1024 assert!(matches!(
1025 arrow_err,
1026 arrow::error::ArrowError::ComputeError(_)
1027 ));
1028 }
1029
1030 #[test]
1034 fn parquet_with_source_retains_the_reader_error() {
1035 let original = std::io::Error::new(std::io::ErrorKind::InvalidData, "Corrupt footer");
1036 let err =
1037 DataProfilerError::parquet_with_source("Failed to create Parquet reader", original);
1038
1039 assert!(err.to_string().contains("Failed to create Parquet reader"));
1040 let io: &std::io::Error = err
1041 .source()
1042 .and_then(|s| s.downcast_ref())
1043 .expect("parquet errors must retain the reader failure");
1044 assert_eq!(io.kind(), std::io::ErrorKind::InvalidData);
1045 }
1046
1047 #[test]
1048 fn errors_built_without_a_cause_report_no_source() {
1049 assert!(
1052 DataProfilerError::io_message("disk budget exceeded")
1053 .source()
1054 .is_none()
1055 );
1056 assert!(
1057 DataProfilerError::json_parsing_error("not an object")
1058 .source()
1059 .is_none()
1060 );
1061 assert!(
1062 DataProfilerError::arrow_error("downcast failed")
1063 .source()
1064 .is_none()
1065 );
1066 assert!(
1067 DataProfilerError::file_not_found("/nope")
1068 .source()
1069 .is_none()
1070 );
1071 }
1072
1073 #[test]
1074 fn retaining_a_source_does_not_change_the_rendered_message() {
1075 let original = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
1078 let text = original.to_string();
1079
1080 let with_source = DataProfilerError::io_error(original).to_string();
1081 let without_source = DataProfilerError::io_message(&text).to_string();
1082 assert_eq!(with_source, without_source);
1083 }
1084
1085 #[test]
1086 fn the_whole_chain_is_walkable_to_the_root() {
1087 let original = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "truncated");
1088 let err = DataProfilerError::io_with_source("reading chunk 4", original);
1089
1090 let mut depth = 0;
1091 let mut current: Option<&(dyn std::error::Error + 'static)> = Some(&err);
1092 while let Some(e) = current {
1093 current = e.source();
1094 depth += 1;
1095 }
1096 assert_eq!(depth, 2);
1099 }
1100}