1
use std::fs::File;
2
use std::path::Path;
3
use std::path::PathBuf;
4

            
5
use log::info;
6
use merc_utilities::MercError;
7
use tempfile::TempDir;
8

            
9
/// A utility for dumping files, mostly used for testing and debugging
10
///
11
/// # Details
12
///
13
/// The given name is used to create a dedicated directory for the output files,
14
/// this is especially useful for files dumped from (random) tests.
15
///
16
/// Uses the `MERC_DUMP=1` environment variable to enable or disable dumping files
17
/// to disk, to avoid unnecessary writes during normal runs. In combination with
18
/// `MERC_SEED` we can reproduce specific tests cases for random runs.
19
pub struct DumpFiles {
20
    // None when dumping is disabled.
21
    directory: Option<PathBuf>,
22
}
23

            
24
impl DumpFiles {
25
    /// Creates a new `DumpFiles` instance with the given directory as output.
26
109200
    pub fn new(directory: &str) -> Self {
27
109200
        Self::with_dump_dir(std::env::var("MERC_DUMP").ok().as_deref(), directory)
28
109200
    }
29

            
30
    /// Constructs a `DumpFiles` from an explicit `MERC_DUMP` value, so the logic
31
    /// can be tested without mutating the process environment.
32
109203
    fn with_dump_dir(dump_dir: Option<&str>, directory: &str) -> Self {
33
109203
        match dump_dir {
34
2
            Some(dump_dir) => {
35
                // Check if the directory is an absolute path
36
2
                if !Path::new(dump_dir).is_absolute() {
37
1
                    panic!("MERC_DUMP must be an absolute path, because tests write relative to their source file.");
38
1
                }
39

            
40
1
                Self {
41
1
                    directory: Some(Path::new(dump_dir).join(directory)),
42
1
                }
43
            }
44
            // Dumping disabled.
45
109201
            None => Self { directory: None },
46
        }
47
109202
    }
48

            
49
    /// Dumps a file with the given filename suffix by calling the provided function
50
    /// to write the contents.
51
9092
    pub fn dump<F>(&self, filename: &str, mut write: F) -> Result<(), MercError>
52
9092
    where
53
9092
        F: FnMut(&mut File) -> Result<(), MercError>,
54
    {
55
9092
        if let Some(directory) = &self.directory {
56
            // Ensure the dump directory exists.
57
1
            std::fs::create_dir_all(directory)?;
58

            
59
1
            let path = Path::new(&directory).join(filename);
60
1
            let mut file = File::create(&path)?;
61
1
            write(&mut file)?;
62

            
63
1
            info!("Dumped file: {}", path.to_string_lossy());
64
        } else {
65
9091
            info!("No MERC_DUMP set, skipping dump: {}", filename);
66
        }
67
9092
        Ok(())
68
9092
    }
69
}
70

            
71
/// Uses `MERC_DUMP` as the temporary directory if set, and otherwise the default temp directory.
72
pub fn temp_dir(name: &str) -> Result<TempDir, MercError> {
73
    temp_dir_in(std::env::var("MERC_DUMP").ok().as_deref(), name)
74
}
75

            
76
/// Creates a temporary directory under an explicit `MERC_DUMP` value, so the
77
/// logic can be tested without mutating the process environment.
78
3
fn temp_dir_in(dump_dir: Option<&str>, name: &str) -> Result<TempDir, MercError> {
79
3
    if let Some(dump_dir) = dump_dir {
80
        // Check if the directory is an absolute path
81
2
        if !Path::new(dump_dir).is_absolute() {
82
1
            panic!("MERC_DUMP must be an absolute path, because tests write relative to their source file.");
83
1
        }
84

            
85
        // If we are asking for MERC_DUMP, disable cleanup.
86
1
        let mut dir = TempDir::with_prefix_in(name, dump_dir)?;
87
1
        dir.disable_cleanup(true);
88
1
        Ok(dir)
89
    } else {
90
1
        tempfile::tempdir().map_err(|e| e.into())
91
    }
92
2
}
93

            
94
#[cfg(test)]
95
mod tests {
96
    use super::DumpFiles;
97
    use super::temp_dir_in;
98

            
99
    use std::io::Write;
100

            
101
    #[test]
102
1
    fn test_disabled_skips_writing() {
103
        // Without MERC_DUMP the write closure must never be invoked.
104
1
        let files = DumpFiles::with_dump_dir(None, "ignored");
105
1
        let mut called = false;
106
1
        files
107
1
            .dump("file.txt", |_| {
108
                called = true;
109
                Ok(())
110
            })
111
1
            .expect("dump should succeed when disabled");
112
1
        assert!(!called, "the write closure must not run when dumping is disabled");
113
1
    }
114

            
115
    #[test]
116
    #[cfg_attr(miri, ignore)] // Miri does not support external calls.
117
1
    fn test_enabled_writes_file() {
118
1
        let base = tempfile::tempdir().expect("failed to create base temp dir");
119
1
        let base_path = base.path().to_str().expect("temp path is valid UTF-8");
120

            
121
1
        let files = DumpFiles::with_dump_dir(Some(base_path), "subdir");
122
1
        files
123
1
            .dump("hello.txt", |f| f.write_all(b"hello world").map_err(Into::into))
124
1
            .expect("dump should succeed when enabled");
125

            
126
1
        let written = base.path().join("subdir").join("hello.txt");
127
1
        assert_eq!(
128
1
            std::fs::read_to_string(written).expect("file should exist"),
129
            "hello world"
130
        );
131
1
    }
132

            
133
    #[test]
134
    #[should_panic(expected = "MERC_DUMP must be an absolute path")]
135
1
    fn test_relative_dump_dir_panics() {
136
1
        DumpFiles::with_dump_dir(Some("relative/path"), "subdir");
137
1
    }
138

            
139
    #[test]
140
    #[cfg_attr(miri, ignore)] // Miri does not support external calls.
141
1
    fn test_temp_dir_disabled_creates_directory() {
142
1
        let dir = temp_dir_in(None, "merc_io_test").expect("temp dir should be created");
143
1
        assert!(dir.path().is_dir(), "temp dir should exist on disk");
144
1
    }
145

            
146
    #[test]
147
    #[cfg_attr(miri, ignore)] // Miri does not support external calls.
148
1
    fn test_temp_dir_enabled_uses_base() {
149
1
        let base = tempfile::tempdir().expect("failed to create base temp dir");
150
1
        let base_path = base.path().to_str().expect("temp path is valid UTF-8");
151

            
152
1
        let dir = temp_dir_in(Some(base_path), "merc_io_test").expect("temp dir should be created");
153
1
        assert!(dir.path().is_dir(), "temp dir should exist on disk");
154
1
        assert!(
155
1
            dir.path().starts_with(base.path()),
156
            "temp dir should be created under the provided base directory"
157
        );
158
1
    }
159

            
160
    #[test]
161
    #[should_panic(expected = "MERC_DUMP must be an absolute path")]
162
1
    fn test_temp_dir_relative_panics() {
163
1
        let _ = temp_dir_in(Some("relative/path"), "merc_io_test");
164
1
    }
165
}