Skip to main content

mabi_cli/
runner.rs

1//! Command runner and execution framework.
2//!
3//! Provides the core abstractions for command execution.
4
5use crate::context::CliContext;
6use crate::error::{CliError, CliResult};
7use async_trait::async_trait;
8use std::sync::Arc;
9use tokio::sync::RwLock;
10
11/// Trait for executable commands.
12///
13/// All CLI commands implement this trait for consistent execution.
14#[async_trait]
15pub trait Command: Send + Sync {
16    /// Get the command name.
17    fn name(&self) -> &str;
18
19    /// Get the command description.
20    fn description(&self) -> &str;
21
22    /// Execute the command.
23    async fn execute(&self, ctx: &mut CliContext) -> CliResult<CommandOutput>;
24
25    /// Validate command arguments before execution.
26    fn validate(&self) -> CliResult<()> {
27        Ok(())
28    }
29
30    /// Check if this command requires an engine instance.
31    fn requires_engine(&self) -> bool {
32        false
33    }
34
35    /// Check if this command supports graceful shutdown.
36    fn supports_shutdown(&self) -> bool {
37        false
38    }
39}
40
41/// Command output type.
42#[derive(Debug, Default)]
43pub struct CommandOutput {
44    /// Exit code (0 = success).
45    pub exit_code: i32,
46    /// Optional message.
47    pub message: Option<String>,
48    /// Whether to suppress default success message.
49    pub quiet: bool,
50}
51
52impl CommandOutput {
53    /// Create a successful output.
54    pub fn success() -> Self {
55        Self {
56            exit_code: 0,
57            message: None,
58            quiet: false,
59        }
60    }
61
62    /// Create a successful output with message.
63    pub fn success_with_message(msg: impl Into<String>) -> Self {
64        Self {
65            exit_code: 0,
66            message: Some(msg.into()),
67            quiet: false,
68        }
69    }
70
71    /// Create a quiet successful output.
72    pub fn quiet_success() -> Self {
73        Self {
74            exit_code: 0,
75            message: None,
76            quiet: true,
77        }
78    }
79
80    /// Create a failed output.
81    pub fn failure(code: i32, msg: impl Into<String>) -> Self {
82        Self {
83            exit_code: code,
84            message: Some(msg.into()),
85            quiet: false,
86        }
87    }
88}
89
90/// Command runner for executing commands with lifecycle management.
91pub struct CommandRunner {
92    ctx: Arc<RwLock<CliContext>>,
93    hooks: Vec<Box<dyn CommandHook>>,
94}
95
96impl CommandRunner {
97    /// Create a new command runner.
98    pub fn new(ctx: CliContext) -> Self {
99        Self {
100            ctx: Arc::new(RwLock::new(ctx)),
101            hooks: Vec::new(),
102        }
103    }
104
105    /// Add a command hook.
106    pub fn add_hook(&mut self, hook: impl CommandHook + 'static) {
107        self.hooks.push(Box::new(hook));
108    }
109
110    /// Run a command.
111    pub async fn run(&self, cmd: &dyn Command) -> CliResult<CommandOutput> {
112        // Validate command
113        cmd.validate()?;
114
115        // Run pre-execution hooks
116        for hook in &self.hooks {
117            hook.before_execute(cmd.name()).await?;
118        }
119
120        // Execute command
121        let mut ctx = self.ctx.write().await;
122        let result = cmd.execute(&mut ctx).await;
123
124        // Run post-execution hooks
125        let is_success = result.is_ok();
126        for hook in &self.hooks {
127            hook.after_execute(cmd.name(), is_success).await?;
128        }
129
130        result
131    }
132
133    /// Run a command with graceful shutdown support.
134    ///
135    /// Handles both Ctrl+C (SIGINT) and Ctrl+Z (SIGTSTP) signals.
136    /// Ctrl+Z triggers a graceful shutdown instead of suspending the process,
137    /// which prevents the zombie-port scenario where a suspended process holds
138    /// a port but never processes incoming data.
139    pub async fn run_with_shutdown<C: Command>(&self, cmd: &C) -> CliResult<CommandOutput> {
140        if !cmd.supports_shutdown() {
141            return self.run(cmd).await;
142        }
143
144        let shutdown_signal = {
145            let ctx = self.ctx.read().await;
146            ctx.shutdown_signal()
147        };
148
149        // Setup Ctrl+C handler
150        let signal = shutdown_signal.clone();
151        ctrlc::set_handler(move || {
152            signal.notify_waiters();
153        })
154        .map_err(|e| CliError::ExecutionFailed {
155            message: format!("Failed to set Ctrl+C handler: {}", e),
156        })?;
157
158        // Setup SIGTSTP handler (Ctrl+Z) — treat as graceful shutdown instead of suspend.
159        // A suspended process keeps holding the port, creating a zombie-port scenario
160        // that is very difficult to diagnose: TCP connects succeed (kernel handles SYN/ACK)
161        // but all application-layer reads time out indefinitely.
162        #[cfg(unix)]
163        {
164            let sigtstp_shutdown = shutdown_signal.clone();
165            let mut sigtstp = tokio::signal::unix::signal(
166                tokio::signal::unix::SignalKind::from_raw(libc::SIGTSTP),
167            )
168            .map_err(|e| CliError::ExecutionFailed {
169                message: format!("Failed to set SIGTSTP handler: {}", e),
170            })?;
171
172            tokio::spawn(async move {
173                if sigtstp.recv().await.is_some() {
174                    eprintln!(
175                        "\n⚠ Received Ctrl+Z (SIGTSTP). Performing graceful shutdown instead of \
176                         suspending to release the port.\n  \
177                         Use 'kill -STOP <pid>' if you really need to suspend."
178                    );
179                    sigtstp_shutdown.notify_waiters();
180                }
181            });
182        }
183
184        // The command owns graceful shutdown: the signal handler only flips the
185        // shared notify so long-running commands can stop their services cleanly.
186        self.run(cmd).await
187    }
188
189    /// Get the context.
190    pub fn context(&self) -> Arc<RwLock<CliContext>> {
191        self.ctx.clone()
192    }
193}
194
195/// Hook trait for command lifecycle events.
196#[async_trait]
197pub trait CommandHook: Send + Sync {
198    /// Called before command execution.
199    async fn before_execute(&self, _cmd_name: &str) -> CliResult<()> {
200        Ok(())
201    }
202
203    /// Called after command execution.
204    async fn after_execute(&self, _cmd_name: &str, _success: bool) -> CliResult<()> {
205        Ok(())
206    }
207}
208
209/// Logging hook for command execution.
210pub struct LoggingHook;
211
212#[async_trait]
213impl CommandHook for LoggingHook {
214    async fn before_execute(&self, cmd_name: &str) -> CliResult<()> {
215        tracing::info!(command = cmd_name, "Executing command");
216        Ok(())
217    }
218
219    async fn after_execute(&self, cmd_name: &str, success: bool) -> CliResult<()> {
220        if success {
221            tracing::info!(command = cmd_name, "Command completed successfully");
222        } else {
223            tracing::warn!(command = cmd_name, "Command failed");
224        }
225        Ok(())
226    }
227}
228
229/// Metrics hook for command execution.
230pub struct MetricsHook {
231    start_time: std::sync::Mutex<Option<std::time::Instant>>,
232}
233
234impl MetricsHook {
235    pub fn new() -> Self {
236        Self {
237            start_time: std::sync::Mutex::new(None),
238        }
239    }
240}
241
242impl Default for MetricsHook {
243    fn default() -> Self {
244        Self::new()
245    }
246}
247
248#[async_trait]
249impl CommandHook for MetricsHook {
250    async fn before_execute(&self, _cmd_name: &str) -> CliResult<()> {
251        *self.start_time.lock().unwrap() = Some(std::time::Instant::now());
252        Ok(())
253    }
254
255    async fn after_execute(&self, cmd_name: &str, success: bool) -> CliResult<()> {
256        if let Some(start) = self.start_time.lock().unwrap().take() {
257            let duration = start.elapsed();
258            tracing::debug!(
259                command = cmd_name,
260                success = success,
261                duration_ms = duration.as_millis() as u64,
262                "Command execution metrics"
263            );
264        }
265        Ok(())
266    }
267}
268
269/// Command factory for dynamic command creation.
270pub trait CommandFactory: Send + Sync {
271    /// Get the protocol this factory supports.
272    fn protocol(&self) -> &str;
273
274    /// Create a run command for this protocol.
275    fn create_run_command(&self, args: &RunCommandArgs) -> Box<dyn Command>;
276
277    /// Create a list command for this protocol.
278    fn create_list_command(&self) -> Box<dyn Command>;
279
280    /// Create a validate command for this protocol.
281    fn create_validate_command(&self, path: std::path::PathBuf) -> Box<dyn Command>;
282}
283
284/// Arguments for run commands.
285#[derive(Debug, Clone)]
286pub struct RunCommandArgs {
287    pub port: Option<u16>,
288    pub devices: usize,
289    pub points_per_device: usize,
290    pub tick_interval_ms: u64,
291}
292
293impl Default for RunCommandArgs {
294    fn default() -> Self {
295        Self {
296            port: None,
297            devices: 1,
298            points_per_device: 100,
299            tick_interval_ms: 100,
300        }
301    }
302}