cli_testing_specialist/analyzer/
option_inferrer.rs1use 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#[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#[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#[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#[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#[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 static ref PATTERN_CACHE: Mutex<Option<OptionPatternsConfig>> = Mutex::new(None);
93
94 static ref NUMERIC_CONSTRAINTS_CACHE: Mutex<Option<NumericConstraintsConfig>> = Mutex::new(None);
96
97 static ref ENUM_DEFINITIONS_CACHE: Mutex<Option<EnumDefinitionsConfig>> = Mutex::new(None);
99}
100
101pub struct OptionInferrer {
103 patterns: Vec<OptionPattern>,
104 settings: PatternSettings,
105 default_type: String,
106}
107
108impl OptionInferrer {
109 pub fn new() -> Result<Self> {
111 Self::from_config_path("config/option-patterns.yaml")
112 }
113
114 pub fn from_config_path(config_path: &str) -> Result<Self> {
116 let mut cache = PATTERN_CACHE.lock().unwrap();
118
119 if cache.is_none() {
120 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 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 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 pub fn infer_type(&self, option: &CliOption) -> OptionType {
146 if matches!(option.option_type, OptionType::Flag) {
148 return OptionType::Flag;
150 }
151
152 let option_name = self.extract_option_name(option);
154
155 let mut sorted_patterns = self.patterns.clone();
157 sorted_patterns.sort_by(|a, b| b.priority.cmp(&a.priority));
158
159 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 self.pattern_type_to_option_type(&self.default_type)
168 }
169
170 fn extract_option_name(&self, option: &CliOption) -> String {
172 if let Some(long) = &option.long {
173 long.trim_start_matches('-').to_string()
175 } else if let Some(short) = &option.short {
176 short.trim_start_matches('-').to_string()
178 } else {
179 String::new()
180 }
181 }
182
183 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 if keyword_normalized.len() >= self.settings.min_keyword_length
201 && name.contains(&keyword_normalized)
202 {
203 return true;
204 }
205 } else {
206 if name == keyword_normalized {
208 return true;
209 }
210 }
211 }
212
213 false
214 }
215
216 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 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
248fn load_numeric_constraints_config() -> Result<NumericConstraintsConfig> {
250 let mut cache = NUMERIC_CONSTRAINTS_CACHE.lock().unwrap();
251
252 if cache.is_none() {
253 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
263pub fn apply_numeric_constraints(options: &mut [CliOption]) {
272 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; }
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 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 if !matched {
311 *min = Some(config.default_constraints.min);
312 *max = Some(config.default_constraints.max);
313 }
314 }
315 }
316}
317
318fn load_enum_definitions_config() -> Result<EnumDefinitionsConfig> {
320 let mut cache = ENUM_DEFINITIONS_CACHE.lock().unwrap();
321
322 if cache.is_none() {
323 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
332pub fn load_enum_values(options: &mut [CliOption]) {
341 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; }
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 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 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 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 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 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 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 assert!(matches!(inferred_type, OptionType::Numeric { .. }));
559 }
560}