1use crate::suite::TestContainerSuite;
9use fs2::FileExt;
10use serde::{Deserialize, Serialize};
11use std::fs::{self, File, OpenOptions};
12use std::io::{Read, Write};
13use std::path::PathBuf;
14use std::sync::atomic::{AtomicU64, Ordering};
15
16const FIXTURE_STATE_FORMAT_VERSION: u32 = 1;
17static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
18
19#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
21pub struct SuiteFixtureState {
22 format_version: u32,
23 fixture: String,
24 container_identity: String,
25 fingerprint: String,
26 resource: String,
27 producer_version: String,
28}
29
30impl SuiteFixtureState {
31 #[must_use]
33 pub fn new(
34 fixture: impl Into<String>,
35 container_identity: impl Into<String>,
36 fingerprint: impl Into<String>,
37 resource: impl Into<String>,
38 producer_version: impl Into<String>,
39 ) -> Self {
40 let state = Self {
41 format_version: FIXTURE_STATE_FORMAT_VERSION,
42 fixture: fixture.into(),
43 container_identity: container_identity.into(),
44 fingerprint: fingerprint.into(),
45 resource: resource.into(),
46 producer_version: producer_version.into(),
47 };
48 state.assert_valid();
49 state
50 }
51
52 #[must_use]
54 pub fn fixture(&self) -> &str {
55 &self.fixture
56 }
57
58 #[must_use]
60 pub fn container_identity(&self) -> &str {
61 &self.container_identity
62 }
63
64 #[must_use]
66 pub fn fingerprint(&self) -> &str {
67 &self.fingerprint
68 }
69
70 #[must_use]
72 pub fn resource(&self) -> &str {
73 &self.resource
74 }
75
76 #[must_use]
78 pub fn producer_version(&self) -> &str {
79 &self.producer_version
80 }
81
82 #[must_use]
84 pub fn matches(
85 &self,
86 fixture: &str,
87 container_identity: &str,
88 fingerprint: &str,
89 producer_version: &str,
90 ) -> bool {
91 self.format_version == FIXTURE_STATE_FORMAT_VERSION
92 && self.fixture == fixture
93 && self.container_identity == container_identity
94 && self.fingerprint == fingerprint
95 && self.producer_version == producer_version
96 }
97
98 fn assert_valid(&self) {
99 assert_eq!(
100 self.format_version, FIXTURE_STATE_FORMAT_VERSION,
101 "unsupported suite fixture state format version {}",
102 self.format_version
103 );
104 for (field, value) in [
105 ("fixture", self.fixture.as_str()),
106 ("container_identity", self.container_identity.as_str()),
107 ("fingerprint", self.fingerprint.as_str()),
108 ("resource", self.resource.as_str()),
109 ("producer_version", self.producer_version.as_str()),
110 ] {
111 assert!(
112 !value.is_empty() && !value.contains(['\r', '\n']),
113 "suite fixture state {field} must be a non-empty single-line value"
114 );
115 }
116 }
117}
118
119pub struct SuiteFixtureLock {
125 _file: File,
126 state_path: PathBuf,
127}
128
129impl SuiteFixtureLock {
130 #[must_use]
136 pub fn acquire(suite: &TestContainerSuite, fixture: &str) -> Self {
137 assert_valid_fixture_name(fixture);
138 let state_path = suite.fixture_state_path(fixture);
139 let lock_path = suite.fixture_lock_path(fixture);
140 let file = OpenOptions::new()
141 .create(true)
142 .truncate(false)
143 .read(true)
144 .write(true)
145 .open(&lock_path)
146 .unwrap_or_else(|error| {
147 panic!(
148 "failed to open suite fixture lock {}: {error}",
149 lock_path.display()
150 )
151 });
152 file.lock_exclusive().unwrap_or_else(|error| {
153 panic!(
154 "failed to lock suite fixture state {}: {error}",
155 lock_path.display()
156 )
157 });
158 Self {
159 _file: file,
160 state_path,
161 }
162 }
163
164 #[must_use]
170 pub fn load(&self) -> Option<SuiteFixtureState> {
171 if !self.state_path.exists() {
172 return None;
173 }
174
175 let mut raw = String::new();
176 File::open(&self.state_path)
177 .and_then(|mut file| file.read_to_string(&mut raw))
178 .unwrap_or_else(|error| {
179 panic!(
180 "failed to read suite fixture state {}: {error}",
181 self.state_path.display()
182 )
183 });
184 if raw.trim().is_empty() {
185 return None;
186 }
187
188 let state: SuiteFixtureState = serde_json::from_str(&raw).unwrap_or_else(|error| {
189 panic!(
190 "failed to parse suite fixture state {}: {error}",
191 self.state_path.display()
192 )
193 });
194 state.assert_valid();
195 Some(state)
196 }
197
198 pub fn publish(&self, state: &SuiteFixtureState) {
204 state.assert_valid();
205 let payload = serde_json::to_vec(state)
206 .unwrap_or_else(|error| panic!("failed to serialize suite fixture state: {error}"));
207 let temporary_path = self.state_path.with_extension(format!(
208 "json.tmp-{}-{}",
209 std::process::id(),
210 TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed)
211 ));
212
213 let mut temporary = OpenOptions::new()
214 .create_new(true)
215 .write(true)
216 .open(&temporary_path)
217 .unwrap_or_else(|error| {
218 panic!(
219 "failed to create suite fixture temporary state {}: {error}",
220 temporary_path.display()
221 )
222 });
223 temporary
224 .write_all(&payload)
225 .and_then(|()| temporary.write_all(b"\n"))
226 .and_then(|()| temporary.sync_all())
227 .unwrap_or_else(|error| {
228 panic!(
229 "failed to write suite fixture temporary state {}: {error}",
230 temporary_path.display()
231 )
232 });
233 drop(temporary);
234
235 if let Err(error) = fs::rename(&temporary_path, &self.state_path) {
236 if self.state_path.exists() {
239 fs::remove_file(&self.state_path).unwrap_or_else(|remove_error| {
240 panic!(
241 "failed to replace suite fixture state {} after rename error {error}: {remove_error}",
242 self.state_path.display()
243 )
244 });
245 fs::rename(&temporary_path, &self.state_path).unwrap_or_else(|retry_error| {
246 panic!(
247 "failed to replace suite fixture state {} after rename error {error}: {retry_error}",
248 self.state_path.display()
249 )
250 });
251 } else {
252 panic!(
253 "failed to publish suite fixture state {}: {error}",
254 self.state_path.display()
255 );
256 }
257 }
258 }
259
260 pub fn clear(&self) {
266 match fs::remove_file(&self.state_path) {
267 Ok(()) => {}
268 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
269 Err(error) => panic!(
270 "failed to clear suite fixture state {}: {error}",
271 self.state_path.display()
272 ),
273 }
274 }
275}
276
277fn assert_valid_fixture_name(name: &str) {
278 assert!(
279 !name.is_empty()
280 && name
281 .bytes()
282 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_'),
283 "suite fixture name must be non-empty ASCII alphanumeric, '-' or '_': {name:?}"
284 );
285}
286
287#[cfg(test)]
288mod tests {
289 use super::{SuiteFixtureLock, SuiteFixtureState};
290 use crate::suite::TestContainerSuite;
291 use std::sync::mpsc;
292 use std::time::Duration;
293
294 #[test]
295 fn fixture_state_round_trips_and_matches_contract() {
296 let suite = TestContainerSuite::new("forge-fixture-state-test");
297 let lock = SuiteFixtureLock::acquire(&suite, "postgres-template");
298 lock.clear();
299
300 let state = SuiteFixtureState::new(
301 "postgres-template",
302 "aster-test-forge-fixture-state-test-postgres",
303 "migration-sha",
304 "fixture_database",
305 "asterdrive-0.4.0",
306 );
307 lock.publish(&state);
308
309 let loaded = lock.load().expect("fixture state should load");
310 assert_eq!(loaded, state);
311 assert!(loaded.matches(
312 "postgres-template",
313 "aster-test-forge-fixture-state-test-postgres",
314 "migration-sha",
315 "asterdrive-0.4.0",
316 ));
317 assert!(!loaded.matches(
318 "postgres-template",
319 "aster-test-forge-fixture-state-test-postgres",
320 "different-sha",
321 "asterdrive-0.4.0",
322 ));
323 lock.clear();
324 }
325
326 #[test]
327 fn fixture_lock_serializes_concurrent_publishers() {
328 let suite = TestContainerSuite::new("forge-fixture-lock-test");
329 let lock = SuiteFixtureLock::acquire(&suite, "schema-template");
330 lock.clear();
331
332 let (sender, receiver) = mpsc::channel();
333 let suite_for_thread = suite.clone();
334 let handle = std::thread::spawn(move || {
335 let other = SuiteFixtureLock::acquire(&suite_for_thread, "schema-template");
336 sender.send(()).expect("lock test receiver should exist");
337 other.clear();
338 });
339
340 assert!(receiver.recv_timeout(Duration::from_millis(100)).is_err());
341 drop(lock);
342 receiver
343 .recv_timeout(Duration::from_secs(2))
344 .expect("second publisher should acquire after first lock releases");
345 handle
346 .join()
347 .expect("fixture lock test thread should finish");
348 }
349
350 #[test]
351 fn fixture_lock_rejects_unsafe_names() {
352 let suite = TestContainerSuite::new("forge-fixture-name-test");
353 for name in ["", "has space", "../escape", "unicode-测试"] {
354 assert!(
355 std::panic::catch_unwind(|| SuiteFixtureLock::acquire(&suite, name)).is_err(),
356 "fixture name {name:?} should be rejected"
357 );
358 }
359 }
360}