dynamic_cli/interface/cli.rs
1//! CLI (Command-Line Interface) implementation
2//!
3//! This module provides a simple CLI interface that parses command-line
4//! arguments, executes the corresponding command, and exits.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use dynamic_cli::interface::CliInterface;
10//! use dynamic_cli::prelude::*;
11//!
12//! # #[derive(Default)]
13//! # struct MyContext;
14//! # impl ExecutionContext for MyContext {
15//! # fn as_any(&self) -> &dyn std::any::Any { self }
16//! # fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
17//! # }
18//! # fn main() -> dynamic_cli::Result<()> {
19//! let registry = CommandRegistry::new();
20//! let context = Box::new(MyContext::default());
21//!
22//! let cli = CliInterface::new(registry, context);
23//! cli.run(std::env::args().skip(1).collect())?;
24//! # Ok(())
25//! # }
26//! ```
27
28use crate::context::ExecutionContext;
29use crate::error::{display_error, DynamicCliError, ExecutionError, Result};
30use crate::parser::{CliParser, ParsedArgs, ReplParser};
31use crate::registry::CommandRegistry;
32use std::path::Path;
33use std::process;
34
35/// CLI (Command-Line Interface) handler
36///
37/// Provides a simple interface for executing commands from command-line arguments.
38/// The CLI parses arguments, executes the command, and exits.
39///
40/// # Architecture
41///
42/// ```text
43/// Command-line args → CliParser → CommandExecutor → Handler
44/// ↓
45/// ExecutionContext
46/// ```
47///
48/// # Error Handling
49///
50/// Errors are displayed to stderr with colored formatting (if enabled)
51/// and the process exits with appropriate exit codes:
52/// - `0`: Success
53/// - `1`: Execution error
54/// - `2`: Argument parsing error
55/// - `3`: Other errors
56pub struct CliInterface {
57 /// Command registry containing all available commands
58 registry: CommandRegistry,
59
60 /// Execution context (owned by the interface)
61 context: Box<dyn ExecutionContext>,
62}
63
64impl CliInterface {
65 /// Create a new CLI interface
66 ///
67 /// # Arguments
68 ///
69 /// * `registry` - Command registry with all registered commands
70 /// * `context` - Execution context (will be consumed by the interface)
71 ///
72 /// # Example
73 ///
74 /// ```no_run
75 /// use dynamic_cli::interface::CliInterface;
76 /// use dynamic_cli::prelude::*;
77 ///
78 /// # #[derive(Default)]
79 /// # struct MyContext;
80 /// # impl ExecutionContext for MyContext {
81 /// # fn as_any(&self) -> &dyn std::any::Any { self }
82 /// # fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
83 /// # }
84 /// let registry = CommandRegistry::new();
85 /// let context = Box::new(MyContext::default());
86 ///
87 /// let cli = CliInterface::new(registry, context);
88 /// ```
89 pub fn new(registry: CommandRegistry, context: Box<dyn ExecutionContext>) -> Self {
90 Self { registry, context }
91 }
92
93 /// Run the CLI with provided arguments
94 ///
95 /// Parses the arguments, executes the corresponding command, and handles errors.
96 /// This method consumes `self` as the CLI typically runs once and exits.
97 ///
98 /// # Arguments
99 ///
100 /// * `args` - Command-line arguments (typically from `env::args().skip(1)`)
101 ///
102 /// # Returns
103 ///
104 /// - `Ok(())` on success
105 /// - `Err(DynamicCliError)` on any error (parsing, validation, execution)
106 ///
107 /// # Exit Codes
108 ///
109 /// The caller should handle errors and exit with appropriate codes:
110 /// - Parse errors → exit code 2
111 /// - Execution errors → exit code 1
112 /// - Other errors → exit code 3
113 ///
114 /// # Example
115 ///
116 /// ```no_run
117 /// use dynamic_cli::interface::CliInterface;
118 /// use dynamic_cli::prelude::*;
119 /// use std::process;
120 ///
121 /// # #[derive(Default)]
122 /// # struct MyContext;
123 /// # impl ExecutionContext for MyContext {
124 /// # fn as_any(&self) -> &dyn std::any::Any { self }
125 /// # fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
126 /// # }
127 /// # fn main() {
128 /// let registry = CommandRegistry::new();
129 /// let context = Box::new(MyContext::default());
130 /// let cli = CliInterface::new(registry, context);
131 ///
132 /// if let Err(e) = cli.run(std::env::args().skip(1).collect()) {
133 /// eprintln!("Error: {}", e);
134 /// process::exit(1);
135 /// }
136 /// # }
137 /// ```
138 pub fn run(mut self, args: Vec<String>) -> Result<()> {
139 // Handle empty arguments (show help or error)
140 if args.is_empty() {
141 return Err(DynamicCliError::Parse(
142 crate::error::ParseError::InvalidSyntax {
143 details: "No command specified".to_string(),
144 hint: Some("Try 'help' to see available commands".to_string()),
145 },
146 ));
147 }
148
149 self.dispatch(&args)
150 }
151
152 /// Resolve, parse, and execute a single already-tokenized command line.
153 ///
154 /// Shared by [`run`][Self::run] (one dispatch from CLI args) and
155 /// [`run_script`][Self::run_script] (one dispatch per script line) —
156 /// the actual resolution/parsing/execution logic lives here exactly
157 /// once, per DD-024's "reuse the existing `ParsedArgs` path, no
158 /// duplicate parsing logic" requirement (see #41).
159 fn dispatch(&mut self, args: &[String]) -> Result<()> {
160 // First argument is the command name
161 let command_name = &args[0];
162
163 // Resolve command name (handles aliases)
164 let resolved_name = self.registry.resolve_name(command_name).ok_or_else(|| {
165 crate::error::ParseError::unknown_command_with_suggestions(
166 command_name,
167 &self
168 .registry
169 .list_commands()
170 .iter()
171 .map(|cmd| cmd.name.clone())
172 .collect::<Vec<_>>(),
173 )
174 })?;
175
176 // Get command definition
177 let definition = self.registry.get_definition(resolved_name).ok_or_else(|| {
178 DynamicCliError::Registry(crate::error::RegistryError::missing_handler(resolved_name))
179 })?;
180
181 // Parse arguments using CLI parser (DD-024/#39: typed to preserve
182 // repeatable-option occurrences; ParsedArgs is the shape every
183 // handler now receives).
184 let parser = CliParser::new(definition);
185 let parsed_args = ParsedArgs::new(parser.parse_typed(&args[1..])?);
186
187 // Get handler and execute command. Sync is tried first (unchanged
188 // behaviour); if no sync handler matches, fall through to the async
189 // path (DD-022) and drive it via `block_on`. Safe here because
190 // `run()`/`run_script()` are strictly sequential, one-shot dispatch —
191 // there is no other async task waiting behind it that `block_on`
192 // could starve.
193 if let Some(handler) = self.registry.get_handler_sync(resolved_name) {
194 handler.execute(&mut *self.context, &parsed_args)?;
195 } else if let Some(handler) = self.registry.get_handler_async(resolved_name) {
196 futures::executor::block_on(handler.execute(&mut *self.context, &parsed_args))?;
197 } else {
198 return Err(DynamicCliError::Execution(
199 crate::error::ExecutionError::handler_not_found(
200 resolved_name,
201 &definition.implementation,
202 ),
203 ));
204 }
205
206 Ok(())
207 }
208
209 /// Run the CLI with automatic error handling and exit
210 ///
211 /// This is a convenience method that:
212 /// 1. Runs the CLI with provided arguments
213 /// 2. Handles errors by displaying them to stderr
214 /// 3. Exits the process with appropriate exit code
215 ///
216 /// This method never returns.
217 ///
218 /// # Arguments
219 ///
220 /// * `args` - Command-line arguments
221 ///
222 /// # Example
223 ///
224 /// ```no_run
225 /// use dynamic_cli::interface::CliInterface;
226 /// use dynamic_cli::prelude::*;
227 ///
228 /// # #[derive(Default)]
229 /// # struct MyContext;
230 /// # impl ExecutionContext for MyContext {
231 /// # fn as_any(&self) -> &dyn std::any::Any { self }
232 /// # fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
233 /// # }
234 /// # fn main() {
235 /// let registry = CommandRegistry::new();
236 /// let context = Box::new(MyContext::default());
237 /// let cli = CliInterface::new(registry, context);
238 ///
239 /// // This will handle errors and exit automatically
240 /// cli.run_and_exit(std::env::args().skip(1).collect());
241 /// # }
242 /// ```
243 pub fn run_and_exit(self, args: Vec<String>) -> ! {
244 match self.run(args) {
245 Ok(()) => process::exit(0),
246 Err(e) => {
247 display_error(&e);
248
249 // Exit with appropriate code based on error type
250 let exit_code = match e {
251 DynamicCliError::Parse(_) => 2,
252 DynamicCliError::Validation(_) => 2,
253 DynamicCliError::Execution(_) => 1,
254 _ => 3,
255 };
256
257 process::exit(exit_code);
258 }
259 }
260 }
261
262 /// Run a batch of command lines read from a file (#41).
263 ///
264 /// Each non-blank, non-comment (`#`-prefixed) line is tokenized the
265 /// same quote-aware way as a typed REPL line (via
266 /// [`ReplParser::tokenize`]), then dispatched through the exact same
267 /// resolve → parse → execute path as [`run`][Self::run] — no
268 /// duplicate parsing logic, and repeatable options (DD-024) are fully
269 /// preserved since dispatch goes through `parse_typed()` either way.
270 ///
271 /// # Error policy
272 ///
273 /// `policy` decides what happens when a line fails:
274 /// - [`ScriptErrorPolicy::Abort`]: stop immediately, returning `Err`
275 /// for the failing line. Lines before it have already run.
276 /// - [`ScriptErrorPolicy::Continue`]: record the failure and proceed
277 /// to the next line. The method still returns `Ok`, with every
278 /// failure listed in the returned [`ScriptOutcome`].
279 ///
280 /// Every failure — whether it aborts the run or not — is reported
281 /// with its 1-based line number, wrapped in
282 /// [`ExecutionError::CommandFailed`][crate::error::ExecutionError::CommandFailed]
283 /// (reusing the existing error hierarchy; no new enum variant, so no
284 /// breaking change to `ExecutionError`'s non-`#[non_exhaustive]`
285 /// shape).
286 ///
287 /// # Example
288 ///
289 /// ```no_run
290 /// use dynamic_cli::interface::{CliInterface, ScriptErrorPolicy};
291 /// use dynamic_cli::prelude::*;
292 ///
293 /// # #[derive(Default)]
294 /// # struct MyContext;
295 /// # impl ExecutionContext for MyContext {
296 /// # fn as_any(&self) -> &dyn std::any::Any { self }
297 /// # fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
298 /// # }
299 /// # fn main() -> dynamic_cli::Result<()> {
300 /// let registry = CommandRegistry::new();
301 /// let context = Box::new(MyContext::default());
302 /// let cli = CliInterface::new(registry, context);
303 ///
304 /// let outcome = cli.run_script("commands.txt", ScriptErrorPolicy::Continue)?;
305 /// println!("{}/{} lines succeeded", outcome.lines_succeeded, outcome.lines_executed);
306 /// # Ok(())
307 /// # }
308 /// ```
309 pub fn run_script(
310 mut self,
311 path: impl AsRef<Path>,
312 policy: ScriptErrorPolicy,
313 ) -> Result<ScriptOutcome> {
314 let path = path.as_ref();
315 let content = std::fs::read_to_string(path).map_err(|e| {
316 DynamicCliError::Execution(ExecutionError::CommandFailed(anyhow::anyhow!(
317 "failed to read script file {}: {}",
318 path.display(),
319 e
320 )))
321 })?;
322
323 let mut outcome = ScriptOutcome {
324 lines_executed: 0,
325 lines_succeeded: 0,
326 failures: Vec::new(),
327 };
328
329 for (idx, raw_line) in content.lines().enumerate() {
330 let line_number = idx + 1;
331 let line = raw_line.trim();
332
333 if line.is_empty() || line.starts_with('#') {
334 continue;
335 }
336
337 outcome.lines_executed += 1;
338
339 // Scoped so the borrow of `self.registry` ends before
340 // `self.dispatch(&mut self, ...)` needs exclusive access below.
341 // `tokenize` is a pure function of the line text — it doesn't
342 // read `self.registry` — but it lives on `ReplParser`, so a
343 // throwaway instance is the reuse path rather than duplicating
344 // the quote-handling logic here.
345 let tokens_result = {
346 let tokenizer = ReplParser::new(&self.registry);
347 tokenizer.tokenize(line)
348 };
349
350 let tokens = match tokens_result {
351 Ok(t) => t,
352 Err(e) => {
353 let wrapped = wrap_line_error(line_number, e);
354 if policy == ScriptErrorPolicy::Abort {
355 return Err(wrapped);
356 }
357 outcome.failures.push((line_number, wrapped));
358 continue;
359 }
360 };
361
362 if tokens.is_empty() {
363 continue;
364 }
365
366 match self.dispatch(&tokens) {
367 Ok(()) => outcome.lines_succeeded += 1,
368 Err(e) => {
369 let wrapped = wrap_line_error(line_number, e);
370 if policy == ScriptErrorPolicy::Abort {
371 return Err(wrapped);
372 }
373 outcome.failures.push((line_number, wrapped));
374 }
375 }
376 }
377
378 Ok(outcome)
379 }
380}
381
382/// Wrap an error with its 1-based script line number, reusing the
383/// existing [`ExecutionError::CommandFailed`] variant so adding
384/// line-number context never requires a breaking change to the error
385/// hierarchy.
386fn wrap_line_error(line_number: usize, source: DynamicCliError) -> DynamicCliError {
387 DynamicCliError::Execution(ExecutionError::CommandFailed(anyhow::anyhow!(
388 "line {}: {}",
389 line_number,
390 source
391 )))
392}
393
394/// What [`CliInterface::run_script`] does when a line fails.
395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub enum ScriptErrorPolicy {
397 /// Stop at the first failing line — [`run_script`][CliInterface::run_script]
398 /// returns `Err` immediately, with the lines before it already run.
399 Abort,
400 /// Record the failure and keep going —
401 /// [`run_script`][CliInterface::run_script] returns `Ok` with every
402 /// failure listed in [`ScriptOutcome::failures`].
403 Continue,
404}
405
406/// Result of a full [`CliInterface::run_script`] run.
407#[derive(Debug)]
408pub struct ScriptOutcome {
409 /// Number of non-blank, non-comment lines dispatched (attempted).
410 pub lines_executed: usize,
411 /// Number of those lines that succeeded.
412 pub lines_succeeded: usize,
413 /// `(1-based line number, wrapped error)` for every line that failed.
414 /// Always empty when `policy` was
415 /// [`ScriptErrorPolicy::Abort`][ScriptErrorPolicy::Abort] and the run
416 /// completed (an abort returns `Err` instead of populating this).
417 pub failures: Vec<(usize, DynamicCliError)>,
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use crate::config::schema::{ArgumentDefinition, ArgumentType, CommandDefinition};
424
425 // Test context
426 #[derive(Default)]
427 struct TestContext {
428 executed_command: Option<String>,
429 }
430
431 impl ExecutionContext for TestContext {
432 fn as_any(&self) -> &dyn std::any::Any {
433 self
434 }
435
436 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
437 self
438 }
439 }
440
441 // Test handler
442 struct TestHandler {
443 name: String,
444 }
445
446 impl crate::executor::CommandHandler for TestHandler {
447 fn execute(&self, context: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
448 let ctx = crate::context::downcast_mut::<TestContext>(context)
449 .expect("Failed to downcast context");
450 ctx.executed_command = Some(self.name.clone());
451 Ok(())
452 }
453 }
454
455 fn create_test_registry() -> CommandRegistry {
456 let mut registry = CommandRegistry::new();
457
458 // Create a simple command definition
459 let cmd_def = CommandDefinition {
460 name: "test".to_string(),
461 aliases: vec!["t".to_string()],
462 description: "Test command".to_string(),
463 required: false,
464 arguments: vec![],
465 options: vec![],
466 implementation: "test_handler".to_string(),
467 };
468
469 let handler = Box::new(TestHandler {
470 name: "test".to_string(),
471 });
472
473 registry
474 .register_sync(cmd_def, handler)
475 .expect("Failed to register command");
476
477 registry
478 }
479
480 #[test]
481 fn test_cli_interface_creation() {
482 let registry = create_test_registry();
483 let context = Box::new(TestContext::default());
484
485 let _cli = CliInterface::new(registry, context);
486 // If this compiles and runs, creation works
487 }
488
489 #[test]
490 fn test_cli_run_simple_command() {
491 let registry = create_test_registry();
492 let context = Box::new(TestContext::default());
493 let cli = CliInterface::new(registry, context);
494
495 let result = cli.run(vec!["test".to_string()]);
496 assert!(result.is_ok());
497 }
498
499 #[test]
500 fn test_cli_run_with_alias() {
501 let registry = create_test_registry();
502 let context = Box::new(TestContext::default());
503 let cli = CliInterface::new(registry, context);
504
505 let result = cli.run(vec!["t".to_string()]);
506 assert!(result.is_ok());
507 }
508
509 #[test]
510 fn test_cli_empty_args() {
511 let registry = create_test_registry();
512 let context = Box::new(TestContext::default());
513 let cli = CliInterface::new(registry, context);
514
515 let result = cli.run(vec![]);
516 assert!(result.is_err());
517
518 match result.unwrap_err() {
519 DynamicCliError::Parse(crate::error::ParseError::InvalidSyntax { .. }) => {}
520 other => panic!("Expected InvalidSyntax error, got: {:?}", other),
521 }
522 }
523
524 #[test]
525 fn test_cli_unknown_command() {
526 let registry = create_test_registry();
527 let context = Box::new(TestContext::default());
528 let cli = CliInterface::new(registry, context);
529
530 let result = cli.run(vec!["unknown".to_string()]);
531 assert!(result.is_err());
532
533 match result.unwrap_err() {
534 DynamicCliError::Parse(crate::error::ParseError::UnknownCommand { .. }) => {}
535 other => panic!("Expected UnknownCommand error, got: {:?}", other),
536 }
537 }
538
539 #[test]
540 fn test_cli_command_with_args() {
541 let mut registry = CommandRegistry::new();
542
543 // Command with argument
544 let cmd_def = CommandDefinition {
545 name: "greet".to_string(),
546 aliases: vec![],
547 description: "Greet someone".to_string(),
548 required: false,
549 arguments: vec![ArgumentDefinition {
550 name: "name".to_string(),
551 arg_type: ArgumentType::String,
552 required: true,
553 description: "Name to greet".to_string(),
554 validation: vec![],
555 secure: false,
556 }],
557 options: vec![],
558 implementation: "greet_handler".to_string(),
559 };
560
561 struct GreetHandler;
562 impl crate::executor::CommandHandler for GreetHandler {
563 fn execute(
564 &self,
565 _context: &mut dyn ExecutionContext,
566 args: &ParsedArgs,
567 ) -> Result<()> {
568 assert_eq!(args.get_scalar("name"), Some("Alice"));
569 Ok(())
570 }
571 }
572
573 registry
574 .register_sync(cmd_def, Box::new(GreetHandler))
575 .unwrap();
576
577 let context = Box::new(TestContext::default());
578 let cli = CliInterface::new(registry, context);
579
580 let result = cli.run(vec!["greet".to_string(), "Alice".to_string()]);
581 assert!(result.is_ok());
582 }
583
584 // ========================================================================
585 // run_script tests (#41)
586 // ========================================================================
587
588 fn write_script(content: &str) -> tempfile::NamedTempFile {
589 use std::io::Write;
590 let mut file = tempfile::NamedTempFile::new().expect("failed to create temp script file");
591 file.write_all(content.as_bytes())
592 .expect("failed to write temp script file");
593 file
594 }
595
596 #[test]
597 fn test_run_script_all_lines_succeed() {
598 let registry = create_test_registry();
599 let context = Box::new(TestContext::default());
600 let cli = CliInterface::new(registry, context);
601
602 let script = write_script("test\nt\ntest\n");
603 let outcome = cli
604 .run_script(script.path(), ScriptErrorPolicy::Abort)
605 .expect("run_script should succeed when every line succeeds");
606
607 assert_eq!(outcome.lines_executed, 3);
608 assert_eq!(outcome.lines_succeeded, 3);
609 assert!(outcome.failures.is_empty());
610 }
611
612 #[test]
613 fn test_run_script_skips_blank_lines_and_comments() {
614 let registry = create_test_registry();
615 let context = Box::new(TestContext::default());
616 let cli = CliInterface::new(registry, context);
617
618 let script = write_script("# a comment\n\ntest\n \n# another\nt\n");
619 let outcome = cli
620 .run_script(script.path(), ScriptErrorPolicy::Abort)
621 .expect("run_script should succeed");
622
623 // Only the two real command lines count.
624 assert_eq!(outcome.lines_executed, 2);
625 assert_eq!(outcome.lines_succeeded, 2);
626 }
627
628 #[test]
629 fn test_run_script_continue_policy_records_failures_and_keeps_going() {
630 let registry = create_test_registry();
631 let context = Box::new(TestContext::default());
632 let cli = CliInterface::new(registry, context);
633
634 let script = write_script("test\nunknown_command\ntest\n");
635 let outcome = cli
636 .run_script(script.path(), ScriptErrorPolicy::Continue)
637 .expect("Continue policy should return Ok even with a failing line");
638
639 assert_eq!(outcome.lines_executed, 3);
640 assert_eq!(outcome.lines_succeeded, 2);
641 assert_eq!(outcome.failures.len(), 1);
642 assert_eq!(outcome.failures[0].0, 2); // 1-based line number
643 }
644
645 #[test]
646 fn test_run_script_abort_policy_stops_at_first_failure() {
647 let registry = create_test_registry();
648 let context = Box::new(TestContext::default());
649 let cli = CliInterface::new(registry, context);
650
651 // A third "test" line would succeed if reached — it must not be.
652 let script = write_script("test\nunknown_command\ntest\n");
653 let result = cli.run_script(script.path(), ScriptErrorPolicy::Abort);
654
655 assert!(result.is_err());
656 match result.unwrap_err() {
657 DynamicCliError::Execution(ExecutionError::CommandFailed(e)) => {
658 assert!(e.to_string().contains("line 2"));
659 }
660 other => panic!("Expected wrapped CommandFailed error, got: {:?}", other),
661 }
662 }
663
664 #[test]
665 fn test_run_script_respects_quoted_tokens() {
666 let mut registry = CommandRegistry::new();
667 let cmd_def = CommandDefinition {
668 name: "greet".to_string(),
669 aliases: vec![],
670 description: "Greet someone".to_string(),
671 required: false,
672 arguments: vec![ArgumentDefinition {
673 name: "name".to_string(),
674 arg_type: ArgumentType::String,
675 required: true,
676 description: "Name to greet".to_string(),
677 validation: vec![],
678 secure: false,
679 }],
680 options: vec![],
681 implementation: "greet_handler".to_string(),
682 };
683
684 struct GreetHandler;
685 impl crate::executor::CommandHandler for GreetHandler {
686 fn execute(
687 &self,
688 _context: &mut dyn ExecutionContext,
689 args: &ParsedArgs,
690 ) -> Result<()> {
691 assert_eq!(args.get_scalar("name"), Some("Alice Wonderland"));
692 Ok(())
693 }
694 }
695
696 registry
697 .register_sync(cmd_def, Box::new(GreetHandler))
698 .unwrap();
699
700 let context = Box::new(TestContext::default());
701 let cli = CliInterface::new(registry, context);
702
703 let script = write_script(r#"greet "Alice Wonderland""#);
704 let outcome = cli
705 .run_script(script.path(), ScriptErrorPolicy::Abort)
706 .expect("quoted argument should tokenize as a single value");
707
708 assert_eq!(outcome.lines_succeeded, 1);
709 }
710
711 #[test]
712 fn test_run_script_missing_file() {
713 let registry = create_test_registry();
714 let context = Box::new(TestContext::default());
715 let cli = CliInterface::new(registry, context);
716
717 let result = cli.run_script("/nonexistent/path/to/script.txt", ScriptErrorPolicy::Abort);
718 assert!(result.is_err());
719 }
720}