Skip to main content

aprender_shell/
validation_result.rs

1
2/// Validation metrics for shell model using aprender's ranking metrics.
3#[derive(Debug, Clone)]
4pub struct ValidationResult {
5    /// Number of commands in training set
6    pub train_size: usize,
7    /// Number of commands in test set
8    pub test_size: usize,
9    /// Number of commands evaluated (with >= 2 tokens)
10    pub evaluated: usize,
11    /// Ranking metrics from aprender (Hit@K, MRR)
12    pub metrics: RankingMetrics,
13}
14
15#[cfg(test)]
16mod tests {
17    use super::*;
18
19    #[test]
20    fn test_train_and_suggest() {
21        let commands = vec![
22            "git status".to_string(),
23            "git commit -m test".to_string(),
24            "git push".to_string(),
25            "git status".to_string(),
26            "git log".to_string(),
27        ];
28
29        let mut model = MarkovModel::new(3);
30        model.train(&commands);
31
32        let suggestions = model.suggest("git ", 3);
33        assert!(!suggestions.is_empty());
34
35        // "status" should be suggested (appears twice)
36        let has_status = suggestions.iter().any(|(s, _)| s.contains("status"));
37        assert!(has_status);
38    }
39
40    #[test]
41    fn test_ngram_counts() {
42        let commands = vec!["ls -la".to_string(), "ls -la /tmp".to_string()];
43
44        let mut model = MarkovModel::new(2);
45        model.train(&commands);
46
47        assert!(model.ngram_count() > 0);
48        assert_eq!(model.vocab_size(), 2);
49    }
50
51    // ==================== EXTREME TDD: Partial Token Tests ====================
52
53    #[test]
54    fn test_partial_token_completion() {
55        // CRITICAL: "git c" should complete to "git commit", "git checkout"
56        // NOT return corrupted full commands like "git commit-m"
57        let commands = vec![
58            "git commit -m test".to_string(),
59            "git checkout main".to_string(),
60            "git clone url".to_string(),
61            "git status".to_string(),
62        ];
63
64        let mut model = MarkovModel::new(3);
65        model.train(&commands);
66
67        let suggestions = model.suggest("git c", 5);
68        assert!(
69            !suggestions.is_empty(),
70            "Should have suggestions for 'git c'"
71        );
72
73        // All suggestions should start with "git c"
74        for (suggestion, _) in &suggestions {
75            assert!(
76                suggestion.starts_with("git c"),
77                "Suggestion '{}' should start with 'git c'",
78                suggestion
79            );
80        }
81
82        // Should suggest commit, checkout, clone
83        let suggestion_text: String = suggestions.iter().map(|(s, _)| s.as_str()).collect();
84        assert!(
85            suggestion_text.contains("commit")
86                || suggestion_text.contains("checkout")
87                || suggestion_text.contains("clone"),
88            "Should suggest commit/checkout/clone, got: {:?}",
89            suggestions
90        );
91    }
92
93    #[test]
94    fn test_is_corrupted_command() {
95        // Test the corruption detection helper
96        assert!(
97            MarkovModel::is_corrupted_command("git commit-m test"),
98            "Should detect 'commit-m' as corrupted"
99        );
100        assert!(
101            MarkovModel::is_corrupted_command("git add-A"),
102            "Should detect 'add-A' as corrupted"
103        );
104        assert!(
105            !MarkovModel::is_corrupted_command("git commit -m test"),
106            "Should NOT detect valid 'commit -m' as corrupted"
107        );
108        assert!(
109            !MarkovModel::is_corrupted_command("git checkout feature-branch"),
110            "Should NOT detect 'feature-branch' as corrupted"
111        );
112    }
113
114    #[test]
115    fn test_partial_token_filters_corrupted() {
116        // Even if corrupted commands exist, partial completion should not return them
117        let commands = vec![
118            "git commit -m test".to_string(),
119            "git commit-m broken".to_string(), // corrupted - no space
120            "git checkout main".to_string(),
121        ];
122
123        let mut model = MarkovModel::new(3);
124        model.train(&commands);
125
126        let suggestions = model.suggest("git co", 5);
127
128        // Should NOT include "git commit-m" - that's corrupted
129        for (suggestion, _) in &suggestions {
130            assert!(
131                !suggestion.contains("commit-m"),
132                "Should not suggest corrupted 'commit-m', got: {}",
133                suggestion
134            );
135        }
136    }
137
138    #[test]
139    fn test_partial_token_single_char() {
140        // "git s" should suggest "git status", "git stash"
141        let commands = vec![
142            "git status".to_string(),
143            "git status".to_string(),
144            "git stash".to_string(),
145            "git show".to_string(),
146        ];
147
148        let mut model = MarkovModel::new(3);
149        model.train(&commands);
150
151        let suggestions = model.suggest("git s", 5);
152        assert!(!suggestions.is_empty());
153
154        // All should start with "git s"
155        for (suggestion, _) in &suggestions {
156            assert!(
157                suggestion.starts_with("git s"),
158                "Expected 'git s*', got: {}",
159                suggestion
160            );
161        }
162
163        // status should rank highest (appears twice)
164        assert!(
165            suggestions[0].0.contains("status"),
166            "Most frequent 'status' should be first, got: {}",
167            suggestions[0].0
168        );
169    }
170
171    #[test]
172    fn test_trailing_space_vs_no_space() {
173        // "git " (with space) = predict next token
174        // "git" (no space) = complete current token
175        let commands = vec![
176            "git status".to_string(),
177            "grep pattern".to_string(),
178            "git commit".to_string(),
179        ];
180
181        let mut model = MarkovModel::new(3);
182        model.train(&commands);
183
184        // With trailing space: predict next token
185        let with_space = model.suggest("git ", 5);
186        assert!(with_space
187            .iter()
188            .any(|(s, _)| s == "git status" || s == "git commit"));
189
190        // Without trailing space: complete "git" to commands starting with "git"
191        let without_space = model.suggest("git", 5);
192        // Should suggest git commands, not grep
193        assert!(without_space.iter().all(|(s, _)| s.starts_with("git")));
194    }
195
196    // ==================== Issue #92: Malformed Suggestions ====================
197
198    #[test]
199    fn test_is_corrupted_double_spaces() {
200        // Double spaces indicate corruption
201        assert!(
202            MarkovModel::is_corrupted_command("cargo-lambda  help"),
203            "Should detect double spaces as corrupted"
204        );
205        assert!(
206            MarkovModel::is_corrupted_command("git  status"),
207            "Should detect double spaces as corrupted"
208        );
209        assert!(
210            !MarkovModel::is_corrupted_command("git status"),
211            "Single space is valid"
212        );
213    }
214
215    #[test]
216    fn test_is_corrupted_trailing_backslash() {
217        // Trailing backslashes indicate incomplete multiline
218        assert!(
219            MarkovModel::is_corrupted_command("git rm -r --cached vendor/\\"),
220            "Should detect trailing backslash"
221        );
222        assert!(
223            MarkovModel::is_corrupted_command("cargo lambda deploy \\\\"),
224            "Should detect trailing escape"
225        );
226        assert!(
227            !MarkovModel::is_corrupted_command("git rm -r --cached vendor/"),
228            "Path without backslash is valid"
229        );
230    }
231
232    #[test]
233    fn test_is_corrupted_typos() {
234        // Common typos where space merged with next word
235        assert!(
236            MarkovModel::is_corrupted_command("gitr push"),
237            "Should detect 'gitr' as typo"
238        );
239        assert!(
240            MarkovModel::is_corrupted_command("giti pull"),
241            "Should detect 'giti' as typo"
242        );
243        assert!(
244            MarkovModel::is_corrupted_command("cargoo build"),
245            "Should detect 'cargoo' as typo"
246        );
247        assert!(
248            !MarkovModel::is_corrupted_command("git push"),
249            "Valid command should pass"
250        );
251        assert!(
252            !MarkovModel::is_corrupted_command("cargo build"),
253            "Valid command should pass"
254        );
255    }
256}
257
258// ============================================================================
259// Property-Based Tests for Model Format (QA Report Fix Verification)
260// ============================================================================
261
262#[cfg(test)]
263mod proptests {
264    use super::*;
265    use proptest::prelude::*;
266    use std::fs;
267    use tempfile::NamedTempFile;
268
269    // Strategy for generating valid shell commands
270    fn arb_command() -> impl Strategy<Value = String> {
271        prop_oneof![
272            Just("git status".to_string()),
273            Just("git commit -m 'test'".to_string()),
274            Just("git push origin main".to_string()),
275            Just("cargo build --release".to_string()),
276            Just("cargo test".to_string()),
277            Just("docker run -it ubuntu".to_string()),
278            Just("kubectl get pods".to_string()),
279            Just("npm install".to_string()),
280            Just("ls -la".to_string()),
281            Just("cd ..".to_string()),
282            // Generate random commands
283            "[a-z]{3,10}( -[a-z])?( [a-z]{2,8})?".prop_map(|s| s),
284        ]
285    }
286
287    // Strategy for generating command lists
288    fn arb_commands(min: usize, max: usize) -> impl Strategy<Value = Vec<String>> {
289        proptest::collection::vec(arb_command(), min..max)
290    }
291
292    proptest! {
293        /// Property: Model save/load roundtrip preserves data
294        #[test]
295        fn prop_roundtrip_preserves_data(commands in arb_commands(5, 50)) {
296            let mut model = MarkovModel::new(3);
297            model.train(&commands);
298
299            let file = NamedTempFile::new().expect("temp file");
300            model.save(file.path()).expect("save");
301
302            let loaded = MarkovModel::load(file.path()).expect("load");
303
304            prop_assert_eq!(loaded.n, model.n, "n-gram size mismatch");
305            prop_assert_eq!(loaded.total_commands, model.total_commands, "command count mismatch");
306            prop_assert_eq!(loaded.command_freq.len(), model.command_freq.len(), "vocab mismatch");
307        }
308
309        /// Property: Model uses NgramLm type (0x0010), not Custom (0x00FF)
310        #[test]
311        fn prop_model_type_is_ngram_lm(commands in arb_commands(3, 20)) {
312            let mut model = MarkovModel::new(3);
313            model.train(&commands);
314
315            let file = NamedTempFile::new().expect("temp file");
316            model.save(file.path()).expect("save");
317
318            let bytes = fs::read(file.path()).expect("read");
319
320            // Model type is at bytes 6-7
321            let model_type = u16::from_le_bytes([bytes[6], bytes[7]]);
322            prop_assert_eq!(model_type, 0x0010, "Model type should be NgramLm (0x0010)");
323        }
324
325        /// Property: Model file has valid APRN magic
326        #[test]
327        fn prop_magic_is_aprn(commands in arb_commands(3, 20)) {
328            let mut model = MarkovModel::new(3);
329            model.train(&commands);
330
331            let file = NamedTempFile::new().expect("temp file");
332            model.save(file.path()).expect("save");
333
334            let bytes = fs::read(file.path()).expect("read");
335            prop_assert_eq!(&bytes[0..4], b"APRN", "Magic should be APRN");
336        }
337
338        /// Property: Command frequencies preserved after roundtrip
339        #[test]
340        fn prop_command_freq_preserved_after_roundtrip(commands in arb_commands(10, 50)) {
341            let mut model = MarkovModel::new(3);
342            model.train(&commands);
343
344            // Get command frequencies before save
345            let before_freq = model.command_freq.clone();
346
347            let file = NamedTempFile::new().expect("temp file");
348            model.save(file.path()).expect("save");
349            let loaded = MarkovModel::load(file.path()).expect("load");
350
351            // Compare command frequencies (exact match expected)
352            prop_assert_eq!(loaded.command_freq, before_freq, "command_freq should match after roundtrip");
353        }
354
355        /// Property: N-gram size is preserved
356        #[test]
357        fn prop_ngram_size_preserved(n in 2usize..=5) {
358            let commands: Vec<String> = vec![
359                "git status".to_string(),
360                "git commit".to_string(),
361                "cargo build".to_string(),
362            ];
363
364            let mut model = MarkovModel::new(n);
365            model.train(&commands);
366
367            let file = NamedTempFile::new().expect("temp file");
368            model.save(file.path()).expect("save");
369            let loaded = MarkovModel::load(file.path()).expect("load");
370
371            prop_assert_eq!(loaded.n, n, "n-gram size should be preserved");
372        }
373
374        /// Property: Empty model can be saved and loaded
375        #[test]
376        fn prop_empty_model_roundtrip(n in 2usize..=5) {
377            let model = MarkovModel::new(n);
378
379            let file = NamedTempFile::new().expect("temp file");
380            model.save(file.path()).expect("save");
381            let loaded = MarkovModel::load(file.path()).expect("load");
382
383            prop_assert_eq!(loaded.n, n);
384            prop_assert_eq!(loaded.total_commands, 0);
385            prop_assert!(loaded.command_freq.is_empty());
386        }
387
388        /// Property: File size is reasonable (not a zip bomb)
389        #[test]
390        fn prop_file_size_reasonable(commands in arb_commands(10, 100)) {
391            let mut model = MarkovModel::new(3);
392            model.train(&commands);
393
394            let file = NamedTempFile::new().expect("temp file");
395            model.save(file.path()).expect("save");
396
397            let metadata = fs::metadata(file.path()).expect("metadata");
398            let size = metadata.len();
399
400            // File should be < 1MB for 100 commands
401            prop_assert!(size < 1_000_000, "File too large: {} bytes", size);
402            // File should be > 100 bytes (has actual content)
403            prop_assert!(size > 100, "File too small: {} bytes", size);
404        }
405    }
406}