Skip to main content

mockforge_core/
fixture_store.rs

1//! Generic fixture loading utilities for protocol crates.
2//!
3//! Provides a shared `load_fixtures_from_dir` function that replaces the duplicated
4//! YAML/JSON fixture loading logic across Kafka, MQTT, AMQP, SMTP, FTP, and TCP crates.
5
6use crate::Result;
7use serde::de::DeserializeOwned;
8use std::path::Path;
9
10/// How to handle parse errors when loading fixture files
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum FixtureLoadErrorMode {
13    /// Stop and return the first error encountered
14    FailFast,
15    /// Log a warning and continue loading remaining files
16    WarnAndContinue,
17}
18
19/// Whether each file contains a single fixture or an array of fixtures
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum FixtureFileGranularity {
22    /// Each file deserializes into exactly one `T`
23    Single,
24    /// Each file deserializes into a `Vec<T>`
25    Array,
26}
27
28/// Supported file formats for fixture loading
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum FixtureFileFormat {
31    /// YAML (.yaml, .yml)
32    Yaml,
33    /// JSON (.json)
34    Json,
35}
36
37/// Configuration for loading fixtures from a directory
38#[derive(Debug, Clone)]
39pub struct FixtureLoadOptions {
40    /// Which file formats to accept
41    pub formats: Vec<FixtureFileFormat>,
42    /// How to handle individual file parse errors
43    pub error_mode: FixtureLoadErrorMode,
44    /// Whether each file holds one fixture or an array
45    pub granularity: FixtureFileGranularity,
46}
47
48impl FixtureLoadOptions {
49    /// YAML-only, single fixture per file, warn and continue
50    pub fn yaml_single() -> Self {
51        Self {
52            formats: vec![FixtureFileFormat::Yaml],
53            error_mode: FixtureLoadErrorMode::WarnAndContinue,
54            granularity: FixtureFileGranularity::Single,
55        }
56    }
57
58    /// YAML + JSON, single fixture per file, warn and continue
59    pub fn yaml_json_single() -> Self {
60        Self {
61            formats: vec![FixtureFileFormat::Yaml, FixtureFileFormat::Json],
62            error_mode: FixtureLoadErrorMode::WarnAndContinue,
63            granularity: FixtureFileGranularity::Single,
64        }
65    }
66
67    /// YAML-only, array of fixtures per file, fail fast
68    pub fn yaml_array_strict() -> Self {
69        Self {
70            formats: vec![FixtureFileFormat::Yaml],
71            error_mode: FixtureLoadErrorMode::FailFast,
72            granularity: FixtureFileGranularity::Array,
73        }
74    }
75}
76
77/// Load fixtures of type `T` from all matching files in a directory.
78///
79/// Walks the directory (non-recursively), filters by the configured file extensions,
80/// and deserializes each file according to the `FixtureLoadOptions`.
81///
82/// # Returns
83/// A `Vec<T>` of all successfully loaded fixtures. Files that fail to parse are
84/// either skipped (with a warning) or cause an immediate error, depending on
85/// `options.error_mode`.
86///
87/// # Example
88/// ```ignore
89/// use mockforge_core::fixture_store::{load_fixtures_from_dir, FixtureLoadOptions};
90///
91/// let fixtures: Vec<MyFixture> = load_fixtures_from_dir(
92///     Path::new("./fixtures"),
93///     &FixtureLoadOptions::yaml_json_single(),
94/// )?;
95/// ```
96pub fn load_fixtures_from_dir<T: DeserializeOwned>(
97    dir: &Path,
98    options: &FixtureLoadOptions,
99) -> Result<Vec<T>> {
100    if !dir.exists() {
101        tracing::debug!("Fixture directory does not exist: {}", dir.display());
102        return Ok(Vec::new());
103    }
104
105    let entries = std::fs::read_dir(dir).map_err(|e| {
106        crate::Error::io_with_context(
107            format!("reading fixture directory {}", dir.display()),
108            e.to_string(),
109        )
110    })?;
111
112    let mut fixtures = Vec::new();
113
114    for entry in entries {
115        let entry = match entry {
116            Ok(e) => e,
117            Err(e) => {
118                tracing::warn!("Failed to read directory entry: {}", e);
119                continue;
120            }
121        };
122
123        let path = entry.path();
124        if !path.is_file() {
125            continue;
126        }
127
128        let format = match path.extension().and_then(|e| e.to_str()) {
129            Some("yaml" | "yml") if options.formats.contains(&FixtureFileFormat::Yaml) => {
130                FixtureFileFormat::Yaml
131            }
132            Some("json") if options.formats.contains(&FixtureFileFormat::Json) => {
133                FixtureFileFormat::Json
134            }
135            _ => continue,
136        };
137
138        match load_fixture_file::<T>(&path, format, options.granularity) {
139            Ok(loaded) => fixtures.extend(loaded),
140            Err(e) => match options.error_mode {
141                FixtureLoadErrorMode::FailFast => return Err(e),
142                FixtureLoadErrorMode::WarnAndContinue => {
143                    tracing::warn!("Failed to load fixture {}: {}", path.display(), e);
144                }
145            },
146        }
147    }
148
149    tracing::debug!("Loaded {} fixtures from {}", fixtures.len(), dir.display());
150    Ok(fixtures)
151}
152
153fn load_fixture_file<T: DeserializeOwned>(
154    path: &Path,
155    format: FixtureFileFormat,
156    granularity: FixtureFileGranularity,
157) -> Result<Vec<T>> {
158    let content = std::fs::read_to_string(path).map_err(|e| {
159        crate::Error::io_with_context(format!("reading fixture {}", path.display()), e.to_string())
160    })?;
161
162    match (format, granularity) {
163        (FixtureFileFormat::Yaml, FixtureFileGranularity::Single) => {
164            let fixture: T = serde_yaml::from_str(&content)?;
165            Ok(vec![fixture])
166        }
167        (FixtureFileFormat::Yaml, FixtureFileGranularity::Array) => {
168            let fixtures: Vec<T> = serde_yaml::from_str(&content)?;
169            Ok(fixtures)
170        }
171        (FixtureFileFormat::Json, FixtureFileGranularity::Single) => {
172            let fixture: T = serde_json::from_str(&content)?;
173            Ok(vec![fixture])
174        }
175        (FixtureFileFormat::Json, FixtureFileGranularity::Array) => {
176            let fixtures: Vec<T> = serde_json::from_str(&content)?;
177            Ok(fixtures)
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use serde::Deserialize;
186    use std::fs;
187
188    #[derive(Debug, Deserialize, PartialEq)]
189    struct TestFixture {
190        name: String,
191        value: i32,
192    }
193
194    #[test]
195    fn test_load_yaml_single() {
196        let dir = tempfile::tempdir().unwrap();
197        fs::write(dir.path().join("test.yaml"), "name: hello\nvalue: 42\n").unwrap();
198
199        let fixtures: Vec<TestFixture> =
200            load_fixtures_from_dir(dir.path(), &FixtureLoadOptions::yaml_single()).unwrap();
201
202        assert_eq!(fixtures.len(), 1);
203        assert_eq!(fixtures[0].name, "hello");
204        assert_eq!(fixtures[0].value, 42);
205    }
206
207    #[test]
208    fn test_load_json_single() {
209        let dir = tempfile::tempdir().unwrap();
210        fs::write(dir.path().join("test.json"), r#"{"name": "world", "value": 99}"#).unwrap();
211
212        let fixtures: Vec<TestFixture> =
213            load_fixtures_from_dir(dir.path(), &FixtureLoadOptions::yaml_json_single()).unwrap();
214
215        assert_eq!(fixtures.len(), 1);
216        assert_eq!(fixtures[0].name, "world");
217    }
218
219    #[test]
220    fn test_load_yaml_array() {
221        let dir = tempfile::tempdir().unwrap();
222        fs::write(dir.path().join("items.yaml"), "- name: a\n  value: 1\n- name: b\n  value: 2\n")
223            .unwrap();
224
225        let fixtures: Vec<TestFixture> =
226            load_fixtures_from_dir(dir.path(), &FixtureLoadOptions::yaml_array_strict()).unwrap();
227
228        assert_eq!(fixtures.len(), 2);
229    }
230
231    #[test]
232    fn test_nonexistent_dir_returns_empty() {
233        let fixtures: Vec<TestFixture> = load_fixtures_from_dir(
234            Path::new("/nonexistent/path"),
235            &FixtureLoadOptions::yaml_single(),
236        )
237        .unwrap();
238
239        assert!(fixtures.is_empty());
240    }
241
242    #[test]
243    fn test_warn_and_continue_skips_bad_files() {
244        let dir = tempfile::tempdir().unwrap();
245        fs::write(dir.path().join("good.yaml"), "name: ok\nvalue: 1\n").unwrap();
246        fs::write(dir.path().join("bad.yaml"), "not valid yaml: [[[").unwrap();
247
248        let fixtures: Vec<TestFixture> =
249            load_fixtures_from_dir(dir.path(), &FixtureLoadOptions::yaml_single()).unwrap();
250
251        assert_eq!(fixtures.len(), 1);
252        assert_eq!(fixtures[0].name, "ok");
253    }
254
255    #[test]
256    fn test_fail_fast_propagates_error() {
257        let dir = tempfile::tempdir().unwrap();
258        fs::write(dir.path().join("bad.yaml"), "not valid yaml: [[[").unwrap();
259
260        let result: Result<Vec<TestFixture>> =
261            load_fixtures_from_dir(dir.path(), &FixtureLoadOptions::yaml_array_strict());
262
263        assert!(result.is_err());
264    }
265
266    #[test]
267    fn test_ignores_non_matching_extensions() {
268        let dir = tempfile::tempdir().unwrap();
269        fs::write(dir.path().join("readme.txt"), "not a fixture").unwrap();
270        fs::write(dir.path().join("data.yaml"), "name: x\nvalue: 0\n").unwrap();
271
272        let fixtures: Vec<TestFixture> =
273            load_fixtures_from_dir(dir.path(), &FixtureLoadOptions::yaml_single()).unwrap();
274
275        assert_eq!(fixtures.len(), 1);
276    }
277}