Skip to main content

iac_forge/testing/
fixtures.rs

1//! Sexpr-based test fixtures for backends and cross-repo tests.
2//!
3//! The backend crates historically hand-constructed `IacResource` and
4//! `IacAttribute` test fixtures as Rust literals. When the IR grew new
5//! fields (`json_encoded`, `optional`, `update_only`) those literals
6//! went stale, silently in some repos and loudly in others. Fixtures
7//! stored as canonical sexpr text avoid that drift: the IR owns the
8//! sexpr shape, every consumer loads through the same canonical path,
9//! and adding a field only requires editing the fixtures once.
10//!
11//! # Pattern
12//!
13//! Write a fixture:
14//!
15//! ```no_run
16//! use iac_forge::testing::fixtures;
17//! use iac_forge::testing::test_resource;
18//!
19//! let resource = test_resource("widget");
20//! fixtures::save_resource(&resource, "tests/fixtures/widget.sexpr").unwrap();
21//! ```
22//!
23//! Load it back in tests:
24//!
25//! ```no_run
26//! use iac_forge::testing::fixtures;
27//!
28//! let resource = fixtures::load_resource("tests/fixtures/widget.sexpr").unwrap();
29//! assert_eq!(resource.name, "widget");
30//! ```
31//!
32//! String-based loaders (`_str` variants) are equivalent but skip disk
33//! I/O — useful for tests that embed the fixture via `include_str!`.
34
35use std::path::Path;
36
37use crate::ir::{IacDataSource, IacProvider, IacResource};
38use crate::sexpr::{FromSExpr, SExpr, SExprError, ToSExpr};
39
40/// Errors from fixture I/O.
41#[derive(Debug)]
42pub enum FixtureError {
43    /// `std::io::Error` from file read/write.
44    Io(std::io::Error),
45    /// Parse or shape error during fixture decode.
46    Sexpr(SExprError),
47}
48
49impl std::fmt::Display for FixtureError {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            Self::Io(e) => write!(f, "fixture io: {e}"),
53            Self::Sexpr(e) => write!(f, "fixture sexpr: {e}"),
54        }
55    }
56}
57
58impl std::error::Error for FixtureError {}
59
60impl From<std::io::Error> for FixtureError {
61    fn from(e: std::io::Error) -> Self {
62        Self::Io(e)
63    }
64}
65
66impl From<SExprError> for FixtureError {
67    fn from(e: SExprError) -> Self {
68        Self::Sexpr(e)
69    }
70}
71
72// ── Generic helpers ────────────────────────────────────────────────
73
74/// Load any `FromSExpr` type from a file path.
75///
76/// # Errors
77/// Returns `FixtureError::Io` on read failure, `FixtureError::Sexpr` on
78/// parse or shape mismatch.
79pub fn load<T: FromSExpr>(path: impl AsRef<Path>) -> Result<T, FixtureError> {
80    let text = std::fs::read_to_string(path)?;
81    load_str(&text)
82}
83
84/// Load any `FromSExpr` type from a string (for `include_str!` or tests).
85///
86/// # Errors
87/// Returns `FixtureError::Sexpr` on parse or shape mismatch.
88pub fn load_str<T: FromSExpr>(text: &str) -> Result<T, FixtureError> {
89    let sexpr = SExpr::parse(text)?;
90    Ok(T::from_sexpr(&sexpr)?)
91}
92
93/// Save any `ToSExpr` type to a file path in canonical emission.
94///
95/// # Errors
96/// Returns `FixtureError::Io` on write failure. Parent directories are
97/// created automatically.
98pub fn save<T: ToSExpr>(value: &T, path: impl AsRef<Path>) -> Result<(), FixtureError> {
99    let path = path.as_ref();
100    if let Some(parent) = path.parent() {
101        std::fs::create_dir_all(parent)?;
102    }
103    let mut text = value.to_sexpr().emit();
104    text.push('\n');
105    std::fs::write(path, text)?;
106    Ok(())
107}
108
109// ── Type-specialised aliases (ergonomic call sites) ────────────────
110
111/// Load an `IacResource` from a fixture file.
112///
113/// # Errors
114/// See [`load`].
115pub fn load_resource(path: impl AsRef<Path>) -> Result<IacResource, FixtureError> {
116    load::<IacResource>(path)
117}
118
119/// Parse an `IacResource` from a fixture string.
120///
121/// # Errors
122/// See [`load_str`].
123pub fn load_resource_str(text: &str) -> Result<IacResource, FixtureError> {
124    load_str::<IacResource>(text)
125}
126
127/// Save an `IacResource` to a fixture file.
128///
129/// # Errors
130/// See [`save`].
131pub fn save_resource(resource: &IacResource, path: impl AsRef<Path>) -> Result<(), FixtureError> {
132    save(resource, path)
133}
134
135/// Load an `IacProvider` from a fixture file.
136///
137/// # Errors
138/// See [`load`].
139pub fn load_provider(path: impl AsRef<Path>) -> Result<IacProvider, FixtureError> {
140    load::<IacProvider>(path)
141}
142
143/// Parse an `IacProvider` from a fixture string.
144///
145/// # Errors
146/// See [`load_str`].
147pub fn load_provider_str(text: &str) -> Result<IacProvider, FixtureError> {
148    load_str::<IacProvider>(text)
149}
150
151/// Save an `IacProvider` to a fixture file.
152///
153/// # Errors
154/// See [`save`].
155pub fn save_provider(provider: &IacProvider, path: impl AsRef<Path>) -> Result<(), FixtureError> {
156    save(provider, path)
157}
158
159/// Load an `IacDataSource` from a fixture file.
160///
161/// # Errors
162/// See [`load`].
163pub fn load_data_source(path: impl AsRef<Path>) -> Result<IacDataSource, FixtureError> {
164    load::<IacDataSource>(path)
165}
166
167/// Save an `IacDataSource` to a fixture file.
168///
169/// # Errors
170/// See [`save`].
171pub fn save_data_source(ds: &IacDataSource, path: impl AsRef<Path>) -> Result<(), FixtureError> {
172    save(ds, path)
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::testing::{test_provider, test_resource};
179    use tempfile::TempDir;
180
181    #[test]
182    fn round_trip_resource_via_disk() {
183        let original = test_resource("widget");
184        let dir = TempDir::new().unwrap();
185        let path = dir.path().join("fx.sexpr");
186        save_resource(&original, &path).unwrap();
187
188        let loaded = load_resource(&path).unwrap();
189        assert_eq!(loaded.name, original.name);
190        assert_eq!(loaded.attributes, original.attributes);
191    }
192
193    #[test]
194    fn round_trip_resource_via_string() {
195        let original = test_resource("widget");
196        let text = original.to_sexpr().emit();
197        let loaded = load_resource_str(&text).unwrap();
198        assert_eq!(loaded.name, original.name);
199        assert_eq!(loaded.attributes, original.attributes);
200    }
201
202    #[test]
203    fn save_creates_parent_directories() {
204        let original = test_resource("widget");
205        let dir = TempDir::new().unwrap();
206        let nested = dir.path().join("a/b/c/widget.sexpr");
207        save_resource(&original, &nested).unwrap();
208        assert!(nested.exists());
209    }
210
211    #[test]
212    fn saved_file_ends_with_newline() {
213        let original = test_resource("widget");
214        let dir = TempDir::new().unwrap();
215        let path = dir.path().join("fx.sexpr");
216        save_resource(&original, &path).unwrap();
217        let contents = std::fs::read_to_string(&path).unwrap();
218        assert!(contents.ends_with('\n'));
219    }
220
221    #[test]
222    fn load_nonexistent_file_errors() {
223        let err = load_resource("/nonexistent/fixture.sexpr").unwrap_err();
224        assert!(matches!(err, FixtureError::Io(_)));
225    }
226
227    #[test]
228    fn load_malformed_sexpr_errors() {
229        let err = load_resource_str("(resource").unwrap_err();
230        assert!(matches!(err, FixtureError::Sexpr(_)));
231    }
232
233    #[test]
234    fn load_wrong_top_level_shape_errors() {
235        // Valid sexpr, but wrong top-level — should reject as shape error.
236        let err = load_resource_str("(unknown-top-level)").unwrap_err();
237        assert!(matches!(err, FixtureError::Sexpr(_)));
238    }
239
240    #[test]
241    fn round_trip_provider_via_disk() {
242        let original = test_provider("acme");
243        let dir = TempDir::new().unwrap();
244        let path = dir.path().join("provider.sexpr");
245        save_provider(&original, &path).unwrap();
246        let loaded = load_provider(&path).unwrap();
247        assert_eq!(loaded.name, original.name);
248        assert_eq!(loaded.platform_config, original.platform_config);
249    }
250
251    #[test]
252    fn round_trip_data_source_via_disk() {
253        use crate::testing::test_data_source;
254        let original = test_data_source("secret");
255        let dir = TempDir::new().unwrap();
256        let path = dir.path().join("ds.sexpr");
257        save_data_source(&original, &path).unwrap();
258        let loaded = load_data_source(&path).unwrap();
259        assert_eq!(loaded.name, original.name);
260        assert_eq!(loaded.attributes, original.attributes);
261    }
262
263    #[test]
264    fn generic_helpers_work_for_any_to_sexpr_type() {
265        // Proves the generic `load`/`save` work for any FromSExpr type.
266        let attr = crate::ir::IacAttribute {
267            api_name: "field".into(),
268            canonical_name: "field".into(),
269            ..Default::default()
270        };
271        let dir = TempDir::new().unwrap();
272        let path = dir.path().join("attr.sexpr");
273        save(&attr, &path).unwrap();
274        let loaded: crate::ir::IacAttribute = load(&path).unwrap();
275        assert_eq!(loaded, attr);
276    }
277
278    #[test]
279    fn fixture_error_display() {
280        let err = FixtureError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "x"));
281        assert!(err.to_string().contains("fixture io"));
282        let err2: FixtureError = SExprError::Parse("x".into()).into();
283        assert!(err2.to_string().contains("fixture sexpr"));
284    }
285}