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`]. 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 (DD-024, #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 one-shot via [`CliParser::parse_typed`], 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`] — itself
244 /// untouched by DD-024, since repeatable options have no interactive
245 /// REPL-typing use case (see `DESIGN_DECISIONS.md`, DD-024 addendum,
246 /// 2026-07-24). Batch/scripted invocations are expected to go through
247 /// [`CliParser::parse_typed`] directly instead (see issue #41,
248 /// `ScriptLoaderPlugin`).
249 pub fn from_scalars(values: HashMap<String, String>) -> Self {
250 Self(
251 values
252 .into_iter()
253 .map(|(k, v)| (k, ParsedValue::Scalar(v)))
254 .collect(),
255 )
256 }
257
258 /// Get a scalar argument or option value by name
259 ///
260 /// Returns `None` both when the name is absent and when it is present
261 /// but holds a [`ParsedValue::Repeated`] value — mirroring
262 /// `HashMap::get`'s silent-on-absence semantics rather than
263 /// distinguishing "wrong kind" from "missing".
264 pub fn get_scalar(&self, name: &str) -> Option<&str> {
265 match self.0.get(name) {
266 Some(ParsedValue::Scalar(s)) => Some(s.as_str()),
267 _ => None,
268 }
269 }
270
271 /// Get every occurrence of a repeatable option by name
272 ///
273 /// Returns `None` both when the name is absent and when it is present
274 /// but holds a [`ParsedValue::Scalar`] value — same silent-on-absence
275 /// contract as [`Self::get_scalar`].
276 pub fn get_repeated(&self, name: &str) -> Option<&[OptionOccurrence]> {
277 match self.0.get(name) {
278 Some(ParsedValue::Repeated(occurrences)) => Some(occurrences.as_slice()),
279 _ => None,
280 }
281 }
282
283 /// Collapse back down to a scalar-only `HashMap<String, String>`
284 ///
285 /// Every [`ParsedValue::Scalar`] entry is kept as-is; any
286 /// [`ParsedValue::Repeated`] entry is silently dropped — mirroring the
287 /// same "no consumer yet" rationale as [`Self::from_scalars`] (see the
288 /// DD-024 addendum in `DESIGN_DECISIONS.md`). Used at the boundary of
289 /// subsystems that predate DD-024 and have not been extended to
290 /// understand repeatable options, e.g. the WASM plugin ABI
291 /// ([`crate::plugin::wasm::WasmHandler`]), which serializes arguments
292 /// as flat `HashMap<String, String>` to the guest.
293 pub fn to_scalar_map(&self) -> HashMap<String, String> {
294 self.0
295 .iter()
296 .filter_map(|(k, v)| match v {
297 ParsedValue::Scalar(s) => Some((k.clone(), s.clone())),
298 ParsedValue::Repeated(_) => None,
299 })
300 .collect()
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use crate::config::schema::{
308 ArgumentDefinition, ArgumentType, CommandDefinition, OptionDefinition,
309 };
310 use crate::context::ExecutionContext;
311 use crate::executor::CommandHandler;
312 use crate::registry::CommandRegistry;
313 use std::collections::HashMap;
314
315 // Dummy handler for integration tests
316 struct IntegrationTestHandler;
317
318 impl CommandHandler for IntegrationTestHandler {
319 fn execute(&self, _context: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
320 Ok(())
321 }
322 }
323
324 /// Helper to create a comprehensive test command
325 fn create_comprehensive_command() -> CommandDefinition {
326 CommandDefinition {
327 name: "analyze".to_string(),
328 aliases: vec!["analyse".to_string(), "check".to_string()],
329 description: "Analyze data files".to_string(),
330 required: false,
331 arguments: vec![
332 ArgumentDefinition {
333 name: "input".to_string(),
334 arg_type: ArgumentType::Path,
335 required: true,
336 description: "Input data file".to_string(),
337 validation: vec![],
338 secure: false,
339 },
340 ArgumentDefinition {
341 name: "output".to_string(),
342 arg_type: ArgumentType::Path,
343 required: false,
344 description: "Output report file".to_string(),
345 validation: vec![],
346 secure: false,
347 },
348 ],
349 options: vec![
350 OptionDefinition {
351 name: "verbose".to_string(),
352 short: Some("v".to_string()),
353 long: Some("verbose".to_string()),
354 option_type: ArgumentType::Bool,
355 required: false,
356 default: Some("false".to_string()),
357 description: "Enable verbose output".to_string(),
358 choices: vec![],
359 repeatable: false,
360 option_parameters: HashMap::new(),
361 },
362 OptionDefinition {
363 name: "iterations".to_string(),
364 short: Some("i".to_string()),
365 long: Some("iterations".to_string()),
366 option_type: ArgumentType::Integer,
367 required: false,
368 default: Some("100".to_string()),
369 description: "Number of iterations".to_string(),
370 choices: vec![],
371 repeatable: false,
372 option_parameters: HashMap::new(),
373 },
374 OptionDefinition {
375 name: "threshold".to_string(),
376 short: Some("t".to_string()),
377 long: Some("threshold".to_string()),
378 option_type: ArgumentType::Float,
379 required: false,
380 default: Some("0.5".to_string()),
381 description: "Analysis threshold".to_string(),
382 choices: vec![],
383 repeatable: false,
384 option_parameters: HashMap::new(),
385 },
386 ],
387 implementation: "analyze_handler".to_string(),
388 continue_on_failure: false,
389 requires_success: false,
390 }
391 }
392
393 // ========================================================================
394 // Integration tests: CLI Parser
395 // ========================================================================
396
397 #[test]
398 fn test_cli_parser_integration_minimal() {
399 let definition = create_comprehensive_command();
400 let parser = CliParser::new(&definition);
401
402 let args = vec!["data.csv".to_string()];
403 let result = parser.parse(&args).unwrap();
404
405 // Required argument
406 assert_eq!(result.get("input"), Some(&"data.csv".to_string()));
407
408 // Defaults should be applied
409 assert_eq!(result.get("verbose"), Some(&"false".to_string()));
410 assert_eq!(result.get("iterations"), Some(&"100".to_string()));
411 assert_eq!(result.get("threshold"), Some(&"0.5".to_string()));
412 }
413
414 #[test]
415 fn test_cli_parser_integration_full() {
416 let definition = create_comprehensive_command();
417 let parser = CliParser::new(&definition);
418
419 let args = vec![
420 "data.csv".to_string(),
421 "report.txt".to_string(),
422 "--verbose".to_string(),
423 "--iterations=200".to_string(),
424 "-t".to_string(),
425 "0.75".to_string(),
426 ];
427 let result = parser.parse(&args).unwrap();
428
429 assert_eq!(result.get("input"), Some(&"data.csv".to_string()));
430 assert_eq!(result.get("output"), Some(&"report.txt".to_string()));
431 assert_eq!(result.get("verbose"), Some(&"true".to_string()));
432 assert_eq!(result.get("iterations"), Some(&"200".to_string()));
433 assert_eq!(result.get("threshold"), Some(&"0.75".to_string()));
434 }
435
436 #[test]
437 fn test_cli_parser_integration_mixed_options() {
438 let definition = create_comprehensive_command();
439 let parser = CliParser::new(&definition);
440
441 // Options can be interspersed with positional arguments
442 let args = vec![
443 "--verbose".to_string(),
444 "data.csv".to_string(),
445 "-i200".to_string(),
446 "report.txt".to_string(),
447 "--threshold".to_string(),
448 "0.9".to_string(),
449 ];
450 let result = parser.parse(&args).unwrap();
451
452 assert_eq!(result.get("input"), Some(&"data.csv".to_string()));
453 assert_eq!(result.get("output"), Some(&"report.txt".to_string()));
454 assert_eq!(result.get("verbose"), Some(&"true".to_string()));
455 assert_eq!(result.get("iterations"), Some(&"200".to_string()));
456 assert_eq!(result.get("threshold"), Some(&"0.9".to_string()));
457 }
458
459 // ========================================================================
460 // Integration tests: REPL Parser
461 // ========================================================================
462
463 #[test]
464 fn test_repl_parser_integration_simple() {
465 let mut registry = CommandRegistry::new();
466 let definition = create_comprehensive_command();
467 registry
468 .register_sync(definition, Box::new(IntegrationTestHandler))
469 .unwrap();
470
471 let parser = ReplParser::new(®istry);
472
473 let parsed = parser.parse_line("analyze data.csv").unwrap();
474 assert_eq!(parsed.command_name, "analyze");
475 assert_eq!(parsed.arguments.get("input"), Some(&"data.csv".to_string()));
476 }
477
478 #[test]
479 fn test_repl_parser_integration_alias() {
480 let mut registry = CommandRegistry::new();
481 let definition = create_comprehensive_command();
482 registry
483 .register_sync(definition, Box::new(IntegrationTestHandler))
484 .unwrap();
485
486 let parser = ReplParser::new(®istry);
487
488 // Use alias instead of command name
489 let parsed = parser.parse_line("check data.csv --verbose").unwrap();
490 assert_eq!(parsed.command_name, "analyze"); // Resolves to canonical name
491 assert_eq!(parsed.arguments.get("input"), Some(&"data.csv".to_string()));
492 assert_eq!(parsed.arguments.get("verbose"), Some(&"true".to_string()));
493 }
494
495 #[test]
496 fn test_repl_parser_integration_quoted_paths() {
497 let mut registry = CommandRegistry::new();
498 let definition = create_comprehensive_command();
499 registry
500 .register_sync(definition, Box::new(IntegrationTestHandler))
501 .unwrap();
502
503 let parser = ReplParser::new(®istry);
504
505 let parsed = parser
506 .parse_line(r#"analyze "/path/with spaces/data.csv" "output report.txt""#)
507 .unwrap();
508
509 assert_eq!(
510 parsed.arguments.get("input"),
511 Some(&"/path/with spaces/data.csv".to_string())
512 );
513 assert_eq!(
514 parsed.arguments.get("output"),
515 Some(&"output report.txt".to_string())
516 );
517 }
518
519 #[test]
520 fn test_repl_parser_integration_complex() {
521 let mut registry = CommandRegistry::new();
522 let definition = create_comprehensive_command();
523 registry
524 .register_sync(definition, Box::new(IntegrationTestHandler))
525 .unwrap();
526
527 let parser = ReplParser::new(®istry);
528
529 let parsed = parser
530 .parse_line(r#"analyse "data file.csv" report.txt -v --iterations=500 -t 0.95"#)
531 .unwrap();
532
533 assert_eq!(parsed.command_name, "analyze");
534 assert_eq!(
535 parsed.arguments.get("input"),
536 Some(&"data file.csv".to_string())
537 );
538 assert_eq!(
539 parsed.arguments.get("output"),
540 Some(&"report.txt".to_string())
541 );
542 assert_eq!(parsed.arguments.get("verbose"), Some(&"true".to_string()));
543 assert_eq!(parsed.arguments.get("iterations"), Some(&"500".to_string()));
544 assert_eq!(parsed.arguments.get("threshold"), Some(&"0.95".to_string()));
545 }
546
547 // ========================================================================
548 // Integration tests: Type Parser
549 // ========================================================================
550
551 #[test]
552 fn test_type_parser_integration_all_types() {
553 use type_parser::parse_value;
554
555 // Test all argument types
556 assert!(parse_value("hello", ArgumentType::String).is_ok());
557 assert!(parse_value("42", ArgumentType::Integer).is_ok());
558 assert!(parse_value("3.14", ArgumentType::Float).is_ok());
559 assert!(parse_value("true", ArgumentType::Bool).is_ok());
560 assert!(parse_value("/path/to/file", ArgumentType::Path).is_ok());
561 }
562
563 #[test]
564 fn test_type_parser_integration_error_propagation() {
565 let definition = create_comprehensive_command();
566 let parser = CliParser::new(&definition);
567
568 // Invalid integer should fail
569 let args = vec![
570 "data.csv".to_string(),
571 "--iterations".to_string(),
572 "not_a_number".to_string(),
573 ];
574
575 let result = parser.parse(&args);
576 assert!(result.is_err());
577 }
578
579 // ========================================================================
580 // Integration tests: End-to-End Workflows
581 // ========================================================================
582
583 #[test]
584 fn test_workflow_cli_to_execution() {
585 // Simulate: User provides CLI args → Parser → Handler could execute
586
587 let definition = create_comprehensive_command();
588 let parser = CliParser::new(&definition);
589
590 let args = vec!["data.csv".to_string(), "-v".to_string()];
591 let parsed = parser.parse(&args).unwrap();
592
593 // Verify parsed data is ready for execution
594 assert!(parsed.contains_key("input"));
595 assert!(parsed.contains_key("verbose"));
596 assert_eq!(parsed.get("verbose"), Some(&"true".to_string()));
597 }
598
599 #[test]
600 fn test_workflow_repl_to_execution() {
601 // Simulate: User types in REPL → Parser → Handler could execute
602
603 let mut registry = CommandRegistry::new();
604 let definition = create_comprehensive_command();
605 registry
606 .register_sync(definition, Box::new(IntegrationTestHandler))
607 .unwrap();
608
609 let parser = ReplParser::new(®istry);
610
611 let line = "analyze data.csv --verbose --iterations=1000";
612 let parsed = parser.parse_line(line).unwrap();
613
614 // Verify parsed command is ready for execution
615 assert_eq!(parsed.command_name, "analyze");
616 assert!(parsed.arguments.contains_key("input"));
617 assert_eq!(parsed.arguments.get("verbose"), Some(&"true".to_string()));
618 assert_eq!(
619 parsed.arguments.get("iterations"),
620 Some(&"1000".to_string())
621 );
622 }
623
624 #[test]
625 fn test_workflow_typo_suggestions() {
626 let mut registry = CommandRegistry::new();
627 let definition = create_comprehensive_command();
628 registry
629 .register_sync(definition, Box::new(IntegrationTestHandler))
630 .unwrap();
631
632 let parser = ReplParser::new(®istry);
633
634 // User makes a typo
635 let result = parser.parse_line("analyz data.csv");
636
637 assert!(result.is_err());
638
639 // Error should contain suggestions
640 let error = result.unwrap_err();
641 let error_msg = format!("{}", error);
642 assert!(error_msg.contains("Unknown command"));
643 }
644
645 // ========================================================================
646 // Re-export verification tests
647 // ========================================================================
648
649 #[test]
650 fn test_reexports_accessible() {
651 // Verify that re-exported types are accessible from module root
652
653 let definition = create_comprehensive_command();
654
655 // CliParser should be accessible
656 let _cli_parser = CliParser::new(&definition);
657
658 // ReplParser should be accessible (needs registry)
659 let registry = CommandRegistry::new();
660 let _repl_parser = ReplParser::new(®istry);
661
662 // ParsedCommand should be accessible
663 let _parsed = ParsedCommand {
664 command_name: "test".to_string(),
665 arguments: HashMap::new(),
666 };
667 }
668}