mina_node_testing/scenario/
mod.rs

1//! # Scenario Management
2//!
3//! This module provides functionality for managing test scenarios, including
4//! loading, saving, and executing deterministic test sequences.
5//!
6//! ## How Scenarios Work
7//!
8//! ### Storage Format
9//! Scenarios are stored as JSON files in the `res/scenarios/` directory relative
10//! to the testing crate. Each scenario file contains:
11//! - **ScenarioInfo**: Metadata (ID, description, parent relationships, node configs)
12//! - **ScenarioSteps**: Ordered sequence of test actions to execute
13//!
14//! ### Load Process
15//! 1. **File Location**: `load()` reads from `{CARGO_MANIFEST_DIR}/res/scenarios/{id}.json`
16//! 2. **JSON Parsing**: Deserializes the file into a `Scenario` struct
17//! 3. **Error Handling**: Returns `anyhow::Error` if file doesn't exist or is malformed
18//!
19//! ### Save Process
20//! 1. **Atomic Write**: Uses temporary file + rename for atomic operations
21//! 2. **Directory Creation**: Automatically creates `res/scenarios/` if needed
22//! 3. **JSON Format**: Pretty-prints JSON for human readability
23//! 4. **Temporary Files**: `.tmp.{scenario_id}.json` during write, renamed on success
24//!
25//! ### Scenario Inheritance
26//! Scenarios can have parent-child relationships where child scenarios inherit
27//! setup steps from their parents, enabling composition and reuse.
28//!
29//! For usage examples, see the [testing documentation](https://o1-labs.github.io/mina-rust/developers/testing/scenario-tests).
30
31mod id;
32pub use id::ScenarioId;
33
34mod step;
35pub use step::{ListenerNode, ScenarioStep};
36
37mod event_details;
38pub use event_details::event_details;
39
40use anyhow::Context;
41use mina_core::log::{debug, info, system_time};
42use serde::{Deserialize, Serialize};
43
44use crate::node::NodeTestingConfig;
45
46#[derive(Serialize, Deserialize, Debug, Clone)]
47pub struct Scenario {
48    pub info: ScenarioInfo,
49    pub steps: Vec<ScenarioStep>,
50}
51
52#[derive(Serialize, Deserialize, Debug, Clone)]
53pub struct ScenarioInfo {
54    pub id: ScenarioId,
55    pub description: String,
56    pub parent_id: Option<ScenarioId>,
57    /// Nodes created in this scenario. Doesn't include ones defined in parent.
58    pub nodes: Vec<NodeTestingConfig>,
59}
60
61impl Scenario {
62    pub const PATH: &'static str = concat!(env!("CARGO_MANIFEST_DIR"), "/res/scenarios");
63
64    pub fn new(id: ScenarioId, parent_id: Option<ScenarioId>) -> Self {
65        Self {
66            info: ScenarioInfo {
67                id,
68                description: String::new(),
69                parent_id,
70                nodes: vec![],
71            },
72            steps: vec![],
73        }
74    }
75
76    pub fn set_description(&mut self, description: String) {
77        self.info.description = description;
78    }
79
80    pub fn add_node(&mut self, config: NodeTestingConfig) {
81        self.info.nodes.push(config);
82    }
83
84    pub fn add_step(&mut self, step: ScenarioStep) -> Result<(), anyhow::Error> {
85        self.steps.push(step);
86        Ok(())
87    }
88
89    fn tmp_file_path(&self) -> String {
90        format!("{}/.tmp.{}.json", Self::PATH, self.info.id)
91    }
92
93    pub fn file_path(&self) -> String {
94        Self::file_path_by_id(&self.info.id)
95    }
96
97    fn file_path_by_id(id: &ScenarioId) -> String {
98        format!("{}/{}.json", Self::PATH, id)
99    }
100
101    pub fn exists(id: &ScenarioId) -> bool {
102        std::path::Path::new(&Self::file_path_by_id(id)).exists()
103    }
104
105    pub async fn list() -> Result<Vec<ScenarioInfo>, anyhow::Error> {
106        let mut files = tokio::fs::read_dir(Self::PATH).await.with_context(|| {
107            format!(
108                "Failed to read scenarios directory '{}'. Ensure the directory \
109                exists or create it with: mkdir -p {}",
110                Self::PATH,
111                Self::PATH
112            )
113        })?;
114        let mut list = vec![];
115
116        while let Some(file) = files.next_entry().await? {
117            let file_path = file.path();
118            let encoded = tokio::fs::read(&file_path).await.with_context(|| {
119                format!("Failed to read scenario file '{}'", file_path.display())
120            })?;
121            // TODO(binier): maybe somehow only parse info part of json?
122            let full: Self = serde_json::from_slice(&encoded).with_context(|| {
123                format!(
124                    "Failed to parse scenario file '{}' as valid JSON",
125                    file_path.display()
126                )
127            })?;
128            list.push(full.info);
129        }
130
131        Ok(list)
132    }
133
134    /// Load a scenario from disk by ID.
135    ///
136    /// This method reads the scenario file from `res/scenarios/{id}.json`,
137    /// deserializes it from JSON, and returns the complete scenario including
138    /// both metadata and steps.
139    ///
140    /// # Arguments
141    /// * `id` - The scenario identifier used to construct the file path
142    ///
143    /// # Returns
144    /// * `Ok(Scenario)` - Successfully loaded scenario
145    /// * `Err(anyhow::Error)` - File not found, invalid JSON, or I/O error
146    pub async fn load(id: &ScenarioId) -> Result<Self, anyhow::Error> {
147        let path = Self::file_path_by_id(id);
148        debug!(system_time(); "Loading scenario '{}' from file '{}'", id, path);
149        let encoded = tokio::fs::read(&path).await.with_context(|| {
150            format!(
151                "Failed to read scenario file '{}'. Ensure the scenario exists. \
152                If using scenarios-run, the scenario must be generated first using \
153                scenarios-generate, or check if the required feature flags (like \
154                'p2p-webrtc') are enabled",
155                path
156            )
157        })?;
158        let scenario = serde_json::from_slice(&encoded)
159            .with_context(|| format!("Failed to parse scenario file '{}' as valid JSON", path))?;
160        info!(system_time(); "Successfully loaded scenario '{}'", id);
161        Ok(scenario)
162    }
163
164    /// Reload this scenario from disk, discarding any in-memory changes.
165    pub async fn reload(&mut self) -> Result<(), anyhow::Error> {
166        *self = Self::load(&self.info.id).await?;
167        Ok(())
168    }
169
170    /// Save the scenario to disk using atomic write operations.
171    ///
172    /// This method implements atomic writes by:
173    /// 1. Creating the scenarios directory if it doesn't exist
174    /// 2. Writing to a temporary file (`.tmp.{id}.json`)
175    /// 3. Pretty-printing JSON for human readability
176    /// 4. Atomically renaming the temp file to the final name
177    ///
178    /// This ensures the scenario file is never in a partially-written state,
179    /// preventing corruption during concurrent access or system crashes.
180    ///
181    /// # File Location
182    /// Saves to: `{CARGO_MANIFEST_DIR}/res/scenarios/{id}.json`
183    ///
184    /// # Errors
185    /// Returns error if:
186    /// - Cannot create the scenarios directory
187    /// - Cannot serialize scenario to JSON
188    /// - File I/O operations fail
189    /// - Atomic rename fails
190    pub async fn save(&self) -> Result<(), anyhow::Error> {
191        let tmp_file = self.tmp_file_path();
192        let final_file = self.file_path();
193
194        debug!(system_time(); "Saving scenario '{}' to file '{}'", self.info.id, final_file);
195
196        let encoded = serde_json::to_vec_pretty(self)
197            .with_context(|| format!("Failed to serialize scenario '{}' to JSON", self.info.id))?;
198
199        tokio::fs::create_dir_all(Self::PATH)
200            .await
201            .with_context(|| format!("Failed to create scenarios directory '{}'", Self::PATH))?;
202
203        tokio::fs::write(&tmp_file, encoded)
204            .await
205            .with_context(|| format!("Failed to write temporary scenario file '{}'", tmp_file))?;
206
207        tokio::fs::rename(&tmp_file, &final_file)
208            .await
209            .with_context(|| {
210                format!(
211                    "Failed to rename temporary file '{}' to final scenario file '{}'",
212                    tmp_file, final_file
213                )
214            })?;
215
216        info!(system_time(); "Successfully saved scenario '{}'", self.info.id);
217        Ok(())
218    }
219
220    /// Synchronous version of `save()` for use in non-async contexts.
221    ///
222    /// Implements the same atomic write pattern as `save()` but uses
223    /// blocking I/O operations instead of async.
224    pub fn save_sync(&self) -> Result<(), anyhow::Error> {
225        let tmp_file = self.tmp_file_path();
226        let final_file = self.file_path();
227
228        debug!(system_time(); "Saving scenario '{}' to file '{}'", self.info.id, final_file);
229
230        let encoded = serde_json::to_vec_pretty(self)
231            .with_context(|| format!("Failed to serialize scenario '{}' to JSON", self.info.id))?;
232
233        std::fs::create_dir_all(Self::PATH)
234            .with_context(|| format!("Failed to create scenarios directory '{}'", Self::PATH))?;
235
236        std::fs::write(&tmp_file, encoded)
237            .with_context(|| format!("Failed to write temporary scenario file '{}'", tmp_file))?;
238
239        std::fs::rename(&tmp_file, &final_file).with_context(|| {
240            format!(
241                "Failed to rename temporary file '{}' to final scenario file '{}'",
242                tmp_file, final_file
243            )
244        })?;
245
246        info!(system_time(); "Successfully saved scenario '{}'", self.info.id);
247        Ok(())
248    }
249}