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 resolve_mount_src(source: &Utf8Path, src: &str) -> Utf8PathBuf {
62 resolve_mount_src_with(source, src, home_dir().as_deref())
63}
64
65pub fn resolve_mount_src_with(
69 source: &Utf8Path,
70 src: &str,
71 home: Option<&Utf8Path>,
72) -> Utf8PathBuf {
73 let expanded = match home {
74 Some(h) => expand_tilde_with(src, h),
75 None => Utf8PathBuf::from(src),
76 };
77 let is_tilde_form = src == "~" || src.starts_with("~/") || src.starts_with("~\\");
78 if is_tilde_form && expanded.as_str() == src {
79 return expanded;
83 }
84 source.join(expanded)
85}
86
87pub fn is_ignored_at(source: &Utf8Path, path: &Utf8Path, is_dir: bool) -> crate::Result<bool> {
108 let Ok(rel) = path.strip_prefix(source) else {
109 return Ok(false);
110 };
111 let mut stack = YuiIgnoreStack::new();
112 stack.push_dir(source)?;
113 let mut cur = source.to_owned();
114 for component in rel.components() {
115 let Utf8Component::Normal(c) = component else {
116 continue;
117 };
118 cur.push(c);
119 if cur == path {
120 break;
121 }
122 if stack.is_ignored(&cur, true) {
123 return Ok(true);
124 }
125 stack.push_dir(&cur)?;
126 }
127 Ok(stack.is_ignored(path, is_dir))
128}
129
130pub fn source_walker(source: &Utf8Path) -> ignore::WalkBuilder {
148 let mut b = ignore::WalkBuilder::new(source);
149 b.hidden(false).git_ignore(false).ignore(false);
150 b.add_custom_ignore_filename(".yuiignore");
151 b.filter_entry(|entry| {
152 let name = entry.file_name();
153 name != ".yui" && name != ".git"
154 });
155 b
156}
157
158#[derive(Debug, Default)]
168pub struct YuiIgnoreStack {
169 layers: Vec<(Utf8PathBuf, ignore::gitignore::Gitignore)>,
170}
171
172impl YuiIgnoreStack {
173 pub fn new() -> Self {
174 Self::default()
175 }
176
177 pub fn push_dir(&mut self, dir: &Utf8Path) -> crate::Result<()> {
180 let path = dir.join(".yuiignore");
181 if !path.is_file() {
182 return Ok(());
183 }
184 let mut builder = ignore::gitignore::GitignoreBuilder::new(dir);
185 if let Some(e) = builder.add(path.as_std_path()) {
186 return Err(crate::Error::Config(format!("parsing {path}: {e}")));
187 }
188 let gi = builder
189 .build()
190 .map_err(|e| crate::Error::Config(format!("building {path}: {e}")))?;
191 self.layers.push((dir.to_owned(), gi));
192 Ok(())
193 }
194
195 pub fn pop_dir(&mut self, dir: &Utf8Path) {
199 if matches!(self.layers.last(), Some((p, _)) if p == dir) {
200 self.layers.pop();
201 }
202 }
203
204 pub fn is_ignored(&self, path: &Utf8Path, is_dir: bool) -> bool {
209 for (anchor, gi) in self.layers.iter().rev() {
210 let Ok(rel) = path.strip_prefix(anchor) else {
211 continue;
212 };
213 match gi.matched_path_or_any_parents(rel.as_std_path(), is_dir) {
214 ignore::Match::Ignore(_) => return true,
215 ignore::Match::Whitelist(_) => return false,
216 ignore::Match::None => continue,
217 }
218 }
219 false
220 }
221}
222
223pub fn mirror_into_backup(backup_root: &Utf8Path, abs_target: &Utf8Path) -> Utf8PathBuf {
231 let mut out = backup_root.to_path_buf();
232 for component in abs_target.components() {
233 match component {
234 Utf8Component::Prefix(p) => {
235 let s = p.as_str().trim_end_matches(':');
236 if !s.is_empty() {
237 out.push(s);
238 }
239 }
240 Utf8Component::RootDir | Utf8Component::CurDir => {}
241 Utf8Component::ParentDir => {}
242 Utf8Component::Normal(s) => {
243 out.push(s);
244 }
245 }
246 }
247 out
248}
249
250pub fn append_timestamp(path: &Utf8Path, ts: &str) -> Utf8PathBuf {
258 let parent = path.parent().map(Utf8PathBuf::from).unwrap_or_default();
259 let file_name = path.file_name().unwrap_or("");
260
261 let (stem, ext) = match (path.file_stem(), path.extension()) {
262 (Some(stem), Some(ext)) if !file_name.starts_with('.') => (stem, Some(ext)),
263 _ => (file_name, None),
264 };
265
266 let new_name = match ext {
267 Some(ext) => format!("{stem}_{ts}.{ext}"),
268 None => format!("{stem}_{ts}"),
269 };
270 parent.join(new_name)
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn mirror_unix_absolute() {
279 let r = mirror_into_backup(
280 Utf8Path::new("/dotfiles/.yui/backup"),
281 Utf8Path::new("/home/u/.config/foo.toml"),
282 );
283 assert_eq!(
284 r,
285 Utf8PathBuf::from("/dotfiles/.yui/backup/home/u/.config/foo.toml")
286 );
287 }
288
289 #[test]
290 fn append_with_extension() {
291 let r = append_timestamp(Utf8Path::new("a/b.yml"), "20260429_143022123");
292 assert_eq!(r, Utf8PathBuf::from("a/b_20260429_143022123.yml"));
293 }
294
295 #[test]
296 fn append_no_extension() {
297 let r = append_timestamp(Utf8Path::new("a/b"), "20260429_143022123");
298 assert_eq!(r, Utf8PathBuf::from("a/b_20260429_143022123"));
299 }
300
301 #[test]
302 fn append_dotfile() {
303 let r = append_timestamp(Utf8Path::new(".gitconfig"), "20260429_143022123");
304 assert_eq!(r, Utf8PathBuf::from(".gitconfig_20260429_143022123"));
305 }
306
307 #[test]
308 fn tilde_slash_expands() {
309 let home = Utf8Path::new("/h/u");
310 assert_eq!(
311 expand_tilde_with("~/foo", home),
312 Utf8PathBuf::from("/h/u/foo")
313 );
314 assert_eq!(
315 expand_tilde_with("~/.config/nvim", home),
316 Utf8PathBuf::from("/h/u/.config/nvim")
317 );
318 }
319
320 #[test]
321 fn tilde_backslash_expands_for_windows_input() {
322 let home = Utf8Path::new("C:/Users/u");
324 assert_eq!(
325 expand_tilde_with("~\\foo", home),
326 Utf8PathBuf::from("C:/Users/u/foo")
327 );
328 }
329
330 #[test]
331 fn lone_tilde_is_home() {
332 let home = Utf8Path::new("/h/u");
333 assert_eq!(expand_tilde_with("~", home), Utf8PathBuf::from("/h/u"));
334 }
335
336 #[test]
337 fn tilde_user_form_is_untouched() {
338 let home = Utf8Path::new("/h/u");
339 assert_eq!(
342 expand_tilde_with("~root/foo", home),
343 Utf8PathBuf::from("~root/foo")
344 );
345 }
346
347 #[test]
355 fn resolve_mount_src_preserves_unresolved_tilde() {
356 let source = Utf8Path::new("/dot");
357 assert_eq!(
359 resolve_mount_src_with(source, "~/foo", None),
360 Utf8PathBuf::from("~/foo"),
361 );
362 assert_eq!(
363 resolve_mount_src_with(source, "~", None),
364 Utf8PathBuf::from("~"),
365 );
366 }
367
368 #[test]
372 fn resolve_mount_src_expands_tilde_when_home_set() {
373 let source = Utf8Path::new("/dot");
374 let home = Utf8Path::new("/h/u");
375 assert_eq!(
376 resolve_mount_src_with(source, "~/private/home", Some(home)),
377 Utf8PathBuf::from("/h/u/private/home"),
378 );
379 assert_eq!(
380 resolve_mount_src_with(source, "~", Some(home)),
381 Utf8PathBuf::from("/h/u"),
382 );
383 }
384
385 #[test]
388 fn resolve_mount_src_relative_joins_under_source() {
389 let source = Utf8Path::new("/dot");
390 assert_eq!(
391 resolve_mount_src_with(source, "home", Some(Utf8Path::new("/h/u"))),
392 Utf8PathBuf::from("/dot/home"),
393 );
394 }
395
396 #[test]
399 fn resolve_mount_src_absolute_returns_verbatim() {
400 let source = Utf8Path::new("/dot");
401 assert_eq!(
402 resolve_mount_src_with(source, "/abs/private/home", Some(Utf8Path::new("/h/u"))),
403 Utf8PathBuf::from("/abs/private/home"),
404 );
405 }
406
407 #[test]
408 fn yui_ignore_stack_root_only() {
409 let tmp = tempfile::TempDir::new().unwrap();
410 let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
411 std::fs::write(root.join(".yuiignore"), "*.lock\n").unwrap();
412 let mut stack = YuiIgnoreStack::new();
413 stack.push_dir(&root).unwrap();
414 assert!(stack.is_ignored(&root.join("foo.lock"), false));
415 assert!(!stack.is_ignored(&root.join("foo.txt"), false));
416 stack.pop_dir(&root);
417 assert!(!stack.is_ignored(&root.join("foo.lock"), false));
419 }
420
421 #[test]
422 fn yui_ignore_stack_nested_overrides_parent() {
423 let tmp = tempfile::TempDir::new().unwrap();
424 let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
425 let inner = root.join("inner");
426 std::fs::create_dir_all(&inner).unwrap();
427 std::fs::write(root.join(".yuiignore"), "*.lock\n").unwrap();
428 std::fs::write(inner.join(".yuiignore"), "!*.lock\n").unwrap();
430
431 let mut stack = YuiIgnoreStack::new();
432 stack.push_dir(&root).unwrap();
433 assert!(stack.is_ignored(&root.join("a.lock"), false));
434 stack.push_dir(&inner).unwrap();
435 assert!(
436 !stack.is_ignored(&inner.join("a.lock"), false),
437 "deeper layer's whitelist should win"
438 );
439 stack.pop_dir(&inner);
440 assert!(stack.is_ignored(&root.join("b.lock"), false));
442 }
443
444 #[test]
445 fn yui_ignore_stack_pop_only_matches_top() {
446 let tmp = tempfile::TempDir::new().unwrap();
449 let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
450 std::fs::write(root.join(".yuiignore"), "*.lock\n").unwrap();
451 let no_ignore = root.join("plain");
452 std::fs::create_dir_all(&no_ignore).unwrap();
453
454 let mut stack = YuiIgnoreStack::new();
455 stack.push_dir(&root).unwrap();
456 stack.push_dir(&no_ignore).unwrap(); stack.pop_dir(&no_ignore); assert!(stack.is_ignored(&root.join("a.lock"), false));
460 }
461
462 #[test]
467 fn is_ignored_at_short_circuits_on_ignored_ancestor() {
468 let tmp = tempfile::TempDir::new().unwrap();
469 let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
470 let keepers = root.join("home").join("keepers");
471 std::fs::create_dir_all(&keepers).unwrap();
472 std::fs::write(root.join(".yuiignore"), "home/keepers/\n").unwrap();
474 std::fs::write(keepers.join(".yuiignore"), "!wanted.lock\n").unwrap();
476 assert!(is_ignored_at(&root, &keepers.join("wanted.lock"), false).unwrap());
479 }
480
481 #[test]
482 fn is_ignored_at_walks_intermediate_yuiignores() {
483 let tmp = tempfile::TempDir::new().unwrap();
484 let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
485 let mid = root.join("mid");
486 let leaf = mid.join("leaf");
487 std::fs::create_dir_all(&leaf).unwrap();
488 std::fs::write(mid.join(".yuiignore"), "secret*\n").unwrap();
489 assert!(is_ignored_at(&root, &leaf.join("secret.txt"), false).unwrap());
491 assert!(!is_ignored_at(&root, &leaf.join("public.txt"), false).unwrap());
492 let outside =
494 Utf8PathBuf::from_path_buf(tmp.path().parent().unwrap().to_path_buf()).unwrap();
495 assert!(!is_ignored_at(&root, &outside.join("anywhere"), false).unwrap());
496 }
497
498 #[test]
499 fn no_tilde_unchanged() {
500 let home = Utf8Path::new("/h/u");
501 assert_eq!(
502 expand_tilde_with("/abs/path", home),
503 Utf8PathBuf::from("/abs/path")
504 );
505 assert_eq!(
506 expand_tilde_with("rel/path", home),
507 Utf8PathBuf::from("rel/path")
508 );
509 assert_eq!(
511 expand_tilde_with("/foo/~/bar", home),
512 Utf8PathBuf::from("/foo/~/bar")
513 );
514 }
515}