yolk/
util.rs

1use std::{
2    collections::HashSet,
3    io::Write,
4    path::{Path, PathBuf},
5};
6
7use cached::UnboundCache;
8use fs_err::OpenOptions;
9use miette::{Context as _, IntoDiagnostic as _};
10use regex::Regex;
11
12use crate::yolk_paths::default_yolk_dir;
13
14/// Rename or move a file, but only if the destination doesn't exist.
15/// This is a safer verison of [`std::fs::rename`] that doesn't overwrite files.
16pub fn rename_safely(original: impl AsRef<Path>, new: impl AsRef<Path>) -> miette::Result<()> {
17    let original = original.as_ref();
18    let new = new.as_ref();
19    tracing::trace!("Renaming {} -> {}", original.abbr(), new.abbr());
20    miette::ensure!(
21        !new.exists(),
22        "Failed to move file {} to {}: File already exists.",
23        original.abbr(),
24        new.abbr()
25    );
26    fs_err::rename(original, new)
27        .into_diagnostic()
28        .wrap_err("Failed to rename file")?;
29    Ok(())
30}
31
32pub fn file_entries_recursive(
33    path: impl AsRef<Path>,
34) -> impl Iterator<Item = miette::Result<PathBuf>> {
35    walkdir::WalkDir::new(path)
36        .into_iter()
37        .filter(|x| x.as_ref().map_or(true, |x| !x.path().is_dir()))
38        .map(|x| x.map(|x| x.into_path()))
39        .map(|x| x.into_diagnostic())
40}
41
42/// Ensure that a file contains the given lines, appending them if they are missing. If the file does not yet exist, it will be created.
43pub fn ensure_file_contains_lines(path: impl AsRef<Path>, lines: &[&str]) -> miette::Result<()> {
44    let path = path.as_ref();
45
46    let mut trailing_newline_exists = true;
47
48    let existing_lines = if path.exists() {
49        let content = fs_err::read_to_string(path).into_diagnostic()?;
50        trailing_newline_exists = content.ends_with('\n');
51        content.lines().map(|x| x.to_string()).collect()
52    } else {
53        HashSet::new()
54    };
55    if lines.iter().all(|x| existing_lines.contains(*x)) {
56        return Ok(());
57    }
58    let mut file = OpenOptions::new()
59        .append(true)
60        .create(true)
61        .open(path)
62        .into_diagnostic()?;
63    let missing_lines = lines.iter().filter(|x| !existing_lines.contains(**x));
64    if !trailing_newline_exists {
65        writeln!(file).into_diagnostic()?;
66    }
67    for line in missing_lines {
68        writeln!(file, "{}", line).into_diagnostic()?;
69    }
70    Ok(())
71}
72
73/// Ensure that a file does not contain the given lines, removing them if they are present.
74pub fn ensure_file_doesnt_contain_lines(
75    path: impl AsRef<Path>,
76    lines: &[&str],
77) -> miette::Result<()> {
78    let path = path.as_ref();
79    if !path.exists() {
80        return Ok(());
81    }
82    let content = fs_err::read_to_string(path).into_diagnostic()?;
83    let trailing_newline_exists = content.ends_with('\n');
84    let remaining_lines = content
85        .lines()
86        .filter(|x| !lines.contains(x))
87        .collect::<Vec<_>>();
88    if remaining_lines.len() == content.lines().count() {
89        return Ok(());
90    }
91    let new_content = format!(
92        "{}{}",
93        remaining_lines.join("\n"),
94        if trailing_newline_exists { "\n" } else { "" }
95    );
96    fs_err::write(path, new_content).into_diagnostic()?;
97    Ok(())
98}
99
100#[extend::ext(pub)]
101impl Path {
102    /// [`fs_err::canonicalize`] but on windows it doesn't return UNC paths.
103    fn canonical(&self) -> miette::Result<PathBuf> {
104        Ok(dunce::simplified(&fs_err::canonicalize(self).into_diagnostic()?).to_path_buf())
105    }
106
107    /// Stringify the path into an abbreviated form.
108    ///
109    /// This replaces the home path with `~`, as well as reducing paths that point into the eggs directory to `eggs/rest/of/path`.
110    fn abbr(&self) -> String {
111        let eggs = default_yolk_dir().join("eggs");
112        match dirs::home_dir() {
113            Some(home) => self
114                .strip_prefix(&eggs)
115                .map(|x| PathBuf::from("eggs").join(x))
116                .or_else(|_| self.strip_prefix(&home).map(|x| PathBuf::from("~").join(x)))
117                .unwrap_or_else(|_| self.into())
118                .display()
119                .to_string(),
120            _ => self.display().to_string(),
121        }
122    }
123
124    /// Expands `~` in a path to the home directory.
125    fn expanduser(&self) -> PathBuf {
126        #[cfg(not(test))]
127        let Some(home) = dirs::home_dir() else {
128            return self.to_path_buf();
129        };
130        #[cfg(test)]
131        let home = test_util::get_home_dir();
132
133        if let Some(first) = self.components().next() {
134            if first.as_os_str().to_string_lossy() == "~" {
135                return home.join(self.strip_prefix("~").unwrap());
136            }
137        }
138        self.to_path_buf()
139    }
140
141    #[track_caller]
142    fn assert_absolute(&self, name: &str) {
143        assert!(
144            self.is_absolute(),
145            "Path {} must be absolute, but was: {}",
146            name,
147            self.display()
148        );
149    }
150
151    #[track_caller]
152    fn assert_starts_with(&self, start: impl AsRef<Path>, name: &str) {
153        assert!(
154            self.starts_with(start.as_ref()),
155            "Path {} must be inside {}, but was: {}",
156            name,
157            start.as_ref().display(),
158            self.display()
159        );
160    }
161}
162
163pub fn create_regex(s: impl AsRef<str>) -> miette::Result<Regex> {
164    cached::cached_key! {
165         REGEXES: UnboundCache<String, Result<Regex, regex::Error>> = UnboundCache::new();
166         Key = { s.to_string() };
167         fn create_regex_cached(s: &str) -> Result<Regex, regex::Error> = {
168             Regex::new(s)
169         }
170    }
171    create_regex_cached(s.as_ref()).into_diagnostic()
172}
173
174#[cfg(test)]
175pub mod test_util {
176    use std::cell::RefCell;
177    use std::path::PathBuf;
178    use std::thread_local;
179
180    use miette::IntoDiagnostic as _;
181
182    thread_local! {
183        static HOME_DIR: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
184    }
185
186    pub fn set_home_dir(path: PathBuf) {
187        HOME_DIR.with(|home_dir| {
188            *home_dir.borrow_mut() = Some(path);
189        });
190    }
191
192    pub fn get_home_dir() -> PathBuf {
193        HOME_DIR.with_borrow(|x| x.clone()).expect(
194            "Home directory not set in this test. Use `set_home_dir` to set the home directory.",
195        )
196    }
197
198    /// like <https://crates.io/crates/testresult>, but shows the debug output instead of display.
199    pub type TestResult<T = ()> = std::result::Result<T, TestError>;
200
201    #[derive(Debug)]
202    pub enum TestError {}
203
204    impl<T: std::fmt::Debug + std::fmt::Display> From<T> for TestError {
205        #[track_caller] // Will show the location of the caller in test failure messages
206        fn from(error: T) -> Self {
207            // Use alternate format for rich error message for anyhow
208            // See: https://docs.rs/anyhow/latest/anyhow/struct.Error.html#display-representations
209            panic!("error: {} - {:?}", std::any::type_name::<T>(), error);
210        }
211    }
212
213    pub fn setup_and_init_test_yolk() -> miette::Result<(
214        assert_fs::TempDir,
215        crate::yolk::Yolk,
216        assert_fs::fixture::ChildPath,
217    )> {
218        use assert_fs::prelude::PathChild as _;
219
220        let home = assert_fs::TempDir::new().into_diagnostic()?;
221        let paths = crate::yolk_paths::YolkPaths::new(home.join("yolk"), home.to_path_buf());
222        let yolk = crate::yolk::Yolk::new(paths);
223        std::env::set_var("HOME", "/tmp/TEST_HOMEDIR_SHOULD_NOT_BE_USED");
224        set_home_dir(home.to_path_buf());
225
226        let eggs = home.child("yolk/eggs");
227        let yolk_binary_path = assert_cmd::cargo::cargo_bin("yolk");
228        yolk.init_yolk(Some(yolk_binary_path.to_string_lossy().as_ref()))?;
229        Ok((home, yolk, eggs))
230    }
231
232    pub fn render_error(e: impl miette::Diagnostic) -> String {
233        use miette::GraphicalReportHandler;
234
235        let mut out = String::new();
236        GraphicalReportHandler::new()
237            .with_theme(miette::GraphicalTheme::unicode_nocolor())
238            .render_report(&mut out, &e)
239            .unwrap();
240        out
241    }
242
243    pub fn render_report(e: miette::Report) -> String {
244        use miette::GraphicalReportHandler;
245
246        let mut out = String::new();
247        GraphicalReportHandler::new()
248            .with_theme(miette::GraphicalTheme::unicode_nocolor())
249            .render_report(&mut out, e.as_ref())
250            .unwrap();
251        out
252    }
253}