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