mx_message/
scenario_config.rs

1// Plasmatic MX Message Parsing Library
2// https://github.com/GoPlasmatic/MXMessage
3//
4// Copyright (c) 2025 Plasmatic
5// Licensed under the Apache License, Version 2.0 (the "License");
6// you may not use this file except in compliance with the License.
7// You may obtain a copy of the License at
8//
9//     http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing, software
12// distributed under the License is distributed on an "AS IS" BASIS,
13// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14// See the License for the specific language governing permissions and
15// limitations under the License.
16//
17// You may obtain a copy of this library at
18// https://github.com/GoPlasmatic/MXMessage
19
20use crate::error::ValidationError;
21use serde_json::Value;
22use std::env;
23use std::fs;
24use std::path::{Path, PathBuf};
25
26type Result<T> = std::result::Result<T, ValidationError>;
27
28/// Configuration for scenario file paths
29#[derive(Debug, Clone)]
30pub struct ScenarioConfig {
31    /// Base paths to search for scenario files
32    pub base_paths: Vec<PathBuf>,
33}
34
35impl Default for ScenarioConfig {
36    fn default() -> Self {
37        // Check environment variable first
38        if let Ok(env_paths) = env::var("MX_SCENARIO_PATH") {
39            let paths = parse_env_paths(&env_paths);
40            if !paths.is_empty() {
41                return Self { base_paths: paths };
42            }
43        }
44
45        // Default paths
46        Self {
47            base_paths: vec![
48                PathBuf::from("test_scenarios"),
49                PathBuf::from("../test_scenarios"),
50            ],
51        }
52    }
53}
54
55impl ScenarioConfig {
56    /// Create a new configuration with default paths
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Create a configuration with specific paths
62    pub fn with_paths(paths: Vec<PathBuf>) -> Self {
63        Self { base_paths: paths }
64    }
65
66    /// Add a path to the configuration
67    pub fn add_path(mut self, path: PathBuf) -> Self {
68        self.base_paths.push(path);
69        self
70    }
71
72    /// Clear all paths and set new ones
73    pub fn set_paths(mut self, paths: Vec<PathBuf>) -> Self {
74        self.base_paths = paths;
75        self
76    }
77}
78
79/// Parse environment variable paths
80fn parse_env_paths(env_value: &str) -> Vec<PathBuf> {
81    // Use OS-specific path separator
82    #[cfg(windows)]
83    let separator = ';';
84    #[cfg(not(windows))]
85    let separator = ':';
86
87    env_value
88        .split(separator)
89        .filter(|s| !s.is_empty())
90        .map(PathBuf::from)
91        .collect()
92}
93
94/// Load a scenario configuration from a JSON file
95pub fn load_scenario_json<P: AsRef<Path>>(path: P) -> Result<Value> {
96    let content = fs::read_to_string(path)
97        .map_err(|e| ValidationError::new(9998, format!("Failed to read scenario file: {e}")))?;
98
99    serde_json::from_str(&content)
100        .map_err(|e| ValidationError::new(9998, format!("Failed to parse scenario JSON: {e}")))
101}
102
103/// Find and load a scenario for a specific message type with custom configuration
104///
105/// Looks for configuration files in the following order:
106/// 1. {base_path}/{message_type}/standard.json
107/// 2. {base_path}/{message_type}/default.json
108/// 3. First .json file in {base_path}/{message_type}/
109pub fn find_scenario_for_message_type_with_config(
110    message_type: &str,
111    config: &ScenarioConfig,
112) -> Result<Value> {
113    for base_path in &config.base_paths {
114        let mt_dir = base_path.join(message_type.to_lowercase());
115
116        // Check if directory exists
117        if !mt_dir.exists() {
118            continue;
119        }
120
121        // Try standard.json first
122        let standard_path = mt_dir.join("standard.json");
123        if standard_path.exists() {
124            return load_scenario_json(standard_path);
125        }
126
127        // Try default.json
128        let default_path = mt_dir.join("default.json");
129        if default_path.exists() {
130            return load_scenario_json(default_path);
131        }
132
133        // Find the first .json file
134        if let Ok(entries) = fs::read_dir(&mt_dir) {
135            for entry in entries.flatten() {
136                let path = entry.path();
137
138                if path.extension().and_then(|s| s.to_str()) == Some("json") {
139                    return load_scenario_json(path);
140                }
141            }
142        }
143    }
144
145    let searched_paths: Vec<String> = config
146        .base_paths
147        .iter()
148        .map(|p| format!("{}/{}", p.display(), message_type.to_lowercase()))
149        .collect();
150
151    Err(ValidationError::new(
152        9998,
153        format!(
154            "No test scenarios found for message type: {}. Searched in: {}",
155            message_type.to_lowercase(),
156            searched_paths.join(", ")
157        ),
158    ))
159}
160
161/// Find and load a scenario for a specific message type (backward compatibility)
162///
163/// Looks for configuration files in the following order:
164/// 1. test_scenarios/{message_type}/standard.json
165/// 2. test_scenarios/{message_type}/default.json
166/// 3. First .json file in test_scenarios/{message_type}/
167pub fn find_scenario_for_message_type(message_type: &str) -> Result<Value> {
168    find_scenario_for_message_type_with_config(message_type, &ScenarioConfig::default())
169}
170
171/// Find and load a specific scenario by name with custom configuration
172pub fn find_scenario_by_name_with_config(
173    message_type: &str,
174    scenario_name: &str,
175    config: &ScenarioConfig,
176) -> Result<Value> {
177    for base_path in &config.base_paths {
178        let scenario_path = base_path
179            .join(message_type.to_lowercase())
180            .join(format!("{scenario_name}.json"));
181
182        if scenario_path.exists() {
183            return load_scenario_json(scenario_path);
184        }
185    }
186
187    let tried_paths: Vec<String> = config
188        .base_paths
189        .iter()
190        .map(|p| {
191            format!(
192                "{}/{}/{}.json",
193                p.display(),
194                message_type.to_lowercase(),
195                scenario_name
196            )
197        })
198        .collect();
199
200    Err(ValidationError::new(
201        9998,
202        format!(
203            "Scenario '{}' not found for {}. Tried paths: {}",
204            scenario_name,
205            message_type,
206            tried_paths.join(", ")
207        ),
208    ))
209}
210
211/// Find and load a specific scenario by name (backward compatibility)
212pub fn find_scenario_by_name(message_type: &str, scenario_name: &str) -> Result<Value> {
213    find_scenario_by_name_with_config(message_type, scenario_name, &ScenarioConfig::default())
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use std::fs;
220    use tempfile::TempDir;
221
222    #[test]
223    fn test_scenario_config_default() {
224        let config = ScenarioConfig::default();
225        assert_eq!(config.base_paths.len(), 2);
226        assert_eq!(config.base_paths[0], PathBuf::from("test_scenarios"));
227        assert_eq!(config.base_paths[1], PathBuf::from("../test_scenarios"));
228    }
229
230    #[test]
231    fn test_scenario_config_with_paths() {
232        let paths = vec![
233            PathBuf::from("/custom/path1"),
234            PathBuf::from("/custom/path2"),
235        ];
236        let config = ScenarioConfig::with_paths(paths.clone());
237        assert_eq!(config.base_paths, paths);
238    }
239
240    #[test]
241    fn test_scenario_config_add_path() {
242        let config = ScenarioConfig::new()
243            .add_path(PathBuf::from("/path1"))
244            .add_path(PathBuf::from("/path2"));
245        assert!(config.base_paths.contains(&PathBuf::from("/path1")));
246        assert!(config.base_paths.contains(&PathBuf::from("/path2")));
247    }
248
249    #[test]
250    fn test_scenario_config_set_paths() {
251        let config = ScenarioConfig::new()
252            .add_path(PathBuf::from("/old"))
253            .set_paths(vec![PathBuf::from("/new1"), PathBuf::from("/new2")]);
254        assert_eq!(config.base_paths.len(), 2);
255        assert!(!config.base_paths.contains(&PathBuf::from("/old")));
256        assert!(config.base_paths.contains(&PathBuf::from("/new1")));
257        assert!(config.base_paths.contains(&PathBuf::from("/new2")));
258    }
259
260    #[test]
261    fn test_parse_env_paths_unix() {
262        #[cfg(not(windows))]
263        {
264            let paths = parse_env_paths("/path1:/path2:/path3");
265            assert_eq!(paths.len(), 3);
266            assert_eq!(paths[0], PathBuf::from("/path1"));
267            assert_eq!(paths[1], PathBuf::from("/path2"));
268            assert_eq!(paths[2], PathBuf::from("/path3"));
269        }
270    }
271
272    #[test]
273    fn test_parse_env_paths_empty_segments() {
274        #[cfg(not(windows))]
275        {
276            let paths = parse_env_paths("/path1::/path2:");
277            assert_eq!(paths.len(), 2);
278            assert_eq!(paths[0], PathBuf::from("/path1"));
279            assert_eq!(paths[1], PathBuf::from("/path2"));
280        }
281    }
282
283    #[test]
284    fn test_find_scenario_with_custom_config() {
285        // Create a temporary directory for test scenarios
286        let temp_dir = TempDir::new().unwrap();
287        let pacs008_dir = temp_dir.path().join("pacs008");
288        fs::create_dir(&pacs008_dir).unwrap();
289
290        // Create a test scenario file
291        let scenario_json = r#"{
292            "variables": {
293                "msg_id": {"fake": ["alphanumeric", 16]}
294            },
295            "schema": {
296                "GrpHdr": {
297                    "MsgId": {"var": "msg_id"}
298                }
299            }
300        }"#;
301
302        let scenario_path = pacs008_dir.join("test_scenario.json");
303        fs::write(&scenario_path, scenario_json).unwrap();
304
305        // Test with custom config
306        let config = ScenarioConfig::with_paths(vec![temp_dir.path().to_path_buf()]);
307
308        // This should find our test scenario
309        let result = find_scenario_for_message_type_with_config("pacs008", &config);
310        assert!(result.is_ok());
311
312        // Test finding by name
313        let result = find_scenario_by_name_with_config("pacs008", "test_scenario", &config);
314        assert!(result.is_ok());
315    }
316
317    #[test]
318    fn test_scenario_not_found_error() {
319        let config = ScenarioConfig::with_paths(vec![PathBuf::from("/nonexistent/path")]);
320
321        let result = find_scenario_for_message_type_with_config("pacs999", &config);
322        assert!(result.is_err());
323
324        if let Err(e) = result {
325            let error_msg = format!("{e:?}");
326            assert!(error_msg.contains("No test scenarios found"));
327            assert!(error_msg.contains("pacs999"));
328        }
329    }
330}