cli_testing_specialist/analyzer/
option_inferrer.rs

1use crate::error::Result;
2use crate::types::analysis::{CliOption, OptionType};
3use lazy_static::lazy_static;
4use serde::Deserialize;
5use std::collections::HashMap;
6use std::sync::Mutex;
7
8/// Pattern configuration loaded from YAML
9#[derive(Debug, Clone, Deserialize)]
10struct OptionPattern {
11    #[serde(rename = "type")]
12    pattern_type: String,
13    priority: u8,
14    keywords: Vec<String>,
15    #[allow(dead_code)]
16    description: String,
17}
18
19#[derive(Debug, Clone, Deserialize)]
20struct OptionPatternsConfig {
21    patterns: Vec<OptionPattern>,
22    default_type: String,
23    settings: PatternSettings,
24}
25
26#[derive(Debug, Clone, Deserialize)]
27struct PatternSettings {
28    case_sensitive: bool,
29    partial_match: bool,
30    min_keyword_length: usize,
31}
32
33lazy_static! {
34    /// Global cache for option patterns loaded from YAML
35    static ref PATTERN_CACHE: Mutex<Option<OptionPatternsConfig>> = Mutex::new(None);
36}
37
38/// Option Type Inferrer - Infers option types from names and patterns
39pub struct OptionInferrer {
40    patterns: Vec<OptionPattern>,
41    settings: PatternSettings,
42    default_type: String,
43}
44
45impl OptionInferrer {
46    /// Create a new option inferrer by loading patterns from YAML
47    pub fn new() -> Result<Self> {
48        Self::from_config_path("config/option-patterns.yaml")
49    }
50
51    /// Create option inferrer from a specific config file
52    pub fn from_config_path(config_path: &str) -> Result<Self> {
53        // Check cache first
54        let mut cache = PATTERN_CACHE.lock().unwrap();
55
56        if cache.is_none() {
57            // Load and parse YAML config
58            let config_content = std::fs::read_to_string(config_path)?;
59            let config: OptionPatternsConfig = serde_yaml::from_str(&config_content)?;
60            *cache = Some(config);
61        }
62
63        // Clone from cache
64        let config = cache.as_ref().unwrap().clone();
65
66        Ok(Self {
67            patterns: config.patterns,
68            settings: config.settings,
69            default_type: config.default_type,
70        })
71    }
72
73    /// Infer option types for a list of options
74    pub fn infer_types(&self, options: &mut [CliOption]) {
75        for option in options.iter_mut() {
76            option.option_type = self.infer_type(option);
77        }
78    }
79
80    /// Infer the type of a single option
81    pub fn infer_type(&self, option: &CliOption) -> OptionType {
82        // If it's already flagged as having a value (from parser), start with that
83        if matches!(option.option_type, OptionType::Flag) {
84            // True flag - no value expected
85            return OptionType::Flag;
86        }
87
88        // Extract option name for pattern matching
89        let option_name = self.extract_option_name(option);
90
91        // Sort patterns by priority (higher first)
92        let mut sorted_patterns = self.patterns.clone();
93        sorted_patterns.sort_by(|a, b| b.priority.cmp(&a.priority));
94
95        // Try to match against patterns
96        for pattern in &sorted_patterns {
97            if self.matches_pattern(&option_name, pattern) {
98                return self.pattern_type_to_option_type(&pattern.pattern_type);
99            }
100        }
101
102        // Fallback to default type
103        self.pattern_type_to_option_type(&self.default_type)
104    }
105
106    /// Extract option name from CliOption (prefer long, fallback to short)
107    fn extract_option_name(&self, option: &CliOption) -> String {
108        if let Some(long) = &option.long {
109            // Remove leading dashes: --timeout -> timeout
110            long.trim_start_matches('-').to_string()
111        } else if let Some(short) = &option.short {
112            // Remove leading dash: -t -> t
113            short.trim_start_matches('-').to_string()
114        } else {
115            String::new()
116        }
117    }
118
119    /// Check if option name matches a pattern
120    fn matches_pattern(&self, option_name: &str, pattern: &OptionPattern) -> bool {
121        let name = if self.settings.case_sensitive {
122            option_name.to_string()
123        } else {
124            option_name.to_lowercase()
125        };
126
127        for keyword in &pattern.keywords {
128            let keyword_normalized = if self.settings.case_sensitive {
129                keyword.clone()
130            } else {
131                keyword.to_lowercase()
132            };
133
134            if self.settings.partial_match {
135                // Partial match: "timeout" matches "connect-timeout"
136                if keyword_normalized.len() >= self.settings.min_keyword_length
137                    && name.contains(&keyword_normalized)
138                {
139                    return true;
140                }
141            } else {
142                // Exact match
143                if name == keyword_normalized {
144                    return true;
145                }
146            }
147        }
148
149        false
150    }
151
152    /// Convert pattern type string to OptionType enum
153    fn pattern_type_to_option_type(&self, pattern_type: &str) -> OptionType {
154        match pattern_type {
155            "numeric" => OptionType::Numeric {
156                min: None,
157                max: None,
158            },
159            "path" => OptionType::Path,
160            "enum" => OptionType::Enum { values: vec![] },
161            "boolean" => OptionType::Flag,
162            _ => OptionType::String,
163        }
164    }
165}
166
167impl Default for OptionInferrer {
168    fn default() -> Self {
169        Self::new().unwrap_or_else(|_| {
170            // Fallback to empty patterns if loading fails
171            Self {
172                patterns: vec![],
173                settings: PatternSettings {
174                    case_sensitive: false,
175                    partial_match: true,
176                    min_keyword_length: 3,
177                },
178                default_type: "string".to_string(),
179            }
180        })
181    }
182}
183
184/// Apply numeric constraints from numeric-constraints.yaml
185///
186/// This function would load constraints like:
187/// - Port numbers: 1-65535
188/// - Timeouts: 0-3600
189/// - Percentages: 0-100
190///
191/// For now, this is a placeholder for future implementation.
192pub fn apply_numeric_constraints(options: &mut [CliOption]) {
193    for option in options.iter_mut() {
194        if let OptionType::Numeric {
195            ref mut min,
196            ref mut max,
197        } = option.option_type
198        {
199            let option_name = option
200                .long
201                .as_ref()
202                .or(option.short.as_ref())
203                .map(|s| s.trim_start_matches('-').to_lowercase())
204                .unwrap_or_default();
205
206            // Apply known constraints
207            if option_name.contains("port") {
208                *min = Some(1);
209                *max = Some(65535);
210            } else if option_name.contains("timeout") || option_name.contains("duration") {
211                *min = Some(0);
212                *max = Some(3600); // 1 hour max
213            } else if option_name.contains("percent") || option_name.contains("ratio") {
214                *min = Some(0);
215                *max = Some(100);
216            }
217        }
218    }
219}
220
221/// Load enum values from enum-definitions.yaml
222///
223/// This function would load known enum values like:
224/// - format: json, yaml, xml, toml
225/// - log-level: debug, info, warn, error
226/// - protocol: http, https, ftp, ssh
227///
228/// For now, this is a placeholder for future implementation.
229pub fn load_enum_values(options: &mut [CliOption]) {
230    let known_enums: HashMap<&str, Vec<&str>> = [
231        ("format", vec!["json", "yaml", "xml", "toml", "csv"]),
232        ("log-level", vec!["debug", "info", "warn", "error", "fatal"]),
233        (
234            "protocol",
235            vec!["http", "https", "ftp", "ssh", "tcp", "udp"],
236        ),
237        ("output", vec!["json", "yaml", "text", "table"]),
238    ]
239    .iter()
240    .cloned()
241    .collect();
242
243    for option in options.iter_mut() {
244        if let OptionType::Enum { ref mut values } = option.option_type {
245            let option_name = option
246                .long
247                .as_ref()
248                .or(option.short.as_ref())
249                .map(|s| s.trim_start_matches('-').to_lowercase())
250                .unwrap_or_default();
251
252            // Check if we have known values for this enum
253            for (key, enum_values) in &known_enums {
254                if option_name.contains(key) {
255                    *values = enum_values.iter().map(|s| s.to_string()).collect();
256                    break;
257                }
258            }
259        }
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn test_extract_option_name() {
269        let inferrer = OptionInferrer::default();
270
271        let option = CliOption {
272            short: None,
273            long: Some("--timeout".to_string()),
274            description: None,
275            option_type: OptionType::String,
276            required: false,
277            default_value: None,
278        };
279
280        assert_eq!(inferrer.extract_option_name(&option), "timeout");
281    }
282
283    #[test]
284    fn test_extract_option_name_short() {
285        let inferrer = OptionInferrer::default();
286
287        let option = CliOption {
288            short: Some("-p".to_string()),
289            long: None,
290            description: None,
291            option_type: OptionType::String,
292            required: false,
293            default_value: None,
294        };
295
296        assert_eq!(inferrer.extract_option_name(&option), "p");
297    }
298
299    #[test]
300    fn test_infer_type_timeout() {
301        let inferrer = OptionInferrer::default();
302
303        let mut option = CliOption {
304            short: None,
305            long: Some("--timeout".to_string()),
306            description: None,
307            option_type: OptionType::String,
308            required: false,
309            default_value: None,
310        };
311
312        let inferred_type = inferrer.infer_type(&option);
313
314        // Should be numeric due to "timeout" keyword
315        assert!(matches!(inferred_type, OptionType::Numeric { .. }));
316
317        option.option_type = inferred_type;
318        let mut options = vec![option];
319        apply_numeric_constraints(&mut options);
320
321        // Check constraints were applied
322        if let OptionType::Numeric { min, max } = &options[0].option_type {
323            assert_eq!(*min, Some(0));
324            assert_eq!(*max, Some(3600));
325        }
326    }
327
328    #[test]
329    fn test_infer_type_path() {
330        let inferrer = OptionInferrer::default();
331
332        let option = CliOption {
333            short: None,
334            long: Some("--config".to_string()),
335            description: None,
336            option_type: OptionType::String,
337            required: false,
338            default_value: None,
339        };
340
341        let inferred_type = inferrer.infer_type(&option);
342
343        // Should be path due to "config" keyword
344        assert!(matches!(inferred_type, OptionType::Path));
345    }
346
347    #[test]
348    fn test_infer_type_enum() {
349        let inferrer = OptionInferrer::default();
350
351        let option = CliOption {
352            short: None,
353            long: Some("--format".to_string()),
354            description: None,
355            option_type: OptionType::String,
356            required: false,
357            default_value: None,
358        };
359
360        let inferred_type = inferrer.infer_type(&option);
361
362        // Should be enum due to "format" keyword
363        assert!(matches!(inferred_type, OptionType::Enum { .. }));
364    }
365
366    #[test]
367    fn test_infer_type_flag() {
368        let inferrer = OptionInferrer::default();
369
370        let option = CliOption {
371            short: None,
372            long: Some("--verbose".to_string()),
373            description: None,
374            option_type: OptionType::Flag,
375            required: false,
376            default_value: None,
377        };
378
379        let inferred_type = inferrer.infer_type(&option);
380
381        // Should remain flag
382        assert!(matches!(inferred_type, OptionType::Flag));
383    }
384
385    #[test]
386    fn test_apply_numeric_constraints_port() {
387        let mut options = vec![CliOption {
388            short: None,
389            long: Some("--port".to_string()),
390            description: None,
391            option_type: OptionType::Numeric {
392                min: None,
393                max: None,
394            },
395            required: false,
396            default_value: None,
397        }];
398
399        apply_numeric_constraints(&mut options);
400
401        if let OptionType::Numeric { min, max } = &options[0].option_type {
402            assert_eq!(*min, Some(1));
403            assert_eq!(*max, Some(65535));
404        } else {
405            panic!("Expected Numeric type");
406        }
407    }
408
409    #[test]
410    fn test_load_enum_values_format() {
411        let mut options = vec![CliOption {
412            short: None,
413            long: Some("--format".to_string()),
414            description: None,
415            option_type: OptionType::Enum { values: vec![] },
416            required: false,
417            default_value: None,
418        }];
419
420        load_enum_values(&mut options);
421
422        if let OptionType::Enum { values } = &options[0].option_type {
423            assert!(!values.is_empty());
424            assert!(values.contains(&"json".to_string()));
425            assert!(values.contains(&"yaml".to_string()));
426        } else {
427            panic!("Expected Enum type");
428        }
429    }
430
431    #[test]
432    fn test_partial_match() {
433        let inferrer = OptionInferrer::default();
434
435        let option = CliOption {
436            short: None,
437            long: Some("--connect-timeout".to_string()),
438            description: None,
439            option_type: OptionType::String,
440            required: false,
441            default_value: None,
442        };
443
444        let inferred_type = inferrer.infer_type(&option);
445
446        // Should match "timeout" keyword via partial match
447        assert!(matches!(inferred_type, OptionType::Numeric { .. }));
448    }
449}