cli_testing_specialist/
error.rs

1use colored::Colorize;
2use std::path::{Path, PathBuf};
3
4/// Result type alias for cli-testing-specialist
5pub type Result<T> = std::result::Result<T, CliTestError>;
6
7/// Sanitize a path for display to end users
8///
9/// This function removes sensitive directory information and only shows
10/// the filename to prevent information disclosure.
11///
12/// # Security
13///
14/// - Strips all directory components
15/// - Shows only the filename
16/// - Prevents path traversal information leakage
17fn sanitize_path_for_display(path: &Path) -> String {
18    path.file_name()
19        .and_then(|n| n.to_str())
20        .map(|s| s.to_string())
21        .unwrap_or_else(|| "<invalid-path>".to_string())
22}
23
24/// Error types for CLI testing operations
25///
26/// # Security Note
27///
28/// The `Display` implementation hides sensitive path information by showing
29/// only filenames (not full paths). For debugging and logging, use the
30/// `detailed_message()` method which includes full path information.
31#[derive(Debug)]
32pub enum CliTestError {
33    /// Binary file not found at specified path
34    BinaryNotFound(PathBuf),
35
36    /// Binary file exists but is not executable
37    BinaryNotExecutable(PathBuf),
38
39    /// Failed to execute the binary
40    ExecutionFailed(String),
41
42    /// Help output is invalid or cannot be parsed
43    InvalidHelpOutput,
44
45    /// Failed to parse option from help text
46    OptionParseError(String),
47
48    /// Template rendering failed
49    TemplateError(String),
50
51    /// BATS test execution failed
52    BatsExecutionFailed(String),
53
54    /// Report generation failed
55    ReportError(String),
56
57    /// Configuration error
58    Config(String),
59
60    /// Validation error
61    Validation(String),
62
63    /// Invalid format specified
64    InvalidFormat(String),
65
66    /// I/O error occurred
67    IoError(std::io::Error),
68
69    /// JSON serialization/deserialization error
70    Json(serde_json::Error),
71
72    /// YAML serialization/deserialization error
73    Yaml(serde_yaml::Error),
74
75    /// Handlebars template error
76    HandlebarsTemplate(handlebars::TemplateError),
77
78    /// Handlebars render error
79    HandlebarsRender(handlebars::RenderError),
80}
81
82// Manual Display implementation that hides sensitive paths
83impl std::fmt::Display for CliTestError {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        match self {
86            Self::BinaryNotFound(path) => {
87                write!(f, "Binary not found: {}", sanitize_path_for_display(path))
88            }
89            Self::BinaryNotExecutable(path) => {
90                write!(
91                    f,
92                    "Binary not executable: {}",
93                    sanitize_path_for_display(path)
94                )
95            }
96            Self::ExecutionFailed(msg) => write!(f, "Failed to execute binary: {}", msg),
97            Self::InvalidHelpOutput => write!(f, "Invalid help output"),
98            Self::OptionParseError(details) => write!(f, "Failed to parse option: {}", details),
99            Self::TemplateError(msg) => write!(f, "Template rendering failed: {}", msg),
100            Self::BatsExecutionFailed(msg) => write!(f, "BATS execution failed: {}", msg),
101            Self::ReportError(msg) => write!(f, "Report generation failed: {}", msg),
102            Self::Config(msg) => write!(f, "Configuration error: {}", msg),
103            Self::Validation(msg) => write!(f, "Validation error: {}", msg),
104            Self::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg),
105            Self::IoError(e) => write!(f, "I/O error: {}", e),
106            Self::Json(e) => write!(f, "JSON error: {}", e),
107            Self::Yaml(e) => write!(f, "YAML error: {}", e),
108            Self::HandlebarsTemplate(e) => write!(f, "Template syntax error: {}", e),
109            Self::HandlebarsRender(e) => write!(f, "Template rendering error: {}", e),
110        }
111    }
112}
113
114// Implement std::error::Error trait manually
115impl std::error::Error for CliTestError {
116    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
117        match self {
118            Self::IoError(e) => Some(e),
119            Self::Json(e) => Some(e),
120            Self::Yaml(e) => Some(e),
121            Self::HandlebarsTemplate(e) => Some(e),
122            Self::HandlebarsRender(e) => Some(e),
123            _ => None,
124        }
125    }
126}
127
128// Manual From implementations (replacing thiserror's #[from])
129impl From<std::io::Error> for CliTestError {
130    fn from(err: std::io::Error) -> Self {
131        Self::IoError(err)
132    }
133}
134
135impl From<serde_json::Error> for CliTestError {
136    fn from(err: serde_json::Error) -> Self {
137        Self::Json(err)
138    }
139}
140
141impl From<serde_yaml::Error> for CliTestError {
142    fn from(err: serde_yaml::Error) -> Self {
143        Self::Yaml(err)
144    }
145}
146
147impl From<handlebars::TemplateError> for CliTestError {
148    fn from(err: handlebars::TemplateError) -> Self {
149        Self::HandlebarsTemplate(err)
150    }
151}
152
153impl From<handlebars::RenderError> for CliTestError {
154    fn from(err: handlebars::RenderError) -> Self {
155        Self::HandlebarsRender(err)
156    }
157}
158
159// Re-export as Error for convenience
160pub use CliTestError as Error;
161
162impl CliTestError {
163    /// Get detailed error message for logging (may contain sensitive info)
164    ///
165    /// This method provides verbose error details suitable for logging
166    /// but should NOT be displayed directly to end users.
167    pub fn detailed_message(&self) -> String {
168        match self {
169            Self::BinaryNotFound(path) => {
170                format!("Binary not found at path: {}", path.display())
171            }
172            Self::BinaryNotExecutable(path) => {
173                format!("Binary at {} is not executable", path.display())
174            }
175            Self::ExecutionFailed(msg) => {
176                format!("Binary execution failed: {}", msg)
177            }
178            Self::InvalidHelpOutput => {
179                "Help output could not be parsed - ensure binary supports --help".to_string()
180            }
181            Self::OptionParseError(details) => {
182                format!("Failed to parse option: {}", details)
183            }
184            Self::TemplateError(msg) => {
185                format!("Template rendering error: {}", msg)
186            }
187            Self::BatsExecutionFailed(msg) => {
188                format!("BATS test execution failed: {}", msg)
189            }
190            Self::ReportError(msg) => {
191                format!("Report generation error: {}", msg)
192            }
193            Self::Config(msg) => {
194                format!("Configuration error: {}", msg)
195            }
196            Self::Validation(msg) => {
197                format!("Validation error: {}", msg)
198            }
199            Self::InvalidFormat(msg) => {
200                format!("Invalid format: {}", msg)
201            }
202            Self::IoError(e) => {
203                format!("I/O error: {}", e)
204            }
205            Self::Json(e) => {
206                format!("JSON error: {}", e)
207            }
208            Self::Yaml(e) => {
209                format!("YAML error: {}", e)
210            }
211            Self::HandlebarsTemplate(e) => {
212                format!("Handlebars template error: {}", e)
213            }
214            Self::HandlebarsRender(e) => {
215                format!("Handlebars render error: {}", e)
216            }
217        }
218    }
219
220    /// Get user-friendly colored error message with helpful suggestions
221    ///
222    /// This method formats errors with colors and provides actionable suggestions
223    /// for users to resolve common issues.
224    pub fn user_message(&self) -> String {
225        match self {
226            Self::BinaryNotFound(path) => {
227                let filename = sanitize_path_for_display(path);
228                format!(
229                    "{} {}\n{} {}",
230                    "Error:".red().bold(),
231                    format!("Binary not found: {}", filename).white(),
232                    "Suggestion:".yellow().bold(),
233                    "Check that the path is correct and the file exists".white()
234                )
235            }
236            Self::BinaryNotExecutable(path) => {
237                let filename = sanitize_path_for_display(path);
238                format!(
239                    "{} {}\n{} {}",
240                    "Error:".red().bold(),
241                    format!("Binary is not executable: {}", filename).white(),
242                    "Suggestion:".yellow().bold(),
243                    format!("Try: chmod +x {}", filename).white()
244                )
245            }
246            Self::ExecutionFailed(msg) => {
247                format!(
248                    "{} {}\n{} {}",
249                    "Error:".red().bold(),
250                    format!("Failed to execute binary: {}", msg).white(),
251                    "Suggestion:".yellow().bold(),
252                    "Verify the binary runs correctly with --help flag".white()
253                )
254            }
255            Self::InvalidHelpOutput => {
256                format!(
257                    "{} {}\n{} {}",
258                    "Error:".red().bold(),
259                    "Help output could not be parsed".white(),
260                    "Suggestion:".yellow().bold(),
261                    "Ensure the binary supports --help and produces valid output".white()
262                )
263            }
264            Self::OptionParseError(details) => {
265                format!(
266                    "{} {}\n{} {}",
267                    "Error:".red().bold(),
268                    format!("Failed to parse option: {}", details).white(),
269                    "Suggestion:".yellow().bold(),
270                    "Check if the help text follows standard CLI conventions".white()
271                )
272            }
273            Self::TemplateError(msg) => {
274                format!(
275                    "{} {}\n{} {}",
276                    "Error:".red().bold(),
277                    format!("Template rendering failed: {}", msg).white(),
278                    "Suggestion:".yellow().bold(),
279                    "Verify template syntax and variable bindings".white()
280                )
281            }
282            Self::BatsExecutionFailed(msg) => {
283                format!(
284                    "{} {}\n{} {}",
285                    "Error:".red().bold(),
286                    format!("BATS test execution failed: {}", msg).white(),
287                    "Suggestion:".yellow().bold(),
288                    "Install BATS: brew install bats-core or apt-get install bats".white()
289                )
290            }
291            Self::ReportError(msg) => {
292                format!(
293                    "{} {}\n{} {}",
294                    "Error:".red().bold(),
295                    format!("Report generation failed: {}", msg).white(),
296                    "Suggestion:".yellow().bold(),
297                    "Check output directory permissions and disk space".white()
298                )
299            }
300            Self::Config(msg) => {
301                format!(
302                    "{} {}\n{} {}",
303                    "Error:".red().bold(),
304                    format!("Configuration error: {}", msg).white(),
305                    "Suggestion:".yellow().bold(),
306                    "Review your configuration file syntax and required fields".white()
307                )
308            }
309            Self::Validation(msg) => {
310                format!(
311                    "{} {}\n{} {}",
312                    "Error:".red().bold(),
313                    format!("Validation error: {}", msg).white(),
314                    "Suggestion:".yellow().bold(),
315                    "Ensure all required parameters are provided".white()
316                )
317            }
318            Self::InvalidFormat(msg) => {
319                format!(
320                    "{} {}\n{} {}",
321                    "Error:".red().bold(),
322                    format!("Invalid format: {}", msg).white(),
323                    "Suggestion:".yellow().bold(),
324                    "Use a supported format (bats, assert_cmd, snapbox)".white()
325                )
326            }
327            Self::IoError(e) => {
328                format!(
329                    "{} {}\n{} {}",
330                    "Error:".red().bold(),
331                    format!("I/O error: {}", e).white(),
332                    "Suggestion:".yellow().bold(),
333                    "Check file permissions and disk space".white()
334                )
335            }
336            Self::Json(e) => {
337                format!(
338                    "{} {}\n{} {}",
339                    "Error:".red().bold(),
340                    format!("JSON error: {}", e).white(),
341                    "Suggestion:".yellow().bold(),
342                    "Validate JSON syntax using a JSON linter".white()
343                )
344            }
345            Self::Yaml(e) => {
346                format!(
347                    "{} {}\n{} {}",
348                    "Error:".red().bold(),
349                    format!("YAML error: {}", e).white(),
350                    "Suggestion:".yellow().bold(),
351                    "Check YAML indentation and syntax".white()
352                )
353            }
354            Self::HandlebarsTemplate(e) => {
355                format!(
356                    "{} {}\n{} {}",
357                    "Error:".red().bold(),
358                    format!("Template syntax error: {}", e).white(),
359                    "Suggestion:".yellow().bold(),
360                    "Check Handlebars template syntax and variable names".white()
361                )
362            }
363            Self::HandlebarsRender(e) => {
364                format!(
365                    "{} {}\n{} {}",
366                    "Error:".red().bold(),
367                    format!("Template rendering error: {}", e).white(),
368                    "Suggestion:".yellow().bold(),
369                    "Verify template data and variable bindings".white()
370                )
371            }
372        }
373    }
374
375    /// Print error with color to stderr
376    pub fn print_error(&self) {
377        eprintln!("{}", self.user_message());
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn test_binary_not_found_error() {
387        let path = PathBuf::from("/nonexistent/binary");
388        let error = CliTestError::BinaryNotFound(path.clone());
389        assert!(error.to_string().contains("Binary not found"));
390    }
391
392    #[test]
393    fn test_binary_not_executable_error() {
394        let path = PathBuf::from("/bin/not-executable");
395        let error = CliTestError::BinaryNotExecutable(path);
396        assert!(error.to_string().contains("not executable"));
397    }
398
399    #[test]
400    fn test_execution_failed_error() {
401        let error = CliTestError::ExecutionFailed("timeout".to_string());
402        assert!(error.to_string().contains("Failed to execute"));
403    }
404
405    #[test]
406    fn test_detailed_message_contains_more_info() {
407        let path = PathBuf::from("/test/binary");
408        let error = CliTestError::BinaryNotFound(path);
409        let detailed = error.detailed_message();
410
411        // Detailed message should contain full path
412        assert!(detailed.contains("/test/binary"));
413    }
414
415    // ========== Security Tests ==========
416
417    #[test]
418    fn test_display_hides_sensitive_paths() {
419        // Test that Display (to_string) hides full paths
420        let path = PathBuf::from("/home/user/secret/project/binary");
421        let error = CliTestError::BinaryNotFound(path);
422        let display_msg = error.to_string();
423
424        // Should NOT contain directory path
425        assert!(!display_msg.contains("/home"));
426        assert!(!display_msg.contains("/user"));
427        assert!(!display_msg.contains("/secret"));
428        assert!(!display_msg.contains("/project"));
429
430        // Should only contain filename
431        assert!(display_msg.contains("binary"));
432    }
433
434    #[test]
435    fn test_display_vs_detailed_security() {
436        let path = PathBuf::from("/var/lib/sensitive/data.json");
437        let error = CliTestError::BinaryNotFound(path);
438
439        let display_msg = error.to_string();
440        let detailed_msg = error.detailed_message();
441
442        // Display should hide path
443        assert!(!display_msg.contains("/var"));
444        assert!(!display_msg.contains("sensitive"));
445        assert!(display_msg.contains("data.json"));
446
447        // Detailed should show full path (for logging)
448        assert!(detailed_msg.contains("/var/lib/sensitive/data.json"));
449    }
450
451    #[test]
452    fn test_path_sanitization_windows_style() {
453        // Note: On Unix, backslashes are treated as regular filename characters
454        // This test verifies the behavior is consistent across platforms
455        #[cfg(windows)]
456        {
457            let path = PathBuf::from("C:\\Users\\Admin\\Documents\\secret.exe");
458            let error = CliTestError::BinaryNotExecutable(path);
459            let display_msg = error.to_string();
460
461            // Should not contain directory components
462            assert!(!display_msg.contains("Users"));
463            assert!(!display_msg.contains("Admin"));
464            assert!(!display_msg.contains("Documents"));
465
466            // Should contain filename
467            assert!(display_msg.contains("secret.exe"));
468        }
469
470        #[cfg(unix)]
471        {
472            // On Unix, test with Unix-style path
473            let path = PathBuf::from("/home/admin/documents/secret.exe");
474            let error = CliTestError::BinaryNotExecutable(path);
475            let display_msg = error.to_string();
476
477            // Should not contain directory components
478            assert!(!display_msg.contains("home"));
479            assert!(!display_msg.contains("admin"));
480            assert!(!display_msg.contains("documents"));
481
482            // Should contain filename
483            assert!(display_msg.contains("secret.exe"));
484        }
485    }
486
487    #[test]
488    fn test_sanitize_path_with_special_characters() {
489        let path = PathBuf::from("/tmp/../../../etc/passwd");
490        let error = CliTestError::BinaryNotFound(path);
491        let display_msg = error.to_string();
492
493        // Should not reveal path traversal attempts
494        assert!(!display_msg.contains(".."));
495        assert!(!display_msg.contains("/etc"));
496        assert!(!display_msg.contains("/tmp"));
497    }
498
499    #[test]
500    fn test_invalid_path_handling() {
501        // Test with empty path or invalid UTF-8
502        let path = PathBuf::from("");
503        let error = CliTestError::BinaryNotFound(path);
504        let display_msg = error.to_string();
505
506        // Should show placeholder instead of crashing
507        assert!(display_msg.contains("<invalid-path>") || display_msg.is_empty());
508    }
509
510    #[test]
511    fn test_user_message_is_safe() {
512        let path = PathBuf::from("/home/user/.ssh/id_rsa");
513        let error = CliTestError::BinaryNotFound(path);
514        let user_msg = error.user_message();
515
516        // User message should also not expose full paths
517        assert!(!user_msg.contains(".ssh"));
518        assert!(!user_msg.contains("/home"));
519    }
520
521    #[test]
522    fn test_io_error_does_not_leak_paths() {
523        // Create an I/O error (these often contain path information)
524        let io_error = std::io::Error::new(
525            std::io::ErrorKind::NotFound,
526            "File not found: /secret/path/file.txt",
527        );
528        let error = CliTestError::from(io_error);
529        let display_msg = error.to_string();
530
531        // The error message itself might contain paths from the OS,
532        // but our Display impl should at least not ADD additional path exposure
533        assert!(display_msg.contains("I/O error"));
534    }
535}