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}