1use std::collections::{BTreeMap, BTreeSet};
5use std::fmt::Write;
6use std::path::{Path, PathBuf};
7use std::{fs, io};
8
9use tempdir::TempDir;
10
11use crate::result::{io_not_found, ResultEx};
12
13pub const TEMP_DIR_PREFIX: &str = "tytanic-utils";
15
16pub fn create_dir<P>(path: P, all: bool) -> io::Result<()>
27where
28 P: AsRef<Path>,
29{
30 fn inner(path: &Path, all: bool) -> io::Result<()> {
31 let res = if all {
32 fs::create_dir_all(path)
33 } else {
34 fs::create_dir(path)
35 };
36 res.ignore_default(|e| e.kind() == io::ErrorKind::AlreadyExists)
37 }
38
39 inner(path.as_ref(), all)
40}
41
42pub fn remove_file<P>(path: P) -> io::Result<()>
52where
53 P: AsRef<Path>,
54{
55 fn inner(path: &Path) -> io::Result<()> {
56 std::fs::remove_file(path).ignore_default(io_not_found)
57 }
58
59 inner(path.as_ref())
60}
61
62pub fn remove_dir<P>(path: P, all: bool) -> io::Result<()>
72where
73 P: AsRef<Path>,
74{
75 fn inner(path: &Path, all: bool) -> io::Result<()> {
76 let res = if all {
77 fs::remove_dir_all(path)
78 } else {
79 fs::remove_dir(path)
80 };
81
82 res.ignore_default(|e| {
83 if io_not_found(e) {
84 let parent_exists = path
85 .parent()
86 .and_then(|p| p.try_exists().ok())
87 .is_some_and(|b| b);
88
89 if !parent_exists {
90 tracing::error!(?path, "tried removing dir, but parent did not exist");
91 }
92
93 parent_exists
94 } else {
95 false
96 }
97 })
98 }
99
100 inner(path.as_ref(), all)
101}
102
103pub fn ensure_empty_dir<P>(path: P, all: bool) -> io::Result<()>
113where
114 P: AsRef<Path>,
115{
116 fn inner(path: &Path, all: bool) -> io::Result<()> {
117 let res = remove_dir(path, true);
118 if all {
119 res.ignore_default(io_not_found)?;
121 } else {
122 res?;
123 }
124
125 create_dir(path, all)
126 }
127
128 inner(path.as_ref(), all)
129}
130
131#[derive(Debug)]
134pub struct TempTestEnv {
135 root: TempDir,
136 found: BTreeMap<PathBuf, Option<Vec<u8>>>,
137 expected: BTreeMap<PathBuf, Option<Option<Vec<u8>>>>,
138}
139
140pub struct Setup(TempTestEnv);
144
145impl Setup {
146 pub fn setup_dir<P: AsRef<Path>>(&mut self, path: P) -> &mut Self {
150 let abs_path = self.0.root.path().join(path.as_ref());
151 create_dir(abs_path, true).unwrap();
152 self
153 }
154
155 pub fn setup_file<P: AsRef<Path>>(&mut self, path: P, content: impl AsRef<[u8]>) -> &mut Self {
159 let abs_path = self.0.root.path().join(path.as_ref());
160 let parent = abs_path.parent().unwrap();
161 if parent != self.0.root.path() {
162 create_dir(parent, true).unwrap();
163 }
164
165 let content = content.as_ref();
166 std::fs::write(&abs_path, content).unwrap();
167 self
168 }
169
170 pub fn setup_file_empty<P: AsRef<Path>>(&mut self, path: P) -> &mut Self {
174 let abs_path = self.0.root.path().join(path.as_ref());
175 let parent = abs_path.parent().unwrap();
176 if parent != self.0.root.path() {
177 create_dir(parent, true).unwrap();
178 }
179
180 std::fs::write(&abs_path, "").unwrap();
181 self
182 }
183}
184
185pub struct Expect(TempTestEnv);
189
190impl Expect {
191 pub fn expect_dir<P: AsRef<Path>>(&mut self, path: P) -> &mut Self {
193 self.0.add_expected(path.as_ref().to_path_buf(), None);
194 self
195 }
196
197 pub fn expect_file<P: AsRef<Path>>(&mut self, path: P) -> &mut Self {
199 self.0.add_expected(path.as_ref().to_path_buf(), Some(None));
200 self
201 }
202
203 pub fn expect_file_content<P: AsRef<Path>>(
205 &mut self,
206 path: P,
207 content: impl AsRef<[u8]>,
208 ) -> &mut Self {
209 let content = content.as_ref();
210 self.0
211 .add_expected(path.as_ref().to_path_buf(), Some(Some(content.to_owned())));
212 self
213 }
214
215 pub fn expect_file_empty<P: AsRef<Path>>(&mut self, path: P) -> &mut Self {
217 self.0.add_expected(path.as_ref().to_path_buf(), None);
218 self
219 }
220}
221
222impl TempTestEnv {
223 pub fn run(
228 setup: impl FnOnce(&mut Setup) -> &mut Setup,
229 test: impl FnOnce(&Path),
230 expect: impl FnOnce(&mut Expect) -> &mut Expect,
231 ) {
232 let dir = Self {
233 root: TempDir::new(TEMP_DIR_PREFIX).unwrap(),
234 found: BTreeMap::new(),
235 expected: BTreeMap::new(),
236 };
237
238 let mut s = Setup(dir);
239 setup(&mut s);
240 let Setup(dir) = s;
241
242 test(dir.root.path());
243
244 let mut e = Expect(dir);
245 expect(&mut e);
246 let Expect(mut dir) = e;
247
248 dir.collect();
249 dir.assert();
250 }
251
252 pub fn run_no_check(setup: impl FnOnce(&mut Setup) -> &mut Setup, test: impl FnOnce(&Path)) {
257 let dir = Self {
258 root: TempDir::new(TEMP_DIR_PREFIX).unwrap(),
259 found: BTreeMap::new(),
260 expected: BTreeMap::new(),
261 };
262
263 let mut s = Setup(dir);
264 setup(&mut s);
265 let Setup(dir) = s;
266
267 test(dir.root.path());
268 }
269}
270
271impl TempTestEnv {
272 fn add_expected(&mut self, expected: PathBuf, content: Option<Option<Vec<u8>>>) {
273 for ancestor in expected.ancestors() {
274 self.expected.insert(ancestor.to_path_buf(), None);
275 }
276 self.expected.insert(expected, content);
277 }
278
279 fn add_found(&mut self, found: PathBuf, content: Option<Vec<u8>>) {
280 for ancestor in found.ancestors() {
281 self.found.insert(ancestor.to_path_buf(), None);
282 }
283 self.found.insert(found, content);
284 }
285
286 fn read(&mut self, path: PathBuf) {
287 let rel = path.strip_prefix(self.root.path()).unwrap().to_path_buf();
288 if path.metadata().unwrap().is_file() {
289 let content = std::fs::read(&path).unwrap();
290 self.add_found(rel, Some(content));
291 } else {
292 let mut empty = true;
293 for entry in path.read_dir().unwrap() {
294 let entry = entry.unwrap();
295 self.read(entry.path());
296 empty = false;
297 }
298
299 if empty && self.root.path() != path {
300 self.add_found(rel, None);
301 }
302 }
303 }
304
305 fn collect(&mut self) {
306 self.read(self.root.path().to_path_buf())
307 }
308
309 fn assert(mut self) {
310 let mut not_found = BTreeSet::new();
311 let mut not_matched = BTreeMap::new();
312 for (expected_path, expected_value) in self.expected {
313 if let Some(found) = self.found.remove(&expected_path) {
314 let expected = expected_value.unwrap_or_default();
315 let found = found.unwrap_or_default();
316 if let Some(expected) = expected {
317 if expected != found {
318 not_matched.insert(expected_path, (found, expected));
319 }
320 }
321 } else {
322 not_found.insert(expected_path);
323 }
324 }
325
326 let not_expected: BTreeSet<_> = self.found.into_keys().collect();
327
328 let mut mismatch = false;
329 let mut msg = String::new();
330 if !not_found.is_empty() {
331 mismatch = true;
332 writeln!(&mut msg, "\n=== Not found ===").unwrap();
333 for not_found in not_found {
334 writeln!(&mut msg, "/{}", not_found.display()).unwrap();
335 }
336 }
337
338 if !not_expected.is_empty() {
339 mismatch = true;
340 writeln!(&mut msg, "\n=== Not expected ===").unwrap();
341 for not_expected in not_expected {
342 writeln!(&mut msg, "/{}", not_expected.display()).unwrap();
343 }
344 }
345
346 if !not_matched.is_empty() {
347 mismatch = true;
348 writeln!(&mut msg, "\n=== Content matched ===").unwrap();
349 for (path, (found, expected)) in not_matched {
350 writeln!(&mut msg, "/{}", path.display()).unwrap();
351 match (std::str::from_utf8(&found), std::str::from_utf8(&expected)) {
352 (Ok(found), Ok(expected)) => {
353 writeln!(&mut msg, "=== Expected ===\n>>>\n{}\n<<<\n", expected).unwrap();
354 writeln!(&mut msg, "=== Found ===\n>>>\n{}\n<<<\n", found).unwrap();
355 }
356 _ => {
357 writeln!(&mut msg, "Binary data differed").unwrap();
358 }
359 }
360 }
361 }
362
363 if mismatch {
364 panic!("{msg}")
365 }
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 #[test]
374 fn test_temp_env_run() {
375 TempTestEnv::run(
376 |test| {
377 test.setup_file_empty("foo/bar/empty.txt")
378 .setup_file_empty("foo/baz/other.txt")
379 },
380 |root| {
381 std::fs::remove_file(root.join("foo/bar/empty.txt")).unwrap();
382 },
383 |test| {
384 test.expect_dir("foo/bar/")
385 .expect_file_empty("foo/baz/other.txt")
386 },
387 );
388 }
389
390 #[test]
391 #[should_panic]
392 fn test_temp_env_run_panic() {
393 TempTestEnv::run(
394 |test| {
395 test.setup_file_empty("foo/bar/empty.txt")
396 .setup_file_empty("foo/baz/other.txt")
397 },
398 |root| {
399 std::fs::remove_file(root.join("foo/bar/empty.txt")).unwrap();
400 },
401 |test| test.expect_dir("foo/bar/"),
402 );
403 }
404
405 #[test]
406 fn test_temp_env_run_no_check() {
407 TempTestEnv::run_no_check(
408 |test| {
409 test.setup_file_empty("foo/bar/empty.txt")
410 .setup_file_empty("foo/baz/other.txt")
411 },
412 |root| {
413 std::fs::remove_file(root.join("foo/bar/empty.txt")).unwrap();
414 },
415 );
416 }
417}