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
14pub 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
42pub 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
73pub 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 fn canonical(&self) -> miette::Result<PathBuf> {
104 Ok(dunce::simplified(&fs_err::canonicalize(self).into_diagnostic()?).to_path_buf())
105 }
106
107 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 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 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] fn from(error: T) -> Self {
207 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}