Skip to main content

mabi_cli/commands/
run.rs

1//! Run command implementation.
2//!
3//! Executes scenario files or starts protocol simulators.
4
5use crate::context::CliContext;
6use crate::error::{CliError, CliResult};
7use crate::output::{StatusType, TableBuilder};
8use crate::runner::{Command, CommandOutput};
9use async_trait::async_trait;
10use mabi_core::prelude::*;
11use std::path::PathBuf;
12use std::time::Duration;
13
14/// Run command for executing scenarios.
15pub struct RunCommand {
16    /// Path to scenario file.
17    scenario_path: PathBuf,
18    /// Time scale factor (1.0 = real-time).
19    time_scale: f64,
20    /// Duration to run (None = until completion or Ctrl+C).
21    duration: Option<Duration>,
22    /// Dry run mode (validate only).
23    dry_run: bool,
24}
25
26impl RunCommand {
27    /// Create a new run command.
28    pub fn new(scenario_path: PathBuf) -> Self {
29        Self {
30            scenario_path,
31            time_scale: 1.0,
32            duration: None,
33            dry_run: false,
34        }
35    }
36
37    /// Set the time scale factor.
38    pub fn with_time_scale(mut self, scale: f64) -> Self {
39        self.time_scale = scale;
40        self
41    }
42
43    /// Set the duration.
44    pub fn with_duration(mut self, duration: Duration) -> Self {
45        self.duration = Some(duration);
46        self
47    }
48
49    /// Enable dry run mode.
50    pub fn with_dry_run(mut self, dry_run: bool) -> Self {
51        self.dry_run = dry_run;
52        self
53    }
54
55    /// Load and validate scenario.
56    async fn load_scenario(&self, ctx: &CliContext) -> CliResult<ScenarioConfig> {
57        let path = ctx.resolve_path(&self.scenario_path);
58
59        if !path.exists() {
60            return Err(CliError::ScenarioNotFound { path });
61        }
62
63        ctx.vprintln(format!("Loading scenario from: {}", path.display()));
64
65        let content = tokio::fs::read_to_string(&path).await?;
66        let config: ScenarioConfig = serde_yaml::from_str(&content)?;
67
68        // Validate scenario
69        self.validate_scenario(&config)?;
70
71        Ok(config)
72    }
73
74    /// Validate scenario configuration.
75    fn validate_scenario(&self, config: &ScenarioConfig) -> CliResult<()> {
76        let mut errors: Vec<String> = Vec::new();
77
78        if config.name.is_empty() {
79            errors.push("Scenario name is required".to_string());
80        }
81
82        if config.devices.is_empty() {
83            errors.push("At least one device is required".to_string());
84        }
85
86        for (idx, device) in config.devices.iter().enumerate() {
87            if device.id.is_empty() {
88                errors.push(format!("Device {} has empty ID", idx));
89            }
90        }
91
92        if errors.is_empty() {
93            Ok(())
94        } else {
95            Err(CliError::validation_failed(errors))
96        }
97    }
98
99    /// Run the scenario.
100    async fn run_scenario(
101        &self,
102        ctx: &mut CliContext,
103        config: ScenarioConfig,
104    ) -> CliResult<CommandOutput> {
105        let output = ctx.output();
106
107        // Display scenario info
108        output.header("Scenario Configuration");
109        output.kv("Name", &config.name);
110        output.kv("Devices", config.devices.len());
111        output.kv("Time Scale", format!("{}x", self.time_scale));
112        if let Some(duration) = self.duration {
113            output.kv("Duration", format!("{:?}", duration));
114        }
115
116        if self.dry_run {
117            output.success("Dry run completed - scenario is valid");
118            return Ok(CommandOutput::quiet_success());
119        }
120
121        // Create and start engine
122        let engine_guard = ctx.engine().await?;
123        let mut engine_lock = engine_guard.write().await;
124        let engine = engine_lock.as_mut().ok_or_else(|| CliError::ExecutionFailed {
125            message: "Engine not initialized".into(),
126        })?;
127
128        // Create devices from config
129        let spinner = output.spinner("Creating devices...");
130        for device_config in &config.devices {
131            // TODO: Use actual device factory based on protocol
132            ctx.dprintln(format!("Creating device: {}", device_config.id));
133        }
134        spinner.finish_with_message("Devices created");
135
136        // Start engine
137        let spinner = output.spinner("Starting simulator...");
138        engine.start().await?;
139        spinner.finish_with_message("Simulator started");
140
141        // Display status
142        output.header("Simulator Status");
143
144        let table = TableBuilder::new(output.colors_enabled())
145            .header(["Protocol", "Devices", "Points", "Status"])
146            .status_row(["Modbus TCP", "0", "0", "Ready"], StatusType::Success)
147            .status_row(["OPC UA", "0", "0", "Ready"], StatusType::Success)
148            .status_row(["BACnet/IP", "0", "0", "Ready"], StatusType::Success)
149            .status_row(["KNXnet/IP", "0", "0", "Ready"], StatusType::Success);
150        table.print();
151
152        output.info("Press Ctrl+C to stop");
153
154        // Wait for shutdown or duration
155        let shutdown = ctx.shutdown_signal();
156        if let Some(duration) = self.duration {
157            tokio::select! {
158                _ = tokio::time::sleep(duration) => {
159                    output.info("Duration reached, shutting down...");
160                }
161                _ = shutdown.notified() => {
162                    output.info("Shutdown signal received");
163                }
164            }
165        } else {
166            shutdown.notified().await;
167        }
168
169        // Stop engine
170        engine.stop().await?;
171        output.success("Simulator stopped");
172
173        Ok(CommandOutput::quiet_success())
174    }
175}
176
177#[async_trait]
178impl Command for RunCommand {
179    fn name(&self) -> &str {
180        "run"
181    }
182
183    fn description(&self) -> &str {
184        "Run a simulation scenario"
185    }
186
187    fn requires_engine(&self) -> bool {
188        true
189    }
190
191    fn supports_shutdown(&self) -> bool {
192        true
193    }
194
195    fn validate(&self) -> CliResult<()> {
196        if self.time_scale <= 0.0 {
197            return Err(CliError::InvalidConfig {
198                message: "Time scale must be positive".into(),
199            });
200        }
201        Ok(())
202    }
203
204    async fn execute(&self, ctx: &mut CliContext) -> CliResult<CommandOutput> {
205        let config = self.load_scenario(ctx).await?;
206        self.run_scenario(ctx, config).await
207    }
208}
209
210/// Scenario configuration loaded from file.
211#[derive(Debug, Clone, serde::Deserialize)]
212pub struct ScenarioConfig {
213    pub name: String,
214    #[serde(default)]
215    pub description: String,
216    #[serde(default)]
217    pub devices: Vec<ScenarioDeviceConfig>,
218    #[serde(default)]
219    pub events: Vec<ScenarioEventConfig>,
220}
221
222#[derive(Debug, Clone, serde::Deserialize)]
223pub struct ScenarioDeviceConfig {
224    pub id: String,
225    pub protocol: String,
226    #[serde(default)]
227    pub name: String,
228    #[serde(default)]
229    pub points: Vec<ScenarioPointConfig>,
230}
231
232#[derive(Debug, Clone, serde::Deserialize)]
233pub struct ScenarioPointConfig {
234    pub id: String,
235    #[serde(default)]
236    pub data_type: String,
237    #[serde(default)]
238    pub initial_value: serde_yaml::Value,
239    #[serde(default)]
240    pub pattern: Option<String>,
241}
242
243#[derive(Debug, Clone, serde::Deserialize)]
244pub struct ScenarioEventConfig {
245    pub name: String,
246    pub trigger: serde_yaml::Value,
247    pub actions: Vec<serde_yaml::Value>,
248}