1
2#[derive(Debug, Clone)]
4pub struct ValidationResult {
5 pub train_size: usize,
7 pub test_size: usize,
9 pub evaluated: usize,
11 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 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 #[test]
54 fn test_partial_token_completion() {
55 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 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 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 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 let commands = vec![
118 "git commit -m test".to_string(),
119 "git commit-m broken".to_string(), "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 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 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 for (suggestion, _) in &suggestions {
156 assert!(
157 suggestion.starts_with("git s"),
158 "Expected 'git s*', got: {}",
159 suggestion
160 );
161 }
162
163 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 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 let with_space = model.suggest("git ", 5);
186 assert!(with_space
187 .iter()
188 .any(|(s, _)| s == "git status" || s == "git commit"));
189
190 let without_space = model.suggest("git", 5);
192 assert!(without_space.iter().all(|(s, _)| s.starts_with("git")));
194 }
195
196 #[test]
199 fn test_is_corrupted_double_spaces() {
200 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 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 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#[cfg(test)]
263mod proptests {
264 use super::*;
265 use proptest::prelude::*;
266 use std::fs;
267 use tempfile::NamedTempFile;
268
269 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 "[a-z]{3,10}( -[a-z])?( [a-z]{2,8})?".prop_map(|s| s),
284 ]
285 }
286
287 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 #[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 #[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 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 #[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 #[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 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 prop_assert_eq!(loaded.command_freq, before_freq, "command_freq should match after roundtrip");
353 }
354
355 #[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 #[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 #[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 prop_assert!(size < 1_000_000, "File too large: {} bytes", size);
402 prop_assert!(size > 100, "File too small: {} bytes", size);
404 }
405 }
406}