Skip to main content

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    pub fn new(name: &str) -> Self {
26        assert!(
27            !name.is_empty()
28                && name
29                    .bytes()
30                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'),
31            "test container suite name must be non-empty ascii alphanumeric or '-': {name:?}"
32        );
33        let state_dir = std::env::temp_dir().join(format!("aster-testcontainers-{name}"));
34        std::fs::create_dir_all(&state_dir).unwrap_or_else(|error| {
35            panic!(
36                "failed to create test container state dir {}: {error}",
37                state_dir.display()
38            )
39        });
40        Self {
41            name: name.to_string(),
42            state_dir,
43            instance: instance_id().to_string(),
44        }
45    }
46
47    /// Returns the suite name.
48    pub fn name(&self) -> &str {
49        &self.name
50    }
51
52    /// Returns the directory holding lock files and state JSON for this suite.
53    pub fn state_dir(&self) -> &Path {
54        &self.state_dir
55    }
56
57    /// Returns the shared container name for a service.
58    pub fn container_name(&self, service: &str) -> String {
59        format!("aster-test-{}-{}-{service}", self.name, self.instance)
60    }
61
62    pub(crate) fn lock_path(&self, service: &str) -> PathBuf {
63        self.state_dir
64            .join(format!("{}-{}-{service}.lock", self.name, self.instance))
65    }
66
67    pub(crate) fn state_path(&self, service: &str) -> PathBuf {
68        self.state_dir
69            .join(format!("{}-{}-{service}.json", self.name, self.instance))
70    }
71}
72
73fn instance_id() -> &'static str {
74    static INSTANCE: OnceLock<String> = OnceLock::new();
75    INSTANCE.get_or_init(|| {
76        let mut hasher = std::collections::hash_map::DefaultHasher::new();
77        std::env::current_dir()
78            .unwrap_or_else(|_| PathBuf::from("."))
79            .hash(&mut hasher);
80        format!("{:016x}", hasher.finish())
81    })
82}
83
84#[cfg(test)]
85mod tests {
86    use super::TestContainerSuite;
87
88    #[test]
89    fn suite_rejects_invalid_names() {
90        for name in ["", "has space", "has/slash", "中文"] {
91            let result = std::panic::catch_unwind(|| TestContainerSuite::new(name));
92            assert!(result.is_err(), "suite name {name:?} should be rejected");
93        }
94    }
95
96    #[test]
97    fn suite_builds_scoped_paths_and_container_names() {
98        let suite = TestContainerSuite::new("forge-test");
99        assert_eq!(suite.name(), "forge-test");
100        assert!(
101            suite
102                .state_dir()
103                .ends_with("aster-testcontainers-forge-test")
104        );
105
106        let container = suite.container_name("redis");
107        assert!(container.starts_with("aster-test-forge-test-"));
108        assert!(container.ends_with("-redis"));
109        assert_ne!(suite.lock_path("redis"), suite.state_path("redis"));
110    }
111}