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