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
33/// Numeric constraint definition from YAML
34#[derive(Debug, Clone, Deserialize)]
35struct NumericConstraint {
36    aliases: Vec<String>,
37    min: i64,
38    max: i64,
39    #[serde(rename = "type")]
40    #[allow(dead_code)]
41    constraint_type: String,
42    #[allow(dead_code)]
43    unit: Option<String>,
44    #[allow(dead_code)]
45    description: String,
46}
47
48/// Numeric constraints configuration from YAML
49#[derive(Debug, Clone, Deserialize)]
50struct NumericConstraintsConfig {
51    constraints: HashMap<String, NumericConstraint>,
52    default_constraints: DefaultNumericConstraints,
53}
54
55#[derive(Debug, Clone, Deserialize)]
56struct DefaultNumericConstraints {
57    min: i64,
58    max: i64,
59    #[serde(rename = "type")]
60    #[allow(dead_code)]
61    constraint_type: String,
62}
63
64/// Enum definition from YAML
65#[derive(Debug, Clone, Deserialize)]
66struct EnumDefinition {
67    aliases: Vec<String>,
68    values: Vec<String>,
69    #[allow(dead_code)]
70    case_sensitive: bool,
71    #[allow(dead_code)]
72    description: String,
73}
74
75/// Enum definitions configuration from YAML
76#[derive(Debug, Clone, Deserialize)]
77struct EnumDefinitionsConfig {
78    enums: HashMap<String, EnumDefinition>,
79    #[allow(dead_code)]
80    default_enum: DefaultEnumConfig,
81}
82
83#[derive(Debug, Clone, Deserialize)]
84#[allow(dead_code)]
85struct DefaultEnumConfig {
86    case_sensitive: bool,
87    allow_partial_match: bool,
88}
89
90lazy_static! {
91    /// Global cache for option patterns loaded from YAML
92    static ref PATTERN_CACHE: Mutex<Option<OptionPatternsConfig>> = Mutex::new(None);
93
94    /// Global cache for numeric constraints loaded from YAML
95    static ref NUMERIC_CONSTRAINTS_CACHE: Mutex<Option<NumericConstraintsConfig>> = Mutex::new(None);
96
97    /// Global cache for enum definitions loaded from YAML
98    static ref ENUM_DEFINITIONS_CACHE: Mutex<Option<EnumDefinitionsConfig>> = Mutex::new(None);
99}
100
101/// Option Type Inferrer - Infers option types from names and patterns
102pub struct OptionInferrer {
103    patterns: Vec<OptionPattern>,
104    settings: PatternSettings,
105    default_type: String,
106}
107
108impl OptionInferrer {
109    /// Create a new option inferrer by loading patterns from YAML
110    pub fn new() -> Result<Self> {
111        Self::from_config_path("config/option-patterns.yaml")
112    }
113
114    /// Create option inferrer from a specific config file
115    pub fn from_config_path(config_path: &str) -> Result<Self> {
116        // Check cache first
117        let mut cache = PATTERN_CACHE.lock().unwrap();
118
119        if cache.is_none() {
120            // Load and parse YAML config (with safe deserialization)
121            let config_content = std::fs::read_to_string(config_path)?;
122            let config: OptionPatternsConfig =
123                crate::utils::deserialize_yaml_safe(&config_content)?;
124            *cache = Some(config);
125        }
126
127        // Clone from cache
128        let config = cache.as_ref().unwrap().clone();
129
130        Ok(Self {
131            patterns: config.patterns,
132            settings: config.settings,
133            default_type: config.default_type,
134        })
135    }
136
137    /// Infer option types for a list of options
138    pub fn infer_types(&self, options: &mut [CliOption]) {
139        for option in options.iter_mut() {
140            option.option_type = self.infer_type(option);
141        }
142    }
143
144    /// Infer the type of a single option
145    pub fn infer_type(&self, option: &CliOption) -> OptionType {
146        // If it's already flagged as having a value (from parser), start with that
147        if matches!(option.option_type, OptionType::Flag) {
148            // True flag - no value expected
149            return OptionType::Flag;
150        }
151
152        // Extract option name for pattern matching
153        let option_name = self.extract_option_name(option);
154
155        // Sort patterns by priority (higher first)
156        let mut sorted_patterns = self.patterns.clone();
157        sorted_patterns.sort_by(|a, b| b.priority.cmp(&a.priority));
158
159        // Try to match against patterns
160        for pattern in &sorted_patterns {
161            if self.matches_pattern(&option_name, pattern) {
162                return self.pattern_type_to_option_type(&pattern.pattern_type);
163            }
164        }
165
166        // Fallback to default type
167        self.pattern_type_to_option_type(&self.default_type)
168    }
169
170    /// Extract option name from CliOption (prefer long, fallback to short)
171    fn extract_option_name(&self, option: &CliOption) -> String {
172        if let Some(long) = &option.long {
173            // Remove leading dashes: --timeout -> timeout
174            long.trim_start_matches('-').to_string()
175        } else if let Some(short) = &option.short {
176            // Remove leading dash: -t -> t
177            short.trim_start_matches('-').to_string()
178        } else {
179            String::new()
180        }
181    }
182
183    /// Check if option name matches a pattern
184    fn matches_pattern(&self, option_name: &str, pattern: &OptionPattern) -> bool {
185        let name = if self.settings.case_sensitive {
186            option_name.to_string()
187        } else {
188            option_name.to_lowercase()
189        };
190
191        for keyword in &pattern.keywords {
192            let keyword_normalized = if self.settings.case_sensitive {
193                keyword.clone()
194            } else {
195                keyword.to_lowercase()
196            };
197
198            if self.settings.partial_match {
199                // Partial match: "timeout" matches "connect-timeout"
200                if keyword_normalized.len() >= self.settings.min_keyword_length
201                    && name.contains(&keyword_normalized)
202                {
203                    return true;
204                }
205            } else {
206                // Exact match
207                if name == keyword_normalized {
208                    return true;
209                }
210            }
211        }
212
213        false
214    }
215
216    /// Convert pattern type string to OptionType enum
217    fn pattern_type_to_option_type(&self, pattern_type: &str) -> OptionType {
218        match pattern_type {
219            "numeric" => OptionType::Numeric {
220                min: None,
221                max: None,
222            },
223            "path" => OptionType::Path,
224            "enum" => OptionType::Enum { values: vec![] },
225            "boolean" => OptionType::Flag,
226            _ => OptionType::String,
227        }
228    }
229}
230
231impl Default for OptionInferrer {
232    fn default() -> Self {
233        Self::new().unwrap_or_else(|_| {
234            // Fallback to empty patterns if loading fails
235            Self {
236                patterns: vec![],
237                settings: PatternSettings {
238                    case_sensitive: false,
239                    partial_match: true,
240                    min_keyword_length: 3,
241                },
242                default_type: "string".to_string(),
243            }
244        })
245    }
246}
247
248/// Load numeric constraints configuration from YAML (with caching)
249fn load_numeric_constraints_config() -> Result<NumericConstraintsConfig> {
250    let mut cache = NUMERIC_CONSTRAINTS_CACHE.lock().unwrap();
251
252    if cache.is_none() {
253        // Load and parse YAML config
254        let config_content = std::fs::read_to_string("config/numeric-constraints.yaml")?;
255        let config: NumericConstraintsConfig =
256            crate::utils::deserialize_yaml_safe(&config_content)?;
257        *cache = Some(config);
258    }
259
260    Ok(cache.as_ref().unwrap().clone())
261}
262
263/// Apply numeric constraints from numeric-constraints.yaml
264///
265/// Loads constraints like:
266/// - Port numbers: 1-65535
267/// - Timeouts: 0-3600
268/// - Percentages: 0-100
269///
270/// Uses global cache for performance (loaded once, reused for all subsequent calls).
271pub fn apply_numeric_constraints(options: &mut [CliOption]) {
272    // Load config from cache (or file if not cached)
273    let config = match load_numeric_constraints_config() {
274        Ok(config) => config,
275        Err(e) => {
276            log::warn!("Failed to load numeric constraints: {}", e);
277            return; // Silently skip if config not available
278        }
279    };
280
281    for option in options.iter_mut() {
282        if let OptionType::Numeric {
283            ref mut min,
284            ref mut max,
285        } = option.option_type
286        {
287            let option_name = option
288                .long
289                .as_ref()
290                .or(option.short.as_ref())
291                .map(|s| s.trim_start_matches('-').to_lowercase())
292                .unwrap_or_default();
293
294            // Try to match against constraint aliases
295            let mut matched = false;
296            for constraint in config.constraints.values() {
297                if constraint
298                    .aliases
299                    .iter()
300                    .any(|alias| option_name.contains(&alias.to_lowercase()))
301                {
302                    *min = Some(constraint.min);
303                    *max = Some(constraint.max);
304                    matched = true;
305                    break;
306                }
307            }
308
309            // Apply default constraints if no match found
310            if !matched {
311                *min = Some(config.default_constraints.min);
312                *max = Some(config.default_constraints.max);
313            }
314        }
315    }
316}
317
318/// Load enum definitions configuration from YAML (with caching)
319fn load_enum_definitions_config() -> Result<EnumDefinitionsConfig> {
320    let mut cache = ENUM_DEFINITIONS_CACHE.lock().unwrap();
321
322    if cache.is_none() {
323        // Load and parse YAML config
324        let config_content = std::fs::read_to_string("config/enum-definitions.yaml")?;
325        let config: EnumDefinitionsConfig = crate::utils::deserialize_yaml_safe(&config_content)?;
326        *cache = Some(config);
327    }
328
329    Ok(cache.as_ref().unwrap().clone())
330}
331
332/// Load enum values from enum-definitions.yaml
333///
334/// Loads known enum values like:
335/// - format: json, yaml, xml, toml
336/// - log-level: debug, info, warn, error
337/// - protocol: http, https, ftp, ssh
338///
339/// Uses global cache for performance (loaded once, reused for all subsequent calls).
340pub fn load_enum_values(options: &mut [CliOption]) {
341    // Load config from cache (or file if not cached)
342    let config = match load_enum_definitions_config() {
343        Ok(config) => config,
344        Err(e) => {
345            log::warn!("Failed to load enum definitions: {}", e);
346            return; // Silently skip if config not available
347        }
348    };
349
350    for option in options.iter_mut() {
351        if let OptionType::Enum { ref mut values } = option.option_type {
352            let option_name = option
353                .long
354                .as_ref()
355                .or(option.short.as_ref())
356                .map(|s| s.trim_start_matches('-').to_lowercase())
357                .unwrap_or_default();
358
359            // Try to match against enum aliases
360            for enum_def in config.enums.values() {
361                if enum_def
362                    .aliases
363                    .iter()
364                    .any(|alias| option_name.contains(&alias.to_lowercase()))
365                {
366                    *values = enum_def.values.clone();
367                    break;
368                }
369            }
370        }
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn test_extract_option_name() {
380        let inferrer = OptionInferrer::default();
381
382        let option = CliOption {
383            short: None,
384            long: Some("--timeout".to_string()),
385            description: None,
386            option_type: OptionType::String,
387            required: false,
388            default_value: None,
389        };
390
391        assert_eq!(inferrer.extract_option_name(&option), "timeout");
392    }
393
394    #[test]
395    fn test_extract_option_name_short() {
396        let inferrer = OptionInferrer::default();
397
398        let option = CliOption {
399            short: Some("-p".to_string()),
400            long: None,
401            description: None,
402            option_type: OptionType::String,
403            required: false,
404            default_value: None,
405        };
406
407        assert_eq!(inferrer.extract_option_name(&option), "p");
408    }
409
410    #[test]
411    fn test_infer_type_timeout() {
412        let inferrer = OptionInferrer::default();
413
414        let mut option = CliOption {
415            short: None,
416            long: Some("--timeout".to_string()),
417            description: None,
418            option_type: OptionType::String,
419            required: false,
420            default_value: None,
421        };
422
423        let inferred_type = inferrer.infer_type(&option);
424
425        // Should be numeric due to "timeout" keyword
426        assert!(matches!(inferred_type, OptionType::Numeric { .. }));
427
428        option.option_type = inferred_type;
429        let mut options = vec![option];
430        apply_numeric_constraints(&mut options);
431
432        // Check constraints were applied
433        if let OptionType::Numeric { min, max } = &options[0].option_type {
434            assert_eq!(*min, Some(0));
435            assert_eq!(*max, Some(3600));
436        }
437    }
438
439    #[test]
440    fn test_infer_type_path() {
441        let inferrer = OptionInferrer::default();
442
443        let option = CliOption {
444            short: None,
445            long: Some("--config".to_string()),
446            description: None,
447            option_type: OptionType::String,
448            required: false,
449            default_value: None,
450        };
451
452        let inferred_type = inferrer.infer_type(&option);
453
454        // Should be path due to "config" keyword
455        assert!(matches!(inferred_type, OptionType::Path));
456    }
457
458    #[test]
459    fn test_infer_type_enum() {
460        let inferrer = OptionInferrer::default();
461
462        let option = CliOption {
463            short: None,
464            long: Some("--format".to_string()),
465            description: None,
466            option_type: OptionType::String,
467            required: false,
468            default_value: None,
469        };
470
471        let inferred_type = inferrer.infer_type(&option);
472
473        // Should be enum due to "format" keyword
474        assert!(matches!(inferred_type, OptionType::Enum { .. }));
475    }
476
477    #[test]
478    fn test_infer_type_flag() {
479        let inferrer = OptionInferrer::default();
480
481        let option = CliOption {
482            short: None,
483            long: Some("--verbose".to_string()),
484            description: None,
485            option_type: OptionType::Flag,
486            required: false,
487            default_value: None,
488        };
489
490        let inferred_type = inferrer.infer_type(&option);
491
492        // Should remain flag
493        assert!(matches!(inferred_type, OptionType::Flag));
494    }
495
496    #[test]
497    fn test_apply_numeric_constraints_port() {
498        let mut options = vec![CliOption {
499            short: None,
500            long: Some("--port".to_string()),
501            description: None,
502            option_type: OptionType::Numeric {
503                min: None,
504                max: None,
505            },
506            required: false,
507            default_value: None,
508        }];
509
510        apply_numeric_constraints(&mut options);
511
512        if let OptionType::Numeric { min, max } = &options[0].option_type {
513            assert_eq!(*min, Some(1));
514            assert_eq!(*max, Some(65535));
515        } else {
516            panic!("Expected Numeric type");
517        }
518    }
519
520    #[test]
521    fn test_load_enum_values_format() {
522        let mut options = vec![CliOption {
523            short: None,
524            long: Some("--format".to_string()),
525            description: None,
526            option_type: OptionType::Enum { values: vec![] },
527            required: false,
528            default_value: None,
529        }];
530
531        load_enum_values(&mut options);
532
533        if let OptionType::Enum { values } = &options[0].option_type {
534            assert!(!values.is_empty());
535            assert!(values.contains(&"json".to_string()));
536            assert!(values.contains(&"yaml".to_string()));
537        } else {
538            panic!("Expected Enum type");
539        }
540    }
541
542    #[test]
543    fn test_partial_match() {
544        let inferrer = OptionInferrer::default();
545
546        let option = CliOption {
547            short: None,
548            long: Some("--connect-timeout".to_string()),
549            description: None,
550            option_type: OptionType::String,
551            required: false,
552            default_value: None,
553        };
554
555        let inferred_type = inferrer.infer_type(&option);
556
557        // Should match "timeout" keyword via partial match
558        assert!(matches!(inferred_type, OptionType::Numeric { .. }));
559    }
560}