Skip to main content

aprender_shell/
validation.rs

1//! Input validation for aprender-shell
2//!
3//! Follows Toyota Way principle *Poka-yoke* (Error-proofing):
4//! Design systems that prevent mistakes.
5
6use crate::error::ShellError;
7use crate::model::MarkovModel;
8use std::path::Path;
9
10/// Sanitize and validate command prefix input.
11///
12/// Removes dangerous characters and validates input meets minimum requirements.
13///
14/// # Arguments
15/// * `input` - Raw user input from shell
16///
17/// # Returns
18/// * `Ok(String)` - Sanitized, valid prefix
19/// * `Err(ShellError)` - If input is invalid
20///
21/// # Example
22/// ```
23/// use aprender_shell::validation::sanitize_prefix;
24///
25/// assert!(sanitize_prefix("").is_err());
26/// assert!(sanitize_prefix("git status").is_ok());
27/// assert_eq!(sanitize_prefix("git \0status").expect("valid after sanitize"), "git status");
28/// ```
29pub fn sanitize_prefix(input: &str) -> Result<String, ShellError> {
30    // Remove null bytes (security)
31    let sanitized = input.replace('\0', "");
32
33    // Trim whitespace
34    let trimmed = sanitized.trim();
35
36    // Reject empty input
37    if trimmed.is_empty() {
38        return Err(ShellError::InvalidInput {
39            message: "Empty prefix".into(),
40        });
41    }
42
43    // Reject if too short (< 2 chars for meaningful suggestions)
44    if trimmed.len() < 2 {
45        return Err(ShellError::InvalidInput {
46            message: "Prefix too short (minimum 2 characters)".into(),
47        });
48    }
49
50    // Reject control characters (except tab which is common in shell)
51    if trimmed.chars().any(|c| c.is_control() && c != '\t') {
52        return Err(ShellError::InvalidInput {
53            message: "Invalid control characters in input".into(),
54        });
55    }
56
57    Ok(trimmed.to_string())
58}
59
60/// Load model with graceful error handling.
61///
62/// Instead of panicking on errors, returns a descriptive ShellError
63/// with hints for resolution.
64///
65/// # Arguments
66/// * `path` - Path to the model file
67///
68/// # Returns
69/// * `Ok(MarkovModel)` - Successfully loaded model
70/// * `Err(ShellError)` - Descriptive error with hints
71pub fn load_model_graceful(path: &Path) -> Result<MarkovModel, ShellError> {
72    // Check if file exists first
73    if !path.exists() {
74        return Err(ShellError::ModelNotFound {
75            path: path.to_path_buf(),
76            hint: "Run 'aprender-shell train' to create a model".into(),
77        });
78    }
79
80    // Try to load the model
81    match MarkovModel::load(path) {
82        Ok(model) => Ok(model),
83        Err(e) => {
84            let msg = e.to_string();
85
86            // Detect specific error types
87            if msg.contains("Checksum")
88                || msg.contains("checksum")
89                || msg.contains("invalid")
90                || msg.contains("corrupt")
91            {
92                Err(ShellError::ModelCorrupted {
93                    path: path.to_path_buf(),
94                    hint: "Model file is corrupted. Run 'aprender-shell train' to rebuild".into(),
95                })
96            } else {
97                Err(ShellError::ModelLoadFailed {
98                    path: path.to_path_buf(),
99                    cause: msg,
100                })
101            }
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use std::io::Write;
110    use tempfile::NamedTempFile;
111
112    // =========================================================================
113    // Input Validation Tests (sanitize_prefix)
114    // =========================================================================
115
116    #[test]
117    fn test_empty_input_rejected() {
118        assert!(sanitize_prefix("").is_err());
119        let err = sanitize_prefix("").unwrap_err();
120        assert!(matches!(err, ShellError::InvalidInput { .. }));
121    }
122
123    #[test]
124    fn test_whitespace_only_rejected() {
125        assert!(sanitize_prefix("   ").is_err());
126        assert!(sanitize_prefix("\t\n").is_err());
127        assert!(sanitize_prefix("  \t  ").is_err());
128    }
129
130    #[test]
131    fn test_null_bytes_removed() {
132        let result = sanitize_prefix("git \0status").expect("should succeed");
133        assert_eq!(result, "git status");
134        assert!(!result.contains('\0'));
135    }
136
137    #[test]
138    fn test_short_input_rejected() {
139        assert!(sanitize_prefix("g").is_err());
140        let err = sanitize_prefix("a").unwrap_err();
141        assert!(matches!(err, ShellError::InvalidInput { .. }));
142    }
143
144    #[test]
145    fn test_two_char_input_accepted() {
146        assert!(sanitize_prefix("gi").is_ok());
147        assert!(sanitize_prefix("ls").is_ok());
148        assert!(sanitize_prefix("cd").is_ok());
149    }
150
151    #[test]
152    fn test_double_dash_accepted() {
153        assert!(sanitize_prefix("--help").is_ok());
154        assert!(sanitize_prefix("-- filename").is_ok());
155        assert!(sanitize_prefix("git checkout --").is_ok());
156    }
157
158    #[test]
159    fn test_control_chars_rejected() {
160        // Bell character
161        assert!(sanitize_prefix("git\x07status").is_err());
162        // Escape character
163        assert!(sanitize_prefix("git\x1bstatus").is_err());
164    }
165
166    #[test]
167    fn test_tab_allowed() {
168        // Tab is allowed as it's common in shell
169        assert!(sanitize_prefix("git\tstatus").is_ok());
170    }
171
172    #[test]
173    fn test_valid_commands_accepted() {
174        assert!(sanitize_prefix("git status").is_ok());
175        assert!(sanitize_prefix("cargo build --release").is_ok());
176        assert!(sanitize_prefix("docker ps -a").is_ok());
177        assert!(sanitize_prefix("kubectl get pods").is_ok());
178    }
179
180    #[test]
181    fn test_whitespace_trimmed() {
182        let result = sanitize_prefix("  git status  ").expect("should succeed");
183        assert_eq!(result, "git status");
184    }
185
186    // =========================================================================
187    // Model Loading Tests (load_model_graceful)
188    // =========================================================================
189
190    #[test]
191    fn test_missing_model_graceful_error() {
192        let result = load_model_graceful(Path::new("/nonexistent/path/model.bin"));
193        assert!(matches!(result, Err(ShellError::ModelNotFound { .. })));
194
195        if let Err(ShellError::ModelNotFound { hint, .. }) = result {
196            assert!(hint.contains("train"));
197        }
198    }
199
200    #[test]
201    fn test_corrupted_model_graceful_error() {
202        // Create a temp file with garbage data
203        let mut tmp = NamedTempFile::new().expect("create temp file");
204        tmp.write_all(b"GARBAGE DATA NOT A MODEL")
205            .expect("write garbage");
206        tmp.flush().expect("flush");
207
208        let result = load_model_graceful(tmp.path());
209
210        // Should be either ModelCorrupted or ModelLoadFailed (not panic!)
211        assert!(
212            matches!(
213                result,
214                Err(ShellError::ModelCorrupted { .. }) | Err(ShellError::ModelLoadFailed { .. })
215            ),
216            "Expected graceful error for corrupted model"
217        );
218    }
219
220    #[test]
221    fn test_valid_model_loads_successfully() {
222        // Create a valid model
223        let mut model = MarkovModel::new(3);
224        model.train(&["git status".to_string(), "git commit".to_string()]);
225
226        let tmp = NamedTempFile::new().expect("create temp file");
227        model.save(tmp.path()).expect("save model");
228
229        let result = load_model_graceful(tmp.path());
230        assert!(result.is_ok(), "Valid model should load successfully");
231    }
232
233    #[test]
234    fn test_empty_file_graceful_error() {
235        // Create an empty file
236        let tmp = NamedTempFile::new().expect("create temp file");
237
238        let result = load_model_graceful(tmp.path());
239
240        // Should not panic, should return an error
241        assert!(result.is_err(), "Empty file should fail gracefully");
242    }
243}