Skip to main content

rskit_testutil/
workspace.rs

1//! Temporary workspace and fixture helpers for tests.
2
3use std::path::{Path, PathBuf};
4
5use rskit_errors::{AppError, AppResult};
6use rskit_fs::{TempDir, safe_join, sync_io::file};
7
8/// Managed temporary workspace for unit and integration tests.
9///
10/// The workspace is deleted when dropped.
11/// Fixture operations are rooted at an explicit fixture directory and reject absolute
12/// or parent-traversing relative paths through `rskit-fs` safe path handling.
13#[derive(Debug)]
14pub struct TestWorkspace {
15    inner: TempDir,
16    fixture_dir: Option<PathBuf>,
17}
18
19impl TestWorkspace {
20    /// Create a temporary workspace, panicking with `label` on failure.
21    pub fn new(label: &str) -> Self {
22        Self::try_new().unwrap_or_else(|error| panic!("create test workspace for {label}: {error}"))
23    }
24
25    /// Try to create a temporary workspace.
26    pub fn try_new() -> AppResult<Self> {
27        Ok(Self {
28            inner: TempDir::new()?,
29            fixture_dir: None,
30        })
31    }
32
33    /// Set the directory used as the root for fixture lookups.
34    #[must_use]
35    pub fn with_fixture_dir(mut self, fixture_dir: impl Into<PathBuf>) -> Self {
36        self.fixture_dir = Some(fixture_dir.into());
37        self
38    }
39
40    /// Return the workspace root directory.
41    #[must_use]
42    pub fn path(&self) -> &Path {
43        self.inner.path()
44    }
45
46    /// Resolve a safe relative path inside the workspace.
47    pub fn child(&self, rel_path: impl AsRef<Path>) -> AppResult<PathBuf> {
48        self.inner.child(rel_path)
49    }
50
51    /// Write a file inside the workspace, creating parent directories.
52    pub fn write_file(&self, rel_path: impl AsRef<Path>, content: &[u8]) -> AppResult<PathBuf> {
53        self.inner.write_file(rel_path, content)
54    }
55
56    /// Resolve a fixture path under the configured fixture root.
57    pub fn fixture_path(&self, rel_path: impl AsRef<Path>) -> AppResult<PathBuf> {
58        let Some(fixture_dir) = &self.fixture_dir else {
59            return Err(AppError::invalid_input(
60                "fixture_dir",
61                "fixture directory is not configured",
62            ));
63        };
64        safe_join(fixture_dir, rel_path.as_ref())
65            .map_err(|error| AppError::invalid_input("fixture_path", error.to_string()))
66    }
67
68    /// Read fixture bytes from the configured fixture root.
69    pub fn read_fixture(&self, rel_path: impl AsRef<Path>) -> AppResult<Vec<u8>> {
70        file::read(&self.fixture_path(rel_path)?)
71    }
72
73    /// Read a UTF-8 fixture from the configured fixture root.
74    pub fn read_fixture_string(&self, rel_path: impl AsRef<Path>) -> AppResult<String> {
75        file::read_string(&self.fixture_path(rel_path)?)
76    }
77
78    /// Copy a fixture into the workspace, creating destination parent directories.
79    pub fn copy_fixture(
80        &self,
81        fixture_rel_path: impl AsRef<Path>,
82        dest_rel_path: impl AsRef<Path>,
83    ) -> AppResult<PathBuf> {
84        let source = self.fixture_path(fixture_rel_path)?;
85        let dest = self.child(dest_rel_path)?;
86        file::copy(&source, &dest)?;
87        Ok(dest)
88    }
89}
90
91/// Create a [`TestWorkspace`] whose fixture root is `<caller>/tests/fixtures`.
92///
93/// The caller manifest directory is captured at macro expansion time,
94/// so this can be used from any crate that depends on `rskit-testutil`.
95#[macro_export]
96macro_rules! test_workspace {
97    ($label:expr) => {
98        $crate::TestWorkspace::new($label).with_fixture_dir(
99            std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
100                .join("tests")
101                .join("fixtures"),
102        )
103    };
104}
105
106#[cfg(test)]
107mod tests {
108    use std::path::Path;
109
110    use rskit_errors::ErrorCode;
111
112    use super::TestWorkspace;
113
114    #[test]
115    fn writes_files_under_workspace() {
116        let workspace = TestWorkspace::new("write");
117
118        let path = workspace.write_file("nested/file.txt", b"hello").unwrap();
119
120        assert!(path.starts_with(workspace.path()));
121        assert_eq!(
122            rskit_fs::sync_io::file::read_string(&path).unwrap(),
123            "hello"
124        );
125    }
126
127    #[test]
128    fn copies_fixture_into_workspace() {
129        let fixtures = TestWorkspace::new("fixtures");
130        fixtures
131            .write_file("config/app.toml", b"name = 'demo'")
132            .unwrap();
133        let workspace = TestWorkspace::new("workspace").with_fixture_dir(fixtures.path());
134
135        let copied = workspace
136            .copy_fixture("config/app.toml", "copied/app.toml")
137            .unwrap();
138
139        assert!(copied.starts_with(workspace.path()));
140        assert_eq!(
141            rskit_fs::sync_io::file::read_string(&copied).unwrap(),
142            "name = 'demo'"
143        );
144    }
145
146    #[test]
147    fn reads_fixture_string() {
148        let fixtures = TestWorkspace::new("fixtures");
149        fixtures.write_file("message.txt", b"hello").unwrap();
150        let workspace = TestWorkspace::new("workspace").with_fixture_dir(fixtures.path());
151
152        let content = workspace.read_fixture_string("message.txt").unwrap();
153
154        assert_eq!(content, "hello");
155    }
156
157    #[test]
158    fn rejects_fixture_access_without_fixture_dir() {
159        let workspace = TestWorkspace::new("workspace");
160
161        let error = workspace.fixture_path("message.txt").unwrap_err();
162
163        assert_eq!(error.code(), ErrorCode::InvalidInput);
164        assert!(
165            error
166                .message()
167                .contains("fixture directory is not configured")
168        );
169    }
170
171    #[test]
172    fn rejects_escaping_fixture_paths() {
173        let fixtures = TestWorkspace::new("fixtures");
174        let workspace = TestWorkspace::new("workspace").with_fixture_dir(fixtures.path());
175
176        let error = workspace
177            .fixture_path(Path::new("../escape.txt"))
178            .unwrap_err();
179
180        assert_eq!(error.code(), ErrorCode::InvalidInput);
181    }
182}