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
173pub struct Scenario {
174 /// Scenario name
175 #[allow(dead_code)]
176 name: String,
177 /// Execution steps
178 steps: Vec<Step>,
179 /// Security policy
180 policy: Policy,
181 /// Deterministic execution flag
182 deterministic: bool,
183 /// Seed for deterministic execution
184 seed: Option<u64>,
185 /// Timeout in milliseconds
186 timeout_ms: Option<u64>,
187 /// Concurrent execution flag
188 concurrent: bool,
189}
190
191impl Scenario {
192 /// Create a new scenario with the given name
193 pub fn new(name: impl Into<String>) -> Self {
194 Self {
195 name: name.into(),
196 steps: Vec::new(),
197 policy: Policy::default(),
198 deterministic: false,
199 seed: None,
200 timeout_ms: None,
201 concurrent: false,
202 }
203 }
204
205 /// Add a step to the scenario
206 pub fn step<I, S>(mut self, label: String, args: I) -> Self
207 where
208 I: IntoIterator<Item = S>,
209 S: AsRef<str>,
210 {
211 let args_vec: Vec<String> = args.into_iter().map(|s| s.as_ref().to_string()).collect();
212 if args_vec.is_empty() {
213 return self;
214 }
215
216 let cmd = Cmd::new(args_vec.first().map_or("", |v| v))
217 .args(&args_vec.get(1..).unwrap_or(&[]).iter().map(|s| s.as_str()).collect::<Vec<_>>());
218 self.steps.push(Step {
219 name: label,
220 cmd,
221 source: StepSource::Inline,
222 });
223 self
224 }
225
226 /// Set the policy for this scenario
227 pub fn with_policy(mut self, policy: Policy) -> Self {
228 self.policy = policy;
229 self
230 }
231
232 /// Enable deterministic execution
233 pub fn deterministic(mut self, seed: Option<u64>) -> Self {
234 self.deterministic = true;
235 self.seed = seed;
236 self
237 }
238
239 /// Set timeout for the scenario
240 pub fn timeout_ms(mut self, timeout: u64) -> Self {
241 self.timeout_ms = Some(timeout);
242 self
243 }
244
245 /// Enable concurrent execution
246 pub fn concurrent(mut self) -> Self {
247 self.concurrent = true;
248 self
249 }
250
251 /// Run the scenario with testcontainers backend
252 pub fn run(self) -> Result<RunResult> {
253 let backend = crate::backend::TestcontainerBackend::new("rust:1-slim")?;
254 self.run_with_backend(backend)
255 }
256
257 /// Run the scenario asynchronously with testcontainers backend
258 ///
259 /// Core Team Compliance:
260 /// - ✅ Async function for I/O operations
261 /// - ✅ Proper error handling with CleanroomError
262 /// - ✅ No unwrap() or expect() calls
263 pub async fn run_async(self) -> Result<RunResult> {
264 let backend = crate::backend::TestcontainerBackend::new("rust:1-slim")?;
265 self.run_with_backend_async(backend).await
266 }
267
268 /// Run the scenario with a specific backend
269 pub fn run_with_backend(
270 self,
271 backend: crate::backend::TestcontainerBackend,
272 ) -> Result<RunResult> {
273 let start_time = std::time::Instant::now();
274 let mut steps = Vec::new();
275 let mut combined_stdout = String::new();
276 let mut combined_stderr = String::new();
277 let mut step_order = Vec::new();
278 let mut last_exit_code = 0;
279
280 for step in self.steps {
281 step_order.push(step.name.clone());
282
283 let step_start = std::time::Instant::now();
284 let result = backend.run_cmd(step.cmd)?;
285 let step_duration = step_start.elapsed().as_millis() as u64;
286
287 let step_result = StepResult {
288 name: step.name,
289 exit_code: result.exit_code,
290 stdout: result.stdout.clone(),
291 stderr: result.stderr.clone(),
292 duration_ms: step_duration,
293 start_ts: step_start.elapsed().as_millis() as u64,
294 success: result.exit_code == 0,
295 source: step.source.to_string(),
296 };
297
298 steps.push(step_result);
299 combined_stdout.push_str(&result.stdout);
300 combined_stderr.push_str(&result.stderr);
301 last_exit_code = result.exit_code;
302
303 // Stop on first failure unless configured otherwise
304 if result.exit_code != 0 && !self.concurrent {
305 break;
306 }
307 }
308
309 let total_duration = start_time.elapsed().as_millis() as u64;
310
311 Ok(RunResult {
312 exit_code: last_exit_code,
313 stdout: combined_stdout,
314 stderr: combined_stderr,
315 duration_ms: total_duration,
316 steps,
317 redacted_env: Vec::new(),
318 backend: backend.name().to_string(),
319 concurrent: self.concurrent,
320 step_order,
321 })
322 }
323
324 /// Run the scenario asynchronously with a specific backend
325 ///
326 /// Core Team Compliance:
327 /// - ✅ Async function for I/O operations
328 /// - ✅ Proper error handling with CleanroomError
329 /// - ✅ No unwrap() or expect() calls
330 /// - ✅ Uses spawn_blocking to avoid nested runtime issues
331 pub async fn run_with_backend_async(
332 self,
333 backend: crate::backend::TestcontainerBackend,
334 ) -> Result<RunResult> {
335 // Use spawn_blocking to run the synchronous backend in a separate thread
336 // This prevents the "Cannot start a runtime from within a runtime" error
337 let result = tokio::task::spawn_blocking(move || {
338 self.run_with_backend(backend)
339 }).await
340 .map_err(|e| crate::error::CleanroomError::internal_error("Task join failed")
341 .with_context("Failed to execute scenario in blocking task")
342 .with_source(e.to_string()))?;
343
344 result
345 }
346}
347
348/// Create a new scenario with the given name
349pub fn scenario(name: impl Into<String>) -> Scenario {
350 Scenario::new(name)
351}