aster_forge_test/
temp.rs

1//! Isolated temporary filesystem fixtures for tests.
2
3use aster_forge_utils::raii::TempDirGuard;
4use std::path::{Component, Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
9
10/// A uniquely named temporary directory backed by [`TempDirGuard`].
11///
12/// The directory name includes the process ID, a process-local counter, and the current timestamp
13/// so parallel test binaries and repeated runs do not share filesystem state. The utils-layer
14/// guard owns recursive cleanup, including early-return and panic paths.
15#[must_use = "keep the fixture alive for as long as its temporary files are in use"]
16pub struct TestTempDir {
17    guard: TempDirGuard,
18}
19
20impl TestTempDir {
21    /// Creates an isolated directory under the platform temporary directory.
22    ///
23    /// # Panics
24    ///
25    /// Panics when `scope` is invalid, the system clock predates the Unix epoch, or the directory
26    /// cannot be created.
27    pub fn new(scope: &str) -> Self {
28        Self::new_in(std::env::temp_dir(), scope)
29    }
30
31    /// Creates an isolated directory below `root`.
32    ///
33    /// This is useful when a test intentionally needs a path below the package directory, such as
34    /// configuration tests that verify runtime-relative path rendering.
35    ///
36    /// # Panics
37    ///
38    /// Panics when `scope` is invalid, the system clock predates the Unix epoch, or the directory
39    /// cannot be created below `root`.
40    pub fn new_in(root: impl AsRef<Path>, scope: &str) -> Self {
41        assert_valid_scope(scope);
42        let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
43        let nanos = SystemTime::now()
44            .duration_since(UNIX_EPOCH)
45            .expect("system clock should be after unix epoch")
46            .as_nanos();
47        let path = root.as_ref().join(format!(
48            "aster-test-{scope}-{}-{id}-{nanos}",
49            std::process::id()
50        ));
51        std::fs::create_dir_all(&path).unwrap_or_else(|error| {
52            panic!(
53                "failed to create isolated test directory {}: {error}",
54                path.display()
55            )
56        });
57        Self {
58            guard: TempDirGuard::new(path, "isolated test directory"),
59        }
60    }
61
62    /// Returns the owned temporary directory path.
63    #[must_use]
64    pub fn path(&self) -> &Path {
65        self.guard.path()
66    }
67
68    /// Joins a test-owned relative path below the temporary directory.
69    ///
70    /// # Panics
71    ///
72    /// Panics when `path` is absolute or contains a parent, root, or platform prefix component.
73    pub fn join(&self, path: impl AsRef<Path>) -> PathBuf {
74        let path = path.as_ref();
75        assert!(
76            path.components()
77                .all(|component| matches!(component, Component::Normal(_) | Component::CurDir)),
78            "test fixture path must stay relative to its temporary directory: {}",
79            path.display()
80        );
81        self.path().join(path)
82    }
83}
84
85impl std::fmt::Debug for TestTempDir {
86    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        formatter
88            .debug_struct("TestTempDir")
89            .field("path", &self.path())
90            .finish()
91    }
92}
93
94/// A file-backed `SQLite` test database inside an isolated temporary directory.
95///
96/// The fixture owns the directory rather than only the main database file, so `SQLite` journal,
97/// WAL, and shared-memory sidecars are cleaned together. Database handles must be closed before
98/// this value is dropped on platforms that lock open files.
99#[derive(Debug)]
100#[must_use = "keep the fixture alive until all SQLite connections have been closed"]
101pub struct SqliteTestDatabase {
102    directory: TestTempDir,
103    path: PathBuf,
104    url: String,
105}
106
107impl SqliteTestDatabase {
108    /// Creates a uniquely named file-backed `SQLite` fixture.
109    ///
110    /// # Panics
111    ///
112    /// Panics when the scope or temporary directory is invalid, directory creation fails, or the
113    /// resulting database path is not valid UTF-8.
114    pub fn new(scope: &str) -> Self {
115        let directory = TestTempDir::new(&format!("sqlite-{scope}"));
116        let path = directory.join("database.sqlite3");
117        let url = sqlite_database_url(&path);
118        Self {
119            directory,
120            path,
121            url,
122        }
123    }
124
125    /// Returns the database file path.
126    #[must_use]
127    pub fn path(&self) -> &Path {
128        &self.path
129    }
130
131    /// Returns a `mode=rwc` `SQLite` URL with the native path percent-encoded.
132    #[must_use]
133    pub fn url(&self) -> &str {
134        &self.url
135    }
136
137    /// Returns the directory that owns the database and any `SQLite` sidecars.
138    #[must_use]
139    pub fn directory(&self) -> &Path {
140        self.directory.path()
141    }
142}
143
144/// Builds a file-backed `SQLite` URL from a native filesystem path.
145///
146/// The opaque `sqlite:` form lets drive letters, backslashes, spaces, `?`, and `#` remain part of
147/// the database filename after `SQLx` percent-decodes it, while the URL still passes generic URL
148/// validation performed by `SeaORM`.
149///
150/// # Panics
151///
152/// Panics when the native path is not valid UTF-8.
153pub fn sqlite_database_url(path: impl AsRef<Path>) -> String {
154    let path = path.as_ref();
155    let path = path.to_str().unwrap_or_else(|| {
156        panic!(
157            "SQLite test database path must be valid UTF-8: {}",
158            path.display()
159        )
160    });
161    let encoded = percent_encode_sqlite_path(path);
162    format!("sqlite:{encoded}?mode=rwc")
163}
164
165fn percent_encode_sqlite_path(path: &str) -> String {
166    const HEX: &[u8; 16] = b"0123456789ABCDEF";
167
168    let mut encoded = String::with_capacity(path.len());
169    for byte in path.bytes() {
170        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
171            encoded.push(char::from(byte));
172        } else {
173            encoded.push('%');
174            encoded.push(char::from(HEX[usize::from(byte >> 4)]));
175            encoded.push(char::from(HEX[usize::from(byte & 0x0F)]));
176        }
177    }
178    encoded
179}
180
181fn assert_valid_scope(scope: &str) {
182    assert!(
183        !scope.is_empty()
184            && scope
185                .bytes()
186                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')),
187        "test temp scope must be non-empty ASCII alphanumeric, '-' or '_': {scope:?}"
188    );
189}
190
191#[cfg(test)]
192mod tests {
193    use super::{SqliteTestDatabase, TestTempDir, sqlite_database_url};
194    use std::path::Path;
195
196    #[test]
197    fn test_temp_dir_creates_and_removes_an_isolated_tree() {
198        let path;
199        {
200            let directory = TestTempDir::new("temp-dir-cleanup");
201            path = directory.path().to_path_buf();
202            std::fs::write(directory.join("payload.txt"), b"fixture")
203                .expect("fixture file should be written");
204            assert!(path.is_dir());
205        }
206        assert!(!path.exists());
207    }
208
209    #[test]
210    fn test_temp_dir_rejects_path_components_in_scope() {
211        for scope in ["", "has space", "../escape", "nested/path", "nested\\path"] {
212            let result = std::panic::catch_unwind(|| TestTempDir::new(scope));
213            assert!(result.is_err(), "scope {scope:?} should be rejected");
214        }
215    }
216
217    #[test]
218    fn test_temp_dir_join_rejects_paths_outside_fixture() {
219        let directory = TestTempDir::new("join-boundary");
220        for path in [Path::new("../escape"), Path::new("nested/../../escape")] {
221            let result = std::panic::catch_unwind(|| directory.join(path));
222            assert!(result.is_err(), "path {path:?} should be rejected");
223        }
224    }
225
226    #[test]
227    fn sqlite_url_percent_encodes_windows_and_reserved_path_characters() {
228        assert_eq!(
229            sqlite_database_url(Path::new(r"C:\Temp Folder\db?#.sqlite3")),
230            "sqlite:C%3A%5CTemp%20Folder%5Cdb%3F%23.sqlite3?mode=rwc"
231        );
232    }
233
234    #[test]
235    fn sqlite_fixture_owns_database_parent_and_parseable_url() {
236        let database = SqliteTestDatabase::new("database-fixture");
237        assert_eq!(database.path().parent(), Some(database.directory()));
238        assert!(database.url().ends_with("?mode=rwc"));
239        url::Url::parse(database.url()).expect("SQLite fixture URL should pass URL validation");
240    }
241}