1use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
5
6pub fn expand_tilde(s: &str) -> Utf8PathBuf {
17 match home_dir() {
18 Some(home) => expand_tilde_with(s, &home),
19 None => Utf8PathBuf::from(s),
20 }
21}
22
23pub fn expand_tilde_with(s: &str, home: &Utf8Path) -> Utf8PathBuf {
26 if let Some(rest) = s.strip_prefix("~/").or_else(|| s.strip_prefix("~\\")) {
27 home.join(rest)
28 } else if s == "~" {
29 home.to_path_buf()
30 } else {
31 Utf8PathBuf::from(s)
32 }
33}
34
35pub fn home_dir() -> Option<Utf8PathBuf> {
37 std::env::var("HOME")
38 .ok()
39 .or_else(|| std::env::var("USERPROFILE").ok())
40 .map(Utf8PathBuf::from)
41}
42
43pub fn is_ignored_at(source: &Utf8Path, path: &Utf8Path, is_dir: bool) -> crate::Result<bool> {
64 let Ok(rel) = path.strip_prefix(source) else {
65 return Ok(false);
66 };
67 let mut stack = YuiIgnoreStack::new();
68 stack.push_dir(source)?;
69 let mut cur = source.to_owned();
70 for component in rel.components() {
71 let Utf8Component::Normal(c) = component else {
72 continue;
73 };
74 cur.push(c);
75 if cur == path {
76 break;
77 }
78 if stack.is_ignored(&cur, true) {
79 return Ok(true);
80 }
81 stack.push_dir(&cur)?;
82 }
83 Ok(stack.is_ignored(path, is_dir))
84}
85
86pub fn source_walker(source: &Utf8Path) -> ignore::WalkBuilder {
101 let mut b = ignore::WalkBuilder::new(source);
102 b.hidden(false).git_ignore(false).ignore(false);
103 b.add_custom_ignore_filename(".yuiignore");
104 b.filter_entry(|entry| entry.file_name() != ".yui");
105 b
106}
107
108#[derive(Debug, Default)]
118pub struct YuiIgnoreStack {
119 layers: Vec<(Utf8PathBuf, ignore::gitignore::Gitignore)>,
120}
121
122impl YuiIgnoreStack {
123 pub fn new() -> Self {
124 Self::default()
125 }
126
127 pub fn push_dir(&mut self, dir: &Utf8Path) -> crate::Result<()> {
130 let path = dir.join(".yuiignore");
131 if !path.is_file() {
132 return Ok(());
133 }
134 let mut builder = ignore::gitignore::GitignoreBuilder::new(dir);
135 if let Some(e) = builder.add(path.as_std_path()) {
136 return Err(crate::Error::Config(format!("parsing {path}: {e}")));
137 }
138 let gi = builder
139 .build()
140 .map_err(|e| crate::Error::Config(format!("building {path}: {e}")))?;
141 self.layers.push((dir.to_owned(), gi));
142 Ok(())
143 }
144
145 pub fn pop_dir(&mut self, dir: &Utf8Path) {
149 if matches!(self.layers.last(), Some((p, _)) if p == dir) {
150 self.layers.pop();
151 }
152 }
153
154 pub fn is_ignored(&self, path: &Utf8Path, is_dir: bool) -> bool {
159 for (anchor, gi) in self.layers.iter().rev() {
160 let Ok(rel) = path.strip_prefix(anchor) else {
161 continue;
162 };
163 match gi.matched_path_or_any_parents(rel.as_std_path(), is_dir) {
164 ignore::Match::Ignore(_) => return true,
165 ignore::Match::Whitelist(_) => return false,
166 ignore::Match::None => continue,
167 }
168 }
169 false
170 }
171}
172
173pub fn mirror_into_backup(backup_root: &Utf8Path, abs_target: &Utf8Path) -> Utf8PathBuf {
181 let mut out = backup_root.to_path_buf();
182 for component in abs_target.components() {
183 match component {
184 Utf8Component::Prefix(p) => {
185 let s = p.as_str().trim_end_matches(':');
186 if !s.is_empty() {
187 out.push(s);
188 }
189 }
190 Utf8Component::RootDir | Utf8Component::CurDir => {}
191 Utf8Component::ParentDir => {}
192 Utf8Component::Normal(s) => {
193 out.push(s);
194 }
195 }
196 }
197 out
198}
199
200pub fn append_timestamp(path: &Utf8Path, ts: &str) -> Utf8PathBuf {
208 let parent = path.parent().map(Utf8PathBuf::from).unwrap_or_default();
209 let file_name = path.file_name().unwrap_or("");
210
211 let (stem, ext) = match (path.file_stem(), path.extension()) {
212 (Some(stem), Some(ext)) if !file_name.starts_with('.') => (stem, Some(ext)),
213 _ => (file_name, None),
214 };
215
216 let new_name = match ext {
217 Some(ext) => format!("{stem}_{ts}.{ext}"),
218 None => format!("{stem}_{ts}"),
219 };
220 parent.join(new_name)
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn mirror_unix_absolute() {
229 let r = mirror_into_backup(
230 Utf8Path::new("/dotfiles/.yui/backup"),
231 Utf8Path::new("/home/u/.config/foo.toml"),
232 );
233 assert_eq!(
234 r,
235 Utf8PathBuf::from("/dotfiles/.yui/backup/home/u/.config/foo.toml")
236 );
237 }
238
239 #[test]
240 fn append_with_extension() {
241 let r = append_timestamp(Utf8Path::new("a/b.yml"), "20260429_143022123");
242 assert_eq!(r, Utf8PathBuf::from("a/b_20260429_143022123.yml"));
243 }
244
245 #[test]
246 fn append_no_extension() {
247 let r = append_timestamp(Utf8Path::new("a/b"), "20260429_143022123");
248 assert_eq!(r, Utf8PathBuf::from("a/b_20260429_143022123"));
249 }
250
251 #[test]
252 fn append_dotfile() {
253 let r = append_timestamp(Utf8Path::new(".gitconfig"), "20260429_143022123");
254 assert_eq!(r, Utf8PathBuf::from(".gitconfig_20260429_143022123"));
255 }
256
257 #[test]
258 fn tilde_slash_expands() {
259 let home = Utf8Path::new("/h/u");
260 assert_eq!(
261 expand_tilde_with("~/foo", home),
262 Utf8PathBuf::from("/h/u/foo")
263 );
264 assert_eq!(
265 expand_tilde_with("~/.config/nvim", home),
266 Utf8PathBuf::from("/h/u/.config/nvim")
267 );
268 }
269
270 #[test]
271 fn tilde_backslash_expands_for_windows_input() {
272 let home = Utf8Path::new("C:/Users/u");
274 assert_eq!(
275 expand_tilde_with("~\\foo", home),
276 Utf8PathBuf::from("C:/Users/u/foo")
277 );
278 }
279
280 #[test]
281 fn lone_tilde_is_home() {
282 let home = Utf8Path::new("/h/u");
283 assert_eq!(expand_tilde_with("~", home), Utf8PathBuf::from("/h/u"));
284 }
285
286 #[test]
287 fn tilde_user_form_is_untouched() {
288 let home = Utf8Path::new("/h/u");
289 assert_eq!(
292 expand_tilde_with("~root/foo", home),
293 Utf8PathBuf::from("~root/foo")
294 );
295 }
296
297 #[test]
298 fn yui_ignore_stack_root_only() {
299 let tmp = tempfile::TempDir::new().unwrap();
300 let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
301 std::fs::write(root.join(".yuiignore"), "*.lock\n").unwrap();
302 let mut stack = YuiIgnoreStack::new();
303 stack.push_dir(&root).unwrap();
304 assert!(stack.is_ignored(&root.join("foo.lock"), false));
305 assert!(!stack.is_ignored(&root.join("foo.txt"), false));
306 stack.pop_dir(&root);
307 assert!(!stack.is_ignored(&root.join("foo.lock"), false));
309 }
310
311 #[test]
312 fn yui_ignore_stack_nested_overrides_parent() {
313 let tmp = tempfile::TempDir::new().unwrap();
314 let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
315 let inner = root.join("inner");
316 std::fs::create_dir_all(&inner).unwrap();
317 std::fs::write(root.join(".yuiignore"), "*.lock\n").unwrap();
318 std::fs::write(inner.join(".yuiignore"), "!*.lock\n").unwrap();
320
321 let mut stack = YuiIgnoreStack::new();
322 stack.push_dir(&root).unwrap();
323 assert!(stack.is_ignored(&root.join("a.lock"), false));
324 stack.push_dir(&inner).unwrap();
325 assert!(
326 !stack.is_ignored(&inner.join("a.lock"), false),
327 "deeper layer's whitelist should win"
328 );
329 stack.pop_dir(&inner);
330 assert!(stack.is_ignored(&root.join("b.lock"), false));
332 }
333
334 #[test]
335 fn yui_ignore_stack_pop_only_matches_top() {
336 let tmp = tempfile::TempDir::new().unwrap();
339 let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
340 std::fs::write(root.join(".yuiignore"), "*.lock\n").unwrap();
341 let no_ignore = root.join("plain");
342 std::fs::create_dir_all(&no_ignore).unwrap();
343
344 let mut stack = YuiIgnoreStack::new();
345 stack.push_dir(&root).unwrap();
346 stack.push_dir(&no_ignore).unwrap(); stack.pop_dir(&no_ignore); assert!(stack.is_ignored(&root.join("a.lock"), false));
350 }
351
352 #[test]
357 fn is_ignored_at_short_circuits_on_ignored_ancestor() {
358 let tmp = tempfile::TempDir::new().unwrap();
359 let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
360 let keepers = root.join("home").join("keepers");
361 std::fs::create_dir_all(&keepers).unwrap();
362 std::fs::write(root.join(".yuiignore"), "home/keepers/\n").unwrap();
364 std::fs::write(keepers.join(".yuiignore"), "!wanted.lock\n").unwrap();
366 assert!(is_ignored_at(&root, &keepers.join("wanted.lock"), false).unwrap());
369 }
370
371 #[test]
372 fn is_ignored_at_walks_intermediate_yuiignores() {
373 let tmp = tempfile::TempDir::new().unwrap();
374 let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
375 let mid = root.join("mid");
376 let leaf = mid.join("leaf");
377 std::fs::create_dir_all(&leaf).unwrap();
378 std::fs::write(mid.join(".yuiignore"), "secret*\n").unwrap();
379 assert!(is_ignored_at(&root, &leaf.join("secret.txt"), false).unwrap());
381 assert!(!is_ignored_at(&root, &leaf.join("public.txt"), false).unwrap());
382 let outside =
384 Utf8PathBuf::from_path_buf(tmp.path().parent().unwrap().to_path_buf()).unwrap();
385 assert!(!is_ignored_at(&root, &outside.join("anywhere"), false).unwrap());
386 }
387
388 #[test]
389 fn no_tilde_unchanged() {
390 let home = Utf8Path::new("/h/u");
391 assert_eq!(
392 expand_tilde_with("/abs/path", home),
393 Utf8PathBuf::from("/abs/path")
394 );
395 assert_eq!(
396 expand_tilde_with("rel/path", home),
397 Utf8PathBuf::from("rel/path")
398 );
399 assert_eq!(
401 expand_tilde_with("/foo/~/bar", home),
402 Utf8PathBuf::from("/foo/~/bar")
403 );
404 }
405}