Skip to main content

dynamic_cli/plugin/
system.rs

1//! Built-in system plugin for `dynamic-cli`
2//!
3//! Provides [`SystemPlugin`], a ready-made plugin 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 plugin 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 plugin 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 plugin = 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 plugin = SystemPlugin::new();
92/// assert_eq!(plugin.name(), "system");
93///
94/// let handlers = plugin.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    /// Called automatically by [`CliBuilder::build()`] when the plugin is
137    /// registered via [`CliBuilder::register_plugin`].
138    ///
139    /// # Example
140    ///
141    /// ```
142    /// use dynamic_cli::plugin::{Plugin, SystemPlugin};
143    /// use dynamic_cli::config::schema::{CommandsConfig, Metadata};
144    ///
145    /// let config = CommandsConfig {
146    ///     metadata: Metadata {
147    ///         version: "1.0.0".to_string(),
148    ///         prompt: "myapp".to_string(),
149    ///         prompt_suffix: " > ".to_string(),
150    ///     },
151    ///     commands: vec![],
152    ///     global_options: vec![],
153    /// };
154    ///
155    /// let plugin = SystemPlugin::new().with_config(config);
156    /// assert_eq!(plugin.name(), "system");
157    /// ```
158    pub fn with_config(mut self, config: CommandsConfig) -> Self {
159        self.config = Some(config);
160        self
161    }
162
163    /// Supply a custom shutdown callback for `system_exit`.
164    ///
165    /// The callback is invoked when the user runs the command bound to
166    /// `system_exit`. Use it to flush buffers, close connections, persist
167    /// state, or display a goodbye message before the process terminates.
168    ///
169    /// The callback must be `Fn() + Send + Sync + 'static`.
170    ///
171    /// # Example
172    ///
173    /// ```no_run
174    /// use dynamic_cli::plugin::SystemPlugin;
175    ///
176    /// let plugin = SystemPlugin::new()
177    ///     .with_exit_fn(|| {
178    ///         eprintln!("Saving session…");
179    ///         // close resources here
180    ///         std::process::exit(0);
181    ///     });
182    /// ```
183    pub fn with_exit_fn<F>(mut self, f: F) -> Self
184    where
185        F: Fn() + Send + Sync + 'static,
186    {
187        self.exit_fn = Arc::new(f);
188        self
189    }
190}
191
192impl Default for SystemPlugin {
193    fn default() -> Self {
194        Self::new()
195    }
196}
197
198impl Plugin for SystemPlugin {
199    fn name(&self) -> &str {
200        "system"
201    }
202
203    fn version(&self) -> &str {
204        env!("CARGO_PKG_VERSION")
205    }
206
207    fn description(&self) -> &str {
208        "Built-in system commands: help, version, exit"
209    }
210
211    fn handlers(&self) -> Vec<(String, Box<dyn CommandHandler>)> {
212        let config = self.config.clone();
213        let exit_fn = self.exit_fn.clone();
214
215        vec![
216            (
217                "system_help".to_string(),
218                Box::new(SystemHelpHandler {
219                    config: config.clone(),
220                }),
221            ),
222            (
223                "system_version".to_string(),
224                Box::new(SystemVersionHandler { config }),
225            ),
226            (
227                "system_exit".to_string(),
228                Box::new(SystemExitHandler { 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.
239struct SystemHelpHandler {
240    config: Option<CommandsConfig>,
241}
242
243impl CommandHandler for SystemHelpHandler {
244    fn execute(&self, _ctx: &mut dyn ExecutionContext, args: &ParsedArgs) -> Result<()> {
245        let formatter = DefaultHelpFormatter::new();
246
247        match self.config.as_ref() {
248            Some(cfg) => {
249                if let Some(command) = args.get_scalar("command") {
250                    print!("{}", formatter.format_command(cfg, command));
251                } else {
252                    print!("{}", formatter.format_app(cfg));
253                }
254            }
255            None => {
256                println!("Help is not available (no configuration loaded).");
257            }
258        }
259        Ok(())
260    }
261}
262
263/// Handler for `system_version` — prints the app version from config metadata.
264struct SystemVersionHandler {
265    config: Option<CommandsConfig>,
266}
267
268impl CommandHandler for SystemVersionHandler {
269    fn execute(&self, _ctx: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
270        match self.config.as_ref() {
271            Some(cfg) => println!("{}", cfg.metadata.version),
272            None => println!("(version unknown)"),
273        }
274        Ok(())
275    }
276}
277
278/// Handler for `system_exit` — invokes the shutdown callback and exits.
279///
280/// The callback is set via [`SystemPlugin::with_exit_fn`]. The default
281/// callback calls `std::process::exit(0)`.
282struct SystemExitHandler {
283    /// Shutdown callback — runs before the process exits.
284    exit_fn: Arc<dyn Fn() + Send + Sync>,
285}
286
287impl CommandHandler for SystemExitHandler {
288    fn execute(&self, _ctx: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
289        // Run the shutdown sequence supplied by the application.
290        // The default implementation calls std::process::exit(0).
291        (self.exit_fn)();
292        // Unreachable in production (exit_fn terminates the process),
293        // but required for the return type in test configurations
294        // where exit_fn does not call std::process::exit.
295        Ok(())
296    }
297}
298
299// ============================================================================
300// Tests
301// ============================================================================
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use crate::config::schema::{CommandsConfig, Metadata};
307    use std::any::Any;
308    use std::collections::HashMap;
309
310    // -------------------------------------------------------------------------
311    // Test fixtures
312    // -------------------------------------------------------------------------
313
314    #[derive(Default)]
315    struct TestContext;
316
317    impl ExecutionContext for TestContext {
318        fn as_any(&self) -> &dyn Any {
319            self
320        }
321        fn as_any_mut(&mut self) -> &mut dyn Any {
322            self
323        }
324    }
325
326    fn test_config() -> CommandsConfig {
327        CommandsConfig {
328            metadata: Metadata {
329                version: "2.0.0".to_string(),
330                prompt: "testapp".to_string(),
331                prompt_suffix: " > ".to_string(),
332            },
333            commands: vec![],
334            global_options: vec![],
335        }
336    }
337
338    // -------------------------------------------------------------------------
339    // Metadata
340    // -------------------------------------------------------------------------
341
342    #[test]
343    fn test_system_plugin_metadata() {
344        let p = SystemPlugin::new();
345        assert_eq!(p.name(), "system");
346        assert!(!p.version().is_empty());
347        assert!(!p.description().is_empty());
348    }
349
350    #[test]
351    fn test_system_plugin_default() {
352        let p = SystemPlugin::default();
353        assert_eq!(p.name(), "system");
354    }
355
356    #[test]
357    fn test_system_plugin_handler_names() {
358        let handlers = SystemPlugin::new().handlers();
359        let names: Vec<&str> = handlers.iter().map(|(n, _)| n.as_str()).collect();
360        assert!(names.contains(&"system_help"));
361        assert!(names.contains(&"system_version"));
362        assert!(names.contains(&"system_exit"));
363        assert_eq!(handlers.len(), 3);
364    }
365
366    // -------------------------------------------------------------------------
367    // with_config
368    // -------------------------------------------------------------------------
369
370    #[test]
371    fn test_system_plugin_with_config() {
372        let plugin = SystemPlugin::new().with_config(test_config());
373        assert!(plugin.config.is_some());
374        assert_eq!(plugin.config.unwrap().metadata.version, "2.0.0");
375    }
376
377    #[test]
378    fn test_system_version_handler_with_config() {
379        let plugin = SystemPlugin::new().with_config(test_config());
380        let handlers = plugin.handlers();
381        let (name, handler) = handlers
382            .iter()
383            .find(|(n, _)| n == "system_version")
384            .unwrap();
385        assert_eq!(name, "system_version");
386        let mut ctx = TestContext;
387        assert!(handler
388            .execute(&mut ctx, &ParsedArgs::from_scalars(HashMap::new()))
389            .is_ok());
390    }
391
392    #[test]
393    fn test_system_version_handler_without_config() {
394        let handlers = SystemPlugin::new().handlers();
395        let (_, handler) = handlers
396            .iter()
397            .find(|(n, _)| n == "system_version")
398            .unwrap();
399        let mut ctx = TestContext;
400        assert!(handler
401            .execute(&mut ctx, &ParsedArgs::from_scalars(HashMap::new()))
402            .is_ok());
403    }
404
405    #[test]
406    fn test_system_help_handler_with_config() {
407        let plugin = SystemPlugin::new().with_config(test_config());
408        let handlers = plugin.handlers();
409        let (_, handler) = handlers.iter().find(|(n, _)| n == "system_help").unwrap();
410        let mut ctx = TestContext;
411        assert!(handler
412            .execute(&mut ctx, &ParsedArgs::from_scalars(HashMap::new()))
413            .is_ok());
414    }
415
416    #[test]
417    fn test_system_help_handler_with_command_arg() {
418        let plugin = SystemPlugin::new().with_config(test_config());
419        let handlers = plugin.handlers();
420        let (_, handler) = handlers.iter().find(|(n, _)| n == "system_help").unwrap();
421        let mut ctx = TestContext;
422        let mut args = HashMap::new();
423        args.insert("command".to_string(), "nonexistent".to_string());
424        let args = ParsedArgs::from_scalars(args);
425        assert!(handler.execute(&mut ctx, &args).is_ok());
426    }
427
428    #[test]
429    fn test_system_help_handler_without_config() {
430        let handlers = SystemPlugin::new().handlers();
431        let (_, handler) = handlers.iter().find(|(n, _)| n == "system_help").unwrap();
432        let mut ctx = TestContext;
433        assert!(handler
434            .execute(&mut ctx, &ParsedArgs::from_scalars(HashMap::new()))
435            .is_ok());
436    }
437
438    // -------------------------------------------------------------------------
439    // Shutdown callback
440    // -------------------------------------------------------------------------
441
442    #[test]
443    fn test_system_exit_default_callback_is_set() {
444        // Verify that SystemPlugin::new() initialises exit_fn without panicking.
445        // The default callback (process::exit) cannot be invoked in tests;
446        // we only check that the plugin builds and exposes the handler.
447        let plugin = SystemPlugin::new();
448        let handlers = plugin.handlers();
449        assert!(handlers.iter().any(|(n, _)| n == "system_exit"));
450    }
451
452    #[test]
453    fn test_system_exit_custom_callback_invoked() {
454        use std::sync::atomic::{AtomicBool, Ordering};
455
456        let called = Arc::new(AtomicBool::new(false));
457        let called_clone = called.clone();
458
459        let plugin = SystemPlugin::new().with_exit_fn(move || {
460            called_clone.store(true, Ordering::SeqCst);
461            // Does NOT call std::process::exit — safe in tests.
462        });
463
464        let handlers = plugin.handlers();
465        let (_, handler) = handlers.iter().find(|(n, _)| n == "system_exit").unwrap();
466
467        let mut ctx = TestContext;
468        let result = handler.execute(&mut ctx, &ParsedArgs::from_scalars(HashMap::new()));
469
470        assert!(result.is_ok());
471        assert!(
472            called.load(Ordering::SeqCst),
473            "shutdown callback was not invoked"
474        );
475    }
476
477    #[test]
478    fn test_system_exit_callback_ignores_args() {
479        use std::sync::atomic::{AtomicBool, Ordering};
480
481        let called = Arc::new(AtomicBool::new(false));
482        let called_clone = called.clone();
483
484        let plugin = SystemPlugin::new().with_exit_fn(move || {
485            called_clone.store(true, Ordering::SeqCst);
486        });
487
488        let handlers = plugin.handlers();
489        let (_, handler) = handlers.iter().find(|(n, _)| n == "system_exit").unwrap();
490
491        let mut ctx = TestContext;
492        let mut args = HashMap::new();
493        args.insert("unexpected_arg".to_string(), "value".to_string());
494        let args = ParsedArgs::from_scalars(args);
495
496        assert!(handler.execute(&mut ctx, &args).is_ok());
497        assert!(called.load(Ordering::SeqCst));
498    }
499
500    #[test]
501    fn test_with_exit_fn_is_send_sync() {
502        fn assert_send_sync<T: Send + Sync>(_: T) {}
503        let plugin = SystemPlugin::new().with_exit_fn(|| {});
504        assert_send_sync(plugin);
505    }
506}