clnrm_core/
scenario.rs

1//! Scenario DSL for multi-step test orchestration
2//!
3//! Provides a fluent API for defining complex test scenarios with
4//! deterministic execution, step aggregation, concurrent execution, and
5//! comprehensive error handling.
6//!
7//! ## Features
8//!
9//! - **Multi-step workflows**: Define complex testing scenarios with multiple steps
10//! - **Error handling**: Automatic rollback and cleanup on step failures
11//! - **Concurrent execution**: Run steps in parallel for improved performance
12//! - **Deterministic execution**: Reproducible results with seeded randomness
13//! - **Comprehensive reporting**: Detailed step-by-step execution results
14//!
15//! ## Usage Examples
16//!
17//! ### Basic Scenario
18//!
19//! ```no_run
20//! use clnrm::{scenario, Policy};
21//!
22//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
23//! let scenario = scenario("integration_test")
24//!     .step("setup".to_string(), ["echo", "setting up test environment"])
25//!     .step("execute".to_string(), ["echo", "running main test logic"])
26//!     .step("verify".to_string(), ["echo", "verifying test results"]);
27//!
28//! let result = scenario.run()?;
29//! println!("Scenario completed in {}ms", result.duration_ms);
30//! # Ok(())
31//! # }
32//! ```
33//!
34//! ### Scenario with Policy
35//!
36//! ```no_run
37//! use clnrm::{scenario, Policy, SecurityLevel};
38//!
39//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
40//! let policy = Policy::with_security_level(SecurityLevel::High);
41//! let scenario = scenario("secure_test")
42//!     .with_policy(policy)
43//!     .step("secure_setup".to_string(), ["echo", "secure environment setup"])
44//!     .step("execute".to_string(), ["echo", "running secure test"]);
45//!
46//! let result = scenario.run()?;
47//! assert!(result.exit_code == 0);
48//! # Ok(())
49//! # }
50//! ```
51//!
52//! ### Concurrent Scenario
53//!
54//! ```no_run
55//! use clnrm::scenario;
56//!
57//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
58//! let scenario = scenario("concurrent_test")
59//!     .concurrent()  // Enable concurrent execution
60//!     .step("task1".to_string(), ["echo", "running task 1"])
61//!     .step("task2".to_string(), ["echo", "running task 2"])
62//!     .step("task3".to_string(), ["echo", "running task 3"]);
63//!
64//! let result = scenario.run()?;
65//! println!("Concurrent execution completed in {}ms", result.duration_ms);
66//! # Ok(())
67//! # }
68//! ```
69
70#![allow(clippy::get_first)]
71
72use crate::backend::{Backend, Cmd};
73use crate::error::Result;
74use crate::policy::Policy;
75use serde::{Deserialize, Serialize};
76
77/// Scenario execution result
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct RunResult {
80    /// Exit code of the last step
81    pub exit_code: i32,
82    /// Combined standard output from all steps
83    pub stdout: String,
84    /// Combined standard error from all steps
85    pub stderr: String,
86    /// Total execution duration in milliseconds
87    pub duration_ms: u64,
88    /// Individual step results
89    pub steps: Vec<StepResult>,
90    /// Environment variables that were redacted in forensics
91    pub redacted_env: Vec<String>,
92    /// Backend used for execution
93    pub backend: String,
94    /// Whether execution was concurrent
95    pub concurrent: bool,
96    /// Step execution order (for deterministic ordering)
97    pub step_order: Vec<String>,
98}
99
100/// Individual step execution result
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct StepResult {
103    /// Step name/label
104    pub name: String,
105    /// Exit code
106    pub exit_code: i32,
107    /// Standard output
108    pub stdout: String,
109    /// Standard error
110    pub stderr: String,
111    /// Execution duration in milliseconds
112    pub duration_ms: u64,
113    /// Step start timestamp (for ordering)
114    pub start_ts: u64,
115    /// Whether step succeeded
116    pub success: bool,
117    /// Source of the step
118    pub source: String,
119}
120
121/// A single execution step in a scenario
122#[derive(Debug, Clone)]
123struct Step {
124    /// Step name/label
125    name: String,
126    /// Command binary
127    cmd: Cmd,
128    /// Source of the step (file, inline, etc.)
129    source: StepSource,
130}
131
132/// Step source information
133#[derive(Debug, Clone)]
134pub enum StepSource {
135    /// Step defined inline in code
136    Inline,
137    /// Step loaded from file
138    /// File-based step
139    File {
140        /// Path to the file
141        path: String,
142    },
143    /// Step from template
144    Template {
145        /// Template content
146        template: String,
147    },
148    /// Step from external source
149    External {
150        /// External source identifier
151        source: String,
152    },
153}
154
155impl Default for StepSource {
156    fn default() -> Self {
157        Self::Inline
158    }
159}
160
161impl std::fmt::Display for StepSource {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        match self {
164            StepSource::Inline => write!(f, "inline"),
165            StepSource::File { path } => write!(f, "file:{}", path),
166            StepSource::Template { template } => write!(f, "template:{}", template),
167            StepSource::External { source } => write!(f, "external:{}", source),
168        }
169    }
170}
171
172/// Scenario builder for multi-step test orchestration
173#[derive(Debug)]
174pub struct Scenario {
175    /// Scenario name
176    #[allow(dead_code)]
177    name: String,
178    /// Execution steps
179    steps: Vec<Step>,
180    /// Security policy
181    policy: Policy,
182    /// Deterministic execution flag
183    deterministic: bool,
184    /// Seed for deterministic execution
185    seed: Option<u64>,
186    /// Timeout in milliseconds
187    timeout_ms: Option<u64>,
188    /// Concurrent execution flag
189    concurrent: bool,
190}
191
192impl Scenario {
193    /// Create a new scenario with the given name
194    pub fn new(name: impl Into<String>) -> Self {
195        Self {
196            name: name.into(),
197            steps: Vec::new(),
198            policy: Policy::default(),
199            deterministic: false,
200            seed: None,
201            timeout_ms: None,
202            concurrent: false,
203        }
204    }
205
206    /// Add a step to the scenario
207    pub fn step<I, S>(mut self, label: String, args: I) -> Self
208    where
209        I: IntoIterator<Item = S>,
210        S: AsRef<str>,
211    {
212        let args_vec: Vec<String> = args.into_iter().map(|s| s.as_ref().to_string()).collect();
213        if args_vec.is_empty() {
214            return self;
215        }
216
217        let cmd = Cmd::new(args_vec.first().map_or("", |v| v)).args(
218            &args_vec
219                .get(1..)
220                .unwrap_or(&[])
221                .iter()
222                .map(|s| s.as_str())
223                .collect::<Vec<_>>(),
224        );
225        self.steps.push(Step {
226            name: label,
227            cmd,
228            source: StepSource::Inline,
229        });
230        self
231    }
232
233    /// Set the policy for this scenario
234    pub fn with_policy(mut self, policy: Policy) -> Self {
235        self.policy = policy;
236        self
237    }
238
239    /// Enable deterministic execution
240    pub fn deterministic(mut self, seed: Option<u64>) -> Self {
241        self.deterministic = true;
242        self.seed = seed;
243        self
244    }
245
246    /// Set timeout for the scenario
247    pub fn timeout_ms(mut self, timeout: u64) -> Self {
248        self.timeout_ms = Some(timeout);
249        self
250    }
251
252    /// Enable concurrent execution
253    pub fn concurrent(mut self) -> Self {
254        self.concurrent = true;
255        self
256    }
257
258    /// Run the scenario with testcontainers backend
259    pub fn run(self) -> Result<RunResult> {
260        let backend = crate::backend::TestcontainerBackend::new("rust:1-slim")?;
261        self.run_with_backend(backend)
262    }
263
264    /// Run the scenario asynchronously with testcontainers backend
265    ///
266    /// Core Team Compliance:
267    /// - ✅ Async function for I/O operations
268    /// - ✅ Proper error handling with CleanroomError
269    /// - ✅ No unwrap() or expect() calls
270    pub async fn run_async(self) -> Result<RunResult> {
271        let backend = crate::backend::TestcontainerBackend::new("rust:1-slim")?;
272        self.run_with_backend_async(backend).await
273    }
274
275    /// Run the scenario with a specific backend
276    pub fn run_with_backend(
277        self,
278        backend: crate::backend::TestcontainerBackend,
279    ) -> Result<RunResult> {
280        let start_time = std::time::Instant::now();
281        let mut steps = Vec::new();
282        let mut combined_stdout = String::new();
283        let mut combined_stderr = String::new();
284        let mut step_order = Vec::new();
285        let mut last_exit_code = 0;
286
287        for step in self.steps {
288            step_order.push(step.name.clone());
289
290            let step_start = std::time::Instant::now();
291            let result = backend.run_cmd(step.cmd)?;
292            let step_duration = step_start.elapsed().as_millis() as u64;
293
294            let step_result = StepResult {
295                name: step.name,
296                exit_code: result.exit_code,
297                stdout: result.stdout.clone(),
298                stderr: result.stderr.clone(),
299                duration_ms: step_duration,
300                start_ts: step_start.elapsed().as_millis() as u64,
301                success: result.exit_code == 0,
302                source: step.source.to_string(),
303            };
304
305            steps.push(step_result);
306            combined_stdout.push_str(&result.stdout);
307            combined_stderr.push_str(&result.stderr);
308            last_exit_code = result.exit_code;
309
310            // Stop on first failure unless configured otherwise
311            if result.exit_code != 0 && !self.concurrent {
312                break;
313            }
314        }
315
316        let total_duration = start_time.elapsed().as_millis() as u64;
317
318        Ok(RunResult {
319            exit_code: last_exit_code,
320            stdout: combined_stdout,
321            stderr: combined_stderr,
322            duration_ms: total_duration,
323            steps,
324            redacted_env: Vec::new(),
325            backend: backend.name().to_string(),
326            concurrent: self.concurrent,
327            step_order,
328        })
329    }
330
331    /// Run the scenario asynchronously with a specific backend
332    ///
333    /// Core Team Compliance:
334    /// - ✅ Async function for I/O operations
335    /// - ✅ Proper error handling with CleanroomError
336    /// - ✅ No unwrap() or expect() calls
337    /// - ✅ Uses spawn_blocking to avoid nested runtime issues
338    pub async fn run_with_backend_async(
339        self,
340        backend: crate::backend::TestcontainerBackend,
341    ) -> Result<RunResult> {
342        // Use spawn_blocking to run the synchronous backend in a separate thread
343        // This prevents the "Cannot start a runtime from within a runtime" error
344        let result = tokio::task::spawn_blocking(move || self.run_with_backend(backend))
345            .await
346            .map_err(|e| {
347                crate::error::CleanroomError::internal_error("Task join failed")
348                    .with_context("Failed to execute scenario in blocking task")
349                    .with_source(e.to_string())
350            })?;
351
352        result
353    }
354}
355
356/// Create a new scenario with the given name
357pub fn scenario(name: impl Into<String>) -> Scenario {
358    Scenario::new(name)
359}