aprender_shell/
config.rs

1//! Configuration and resource limits for aprender-shell
2//!
3//! Follows Toyota Way principle *Heijunka* (Level Loading):
4//! Balance workloads to prevent resource exhaustion.
5
6use std::time::Duration;
7
8/// Configuration with safety limits.
9///
10/// These limits protect against resource exhaustion from malformed input
11/// or corrupted data.
12///
13/// # Example
14/// ```
15/// use aprender_shell::config::ShellConfig;
16///
17/// let config = ShellConfig::default();
18/// assert_eq!(config.suggest_timeout_ms, 100);
19/// assert_eq!(config.max_suggestions, 10);
20///
21/// let custom = ShellConfig::default()
22///     .with_suggest_timeout_ms(50)
23///     .with_max_suggestions(5);
24/// assert_eq!(custom.suggest_timeout_ms, 50);
25/// assert_eq!(custom.max_suggestions, 5);
26/// ```
27#[derive(Debug, Clone, PartialEq)]
28pub struct ShellConfig {
29    /// Maximum time for suggestion generation (ms)
30    pub suggest_timeout_ms: u64,
31
32    /// Maximum model file size (bytes)
33    pub max_model_size: usize,
34
35    /// Maximum history file size (bytes)
36    pub max_history_size: usize,
37
38    /// Maximum number of suggestions to return
39    pub max_suggestions: usize,
40
41    /// Maximum prefix length to process
42    pub max_prefix_length: usize,
43
44    /// Minimum prefix length to process
45    pub min_prefix_length: usize,
46
47    /// Minimum quality score for suggestions (0.0 to 1.0)
48    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,   // 100 MB
56            max_history_size: 500 * 1024 * 1024, // 500 MB
57            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    /// Create a new config with default values.
67    #[must_use]
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    /// Set the suggestion timeout in milliseconds.
73    #[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    /// Set the maximum model size in bytes.
80    #[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    /// Set the maximum history file size in bytes.
87    #[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    /// Set the maximum number of suggestions.
94    #[must_use]
95    pub fn with_max_suggestions(mut self, count: usize) -> Self {
96        self.max_suggestions = count;
97        self
98    }
99
100    /// Set the maximum prefix length.
101    #[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    /// Set the minimum prefix length.
108    #[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    /// Set the minimum quality score (0.0 to 1.0).
115    #[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    /// Get the suggestion timeout as a Duration.
122    #[must_use]
123    pub fn suggest_timeout(&self) -> Duration {
124        Duration::from_millis(self.suggest_timeout_ms)
125    }
126
127    /// Check if a model file size is within limits.
128    #[must_use]
129    pub fn is_model_size_valid(&self, size: usize) -> bool {
130        size <= self.max_model_size
131    }
132
133    /// Check if a history file size is within limits.
134    #[must_use]
135    pub fn is_history_size_valid(&self, size: usize) -> bool {
136        size <= self.max_history_size
137    }
138
139    /// Check if a prefix length is within limits.
140    #[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    /// Truncate prefix to max length if needed.
147    #[must_use]
148    pub fn truncate_prefix<'a>(&self, prefix: &'a str) -> &'a str {
149        if prefix.len() > self.max_prefix_length {
150            // Find the last valid UTF-8 boundary before max_prefix_length
151            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
162/// Preset configurations for different use cases.
163impl ShellConfig {
164    /// Fast configuration for interactive use.
165    ///
166    /// Lower timeouts and limits for snappy response.
167    #[must_use]
168    pub fn fast() -> Self {
169        Self {
170            suggest_timeout_ms: 50,
171            max_model_size: 50 * 1024 * 1024,    // 50 MB
172            max_history_size: 100 * 1024 * 1024, // 100 MB
173            max_suggestions: 5,
174            max_prefix_length: 200,
175            min_prefix_length: 2,
176            min_quality_score: 0.5,
177        }
178    }
179
180    /// Thorough configuration for batch processing.
181    ///
182    /// Higher limits for comprehensive results.
183    #[must_use]
184    pub fn thorough() -> Self {
185        Self {
186            suggest_timeout_ms: 500,
187            max_model_size: 500 * 1024 * 1024,    // 500 MB
188            max_history_size: 1024 * 1024 * 1024, // 1 GB
189            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
202/// Suggestion with graceful degradation and timeout handling.
203///
204/// This function applies all hardening measures:
205/// - Prefix truncation if too long
206/// - Timeout-based suggestion generation
207/// - Quality filtering
208/// - Security filtering
209/// - Result limiting
210///
211/// # Arguments
212/// * `prefix` - The command prefix to complete
213/// * `model` - Optional model reference (returns empty if None)
214/// * `config` - Configuration with limits
215///
216/// # Example
217/// ```
218/// use aprender_shell::config::{ShellConfig, suggest_with_fallback};
219/// use aprender_shell::model::MarkovModel;
220///
221/// let config = ShellConfig::fast();
222/// // Without model - returns empty
223/// let suggestions = suggest_with_fallback("git ", None, &config);
224/// assert!(suggestions.is_empty());
225/// ```
226pub 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
246/// Check if prefix meets minimum length requirements.
247fn is_prefix_processable(prefix: &str, config: &ShellConfig) -> bool {
248    prefix.len() >= config.min_prefix_length
249}
250
251/// Filter and score suggestions with timeout, security, and quality checks.
252fn 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
272/// Check if filtering should stop due to timeout or result limit.
273fn 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
281/// Process a single suggestion: security and quality filtering.
282fn 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")); // Too short
359        assert!(config.is_prefix_valid("ab")); // Minimum
360        assert!(config.is_prefix_valid("hello")); // Within range
361        assert!(config.is_prefix_valid("0123456789")); // Maximum
362        assert!(!config.is_prefix_valid("01234567890")); // Too long
363    }
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        // "日本" is 6 bytes (3 bytes each), truncating to 5 should give first char
379        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    // =========================================================================
407    // suggest_with_fallback Tests
408    // =========================================================================
409
410    #[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        // Should have at least one suggestion
441        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        // Should not contain sensitive commands
474        for (suggestion, _) in &suggestions {
475            assert!(!suggestion.contains("SECRET="));
476        }
477    }
478}