Skip to main content

dynamic_cli/plugin/
system.rs

1//! Built-in system builtin for `dynamic-cli`
2//!
3//! Provides [`SystemPlugin`], a ready-made builtin supplying the three handlers
4//! that every `dynamic-cli` application typically needs: `system_help`,
5//! `system_version`, and `system_exit`.
6//!
7//! See [`SystemPlugin`] for usage and YAML configuration examples.
8
9use crate::config::schema::CommandsConfig;
10use crate::context::ExecutionContext;
11use crate::executor::CommandHandler;
12use crate::help::{DefaultHelpFormatter, HelpFormatter};
13use crate::parser::ParsedArgs;
14use crate::plugin::Plugin;
15use crate::Result;
16use std::sync::Arc;
17
18// ============================================================================
19// SystemPlugin
20// ============================================================================
21
22/// Built-in builtin providing standard system commands.
23///
24/// Supplies ready-made handlers for the commands that every `dynamic-cli`
25/// application typically needs. Users declare the corresponding commands in
26/// their YAML config and register the builtin once — no manual handler wiring.
27///
28/// # Provided handlers
29///
30/// | Implementation name | Behaviour |
31/// |---------------------|-----------|
32/// | `system_help`       | Prints application or per-command help via the active [`HelpFormatter`] |
33/// | `system_version`    | Prints the version from `metadata.version` in the config |
34/// | `system_exit`       | Runs the shutdown callback then exits (default: `std::process::exit(0)`) |
35///
36/// # Shutdown callback
37///
38/// `system_exit` accepts an optional callback via [`SystemPlugin::with_exit_fn`].
39/// The callback runs **before** the process exits, allowing the application to
40/// flush buffers, close connections, save state, or log a goodbye message.
41///
42/// The default callback calls `std::process::exit(0)` directly. Provide a
43/// custom one when a clean shutdown sequence is required:
44///
45/// ```no_run
46/// use dynamic_cli::plugin::SystemPlugin;
47///
48/// let builtin = SystemPlugin::new()
49///     .with_exit_fn(|| {
50///         // flush logs, close DB connections, save session…
51///         eprintln!("Goodbye.");
52///         std::process::exit(0);
53///     });
54/// ```
55///
56/// # YAML config
57///
58/// Declare the commands you want to activate:
59///
60/// ```yaml
61/// commands:
62///   - name: help
63///     implementation: system_help
64///     description: "Show help"
65///     aliases: ["h", "?"]
66///     required: false
67///     arguments: []
68///     options: []
69///
70///   - name: version
71///     implementation: system_version
72///     description: "Show version"
73///     required: false
74///     arguments: []
75///     options: []
76///
77///   - name: exit
78///     implementation: system_exit
79///     description: "Exit the application"
80///     aliases: ["quit", "q"]
81///     required: false
82///     arguments: []
83///     options: []
84/// ```
85///
86/// # Example
87///
88/// ```
89/// use dynamic_cli::plugin::{Plugin, SystemPlugin};
90///
91/// let builtin = SystemPlugin::new();
92/// assert_eq!(builtin.name(), "system");
93///
94/// let handlers = builtin.handlers();
95/// let names: Vec<&str> = handlers.iter().map(|(n, _)| n.as_str()).collect();
96/// assert!(names.contains(&"system_help"));
97/// assert!(names.contains(&"system_version"));
98/// assert!(names.contains(&"system_exit"));
99/// ```
100pub struct SystemPlugin {
101    /// Application config, needed by `system_help` and `system_version`.
102    config: Option<CommandsConfig>,
103
104    /// Shutdown callback invoked by `system_exit`.
105    ///
106    /// Defaults to `|| std::process::exit(0)`.
107    /// Override with [`SystemPlugin::with_exit_fn`] for a clean shutdown
108    /// sequence (flush buffers, close connections, save state, etc.).
109    exit_fn: Arc<dyn Fn() + Send + Sync>,
110}
111
112impl SystemPlugin {
113    /// Create a new `SystemPlugin` with the default shutdown behaviour.
114    ///
115    /// The default exit callback calls `std::process::exit(0)`. Use
116    /// [`with_exit_fn`][Self::with_exit_fn] to supply a custom shutdown
117    /// sequence.
118    ///
119    /// # Example
120    ///
121    /// ```
122    /// use dynamic_cli::plugin::{Plugin, SystemPlugin};
123    ///
124    /// let plugin = SystemPlugin::new();
125    /// assert_eq!(plugin.name(), "system");
126    /// ```
127    pub fn new() -> Self {
128        Self {
129            config: None,
130            exit_fn: Arc::new(|| std::process::exit(0)),
131        }
132    }
133
134    /// Attach a config so the system handlers can access app metadata.
135    ///
136    /// Call this yourself before
137    /// [`register_plugin`](crate::CliBuilder::register_plugin) —
138    /// `CliBuilder::build()` does not attach config to plugins on its own.
139    ///
140    /// # Example
141    ///
142    /// ```
143    /// use dynamic_cli::plugin::{Plugin, SystemPlugin};
144    /// use dynamic_cli::config::schema::{CommandsConfig, Metadata};
145    ///
146    /// let config = CommandsConfig {
147    ///     metadata: Metadata {
148    ///         version: "1.0.0".to_string(),
149    ///         prompt: "myapp".to_string(),
150    ///         prompt_suffix: " > ".to_string(),
151    ///     },
152    ///     commands: vec![],
153    ///     global_options: vec![],
154    ///     directives: vec![],
155    /// };
156    ///
157    /// let builtin = SystemPlugin::new().with_config(config);
158    /// assert_eq!(builtin.name(), "system");
159    /// ```
160    pub fn with_config(mut self, config: CommandsConfig) -> Self {
161        self.config = Some(config);
162        self
163    }
164
165    /// Supply a custom shutdown callback for `system_exit`.
166    ///
167    /// The callback is invoked when the user runs the command bound to
168    /// `system_exit`. Use it to flush buffers, close connections, persist
169    /// state, or display a goodbye message before the process terminates.
170    ///
171    /// The callback must be `Fn() + Send + Sync + 'static`.
172    ///
173    /// # Example
174    ///
175    /// ```no_run
176    /// use dynamic_cli::plugin::SystemPlugin;
177    ///
178    /// let plugin = SystemPlugin::new()
179    ///     .with_exit_fn(|| {
180    ///         eprintln!("Saving session…");
181    ///         // close resources here
182    ///         std::process::exit(0);
183    ///     });
184    /// ```
185    pub fn with_exit_fn<F>(mut self, f: F) -> Self
186    where
187        F: Fn() + Send + Sync + 'static,
188    {
189        self.exit_fn = Arc::new(f);
190        self
191    }
192}
193
194impl Default for SystemPlugin {
195    fn default() -> Self {
196        Self::new()
197    }
198}
199
200impl Plugin for SystemPlugin {
201    fn name(&self) -> &str {
202        "system"
203    }
204
205    fn version(&self) -> &str {
206        env!("CARGO_PKG_VERSION")
207    }
208
209    fn description(&self) -> &str {
210        "Built-in system commands: help, version, exit"
211    }
212
213    fn handlers(&self) -> Vec<(String, Box<dyn CommandHandler>)> {
214        let config = self.config.clone();
215        let exit_fn = self.exit_fn.clone();
216
217        vec![
218            (
219                "system_help".to_string(),
220                Box::new(SystemHelpHandler::new(config.clone())),
221            ),
222            (
223                "system_version".to_string(),
224                Box::new(SystemVersionHandler::new(config)),
225            ),
226            (
227                "system_exit".to_string(),
228                Box::new(SystemExitHandler::new(exit_fn)),
229            ),
230        ]
231    }
232}
233
234// ============================================================================
235// System handlers (private)
236// ============================================================================
237
238/// Handler for `system_help` — prints app-level or per-command help.
239///
240/// `pub(crate)` so [`crate::plugin::builtin::HelpPlugin`] can reuse the
241/// exact same logic without duplicating it (see #44).
242pub(crate) struct SystemHelpHandler {
243    config: Option<CommandsConfig>,
244}
245
246impl SystemHelpHandler {
247    /// Build a new handler carrying the given (optional) config.
248    pub(crate) fn new(config: Option<CommandsConfig>) -> Self {
249        Self { config }
250    }
251}
252
253impl CommandHandler for SystemHelpHandler {
254    fn execute(&self, _ctx: &mut dyn ExecutionContext, args: &ParsedArgs) -> Result<()> {
255        let formatter = DefaultHelpFormatter::new();
256
257        match self.config.as_ref() {
258            Some(cfg) => {
259                if let Some(command) = args.get_scalar("command") {
260                    print!("{}", formatter.format_command(cfg, command));
261                } else {
262                    print!("{}", formatter.format_app(cfg));
263                }
264            }
265            None => {
266                println!("Help is not available (no configuration loaded).");
267            }
268        }
269        Ok(())
270    }
271}
272
273/// Handler for `system_version` — prints the app version from config metadata.
274///
275/// `pub(crate)` so [`crate::plugin::builtin::VersionPlugin`] can
276/// reuse the exact same logic without duplicating it (see #44).
277pub(crate) struct SystemVersionHandler {
278    config: Option<CommandsConfig>,
279}
280
281impl SystemVersionHandler {
282    /// Build a new handler carrying the given (optional) config.
283    pub(crate) fn new(config: Option<CommandsConfig>) -> Self {
284        Self { config }
285    }
286}
287
288impl CommandHandler for SystemVersionHandler {
289    fn execute(&self, _ctx: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
290        match self.config.as_ref() {
291            Some(cfg) => println!("{}", cfg.metadata.version),
292            None => println!("(version unknown)"),
293        }
294        Ok(())
295    }
296}
297
298/// Handler for `system_exit` — invokes the shutdown callback and exits.
299///
300/// The callback is set via [`SystemPlugin::with_exit_fn`]. The default
301/// callback calls `std::process::exit(0)`.
302///
303/// `pub(crate)` so [`crate::plugin::builtin::ExitPlugin`] can reuse the
304/// exact same logic without duplicating it (see #44).
305pub(crate) struct SystemExitHandler {
306    /// Shutdown callback — runs before the process exits.
307    exit_fn: Arc<dyn Fn() + Send + Sync>,
308}
309
310impl SystemExitHandler {
311    /// Build a new handler carrying the given shutdown callback.
312    pub(crate) fn new(exit_fn: Arc<dyn Fn() + Send + Sync>) -> Self {
313        Self { exit_fn }
314    }
315}
316
317impl CommandHandler for SystemExitHandler {
318    fn execute(&self, _ctx: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
319        // Run the shutdown sequence supplied by the application.
320        // The default implementation calls std::process::exit(0).
321        (self.exit_fn)();
322        // Unreachable in production (exit_fn terminates the process),
323        // but required for the return type in test configurations
324        // where exit_fn does not call std::process::exit.
325        Ok(())
326    }
327}
328
329// ============================================================================
330// Tests
331// ============================================================================
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::config::schema::{CommandsConfig, Metadata};
337    use std::any::Any;
338    use std::collections::HashMap;
339
340    // -------------------------------------------------------------------------
341    // Test fixtures
342    // -------------------------------------------------------------------------
343
344    #[derive(Default)]
345    struct TestContext;
346
347    impl ExecutionContext for TestContext {
348        fn as_any(&self) -> &dyn Any {
349            self
350        }
351        fn as_any_mut(&mut self) -> &mut dyn Any {
352            self
353        }
354    }
355
356    fn test_config() -> CommandsConfig {
357        CommandsConfig {
358            metadata: Metadata {
359                version: "2.0.0".to_string(),
360                prompt: "testapp".to_string(),
361                prompt_suffix: " > ".to_string(),
362            },
363            commands: vec![],
364            global_options: vec![],
365            directives: vec![],
366        }
367    }
368
369    // -------------------------------------------------------------------------
370    // Metadata
371    // -------------------------------------------------------------------------
372
373    #[test]
374    fn test_system_plugin_metadata() {
375        let p = SystemPlugin::new();
376        assert_eq!(p.name(), "system");
377        assert!(!p.version().is_empty());
378        assert!(!p.description().is_empty());
379    }
380
381    #[test]
382    fn test_system_plugin_default() {
383        let p = SystemPlugin::default();
384        assert_eq!(p.name(), "system");
385    }
386
387    #[test]
388    fn test_system_plugin_handler_names() {
389        let handlers = SystemPlugin::new().handlers();
390        let names: Vec<&str> = handlers.iter().map(|(n, _)| n.as_str()).collect();
391        assert!(names.contains(&"system_help"));
392        assert!(names.contains(&"system_version"));
393        assert!(names.contains(&"system_exit"));
394        assert_eq!(handlers.len(), 3);
395    }
396
397    // -------------------------------------------------------------------------
398    // with_config
399    // -------------------------------------------------------------------------
400
401    #[test]
402    fn test_system_plugin_with_config() {
403        let plugin = SystemPlugin::new().with_config(test_config());
404        assert!(plugin.config.is_some());
405        assert_eq!(plugin.config.unwrap().metadata.version, "2.0.0");
406    }
407
408    #[test]
409    fn test_system_version_handler_with_config() {
410        let plugin = SystemPlugin::new().with_config(test_config());
411        let handlers = plugin.handlers();
412        let (name, handler) = handlers
413            .iter()
414            .find(|(n, _)| n == "system_version")
415            .unwrap();
416        assert_eq!(name, "system_version");
417        let mut ctx = TestContext;
418        assert!(handler
419            .execute(&mut ctx, &ParsedArgs::from_scalars(HashMap::new()))
420            .is_ok());
421    }
422
423    #[test]
424    fn test_system_version_handler_without_config() {
425        let handlers = SystemPlugin::new().handlers();
426        let (_, handler) = handlers
427            .iter()
428            .find(|(n, _)| n == "system_version")
429            .unwrap();
430        let mut ctx = TestContext;
431        assert!(handler
432            .execute(&mut ctx, &ParsedArgs::from_scalars(HashMap::new()))
433            .is_ok());
434    }
435
436    #[test]
437    fn test_system_help_handler_with_config() {
438        let plugin = SystemPlugin::new().with_config(test_config());
439        let handlers = plugin.handlers();
440        let (_, handler) = handlers.iter().find(|(n, _)| n == "system_help").unwrap();
441        let mut ctx = TestContext;
442        assert!(handler
443            .execute(&mut ctx, &ParsedArgs::from_scalars(HashMap::new()))
444            .is_ok());
445    }
446
447    #[test]
448    fn test_system_help_handler_with_command_arg() {
449        let plugin = SystemPlugin::new().with_config(test_config());
450        let handlers = plugin.handlers();
451        let (_, handler) = handlers.iter().find(|(n, _)| n == "system_help").unwrap();
452        let mut ctx = TestContext;
453        let mut args = HashMap::new();
454        args.insert("command".to_string(), "nonexistent".to_string());
455        let args = ParsedArgs::from_scalars(args);
456        assert!(handler.execute(&mut ctx, &args).is_ok());
457    }
458
459    #[test]
460    fn test_system_help_handler_without_config() {
461        let handlers = SystemPlugin::new().handlers();
462        let (_, handler) = handlers.iter().find(|(n, _)| n == "system_help").unwrap();
463        let mut ctx = TestContext;
464        assert!(handler
465            .execute(&mut ctx, &ParsedArgs::from_scalars(HashMap::new()))
466            .is_ok());
467    }
468
469    // -------------------------------------------------------------------------
470    // Shutdown callback
471    // -------------------------------------------------------------------------
472
473    #[test]
474    fn test_system_exit_default_callback_is_set() {
475        // Verify that SystemPlugin::new() initialises exit_fn without panicking.
476        // The default callback (process::exit) cannot be invoked in tests;
477        // we only check that the builtin builds and exposes the handler.
478        let plugin = SystemPlugin::new();
479        let handlers = plugin.handlers();
480        assert!(handlers.iter().any(|(n, _)| n == "system_exit"));
481    }
482
483    #[test]
484    fn test_system_exit_custom_callback_invoked() {
485        use std::sync::atomic::{AtomicBool, Ordering};
486
487        let called = Arc::new(AtomicBool::new(false));
488        let called_clone = called.clone();
489
490        let plugin = SystemPlugin::new().with_exit_fn(move || {
491            called_clone.store(true, Ordering::SeqCst);
492            // Does NOT call std::process::exit — safe in tests.
493        });
494
495        let handlers = plugin.handlers();
496        let (_, handler) = handlers.iter().find(|(n, _)| n == "system_exit").unwrap();
497
498        let mut ctx = TestContext;
499        let result = handler.execute(&mut ctx, &ParsedArgs::from_scalars(HashMap::new()));
500
501        assert!(result.is_ok());
502        assert!(
503            called.load(Ordering::SeqCst),
504            "shutdown callback was not invoked"
505        );
506    }
507
508    #[test]
509    fn test_system_exit_callback_ignores_args() {
510        use std::sync::atomic::{AtomicBool, Ordering};
511
512        let called = Arc::new(AtomicBool::new(false));
513        let called_clone = called.clone();
514
515        let plugin = SystemPlugin::new().with_exit_fn(move || {
516            called_clone.store(true, Ordering::SeqCst);
517        });
518
519        let handlers = plugin.handlers();
520        let (_, handler) = handlers.iter().find(|(n, _)| n == "system_exit").unwrap();
521
522        let mut ctx = TestContext;
523        let mut args = HashMap::new();
524        args.insert("unexpected_arg".to_string(), "value".to_string());
525        let args = ParsedArgs::from_scalars(args);
526
527        assert!(handler.execute(&mut ctx, &args).is_ok());
528        assert!(called.load(Ordering::SeqCst));
529    }
530
531    #[test]
532    fn test_with_exit_fn_is_send_sync() {
533        fn assert_send_sync<T: Send + Sync>(_: T) {}
534        let plugin = SystemPlugin::new().with_exit_fn(|| {});
535        assert_send_sync(plugin);
536    }
537}