aster_forge_test/
suite.rs1use std::hash::{Hash, Hasher};
4use std::path::{Path, PathBuf};
5use std::sync::OnceLock;
6
7#[derive(Debug, Clone)]
14pub struct TestContainerSuite {
15 name: String,
16 state_dir: PathBuf,
17 instance: String,
18}
19
20impl TestContainerSuite {
21 #[must_use]
30 pub fn new(name: &str) -> Self {
31 assert!(
32 !name.is_empty()
33 && name
34 .bytes()
35 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'),
36 "test container suite name must be non-empty ascii alphanumeric or '-': {name:?}"
37 );
38 let state_dir = std::env::temp_dir().join(format!("aster-testcontainers-{name}"));
39 std::fs::create_dir_all(&state_dir).unwrap_or_else(|error| {
40 panic!(
41 "failed to create test container state dir {}: {error}",
42 state_dir.display()
43 )
44 });
45 Self {
46 name: name.to_string(),
47 state_dir,
48 instance: instance_id().to_string(),
49 }
50 }
51
52 #[must_use]
54 pub fn name(&self) -> &str {
55 &self.name
56 }
57
58 #[must_use]
60 pub fn state_dir(&self) -> &Path {
61 &self.state_dir
62 }
63
64 #[must_use]
66 pub fn container_name(&self, service: &str) -> String {
67 format!("aster-test-{}-{}-{service}", self.name, self.instance)
68 }
69
70 pub(crate) fn lock_path(&self, service: &str) -> PathBuf {
71 self.state_dir
72 .join(format!("{}-{}-{service}.lock", self.name, self.instance))
73 }
74
75 pub(crate) fn state_path(&self, service: &str) -> PathBuf {
76 self.state_dir
77 .join(format!("{}-{}-{service}.json", self.name, self.instance))
78 }
79
80 pub(crate) fn fixture_lock_path(&self, fixture: &str) -> PathBuf {
81 self.state_dir.join(format!(
82 "{}-{}-fixture-{fixture}.lock",
83 self.name, self.instance
84 ))
85 }
86
87 pub(crate) fn fixture_state_path(&self, fixture: &str) -> PathBuf {
88 self.state_dir.join(format!(
89 "{}-{}-fixture-{fixture}.json",
90 self.name, self.instance
91 ))
92 }
93}
94
95fn instance_id() -> &'static str {
96 static INSTANCE: OnceLock<String> = OnceLock::new();
97 INSTANCE.get_or_init(|| {
98 let mut hasher = std::collections::hash_map::DefaultHasher::new();
99 std::env::current_dir()
100 .unwrap_or_else(|_| PathBuf::from("."))
101 .hash(&mut hasher);
102 format!("{:016x}", hasher.finish())
103 })
104}
105
106#[cfg(test)]
107mod tests {
108 use super::TestContainerSuite;
109
110 #[test]
111 fn suite_rejects_invalid_names() {
112 for name in ["", "has space", "has/slash", "中文"] {
113 let result = std::panic::catch_unwind(|| TestContainerSuite::new(name));
114 assert!(result.is_err(), "suite name {name:?} should be rejected");
115 }
116 }
117
118 #[test]
119 fn suite_builds_scoped_paths_and_container_names() {
120 let suite = TestContainerSuite::new("forge-test");
121 assert_eq!(suite.name(), "forge-test");
122 assert!(
123 suite
124 .state_dir()
125 .ends_with("aster-testcontainers-forge-test")
126 );
127
128 let container = suite.container_name("redis");
129 assert!(container.starts_with("aster-test-forge-test-"));
130 assert!(container.ends_with("-redis"));
131 assert_ne!(suite.lock_path("redis"), suite.state_path("redis"));
132 assert_ne!(
133 suite.fixture_lock_path("database-template"),
134 suite.fixture_state_path("database-template")
135 );
136 assert!(
137 suite
138 .fixture_state_path("database-template")
139 .file_name()
140 .expect("fixture state path should have a file name")
141 .to_string_lossy()
142 .contains(&suite.instance)
143 );
144 }
145}