Skip to main content

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