Skip to main content

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(&registry);
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(&registry);
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(&registry);
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(&registry);
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(&registry);
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(&registry);
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(&registry);
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(&registry);
661
662        // ParsedCommand should be accessible
663        let _parsed = ParsedCommand {
664            command_name: "test".to_string(),
665            arguments: HashMap::new(),
666        };
667    }
668}