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}