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