1use std::time::Duration;
7
8#[derive(Debug, Clone, PartialEq)]
28pub struct ShellConfig {
29 pub suggest_timeout_ms: u64,
31
32 pub max_model_size: usize,
34
35 pub max_history_size: usize,
37
38 pub max_suggestions: usize,
40
41 pub max_prefix_length: usize,
43
44 pub min_prefix_length: usize,
46
47 pub min_quality_score: f32,
49}
50
51impl Default for ShellConfig {
52 fn default() -> Self {
53 Self {
54 suggest_timeout_ms: 100,
55 max_model_size: 100 * 1024 * 1024, max_history_size: 500 * 1024 * 1024, max_suggestions: 10,
58 max_prefix_length: 500,
59 min_prefix_length: 2,
60 min_quality_score: 0.3,
61 }
62 }
63}
64
65impl ShellConfig {
66 #[must_use]
68 pub fn new() -> Self {
69 Self::default()
70 }
71
72 #[must_use]
74 pub fn with_suggest_timeout_ms(mut self, timeout: u64) -> Self {
75 self.suggest_timeout_ms = timeout;
76 self
77 }
78
79 #[must_use]
81 pub fn with_max_model_size(mut self, size: usize) -> Self {
82 self.max_model_size = size;
83 self
84 }
85
86 #[must_use]
88 pub fn with_max_history_size(mut self, size: usize) -> Self {
89 self.max_history_size = size;
90 self
91 }
92
93 #[must_use]
95 pub fn with_max_suggestions(mut self, count: usize) -> Self {
96 self.max_suggestions = count;
97 self
98 }
99
100 #[must_use]
102 pub fn with_max_prefix_length(mut self, length: usize) -> Self {
103 self.max_prefix_length = length;
104 self
105 }
106
107 #[must_use]
109 pub fn with_min_prefix_length(mut self, length: usize) -> Self {
110 self.min_prefix_length = length;
111 self
112 }
113
114 #[must_use]
116 pub fn with_min_quality_score(mut self, score: f32) -> Self {
117 self.min_quality_score = score.clamp(0.0, 1.0);
118 self
119 }
120
121 #[must_use]
123 pub fn suggest_timeout(&self) -> Duration {
124 Duration::from_millis(self.suggest_timeout_ms)
125 }
126
127 #[must_use]
129 pub fn is_model_size_valid(&self, size: usize) -> bool {
130 size <= self.max_model_size
131 }
132
133 #[must_use]
135 pub fn is_history_size_valid(&self, size: usize) -> bool {
136 size <= self.max_history_size
137 }
138
139 #[must_use]
141 pub fn is_prefix_valid(&self, prefix: &str) -> bool {
142 let len = prefix.len();
143 len >= self.min_prefix_length && len <= self.max_prefix_length
144 }
145
146 #[must_use]
148 pub fn truncate_prefix<'a>(&self, prefix: &'a str) -> &'a str {
149 if prefix.len() > self.max_prefix_length {
150 let mut end = self.max_prefix_length;
152 while end > 0 && !prefix.is_char_boundary(end) {
153 end -= 1;
154 }
155 &prefix[..end]
156 } else {
157 prefix
158 }
159 }
160}
161
162impl ShellConfig {
164 #[must_use]
168 pub fn fast() -> Self {
169 Self {
170 suggest_timeout_ms: 50,
171 max_model_size: 50 * 1024 * 1024, max_history_size: 100 * 1024 * 1024, max_suggestions: 5,
174 max_prefix_length: 200,
175 min_prefix_length: 2,
176 min_quality_score: 0.5,
177 }
178 }
179
180 #[must_use]
184 pub fn thorough() -> Self {
185 Self {
186 suggest_timeout_ms: 500,
187 max_model_size: 500 * 1024 * 1024, max_history_size: 1024 * 1024 * 1024, max_suggestions: 20,
190 max_prefix_length: 1000,
191 min_prefix_length: 1,
192 min_quality_score: 0.1,
193 }
194 }
195}
196
197use crate::model::MarkovModel;
198use crate::quality::suggestion_quality_score;
199use crate::security::is_sensitive_command;
200use std::time::Instant;
201
202pub fn suggest_with_fallback(
227 prefix: &str,
228 model: Option<&MarkovModel>,
229 config: &ShellConfig,
230) -> Vec<(String, f32)> {
231 let model = match model {
232 Some(m) => m,
233 None => return vec![],
234 };
235
236 if !is_prefix_processable(prefix, config) {
237 return vec![];
238 }
239
240 let prefix = config.truncate_prefix(prefix);
241 let raw_suggestions = model.suggest(prefix, config.max_suggestions * 2);
242
243 filter_suggestions(raw_suggestions, config)
244}
245
246fn is_prefix_processable(prefix: &str, config: &ShellConfig) -> bool {
248 prefix.len() >= config.min_prefix_length
249}
250
251fn filter_suggestions(
253 raw_suggestions: Vec<(String, f32)>,
254 config: &ShellConfig,
255) -> Vec<(String, f32)> {
256 let deadline = Instant::now() + config.suggest_timeout();
257 let mut results = Vec::with_capacity(config.max_suggestions);
258
259 for (suggestion, score) in raw_suggestions {
260 if should_stop_filtering(&results, &deadline, config) {
261 break;
262 }
263
264 if let Some(scored) = process_suggestion(&suggestion, score, config) {
265 results.push(scored);
266 }
267 }
268
269 results
270}
271
272fn should_stop_filtering(
274 results: &[(String, f32)],
275 deadline: &Instant,
276 config: &ShellConfig,
277) -> bool {
278 Instant::now() > *deadline || results.len() >= config.max_suggestions
279}
280
281fn process_suggestion(suggestion: &str, score: f32, config: &ShellConfig) -> Option<(String, f32)> {
283 if is_sensitive_command(suggestion) {
284 return None;
285 }
286
287 let quality = suggestion_quality_score(suggestion);
288 if quality < config.min_quality_score {
289 return None;
290 }
291
292 Some((suggestion.to_string(), score * quality))
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn test_default_values() {
301 let config = ShellConfig::default();
302 assert_eq!(config.suggest_timeout_ms, 100);
303 assert_eq!(config.max_model_size, 100 * 1024 * 1024);
304 assert_eq!(config.max_history_size, 500 * 1024 * 1024);
305 assert_eq!(config.max_suggestions, 10);
306 assert_eq!(config.max_prefix_length, 500);
307 assert_eq!(config.min_prefix_length, 2);
308 }
309
310 #[test]
311 fn test_builder_pattern() {
312 let config = ShellConfig::new()
313 .with_suggest_timeout_ms(50)
314 .with_max_suggestions(5)
315 .with_min_quality_score(0.5);
316
317 assert_eq!(config.suggest_timeout_ms, 50);
318 assert_eq!(config.max_suggestions, 5);
319 assert!((config.min_quality_score - 0.5).abs() < f32::EPSILON);
320 }
321
322 #[test]
323 fn test_quality_score_clamped() {
324 let config = ShellConfig::new().with_min_quality_score(1.5);
325 assert!((config.min_quality_score - 1.0).abs() < f32::EPSILON);
326
327 let config = ShellConfig::new().with_min_quality_score(-0.5);
328 assert!((config.min_quality_score - 0.0).abs() < f32::EPSILON);
329 }
330
331 #[test]
332 fn test_suggest_timeout_duration() {
333 let config = ShellConfig::new().with_suggest_timeout_ms(100);
334 assert_eq!(config.suggest_timeout(), Duration::from_millis(100));
335 }
336
337 #[test]
338 fn test_model_size_validation() {
339 let config = ShellConfig::new().with_max_model_size(1024);
340 assert!(config.is_model_size_valid(512));
341 assert!(config.is_model_size_valid(1024));
342 assert!(!config.is_model_size_valid(2048));
343 }
344
345 #[test]
346 fn test_history_size_validation() {
347 let config = ShellConfig::new().with_max_history_size(1024);
348 assert!(config.is_history_size_valid(512));
349 assert!(!config.is_history_size_valid(2048));
350 }
351
352 #[test]
353 fn test_prefix_validation() {
354 let config = ShellConfig::new()
355 .with_min_prefix_length(2)
356 .with_max_prefix_length(10);
357
358 assert!(!config.is_prefix_valid("a")); assert!(config.is_prefix_valid("ab")); assert!(config.is_prefix_valid("hello")); assert!(config.is_prefix_valid("0123456789")); assert!(!config.is_prefix_valid("01234567890")); }
364
365 #[test]
366 fn test_truncate_prefix() {
367 let config = ShellConfig::new().with_max_prefix_length(5);
368
369 assert_eq!(config.truncate_prefix("abc"), "abc");
370 assert_eq!(config.truncate_prefix("abcde"), "abcde");
371 assert_eq!(config.truncate_prefix("abcdefgh"), "abcde");
372 }
373
374 #[test]
375 fn test_truncate_prefix_utf8_boundary() {
376 let config = ShellConfig::new().with_max_prefix_length(5);
377
378 let jp = "日本";
380 let truncated = config.truncate_prefix(jp);
381 assert!(truncated.len() <= 5);
382 assert!(truncated.is_char_boundary(truncated.len()));
383 }
384
385 #[test]
386 fn test_fast_preset() {
387 let config = ShellConfig::fast();
388 assert_eq!(config.suggest_timeout_ms, 50);
389 assert_eq!(config.max_suggestions, 5);
390 }
391
392 #[test]
393 fn test_thorough_preset() {
394 let config = ShellConfig::thorough();
395 assert_eq!(config.suggest_timeout_ms, 500);
396 assert_eq!(config.max_suggestions, 20);
397 }
398
399 #[test]
400 fn test_clone_and_eq() {
401 let config1 = ShellConfig::default();
402 let config2 = config1.clone();
403 assert_eq!(config1, config2);
404 }
405
406 #[test]
411 fn test_suggest_without_model_returns_empty() {
412 let config = ShellConfig::default();
413 let suggestions = suggest_with_fallback("git ", None, &config);
414 assert!(suggestions.is_empty());
415 }
416
417 #[test]
418 fn test_suggest_with_short_prefix_returns_empty() {
419 let config = ShellConfig::default().with_min_prefix_length(3);
420
421 let mut model = MarkovModel::new(3);
422 model.train(&["git status".to_string()]);
423
424 let suggestions = suggest_with_fallback("g", Some(&model), &config);
425 assert!(suggestions.is_empty());
426 }
427
428 #[test]
429 fn test_suggest_with_model_returns_results() {
430 let config = ShellConfig::default();
431
432 let mut model = MarkovModel::new(3);
433 model.train(&[
434 "git status".to_string(),
435 "git commit".to_string(),
436 "git push".to_string(),
437 ]);
438
439 let suggestions = suggest_with_fallback("git ", Some(&model), &config);
440 assert!(!suggestions.is_empty());
442 }
443
444 #[test]
445 fn test_suggest_respects_max_suggestions() {
446 let config = ShellConfig::default().with_max_suggestions(2);
447
448 let mut model = MarkovModel::new(3);
449 model.train(&[
450 "git status".to_string(),
451 "git commit".to_string(),
452 "git push".to_string(),
453 "git pull".to_string(),
454 "git fetch".to_string(),
455 ]);
456
457 let suggestions = suggest_with_fallback("git ", Some(&model), &config);
458 assert!(suggestions.len() <= 2);
459 }
460
461 #[test]
462 fn test_suggest_filters_sensitive_commands() {
463 let config = ShellConfig::default().with_min_quality_score(0.0);
464
465 let mut model = MarkovModel::new(3);
466 model.train(&[
467 "git status".to_string(),
468 "export SECRET=abc".to_string(),
469 "curl -u admin:pass http://localhost".to_string(),
470 ]);
471
472 let suggestions = suggest_with_fallback("export ", Some(&model), &config);
473 for (suggestion, _) in &suggestions {
475 assert!(!suggestion.contains("SECRET="));
476 }
477 }
478}