dynamic_cli/parser/mod.rs
1//! Command-line and REPL parsing
2//!
3//! This module provides comprehensive parsing functionality for both
4//! traditional command-line interfaces (CLI) and interactive REPL mode.
5//!
6//! # Module Structure
7//!
8//! The parser module consists of three main components:
9//!
10//! - [`type_parser`]: Type conversion functions (string → typed values)
11//! - [`cli_parser`]: CLI argument parser (Unix-style options)
12//! - [`repl_parser`]: REPL line parser (interactive mode)
13//!
14//! # Architecture
15//!
16//! ```text
17//! ┌─────────────────────────────────────────┐
18//! │ User Input │
19//! │ "process file.txt --verbose" │
20//! └──────────────┬──────────────────────────┘
21//! │
22//! ▼
23//! ┌─────────────────────────────────────────┐
24//! │ ReplParser (REPL mode) │
25//! │ - Tokenize line │
26//! │ - Resolve command name via Registry │
27//! │ - Delegate to CliParser │
28//! └──────────────┬──────────────────────────┘
29//! │
30//! ▼
31//! ┌─────────────────────────────────────────┐
32//! │ CliParser (CLI mode) │
33//! │ - Parse positional arguments │
34//! │ - Parse options (-v, --verbose) │
35//! │ - Apply defaults │
36//! │ - Use TypeParser for conversion │
37//! └──────────────┬──────────────────────────┘
38//! │
39//! ▼
40//! ┌─────────────────────────────────────────┐
41//! │ TypeParser │
42//! │ - Convert strings to typed values │
43//! │ - Validate type constraints │
44//! └──────────────┬──────────────────────────┘
45//! │
46//! ▼
47//! ┌─────────────────────────────────────────┐
48//! │ HashMap<String, String> │
49//! │ {"input": "file.txt", │
50//! │ "verbose": "true"} │
51//! └─────────────────────────────────────────┘
52//! ```
53//!
54//! # Design Principles
55//!
56//! ## 1. Separation of Concerns
57//!
58//! Each parser has a specific responsibility:
59//! - **TypeParser**: Handles type conversion only
60//! - **CliParser**: Handles CLI syntax (options, arguments)
61//! - **ReplParser**: Handles REPL-specific concerns (tokenization, command resolution)
62//!
63//! ## 2. Composability
64//!
65//! Parsers compose naturally:
66//! - ReplParser uses CliParser for argument parsing
67//! - CliParser uses TypeParser for type conversion
68//! - Each can be used independently when needed
69//!
70//! ## 3. Error Clarity
71//!
72//! All parsers provide detailed error messages with:
73//! - Clear descriptions of what went wrong
74//! - Suggestions for typos (via Levenshtein distance)
75//! - Hints for correct usage
76//!
77//! # Usage Examples
78//!
79//! ## CLI Mode (Direct Argument Parsing)
80//!
81//! ```
82//! use dynamic_cli::parser::cli_parser::CliParser;
83//! use dynamic_cli::config::schema::{CommandDefinition, ArgumentDefinition, ArgumentType};
84//!
85//! let definition = CommandDefinition {
86//! name: "process".to_string(),
87//! aliases: vec![],
88//! description: "Process files".to_string(),
89//! required: false,
90//! arguments: vec![
91//! ArgumentDefinition {
92//! name: "input".to_string(),
93//! arg_type: ArgumentType::Path,
94//! required: true,
95//! description: "Input file".to_string(),
96//! validation: vec![],
97//! secure: false,
98//! }
99//! ],
100//! options: vec![],
101//! implementation: "handler".to_string(),
102//! continue_on_failure: false,
103//! requires_success: false,
104//! };
105//!
106//! let parser = CliParser::new(&definition);
107//! let args = vec!["input.txt".to_string()];
108//! let parsed = parser.parse(&args).unwrap();
109//!
110//! assert_eq!(parsed.get("input"), Some(&"input.txt".to_string()));
111//! ```
112//!
113//! ## REPL Mode (Interactive Parsing)
114//!
115//! ```no_run
116//! use dynamic_cli::parser::repl_parser::ReplParser;
117//! use dynamic_cli::registry::CommandRegistry;
118//!
119//! let registry = CommandRegistry::new();
120//! // ... register commands ...
121//!
122//! let parser = ReplParser::new(®istry);
123//!
124//! // Parse user input
125//! let line = "process input.txt --verbose";
126//! let parsed = parser.parse_line(line).unwrap();
127//!
128//! println!("Command: {}", parsed.command_name);
129//! println!("Arguments: {:?}", parsed.arguments);
130//! ```
131//!
132//! ## Type Parsing (Low-Level)
133//!
134//! ```
135//! use dynamic_cli::parser::type_parser::{parse_integer, parse_bool};
136//!
137//! let number = parse_integer("42").unwrap();
138//! assert_eq!(number, 42);
139//!
140//! let flag = parse_bool("yes").unwrap();
141//! assert_eq!(flag, true);
142//! ```
143//!
144//! # Error Handling
145//!
146//! All parsing functions return [`Result<T>`] where errors are instances
147//! of [`ParseError`](crate::error::ParseError). Common error scenarios:
148//!
149//! - **Unknown command**: User typed a non-existent command
150//! ```text
151//! Error: Unknown command: 'simulat'
152//! ? Did you mean:
153//! • simulate
154//! • validation
155//! ```
156//!
157//! - **Type mismatch**: Value cannot be converted to expected type
158//! ```text
159//! Error: Failed to parse count as integer: 'abc'
160//! ```
161//!
162//! - **Missing argument**: Required argument not provided
163//! ```text
164//! Error: Missing required argument: input for command 'process'
165//! ```
166//!
167//! # Performance Considerations
168//!
169//! - **Type parsing**: O(1) for most types, O(n) for string length
170//! - **CLI parsing**: O(n) where n = number of arguments
171//! - **REPL parsing**: O(m + n) where m = line length (tokenization), n = arguments
172//! - **Command resolution**: O(1) via HashMap lookup in registry
173//!
174//! # Thread Safety
175//!
176//! All parsers are:
177//! - **Stateless**: Can be used concurrently from multiple threads
178//! - **Borrowing**: Use references to definitions/registry (no ownership)
179//! - **Reusable**: Can parse multiple commands with the same parser instance
180//!
181//! # Future Extensions
182//!
183//! Potential enhancements for future versions:
184//! - Support for subcommands (e.g., `git commit`)
185//! - Environment variable expansion
186//! - Glob pattern matching for paths
187//! - Command history and auto-completion hints
188//! - Streaming parser for very large inputs
189
190#[allow(unused_imports)]
191use crate::error::Result;
192use cli_parser::{OptionOccurrence, ParsedValue};
193use std::collections::HashMap;
194
195// Public submodules
196pub mod cli_parser;
197pub mod repl_parser;
198pub mod type_parser;
199
200// Re-export commonly used types
201pub use cli_parser::CliParser;
202pub use repl_parser::{ParsedCommand, ReplParser};
203
204/// Parsed command arguments, passed to [`crate::executor::CommandHandler::execute`]
205/// and [`crate::executor::AsyncCommandHandler::execute`]
206///
207/// Wraps the output of [`CliParser::parse_typed`], exposing typed accessors
208/// so handlers never need to match on [`ParsedValue`] directly. Introduced
209/// in v0.6.0 (#39) to replace `&HashMap<String, String>`, which
210/// could not represent repeatable options.
211///
212/// Lives directly in `parser` rather than nested under `cli_parser`: it is
213/// the shared type consumed by every handler regardless of dispatch path
214/// (CLI via [`CliParser::parse_typed_segment`], or REPL via
215/// [`ParsedArgs::from_scalars`]) — not a CLI-specific detail.
216///
217/// # Example
218///
219/// ```
220/// use dynamic_cli::parser::ParsedArgs;
221///
222/// let args = ParsedArgs::from_scalars(
223/// [("name".to_string(), "World".to_string())].into_iter().collect(),
224/// );
225/// assert_eq!(args.get_scalar("name"), Some("World"));
226/// assert_eq!(args.get_scalar("missing"), None);
227/// ```
228#[derive(Debug, Clone, PartialEq, Default)]
229pub struct ParsedArgs(HashMap<String, ParsedValue>);
230
231impl ParsedArgs {
232 /// Wrap an already-typed result, as produced by [`CliParser::parse_typed`]
233 pub fn new(values: HashMap<String, ParsedValue>) -> Self {
234 Self(values)
235 }
236
237 /// Build a scalar-only `ParsedArgs` from a plain `HashMap<String, String>`
238 ///
239 /// Every entry becomes a [`ParsedValue::Scalar`]; there is no way to
240 /// represent [`ParsedValue::Repeated`] through this constructor.
241 ///
242 /// Used by the REPL dispatch path (`interface/repl.rs`), which relies
243 /// on [`crate::parser::repl_parser::ReplParser::parse_line`], which
244 /// only produces scalar values: repeatable options have no use case at
245 /// the interactive prompt so far, so that path was left scalar-only
246 /// when they were introduced. Scripts run with
247 /// [`CliInterface::run_script`](crate::interface::CliInterface::run_script)
248 /// go through the CLI dispatch path and keep repeatable options; the
249 /// REPL `:load` directive replays its lines through this scalar path.
250 pub fn from_scalars(values: HashMap<String, String>) -> Self {
251 Self(
252 values
253 .into_iter()
254 .map(|(k, v)| (k, ParsedValue::Scalar(v)))
255 .collect(),
256 )
257 }
258
259 /// Get a scalar argument or option value by name
260 ///
261 /// Returns `None` both when the name is absent and when it is present
262 /// but holds a [`ParsedValue::Repeated`] value — mirroring
263 /// `HashMap::get`'s silent-on-absence semantics rather than
264 /// distinguishing "wrong kind" from "missing".
265 pub fn get_scalar(&self, name: &str) -> Option<&str> {
266 match self.0.get(name) {
267 Some(ParsedValue::Scalar(s)) => Some(s.as_str()),
268 _ => None,
269 }
270 }
271
272 /// Get every occurrence of a repeatable option by name
273 ///
274 /// Returns `None` both when the name is absent and when it is present
275 /// but holds a [`ParsedValue::Scalar`] value — same silent-on-absence
276 /// contract as [`Self::get_scalar`].
277 pub fn get_repeated(&self, name: &str) -> Option<&[OptionOccurrence]> {
278 match self.0.get(name) {
279 Some(ParsedValue::Repeated(occurrences)) => Some(occurrences.as_slice()),
280 _ => None,
281 }
282 }
283
284 /// Collapse back down to a scalar-only `HashMap<String, String>`
285 ///
286 /// Every [`ParsedValue::Scalar`] entry is kept as-is; any
287 /// [`ParsedValue::Repeated`] entry is silently dropped, for the same
288 /// reason as [`Self::from_scalars`]: nothing on the scalar side
289 /// consumes repeatable options yet. Used at the boundary of subsystems
290 /// that predate repeatable options (v0.6.0) and have not been extended to
291 /// understand repeatable options, e.g. the WASM plugin ABI
292 /// (`WasmHandler`, in `plugin::wasm`), which serializes arguments
293 /// as flat `HashMap<String, String>` to the guest.
294 pub fn to_scalar_map(&self) -> HashMap<String, String> {
295 self.0
296 .iter()
297 .filter_map(|(k, v)| match v {
298 ParsedValue::Scalar(s) => Some((k.clone(), s.clone())),
299 ParsedValue::Repeated(_) => None,
300 })
301 .collect()
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use crate::config::schema::{
309 ArgumentDefinition, ArgumentType, CommandDefinition, OptionDefinition,
310 };
311 use crate::context::ExecutionContext;
312 use crate::executor::CommandHandler;
313 use crate::registry::CommandRegistry;
314 use std::collections::HashMap;
315
316 // Dummy handler for integration tests
317 struct IntegrationTestHandler;
318
319 impl CommandHandler for IntegrationTestHandler {
320 fn execute(&self, _context: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
321 Ok(())
322 }
323 }
324
325 /// Helper to create a comprehensive test command
326 fn create_comprehensive_command() -> CommandDefinition {
327 CommandDefinition {
328 name: "analyze".to_string(),
329 aliases: vec!["analyse".to_string(), "check".to_string()],
330 description: "Analyze data files".to_string(),
331 required: false,
332 arguments: vec![
333 ArgumentDefinition {
334 name: "input".to_string(),
335 arg_type: ArgumentType::Path,
336 required: true,
337 description: "Input data file".to_string(),
338 validation: vec![],
339 secure: false,
340 },
341 ArgumentDefinition {
342 name: "output".to_string(),
343 arg_type: ArgumentType::Path,
344 required: false,
345 description: "Output report file".to_string(),
346 validation: vec![],
347 secure: false,
348 },
349 ],
350 options: vec![
351 OptionDefinition {
352 name: "verbose".to_string(),
353 short: Some("v".to_string()),
354 long: Some("verbose".to_string()),
355 option_type: ArgumentType::Bool,
356 required: false,
357 default: Some("false".to_string()),
358 description: "Enable verbose output".to_string(),
359 choices: vec![],
360 repeatable: false,
361 option_parameters: HashMap::new(),
362 },
363 OptionDefinition {
364 name: "iterations".to_string(),
365 short: Some("i".to_string()),
366 long: Some("iterations".to_string()),
367 option_type: ArgumentType::Integer,
368 required: false,
369 default: Some("100".to_string()),
370 description: "Number of iterations".to_string(),
371 choices: vec![],
372 repeatable: false,
373 option_parameters: HashMap::new(),
374 },
375 OptionDefinition {
376 name: "threshold".to_string(),
377 short: Some("t".to_string()),
378 long: Some("threshold".to_string()),
379 option_type: ArgumentType::Float,
380 required: false,
381 default: Some("0.5".to_string()),
382 description: "Analysis threshold".to_string(),
383 choices: vec![],
384 repeatable: false,
385 option_parameters: HashMap::new(),
386 },
387 ],
388 implementation: "analyze_handler".to_string(),
389 continue_on_failure: false,
390 requires_success: false,
391 }
392 }
393
394 // ========================================================================
395 // Integration tests: CLI Parser
396 // ========================================================================
397
398 #[test]
399 fn test_cli_parser_integration_minimal() {
400 let definition = create_comprehensive_command();
401 let parser = CliParser::new(&definition);
402
403 let args = vec!["data.csv".to_string()];
404 let result = parser.parse(&args).unwrap();
405
406 // Required argument
407 assert_eq!(result.get("input"), Some(&"data.csv".to_string()));
408
409 // Defaults should be applied
410 assert_eq!(result.get("verbose"), Some(&"false".to_string()));
411 assert_eq!(result.get("iterations"), Some(&"100".to_string()));
412 assert_eq!(result.get("threshold"), Some(&"0.5".to_string()));
413 }
414
415 #[test]
416 fn test_cli_parser_integration_full() {
417 let definition = create_comprehensive_command();
418 let parser = CliParser::new(&definition);
419
420 let args = vec![
421 "data.csv".to_string(),
422 "report.txt".to_string(),
423 "--verbose".to_string(),
424 "--iterations=200".to_string(),
425 "-t".to_string(),
426 "0.75".to_string(),
427 ];
428 let result = parser.parse(&args).unwrap();
429
430 assert_eq!(result.get("input"), Some(&"data.csv".to_string()));
431 assert_eq!(result.get("output"), Some(&"report.txt".to_string()));
432 assert_eq!(result.get("verbose"), Some(&"true".to_string()));
433 assert_eq!(result.get("iterations"), Some(&"200".to_string()));
434 assert_eq!(result.get("threshold"), Some(&"0.75".to_string()));
435 }
436
437 #[test]
438 fn test_cli_parser_integration_mixed_options() {
439 let definition = create_comprehensive_command();
440 let parser = CliParser::new(&definition);
441
442 // Options can be interspersed with positional arguments
443 let args = vec![
444 "--verbose".to_string(),
445 "data.csv".to_string(),
446 "-i200".to_string(),
447 "report.txt".to_string(),
448 "--threshold".to_string(),
449 "0.9".to_string(),
450 ];
451 let result = parser.parse(&args).unwrap();
452
453 assert_eq!(result.get("input"), Some(&"data.csv".to_string()));
454 assert_eq!(result.get("output"), Some(&"report.txt".to_string()));
455 assert_eq!(result.get("verbose"), Some(&"true".to_string()));
456 assert_eq!(result.get("iterations"), Some(&"200".to_string()));
457 assert_eq!(result.get("threshold"), Some(&"0.9".to_string()));
458 }
459
460 // ========================================================================
461 // Integration tests: REPL Parser
462 // ========================================================================
463
464 #[test]
465 fn test_repl_parser_integration_simple() {
466 let mut registry = CommandRegistry::new();
467 let definition = create_comprehensive_command();
468 registry
469 .register_sync(definition, Box::new(IntegrationTestHandler))
470 .unwrap();
471
472 let parser = ReplParser::new(®istry);
473
474 let parsed = parser.parse_line("analyze data.csv").unwrap();
475 assert_eq!(parsed.command_name, "analyze");
476 assert_eq!(parsed.arguments.get("input"), Some(&"data.csv".to_string()));
477 }
478
479 #[test]
480 fn test_repl_parser_integration_alias() {
481 let mut registry = CommandRegistry::new();
482 let definition = create_comprehensive_command();
483 registry
484 .register_sync(definition, Box::new(IntegrationTestHandler))
485 .unwrap();
486
487 let parser = ReplParser::new(®istry);
488
489 // Use alias instead of command name
490 let parsed = parser.parse_line("check data.csv --verbose").unwrap();
491 assert_eq!(parsed.command_name, "analyze"); // Resolves to canonical name
492 assert_eq!(parsed.arguments.get("input"), Some(&"data.csv".to_string()));
493 assert_eq!(parsed.arguments.get("verbose"), Some(&"true".to_string()));
494 }
495
496 #[test]
497 fn test_repl_parser_integration_quoted_paths() {
498 let mut registry = CommandRegistry::new();
499 let definition = create_comprehensive_command();
500 registry
501 .register_sync(definition, Box::new(IntegrationTestHandler))
502 .unwrap();
503
504 let parser = ReplParser::new(®istry);
505
506 let parsed = parser
507 .parse_line(r#"analyze "/path/with spaces/data.csv" "output report.txt""#)
508 .unwrap();
509
510 assert_eq!(
511 parsed.arguments.get("input"),
512 Some(&"/path/with spaces/data.csv".to_string())
513 );
514 assert_eq!(
515 parsed.arguments.get("output"),
516 Some(&"output report.txt".to_string())
517 );
518 }
519
520 #[test]
521 fn test_repl_parser_integration_complex() {
522 let mut registry = CommandRegistry::new();
523 let definition = create_comprehensive_command();
524 registry
525 .register_sync(definition, Box::new(IntegrationTestHandler))
526 .unwrap();
527
528 let parser = ReplParser::new(®istry);
529
530 let parsed = parser
531 .parse_line(r#"analyse "data file.csv" report.txt -v --iterations=500 -t 0.95"#)
532 .unwrap();
533
534 assert_eq!(parsed.command_name, "analyze");
535 assert_eq!(
536 parsed.arguments.get("input"),
537 Some(&"data file.csv".to_string())
538 );
539 assert_eq!(
540 parsed.arguments.get("output"),
541 Some(&"report.txt".to_string())
542 );
543 assert_eq!(parsed.arguments.get("verbose"), Some(&"true".to_string()));
544 assert_eq!(parsed.arguments.get("iterations"), Some(&"500".to_string()));
545 assert_eq!(parsed.arguments.get("threshold"), Some(&"0.95".to_string()));
546 }
547
548 // ========================================================================
549 // Integration tests: Type Parser
550 // ========================================================================
551
552 #[test]
553 fn test_type_parser_integration_all_types() {
554 use type_parser::parse_value;
555
556 // Test all argument types
557 assert!(parse_value("hello", ArgumentType::String).is_ok());
558 assert!(parse_value("42", ArgumentType::Integer).is_ok());
559 assert!(parse_value("3.14", ArgumentType::Float).is_ok());
560 assert!(parse_value("true", ArgumentType::Bool).is_ok());
561 assert!(parse_value("/path/to/file", ArgumentType::Path).is_ok());
562 }
563
564 #[test]
565 fn test_type_parser_integration_error_propagation() {
566 let definition = create_comprehensive_command();
567 let parser = CliParser::new(&definition);
568
569 // Invalid integer should fail
570 let args = vec![
571 "data.csv".to_string(),
572 "--iterations".to_string(),
573 "not_a_number".to_string(),
574 ];
575
576 let result = parser.parse(&args);
577 assert!(result.is_err());
578 }
579
580 // ========================================================================
581 // Integration tests: End-to-End Workflows
582 // ========================================================================
583
584 #[test]
585 fn test_workflow_cli_to_execution() {
586 // Simulate: User provides CLI args → Parser → Handler could execute
587
588 let definition = create_comprehensive_command();
589 let parser = CliParser::new(&definition);
590
591 let args = vec!["data.csv".to_string(), "-v".to_string()];
592 let parsed = parser.parse(&args).unwrap();
593
594 // Verify parsed data is ready for execution
595 assert!(parsed.contains_key("input"));
596 assert!(parsed.contains_key("verbose"));
597 assert_eq!(parsed.get("verbose"), Some(&"true".to_string()));
598 }
599
600 #[test]
601 fn test_workflow_repl_to_execution() {
602 // Simulate: User types in REPL → Parser → Handler could execute
603
604 let mut registry = CommandRegistry::new();
605 let definition = create_comprehensive_command();
606 registry
607 .register_sync(definition, Box::new(IntegrationTestHandler))
608 .unwrap();
609
610 let parser = ReplParser::new(®istry);
611
612 let line = "analyze data.csv --verbose --iterations=1000";
613 let parsed = parser.parse_line(line).unwrap();
614
615 // Verify parsed command is ready for execution
616 assert_eq!(parsed.command_name, "analyze");
617 assert!(parsed.arguments.contains_key("input"));
618 assert_eq!(parsed.arguments.get("verbose"), Some(&"true".to_string()));
619 assert_eq!(
620 parsed.arguments.get("iterations"),
621 Some(&"1000".to_string())
622 );
623 }
624
625 #[test]
626 fn test_workflow_typo_suggestions() {
627 let mut registry = CommandRegistry::new();
628 let definition = create_comprehensive_command();
629 registry
630 .register_sync(definition, Box::new(IntegrationTestHandler))
631 .unwrap();
632
633 let parser = ReplParser::new(®istry);
634
635 // User makes a typo
636 let result = parser.parse_line("analyz data.csv");
637
638 assert!(result.is_err());
639
640 // Error should contain suggestions
641 let error = result.unwrap_err();
642 let error_msg = format!("{}", error);
643 assert!(error_msg.contains("Unknown command"));
644 }
645
646 // ========================================================================
647 // Re-export verification tests
648 // ========================================================================
649
650 #[test]
651 fn test_reexports_accessible() {
652 // Verify that re-exported types are accessible from module root
653
654 let definition = create_comprehensive_command();
655
656 // CliParser should be accessible
657 let _cli_parser = CliParser::new(&definition);
658
659 // ReplParser should be accessible (needs registry)
660 let registry = CommandRegistry::new();
661 let _repl_parser = ReplParser::new(®istry);
662
663 // ParsedCommand should be accessible
664 let _parsed = ParsedCommand {
665 command_name: "test".to_string(),
666 arguments: HashMap::new(),
667 };
668 }
669}