precious_testhelper/
lib.rs

1use anyhow::{Context, Result};
2use log::debug;
3use mitsein::prelude::*;
4use precious_helpers::exec::Exec;
5use pushd::Pushd;
6use regex::Regex;
7use std::{
8    env,
9    ffi::OsString,
10    fs,
11    io::prelude::*,
12    path::{Path, PathBuf},
13    sync::{LazyLock, OnceLock},
14};
15use tempfile::TempDir;
16
17pub struct TestHelper {
18    // While we never access this field we need to hold onto the tempdir or
19    // else the directory it references will be deleted.
20    _tempdir: TempDir,
21    git_root: PathBuf,
22    precious_root: PathBuf,
23    paths: Vec<PathBuf>,
24    root_gitignore_file: PathBuf,
25    tests_data_gitignore_file: PathBuf,
26}
27
28static RERERE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new("Recorded preimage").unwrap());
29
30impl TestHelper {
31    const PATHS: &'static [&'static str] = &[
32        "README.md",
33        "can_ignore.x",
34        "merge-conflict-file",
35        "src/bar.rs",
36        "src/can_ignore.rs",
37        "src/main.rs",
38        "src/module.rs",
39        "src/sub/mod.rs",
40        "tests/data/bar.txt",
41        "tests/data/foo.txt",
42        "tests/data/generated.txt",
43    ];
44
45    pub fn new() -> Result<Self> {
46        static LOGGER_INIT: OnceLock<bool> = OnceLock::new();
47        LOGGER_INIT.get_or_init(|| {
48            env_logger::builder().is_test(true).init();
49            true
50        });
51
52        let mut td = tempfile::Builder::new()
53            .prefix("precious-testhelper-")
54            .tempdir()?;
55        if let Ok(p) = env::var("PRECIOUS_TESTS_PRESERVE_TEMPDIR") {
56            if !(p.is_empty() || p == "0") {
57                td.disable_cleanup(true);
58            }
59        }
60
61        let root = maybe_canonicalize(td.path())?;
62
63        let helper = TestHelper {
64            _tempdir: td,
65            git_root: root.clone(),
66            precious_root: root,
67            paths: Self::PATHS.iter().map(PathBuf::from).collect(),
68            root_gitignore_file: PathBuf::from(".gitignore"),
69            tests_data_gitignore_file: PathBuf::from("tests/data/.gitignore"),
70        };
71        Ok(helper)
72    }
73
74    pub fn with_precious_root_in_subdir<P: AsRef<Path>>(mut self, subdir: P) -> Self {
75        self.precious_root.push(subdir);
76        self
77    }
78
79    pub fn with_git_repo(self) -> Result<Self> {
80        self.create_git_repo()?;
81        Ok(self)
82    }
83
84    fn create_git_repo(&self) -> Result<()> {
85        debug!("Creating git repo in {}", self.git_root.display());
86        for p in self.paths.iter() {
87            let content = if is_rust_file(p) {
88                "fn foo() {}\n"
89            } else {
90                "some text"
91            };
92            self.write_file(p, content)?;
93        }
94
95        self.run_git(vec!["init", "--initial-branch", "master"])?;
96
97        // If the tests are run in a totally clean environment they will blow
98        // up if this isnt't set. This fixes
99        // https://github.com/houseabsolute/precious/issues/15.
100        self.run_git(vec!["config", "user.email", "precious@example.com"])?;
101        // With this on I get line ending warnings from git on Windows if I
102        // don't write out files with CRLF. Disabling this simplifies things
103        // greatly.
104        self.run_git(vec!["config", "core.autocrlf", "false"])?;
105
106        self.stage_all()?;
107        self.run_git(vec!["commit", "-m", "initial commit"])?;
108
109        Ok(())
110    }
111
112    pub fn with_config_file(self, file_name: &str, content: &str) -> Result<Self> {
113        if cfg!(windows) {
114            self.write_file(self.config_file(file_name), &content.replace('\n', "\r\n"))?;
115        } else {
116            self.write_file(self.config_file(file_name), content)?;
117        }
118        Ok(self)
119    }
120
121    pub fn pushd_to_git_root(&self) -> Result<Pushd> {
122        Ok(Pushd::new(self.git_root.clone())?)
123    }
124
125    pub fn pushd_to_subdir(&self) -> Result<Pushd> {
126        let mut subdir = self.git_root.clone();
127        subdir.push("src");
128        Ok(Pushd::new(subdir)?)
129    }
130
131    pub fn git_root(&self) -> PathBuf {
132        self.git_root.clone()
133    }
134
135    pub fn precious_root(&self) -> PathBuf {
136        self.precious_root.clone()
137    }
138
139    pub fn config_file(&self, file_name: &str) -> PathBuf {
140        let mut path = self.precious_root.clone();
141        path.push(file_name);
142        path
143    }
144
145    pub fn all_files(&self) -> Vec<PathBuf> {
146        let mut files = self.paths.clone();
147        files.sort();
148        files
149    }
150
151    pub fn all_files1(&self) -> Vec1<PathBuf> {
152        let mut files = self.paths.clone();
153        files.sort();
154        files.try_into().unwrap()
155    }
156
157    pub fn stage_all(&self) -> Result<()> {
158        self.run_git(vec!["add", "."])
159    }
160
161    pub fn stage_some(&self, files: &[&Path]) -> Result<()> {
162        let mut cmd = vec!["add"];
163        cmd.append(&mut files.iter().map(|f| f.to_str().unwrap()).collect());
164        self.run_git(cmd)
165    }
166
167    pub fn commit_all(&self) -> Result<()> {
168        self.run_git(vec!["commit", "-a", "-m", "committed"])
169    }
170
171    const ROOT_GITIGNORE: &'static str = "
172/**/bar.*
173can_ignore.*
174";
175
176    const TESTS_DATA_GITIGNORE: &'static str = "
177generated.*
178";
179
180    pub fn non_ignored_files() -> Vec<PathBuf> {
181        Self::PATHS
182            .iter()
183            .filter_map(|&p| {
184                if p.contains("can_ignore") || p.contains("bar.") || p.contains("generated.txt") {
185                    None
186                } else {
187                    Some(PathBuf::from(p))
188                }
189            })
190            .collect()
191    }
192
193    pub fn switch_to_branch(&self, branch: &str, exists: bool) -> Result<()> {
194        let mut args: Vec<&str> = vec!["checkout", "--quiet"];
195        if !exists {
196            args.push("-b");
197        }
198        args.push(branch);
199
200        Exec::builder()
201            .exe("git")
202            .args(args)
203            .ok_exit_codes(&[0])
204            .in_dir(&self.git_root)
205            .build()
206            .run()?;
207        Ok(())
208    }
209
210    pub fn merge_master(&self, expect_fail: bool) -> Result<()> {
211        let mut expect_codes = [0].to_vec();
212        if expect_fail {
213            expect_codes.push(1);
214        }
215
216        Exec::builder()
217            .exe("git")
218            .args(vec!["merge", "--quiet", "--no-ff", "--no-commit", "master"])
219            .ok_exit_codes(&expect_codes)
220            .in_dir(&self.git_root)
221            .ignore_stderr(vec![RERERE_RE.clone()])
222            .build()
223            .run()?;
224        Ok(())
225    }
226
227    pub fn add_gitignore_files(&self) -> Result<Vec<PathBuf>> {
228        self.write_file(&self.root_gitignore_file, Self::ROOT_GITIGNORE)?;
229        self.write_file(&self.tests_data_gitignore_file, Self::TESTS_DATA_GITIGNORE)?;
230
231        Ok(vec![
232            self.root_gitignore_file.clone(),
233            self.tests_data_gitignore_file.clone(),
234        ])
235    }
236
237    fn run_git(&self, args: Vec<&str>) -> Result<()> {
238        Exec::builder()
239            .exe("git")
240            .args(args)
241            .ok_exit_codes(&[0])
242            .in_dir(&self.git_root)
243            .build()
244            .run()?;
245        Ok(())
246    }
247
248    const TO_MODIFY: &'static [&'static str] = &["src/module.rs", "tests/data/foo.txt"];
249
250    pub fn modify_files(&self) -> Result<Vec<PathBuf>> {
251        let mut paths: Vec<PathBuf> = vec![];
252        for p in Self::TO_MODIFY.iter().map(PathBuf::from) {
253            let content = if is_rust_file(&p) {
254                "fn bar() {}\n"
255            } else {
256                "new text"
257            };
258            self.write_file(&p, content)?;
259            paths.push(p.clone());
260        }
261        paths.sort();
262        Ok(paths)
263    }
264
265    pub fn write_file<P: AsRef<Path>>(&self, rel: P, content: &str) -> Result<()> {
266        let mut full = self.precious_root.clone();
267        full.push(rel.as_ref());
268        let parent = full.parent().unwrap();
269        debug!("creating dir at {}", parent.display());
270        fs::create_dir_all(parent)
271            .with_context(|| format!("Creating dir at {}", parent.display()))?;
272        debug!("writing file at {}", full.display());
273        let mut file = fs::File::create(full.clone())
274            .context(format!("Creating file at {}", full.display()))?;
275        file.write_all(content.as_bytes())
276            .context(format!("Writing to file at {}", full.display()))?;
277
278        Ok(())
279    }
280
281    pub fn delete_file<P: AsRef<Path>>(&self, rel: P) -> Result<()> {
282        let mut full = self.precious_root.clone();
283        full.push(rel.as_ref());
284        debug!("deleting path at {}", full.display());
285        if full.is_file() {
286            return Ok(fs::remove_file(full)?);
287        }
288
289        Ok(fs::remove_dir_all(full)?)
290    }
291
292    #[cfg(not(target_os = "windows"))]
293    pub fn read_file(&self, rel: &Path) -> Result<String> {
294        let mut full = self.precious_root.clone();
295        full.push(rel);
296        let content = fs::read_to_string(full.clone())
297            .context(format!("Reading file at {}", full.display()))?;
298
299        Ok(content)
300    }
301}
302
303fn is_rust_file(p: &Path) -> bool {
304    if let Some(e) = p.extension() {
305        let rs = OsString::from("rs");
306        return *e == rs;
307    }
308    false
309}
310
311// The temp directory on macOS in GitHub Actions appears to be a symlink, but
312// canonicalizing on Windows breaks tests for some reason.
313pub fn maybe_canonicalize(path: &Path) -> Result<PathBuf> {
314    if cfg!(windows) {
315        return Ok(path.to_owned());
316    }
317    Ok(fs::canonicalize(path)?)
318}