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    /// Create a failed output without a default message.
90    pub fn quiet_failure(code: i32) -> Self {
91        Self {
92            exit_code: code,
93            message: None,
94            quiet: true,
95        }
96    }
97}
98
99/// Command runner for executing commands with lifecycle management.
100pub struct CommandRunner {
101    ctx: Arc<RwLock<CliContext>>,
102    hooks: Vec<Box<dyn CommandHook>>,
103}
104
105impl CommandRunner {
106    /// Create a new command runner.
107    pub fn new(ctx: CliContext) -> Self {
108        Self {
109            ctx: Arc::new(RwLock::new(ctx)),
110            hooks: Vec::new(),
111        }
112    }
113
114    /// Add a command hook.
115    pub fn add_hook(&mut self, hook: impl CommandHook + 'static) {
116        self.hooks.push(Box::new(hook));
117    }
118
119    /// Run a command.
120    pub async fn run(&self, cmd: &dyn Command) -> CliResult<CommandOutput> {
121        // Validate command
122        cmd.validate()?;
123
124        // Run pre-execution hooks
125        for hook in &self.hooks {
126            hook.before_execute(cmd.name()).await?;
127        }
128
129        // Execute command
130        let mut ctx = self.ctx.write().await;
131        let result = cmd.execute(&mut ctx).await;
132
133        // Run post-execution hooks
134        let is_success = result.is_ok();
135        for hook in &self.hooks {
136            hook.after_execute(cmd.name(), is_success).await?;
137        }
138
139        result
140    }
141
142    /// Run a command with graceful shutdown support.
143    ///
144    /// Handles both Ctrl+C (SIGINT) and Ctrl+Z (SIGTSTP) signals.
145    /// Ctrl+Z triggers a graceful shutdown instead of suspending the process,
146    /// which prevents the zombie-port scenario where a suspended process holds
147    /// a port but never processes incoming data.
148    pub async fn run_with_shutdown<C: Command>(&self, cmd: &C) -> CliResult<CommandOutput> {
149        if !cmd.supports_shutdown() {
150            return self.run(cmd).await;
151        }
152
153        let shutdown_signal = {
154            let ctx = self.ctx.read().await;
155            ctx.shutdown_signal()
156        };
157
158        // Setup Ctrl+C handler
159        let signal = shutdown_signal.clone();
160        ctrlc::set_handler(move || {
161            signal.notify_waiters();
162        })
163        .map_err(|e| CliError::ExecutionFailed {
164            message: format!("Failed to set Ctrl+C handler: {}", e),
165        })?;
166
167        // Setup SIGTSTP handler (Ctrl+Z) — treat as graceful shutdown instead of suspend.
168        // A suspended process keeps holding the port, creating a zombie-port scenario
169        // that is very difficult to diagnose: TCP connects succeed (kernel handles SYN/ACK)
170        // but all application-layer reads time out indefinitely.
171        #[cfg(unix)]
172        {
173            let sigtstp_shutdown = shutdown_signal.clone();
174            let mut sigtstp = tokio::signal::unix::signal(
175                tokio::signal::unix::SignalKind::from_raw(libc::SIGTSTP),
176            )
177            .map_err(|e| CliError::ExecutionFailed {
178                message: format!("Failed to set SIGTSTP handler: {}", e),
179            })?;
180
181            tokio::spawn(async move {
182                if sigtstp.recv().await.is_some() {
183                    eprintln!(
184                        "\n⚠ Received Ctrl+Z (SIGTSTP). Performing graceful shutdown instead of \
185                         suspending to release the port.\n  \
186                         Use 'kill -STOP <pid>' if you really need to suspend."
187                    );
188                    sigtstp_shutdown.notify_waiters();
189                }
190            });
191        }
192
193        // The command owns graceful shutdown: the signal handler only flips the
194        // shared notify so long-running commands can stop their services cleanly.
195        self.run(cmd).await
196    }
197
198    /// Get the context.
199    pub fn context(&self) -> Arc<RwLock<CliContext>> {
200        self.ctx.clone()
201    }
202}
203
204/// Hook trait for command lifecycle events.
205#[async_trait]
206pub trait CommandHook: Send + Sync {
207    /// Called before command execution.
208    async fn before_execute(&self, _cmd_name: &str) -> CliResult<()> {
209        Ok(())
210    }
211
212    /// Called after command execution.
213    async fn after_execute(&self, _cmd_name: &str, _success: bool) -> CliResult<()> {
214        Ok(())
215    }
216}
217
218/// Logging hook for command execution.
219pub struct LoggingHook;
220
221#[async_trait]
222impl CommandHook for LoggingHook {
223    async fn before_execute(&self, cmd_name: &str) -> CliResult<()> {
224        tracing::info!(command = cmd_name, "Executing command");
225        Ok(())
226    }
227
228    async fn after_execute(&self, cmd_name: &str, success: bool) -> CliResult<()> {
229        if success {
230            tracing::info!(command = cmd_name, "Command completed successfully");
231        } else {
232            tracing::warn!(command = cmd_name, "Command failed");
233        }
234        Ok(())
235    }
236}
237
238/// Metrics hook for command execution.
239pub struct MetricsHook {
240    start_time: std::sync::Mutex<Option<std::time::Instant>>,
241}
242
243impl MetricsHook {
244    pub fn new() -> Self {
245        Self {
246            start_time: std::sync::Mutex::new(None),
247        }
248    }
249}
250
251impl Default for MetricsHook {
252    fn default() -> Self {
253        Self::new()
254    }
255}
256
257#[async_trait]
258impl CommandHook for MetricsHook {
259    async fn before_execute(&self, _cmd_name: &str) -> CliResult<()> {
260        *self.start_time.lock().unwrap() = Some(std::time::Instant::now());
261        Ok(())
262    }
263
264    async fn after_execute(&self, cmd_name: &str, success: bool) -> CliResult<()> {
265        if let Some(start) = self.start_time.lock().unwrap().take() {
266            let duration = start.elapsed();
267            tracing::debug!(
268                command = cmd_name,
269                success = success,
270                duration_ms = duration.as_millis() as u64,
271                "Command execution metrics"
272            );
273        }
274        Ok(())
275    }
276}
277
278/// Command factory for dynamic command creation.
279pub trait CommandFactory: Send + Sync {
280    /// Get the protocol this factory supports.
281    fn protocol(&self) -> &str;
282
283    /// Create a run command for this protocol.
284    fn create_run_command(&self, args: &RunCommandArgs) -> Box<dyn Command>;
285
286    /// Create a list command for this protocol.
287    fn create_list_command(&self) -> Box<dyn Command>;
288
289    /// Create a validate command for this protocol.
290    fn create_validate_command(&self, path: std::path::PathBuf) -> Box<dyn Command>;
291}
292
293/// Arguments for run commands.
294#[derive(Debug, Clone)]
295pub struct RunCommandArgs {
296    pub port: Option<u16>,
297    pub devices: usize,
298    pub points_per_device: usize,
299    pub tick_interval_ms: u64,
300}
301
302impl Default for RunCommandArgs {
303    fn default() -> Self {
304        Self {
305            port: None,
306            devices: 1,
307            points_per_device: 100,
308            tick_interval_ms: 100,
309        }
310    }
311}