1use crate::errors::DataProfilerError;
2use anyhow::Result;
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5
6pub fn validate_unique_column_names(
21 names: &[String],
22 source: &str,
23) -> Result<(), DataProfilerError> {
24 let mut seen: HashSet<&str> = HashSet::with_capacity(names.len());
25 let mut reported: HashSet<&str> = HashSet::new();
26 let mut duplicates: Vec<String> = Vec::new();
27 for name in names {
28 if !seen.insert(name.as_str()) && reported.insert(name.as_str()) {
29 duplicates.push(name.clone());
30 }
31 }
32 if duplicates.is_empty() {
33 Ok(())
34 } else {
35 Err(DataProfilerError::duplicate_column_name(
36 &duplicates,
37 source,
38 ))
39 }
40}
41
42pub struct InputValidator;
44
45#[derive(Debug)]
46pub struct ValidationError {
47 pub message: String,
48 pub suggestion: String,
49 pub error_code: i32,
50}
51
52impl std::fmt::Display for ValidationError {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 write!(f, "{}\n{}", self.message, self.suggestion)
55 }
56}
57
58impl std::error::Error for ValidationError {}
59
60impl InputValidator {
61 pub fn validate_file_input(file_path: &Path) -> Result<(), ValidationError> {
63 if !file_path.exists() {
65 return Err(ValidationError {
66 message: format!("File not found: {}", file_path.display()),
67 suggestion: Self::generate_file_suggestions(file_path),
68 error_code: 2, });
70 }
71
72 if file_path.is_dir() {
74 return Err(ValidationError {
75 message: format!("Path is a directory, not a file: {}", file_path.display()),
76 suggestion: "Use --recursive flag to process directories, or specify a file path"
77 .to_string(),
78 error_code: 21, });
80 }
81
82 Self::validate_file_extension(file_path)?;
84
85 Self::validate_file_permissions(file_path)?;
87
88 Self::validate_file_size(file_path)?;
90
91 Ok(())
92 }
93
94 pub fn validate_config_file(config_path: &Path) -> Result<(), ValidationError> {
96 if !config_path.exists() {
97 return Err(ValidationError {
98 message: format!("Configuration file not found: {}", config_path.display()),
99 suggestion: format!(
100 "Create a config file at {} or use 'dataprof --help' to see configuration options",
101 config_path.display()
102 ),
103 error_code: 2,
104 });
105 }
106
107 if let Some(ext) = config_path.extension()
109 && ext != "toml"
110 {
111 return Err(ValidationError {
112 message: "Configuration file must have .toml extension".to_string(),
113 suggestion:
114 "Rename your config file to have .toml extension (e.g., .dataprof.toml)"
115 .to_string(),
116 error_code: 22, });
118 }
119
120 Ok(())
121 }
122
123 pub fn validate_chunk_size(chunk_size: usize) -> Result<(), ValidationError> {
125 if chunk_size == 0 {
126 return Err(ValidationError {
127 message: "Chunk size cannot be zero".to_string(),
128 suggestion: "Use a positive chunk size (e.g. `ChunkSize::Fixed(1000)`) or `ChunkSize::Adaptive` for automatic sizing".to_string(),
129 error_code: 22,
130 });
131 }
132
133 if chunk_size < 10 {
134 return Err(ValidationError {
135 message: format!("Chunk size too small: {}", chunk_size),
136 suggestion: "Use at least 10 rows per chunk for efficient processing".to_string(),
137 error_code: 22,
138 });
139 }
140
141 if chunk_size > 10_000_000 {
142 return Err(ValidationError {
143 message: format!("Chunk size very large: {}", chunk_size),
144 suggestion: "Consider using smaller chunks (< 10M rows) to avoid memory issues"
145 .to_string(),
146 error_code: 22,
147 });
148 }
149
150 Ok(())
151 }
152
153 pub fn validate_sample_size(sample_size: usize) -> Result<(), ValidationError> {
155 if sample_size == 0 {
156 return Err(ValidationError {
157 message: "Sample size cannot be zero".to_string(),
158 suggestion:
159 "Use a positive sample size (e.g., --sample 10000) or omit for full analysis"
160 .to_string(),
161 error_code: 22,
162 });
163 }
164
165 if sample_size < 100 {
166 return Err(ValidationError {
167 message: format!("Sample size very small: {}", sample_size),
168 suggestion: "Use at least 100 samples for meaningful statistical analysis"
169 .to_string(),
170 error_code: 22,
171 });
172 }
173
174 Ok(())
175 }
176
177 pub fn validate_argument_combinations(
179 streaming: bool,
180 sample: Option<usize>,
181 progress: bool,
182 benchmark: bool,
183 ) -> Result<(), ValidationError> {
184 if progress && !streaming {
186 return Err(ValidationError {
187 message: "Progress display requires streaming mode".to_string(),
188 suggestion: "Add --streaming flag when using --progress".to_string(),
189 error_code: 22,
190 });
191 }
192
193 if benchmark && streaming {
195 return Err(ValidationError {
196 message: "Benchmark mode conflicts with streaming".to_string(),
197 suggestion: "Use either --benchmark OR --streaming, not both".to_string(),
198 error_code: 22,
199 });
200 }
201
202 if benchmark && sample.is_some() {
203 return Err(ValidationError {
204 message: "Benchmark mode conflicts with sampling".to_string(),
205 suggestion: "Use either --benchmark OR --sample, not both".to_string(),
206 error_code: 22,
207 });
208 }
209
210 Ok(())
211 }
212
213 fn generate_file_suggestions(file_path: &Path) -> String {
215 let mut suggestions = Vec::new();
216
217 if let Some(parent) = file_path.parent() {
219 if parent.exists() {
220 if let Ok(entries) = std::fs::read_dir(parent) {
222 let similar_files: Vec<PathBuf> = entries
223 .filter_map(|entry| entry.ok())
224 .filter(|entry| {
225 if let Some(ext) = entry.path().extension() {
226 matches!(ext.to_str(), Some("csv") | Some("json") | Some("jsonl"))
227 } else {
228 false
229 }
230 })
231 .take(3)
232 .map(|entry| entry.path())
233 .collect();
234
235 if !similar_files.is_empty() {
236 suggestions.push(format!("Similar files found in {}:", parent.display()));
237 for file in similar_files {
238 suggestions.push(format!(" • {}", file.display()));
239 }
240 }
241 }
242 } else {
243 suggestions.push(format!(
244 "Parent directory does not exist: {}",
245 parent.display()
246 ));
247 }
248 }
249
250 if file_path.is_relative() {
252 suggestions
253 .push("Try using an absolute path or check your current directory".to_string());
254 }
255
256 if suggestions.is_empty() {
257 "Check the file path and make sure the file exists".to_string()
258 } else {
259 suggestions.join("\n")
260 }
261 }
262
263 fn validate_file_extension(file_path: &Path) -> Result<(), ValidationError> {
265 if let Some(ext) = file_path.extension().and_then(|e| e.to_str()) {
266 match ext.to_lowercase().as_str() {
267 "csv" | "json" | "jsonl" => Ok(()),
268 _ => Err(ValidationError {
269 message: format!("Unsupported file format: .{}", ext),
270 suggestion: "Supported formats: .csv, .json, .jsonl".to_string(),
271 error_code: 22,
272 }),
273 }
274 } else {
275 Err(ValidationError {
276 message: "File has no extension or unrecognizable format".to_string(),
277 suggestion: "Use files with extensions: .csv, .json, or .jsonl".to_string(),
278 error_code: 22,
279 })
280 }
281 }
282
283 fn validate_file_permissions(file_path: &Path) -> Result<(), ValidationError> {
285 match std::fs::metadata(file_path) {
286 Ok(metadata) => {
287 if metadata.permissions().readonly() {
288 log::debug!("File is read-only: {}", file_path.display());
290 }
291 Ok(())
292 }
293 Err(e) => Err(ValidationError {
294 message: format!("Cannot access file metadata: {}", e),
295 suggestion: "Check file permissions and try again".to_string(),
296 error_code: 13,
297 }),
298 }
299 }
300
301 fn validate_file_size(file_path: &Path) -> Result<(), ValidationError> {
303 match std::fs::metadata(file_path) {
304 Ok(metadata) => {
305 let size_mb = metadata.len() as f64 / 1_048_576.0;
306
307 if size_mb > 1000.0 {
308 log::warn!(
310 "Large file detected ({:.1} MB). Consider using --streaming for better performance",
311 size_mb
312 );
313 }
314
315 if size_mb > 10_000.0 {
316 return Err(ValidationError {
317 message: format!("File very large: {:.1} GB", size_mb / 1024.0),
318 suggestion: "Use --streaming --sample for very large files, or ensure sufficient memory".to_string(),
319 error_code: 27, });
321 }
322
323 Ok(())
324 }
325 Err(e) => Err(ValidationError {
326 message: format!("Cannot check file size: {}", e),
327 suggestion: "Ensure file is accessible and try again".to_string(),
328 error_code: 13,
329 }),
330 }
331 }
332
333 #[cfg(feature = "database")]
335 pub fn validate_database_connection(connection_string: &str) -> Result<(), ValidationError> {
336 if connection_string.is_empty() {
337 return Err(ValidationError {
338 message: "Database connection string is empty".to_string(),
339 suggestion:
340 "Provide a valid connection string (e.g., postgresql://user:pass@host/db)"
341 .to_string(),
342 error_code: 22,
343 });
344 }
345
346 if !connection_string.contains("://") {
348 return Err(ValidationError {
349 message: "Invalid connection string format".to_string(),
350 suggestion: "Use format: protocol://[user:password@]host[:port]/database"
351 .to_string(),
352 error_code: 22,
353 });
354 }
355
356 let supported_protocols = ["postgresql", "postgres", "mysql", "sqlite"];
358 let protocol = connection_string.split("://").next().unwrap_or("");
359
360 if !supported_protocols.contains(&protocol) {
361 return Err(ValidationError {
362 message: format!("Unsupported database protocol: {}", protocol),
363 suggestion: format!("Supported protocols: {}", supported_protocols.join(", ")),
364 error_code: 22,
365 });
366 }
367
368 Ok(())
369 }
370
371 pub fn validate_glob_pattern(pattern: &str) -> Result<(), ValidationError> {
373 if pattern.is_empty() {
374 return Err(ValidationError {
375 message: "Glob pattern is empty".to_string(),
376 suggestion: "Provide a valid glob pattern (e.g., \"data/**/*.csv\")".to_string(),
377 error_code: 22,
378 });
379 }
380
381 match glob::Pattern::new(pattern) {
383 Ok(_) => Ok(()),
384 Err(e) => Err(ValidationError {
385 message: format!("Invalid glob pattern: {}", e),
386 suggestion: "Use valid glob syntax with *, **, ?, [abc], etc.".to_string(),
387 error_code: 22,
388 }),
389 }
390 }
391
392 pub fn get_exit_code(error: &ValidationError) -> i32 {
394 error.error_code
395 }
396}
397
398pub mod exit_codes {
400 pub const SUCCESS: i32 = 0;
401 pub const GENERAL_ERROR: i32 = 1;
402 pub const FILE_NOT_FOUND: i32 = 2;
403 pub const PERMISSION_DENIED: i32 = 13;
404 pub const INVALID_ARGUMENT: i32 = 22;
405 pub const FILE_TOO_LARGE: i32 = 27;
406 pub const NO_SPACE_LEFT: i32 = 28;
407 pub const BROKEN_PIPE: i32 = 32;
408
409 pub const INVALID_DATA_FORMAT: i32 = 65;
411 pub const PROCESSING_ERROR: i32 = 66;
412 pub const CONFIG_ERROR: i32 = 67;
413 pub const DATABASE_ERROR: i32 = 68;
414 pub const NETWORK_ERROR: i32 = 69;
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use std::io::Write;
421 use tempfile::NamedTempFile;
422
423 #[test]
426 fn test_chunk_size_zero_rejected() {
427 assert!(InputValidator::validate_chunk_size(0).is_err());
428 }
429
430 #[test]
431 fn test_chunk_size_too_small_rejected() {
432 assert!(InputValidator::validate_chunk_size(5).is_err());
433 }
434
435 #[test]
436 fn test_chunk_size_too_large_rejected() {
437 assert!(InputValidator::validate_chunk_size(20_000_000).is_err());
438 }
439
440 #[test]
441 fn test_chunk_size_valid() {
442 assert!(InputValidator::validate_chunk_size(1000).is_ok());
443 assert!(InputValidator::validate_chunk_size(10).is_ok());
444 assert!(InputValidator::validate_chunk_size(10_000_000).is_ok());
445 }
446
447 #[test]
450 fn test_sample_size_zero_rejected() {
451 assert!(InputValidator::validate_sample_size(0).is_err());
452 }
453
454 #[test]
455 fn test_sample_size_too_small_rejected() {
456 assert!(InputValidator::validate_sample_size(50).is_err());
457 }
458
459 #[test]
460 fn test_sample_size_valid() {
461 assert!(InputValidator::validate_sample_size(100).is_ok());
462 assert!(InputValidator::validate_sample_size(10_000).is_ok());
463 }
464
465 #[test]
468 fn test_progress_without_streaming_rejected() {
469 let result = InputValidator::validate_argument_combinations(false, None, true, false);
470 assert!(result.is_err());
471 }
472
473 #[test]
474 fn test_benchmark_with_streaming_rejected() {
475 let result = InputValidator::validate_argument_combinations(true, None, false, true);
476 assert!(result.is_err());
477 }
478
479 #[test]
480 fn test_benchmark_with_sample_rejected() {
481 let result = InputValidator::validate_argument_combinations(false, Some(1000), false, true);
482 assert!(result.is_err());
483 }
484
485 #[test]
486 fn test_valid_argument_combinations() {
487 assert!(InputValidator::validate_argument_combinations(true, None, true, false).is_ok());
489 assert!(InputValidator::validate_argument_combinations(false, None, false, false).is_ok());
491 assert!(InputValidator::validate_argument_combinations(false, None, false, true).is_ok());
493 }
494
495 #[test]
498 fn test_validate_file_nonexistent() {
499 let result = InputValidator::validate_file_input(Path::new("/nonexistent/file.csv"));
500 assert!(result.is_err());
501 let err = result.unwrap_err();
502 assert_eq!(err.error_code, 2); }
504
505 #[test]
506 fn test_validate_file_directory_rejected() {
507 let dir = tempfile::tempdir().unwrap();
508 let result = InputValidator::validate_file_input(dir.path());
509 assert!(result.is_err());
510 let err = result.unwrap_err();
511 assert_eq!(err.error_code, 21); }
513
514 #[test]
515 fn test_validate_file_unsupported_extension() {
516 let mut f = NamedTempFile::with_suffix(".xlsx").unwrap();
517 write!(f, "data").unwrap();
518 f.flush().unwrap();
519 let result = InputValidator::validate_file_input(f.path());
520 assert!(result.is_err());
521 assert_eq!(result.unwrap_err().error_code, 22); }
523
524 #[test]
525 fn test_validate_file_valid_csv() {
526 let mut f = NamedTempFile::with_suffix(".csv").unwrap();
527 write!(f, "a,b\n1,2\n").unwrap();
528 f.flush().unwrap();
529 assert!(InputValidator::validate_file_input(f.path()).is_ok());
530 }
531
532 #[test]
533 fn test_validate_file_valid_json() {
534 let mut f = NamedTempFile::with_suffix(".json").unwrap();
535 write!(f, "[]").unwrap();
536 f.flush().unwrap();
537 assert!(InputValidator::validate_file_input(f.path()).is_ok());
538 }
539
540 #[test]
543 fn test_glob_pattern_empty_rejected() {
544 assert!(InputValidator::validate_glob_pattern("").is_err());
545 }
546
547 #[test]
548 fn test_glob_pattern_valid() {
549 assert!(InputValidator::validate_glob_pattern("*.csv").is_ok());
550 assert!(InputValidator::validate_glob_pattern("data/**/*.json").is_ok());
551 }
552
553 #[test]
556 fn test_config_file_nonexistent() {
557 assert!(InputValidator::validate_config_file(Path::new("/no/config.toml")).is_err());
558 }
559
560 #[test]
561 fn test_config_file_wrong_extension() {
562 let mut f = NamedTempFile::with_suffix(".yaml").unwrap();
563 write!(f, "key: value").unwrap();
564 f.flush().unwrap();
565 assert!(InputValidator::validate_config_file(f.path()).is_err());
566 }
567
568 #[test]
569 fn test_config_file_valid_toml() {
570 let mut f = NamedTempFile::with_suffix(".toml").unwrap();
571 write!(f, "[settings]").unwrap();
572 f.flush().unwrap();
573 assert!(InputValidator::validate_config_file(f.path()).is_ok());
574 }
575
576 fn names(v: &[&str]) -> Vec<String> {
579 v.iter().map(|s| s.to_string()).collect()
580 }
581
582 #[test]
583 fn unique_column_names_pass() {
584 assert!(validate_unique_column_names(&names(&["x", "y", "z"]), "CSV header").is_ok());
585 assert!(validate_unique_column_names(&[], "CSV header").is_ok());
586 }
587
588 #[test]
589 fn duplicate_first_middle_last_rejected() {
590 for cols in [
591 names(&["x", "x", "y"]), names(&["a", "x", "x"]), names(&["a", "x", "b", "x", "c"]),
594 ] {
595 let err = validate_unique_column_names(&cols, "CSV header").unwrap_err();
596 assert!(matches!(err, DataProfilerError::DuplicateColumnName { .. }));
597 }
598 }
599
600 #[test]
601 fn duplicate_error_names_offender_and_source_only() {
602 let cols = names(&["id", "amount", "id", "amount", "note"]);
603 let err = validate_unique_column_names(&cols, "CSV bytes").unwrap_err();
604 let msg = err.to_string();
605 assert!(msg.contains("'id'"));
607 assert!(msg.contains("'amount'"));
608 assert_eq!(msg.matches("'id'").count(), 1);
609 assert!(msg.contains("CSV bytes"));
610 assert!(msg.contains("Duplicate column names:")); }
612}