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
33lazy_static! {
34 static ref PATTERN_CACHE: Mutex<Option<OptionPatternsConfig>> = Mutex::new(None);
36}
37
38pub struct OptionInferrer {
40 patterns: Vec<OptionPattern>,
41 settings: PatternSettings,
42 default_type: String,
43}
44
45impl OptionInferrer {
46 pub fn new() -> Result<Self> {
48 Self::from_config_path("config/option-patterns.yaml")
49 }
50
51 pub fn from_config_path(config_path: &str) -> Result<Self> {
53 let mut cache = PATTERN_CACHE.lock().unwrap();
55
56 if cache.is_none() {
57 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 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 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 pub fn infer_type(&self, option: &CliOption) -> OptionType {
82 if matches!(option.option_type, OptionType::Flag) {
84 return OptionType::Flag;
86 }
87
88 let option_name = self.extract_option_name(option);
90
91 let mut sorted_patterns = self.patterns.clone();
93 sorted_patterns.sort_by(|a, b| b.priority.cmp(&a.priority));
94
95 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 self.pattern_type_to_option_type(&self.default_type)
104 }
105
106 fn extract_option_name(&self, option: &CliOption) -> String {
108 if let Some(long) = &option.long {
109 long.trim_start_matches('-').to_string()
111 } else if let Some(short) = &option.short {
112 short.trim_start_matches('-').to_string()
114 } else {
115 String::new()
116 }
117 }
118
119 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 if keyword_normalized.len() >= self.settings.min_keyword_length
137 && name.contains(&keyword_normalized)
138 {
139 return true;
140 }
141 } else {
142 if name == keyword_normalized {
144 return true;
145 }
146 }
147 }
148
149 false
150 }
151
152 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 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
184pub 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 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); } else if option_name.contains("percent") || option_name.contains("ratio") {
214 *min = Some(0);
215 *max = Some(100);
216 }
217 }
218 }
219}
220
221pub 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 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 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 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 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 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 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 assert!(matches!(inferred_type, OptionType::Numeric { .. }));
448 }
449}