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