aster_forge_test/
suite.rs

1//! Test-suite identity shared by container helpers.
2
3use std::hash::{Hash, Hasher};
4use std::path::{Path, PathBuf};
5use std::sync::OnceLock;
6
7/// Identity of a test suite that owns shared containers.
8///
9/// The suite name keeps containers from different products apart, while a hash of the current
10/// working directory isolates parallel checkouts of the same product on one machine. Cargo runs
11/// test binaries with the package directory as working directory, so each checkout gets its own
12/// instance id without any compile-time env tricks.
13#[derive(Debug, Clone)]
14pub struct TestContainerSuite {
15    name: String,
16    state_dir: PathBuf,
17    instance: String,
18}
19
20impl TestContainerSuite {
21    /// Creates a suite rooted at `<temp dir>/aster-testcontainers-<name>`.
22    ///
23    /// The name becomes part of container names and lock file paths, so it must be non-empty
24    /// ASCII alphanumeric or `-`.
25    ///
26    /// # Panics
27    ///
28    /// Panics when `name` is invalid or the suite state directory cannot be created.
29    #[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    /// Returns the suite name.
53    #[must_use]
54    pub fn name(&self) -> &str {
55        &self.name
56    }
57
58    /// Returns the directory holding lock files and state JSON for this suite.
59    #[must_use]
60    pub fn state_dir(&self) -> &Path {
61        &self.state_dir
62    }
63
64    /// Returns the shared container name for a service.
65    #[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}