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 {
104 let mut b = ignore::WalkBuilder::new(source);
105 b.hidden(false).git_ignore(false).ignore(false);
106 b.add_custom_ignore_filename(".yuiignore");
107 b.filter_entry(|entry| {
108 let name = entry.file_name();
109 name != ".yui" && name != ".git"
110 });
111 b
112}
113
114#[derive(Debug, Default)]
124pub struct YuiIgnoreStack {
125 layers: Vec<(Utf8PathBuf, ignore::gitignore::Gitignore)>,
126}
127
128impl YuiIgnoreStack {
129 pub fn new() -> Self {
130 Self::default()
131 }
132
133 pub fn push_dir(&mut self, dir: &Utf8Path) -> crate::Result<()> {
136 let path = dir.join(".yuiignore");
137 if !path.is_file() {
138 return Ok(());
139 }
140 let mut builder = ignore::gitignore::GitignoreBuilder::new(dir);
141 if let Some(e) = builder.add(path.as_std_path()) {
142 return Err(crate::Error::Config(format!("parsing {path}: {e}")));
143 }
144 let gi = builder
145 .build()
146 .map_err(|e| crate::Error::Config(format!("building {path}: {e}")))?;
147 self.layers.push((dir.to_owned(), gi));
148 Ok(())
149 }
150
151 pub fn pop_dir(&mut self, dir: &Utf8Path) {
155 if matches!(self.layers.last(), Some((p, _)) if p == dir) {
156 self.layers.pop();
157 }
158 }
159
160 pub fn is_ignored(&self, path: &Utf8Path, is_dir: bool) -> bool {
165 for (anchor, gi) in self.layers.iter().rev() {
166 let Ok(rel) = path.strip_prefix(anchor) else {
167 continue;
168 };
169 match gi.matched_path_or_any_parents(rel.as_std_path(), is_dir) {
170 ignore::Match::Ignore(_) => return true,
171 ignore::Match::Whitelist(_) => return false,
172 ignore::Match::None => continue,
173 }
174 }
175 false
176 }
177}
178
179pub fn mirror_into_backup(backup_root: &Utf8Path, abs_target: &Utf8Path) -> Utf8PathBuf {
187 let mut out = backup_root.to_path_buf();
188 for component in abs_target.components() {
189 match component {
190 Utf8Component::Prefix(p) => {
191 let s = p.as_str().trim_end_matches(':');
192 if !s.is_empty() {
193 out.push(s);
194 }
195 }
196 Utf8Component::RootDir | Utf8Component::CurDir => {}
197 Utf8Component::ParentDir => {}
198 Utf8Component::Normal(s) => {
199 out.push(s);
200 }
201 }
202 }
203 out
204}
205
206pub fn append_timestamp(path: &Utf8Path, ts: &str) -> Utf8PathBuf {
214 let parent = path.parent().map(Utf8PathBuf::from).unwrap_or_default();
215 let file_name = path.file_name().unwrap_or("");
216
217 let (stem, ext) = match (path.file_stem(), path.extension()) {
218 (Some(stem), Some(ext)) if !file_name.starts_with('.') => (stem, Some(ext)),
219 _ => (file_name, None),
220 };
221
222 let new_name = match ext {
223 Some(ext) => format!("{stem}_{ts}.{ext}"),
224 None => format!("{stem}_{ts}"),
225 };
226 parent.join(new_name)
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn mirror_unix_absolute() {
235 let r = mirror_into_backup(
236 Utf8Path::new("/dotfiles/.yui/backup"),
237 Utf8Path::new("/home/u/.config/foo.toml"),
238 );
239 assert_eq!(
240 r,
241 Utf8PathBuf::from("/dotfiles/.yui/backup/home/u/.config/foo.toml")
242 );
243 }
244
245 #[test]
246 fn append_with_extension() {
247 let r = append_timestamp(Utf8Path::new("a/b.yml"), "20260429_143022123");
248 assert_eq!(r, Utf8PathBuf::from("a/b_20260429_143022123.yml"));
249 }
250
251 #[test]
252 fn append_no_extension() {
253 let r = append_timestamp(Utf8Path::new("a/b"), "20260429_143022123");
254 assert_eq!(r, Utf8PathBuf::from("a/b_20260429_143022123"));
255 }
256
257 #[test]
258 fn append_dotfile() {
259 let r = append_timestamp(Utf8Path::new(".gitconfig"), "20260429_143022123");
260 assert_eq!(r, Utf8PathBuf::from(".gitconfig_20260429_143022123"));
261 }
262
263 #[test]
264 fn tilde_slash_expands() {
265 let home = Utf8Path::new("/h/u");
266 assert_eq!(
267 expand_tilde_with("~/foo", home),
268 Utf8PathBuf::from("/h/u/foo")
269 );
270 assert_eq!(
271 expand_tilde_with("~/.config/nvim", home),
272 Utf8PathBuf::from("/h/u/.config/nvim")
273 );
274 }
275
276 #[test]
277 fn tilde_backslash_expands_for_windows_input() {
278 let home = Utf8Path::new("C:/Users/u");
280 assert_eq!(
281 expand_tilde_with("~\\foo", home),
282 Utf8PathBuf::from("C:/Users/u/foo")
283 );
284 }
285
286 #[test]
287 fn lone_tilde_is_home() {
288 let home = Utf8Path::new("/h/u");
289 assert_eq!(expand_tilde_with("~", home), Utf8PathBuf::from("/h/u"));
290 }
291
292 #[test]
293 fn tilde_user_form_is_untouched() {
294 let home = Utf8Path::new("/h/u");
295 assert_eq!(
298 expand_tilde_with("~root/foo", home),
299 Utf8PathBuf::from("~root/foo")
300 );
301 }
302
303 #[test]
304 fn yui_ignore_stack_root_only() {
305 let tmp = tempfile::TempDir::new().unwrap();
306 let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
307 std::fs::write(root.join(".yuiignore"), "*.lock\n").unwrap();
308 let mut stack = YuiIgnoreStack::new();
309 stack.push_dir(&root).unwrap();
310 assert!(stack.is_ignored(&root.join("foo.lock"), false));
311 assert!(!stack.is_ignored(&root.join("foo.txt"), false));
312 stack.pop_dir(&root);
313 assert!(!stack.is_ignored(&root.join("foo.lock"), false));
315 }
316
317 #[test]
318 fn yui_ignore_stack_nested_overrides_parent() {
319 let tmp = tempfile::TempDir::new().unwrap();
320 let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
321 let inner = root.join("inner");
322 std::fs::create_dir_all(&inner).unwrap();
323 std::fs::write(root.join(".yuiignore"), "*.lock\n").unwrap();
324 std::fs::write(inner.join(".yuiignore"), "!*.lock\n").unwrap();
326
327 let mut stack = YuiIgnoreStack::new();
328 stack.push_dir(&root).unwrap();
329 assert!(stack.is_ignored(&root.join("a.lock"), false));
330 stack.push_dir(&inner).unwrap();
331 assert!(
332 !stack.is_ignored(&inner.join("a.lock"), false),
333 "deeper layer's whitelist should win"
334 );
335 stack.pop_dir(&inner);
336 assert!(stack.is_ignored(&root.join("b.lock"), false));
338 }
339
340 #[test]
341 fn yui_ignore_stack_pop_only_matches_top() {
342 let tmp = tempfile::TempDir::new().unwrap();
345 let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
346 std::fs::write(root.join(".yuiignore"), "*.lock\n").unwrap();
347 let no_ignore = root.join("plain");
348 std::fs::create_dir_all(&no_ignore).unwrap();
349
350 let mut stack = YuiIgnoreStack::new();
351 stack.push_dir(&root).unwrap();
352 stack.push_dir(&no_ignore).unwrap(); stack.pop_dir(&no_ignore); assert!(stack.is_ignored(&root.join("a.lock"), false));
356 }
357
358 #[test]
363 fn is_ignored_at_short_circuits_on_ignored_ancestor() {
364 let tmp = tempfile::TempDir::new().unwrap();
365 let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
366 let keepers = root.join("home").join("keepers");
367 std::fs::create_dir_all(&keepers).unwrap();
368 std::fs::write(root.join(".yuiignore"), "home/keepers/\n").unwrap();
370 std::fs::write(keepers.join(".yuiignore"), "!wanted.lock\n").unwrap();
372 assert!(is_ignored_at(&root, &keepers.join("wanted.lock"), false).unwrap());
375 }
376
377 #[test]
378 fn is_ignored_at_walks_intermediate_yuiignores() {
379 let tmp = tempfile::TempDir::new().unwrap();
380 let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
381 let mid = root.join("mid");
382 let leaf = mid.join("leaf");
383 std::fs::create_dir_all(&leaf).unwrap();
384 std::fs::write(mid.join(".yuiignore"), "secret*\n").unwrap();
385 assert!(is_ignored_at(&root, &leaf.join("secret.txt"), false).unwrap());
387 assert!(!is_ignored_at(&root, &leaf.join("public.txt"), false).unwrap());
388 let outside =
390 Utf8PathBuf::from_path_buf(tmp.path().parent().unwrap().to_path_buf()).unwrap();
391 assert!(!is_ignored_at(&root, &outside.join("anywhere"), false).unwrap());
392 }
393
394 #[test]
395 fn no_tilde_unchanged() {
396 let home = Utf8Path::new("/h/u");
397 assert_eq!(
398 expand_tilde_with("/abs/path", home),
399 Utf8PathBuf::from("/abs/path")
400 );
401 assert_eq!(
402 expand_tilde_with("rel/path", home),
403 Utf8PathBuf::from("rel/path")
404 );
405 assert_eq!(
407 expand_tilde_with("/foo/~/bar", home),
408 Utf8PathBuf::from("/foo/~/bar")
409 );
410 }
411}