1use std::fmt::Write as _;
7
8use anyhow::{Context as _, Result};
9use camino::{Utf8Path, Utf8PathBuf};
10use tera::Context as TeraContext;
11use tracing::{info, warn};
12
13use crate::config::{self, Config, HookPhase, IconsMode, MountStrategy};
14use crate::hook::{self, HookOutcome};
15use crate::icons::Icons;
16use crate::link::{self, EffectiveDirMode, EffectiveFileMode, resolve_dir_mode, resolve_file_mode};
17use crate::marker::{self, MarkerSpec};
18use crate::mount::{self, ResolvedMount};
19use crate::render::{self, RenderReport};
20use crate::template;
21use crate::vars::YuiVars;
22use crate::{absorb, backup, paths};
23
24pub fn init(source: Option<Utf8PathBuf>, git_hooks: bool) -> Result<()> {
31 let dir = match source {
32 Some(s) => absolutize(&s)?,
33 None => current_dir_utf8()?,
34 };
35 std::fs::create_dir_all(&dir)?;
36 let config_path = dir.join("config.toml");
37 let scaffolded = if !config_path.exists() {
38 std::fs::write(&config_path, SKELETON_CONFIG)?;
39 info!("initialized yui source repo at {dir}");
40 info!("created: {config_path}");
41 true
42 } else if git_hooks {
43 info!(
48 "config.toml already exists at {config_path} \
49 — skipping scaffold, installing git hooks only"
50 );
51 false
52 } else {
53 anyhow::bail!("config.toml already exists at {config_path}");
54 };
55
56 ensure_gitignore_yui_entries(&dir)?;
63
64 if git_hooks {
65 install_git_hooks(&dir)?;
66 }
67 if scaffolded {
68 info!("next: edit config.toml, then run `yui apply`");
69 }
70 Ok(())
71}
72
73const YUI_REQUIRED_GITIGNORE: &[&str] = &[
78 "/.yui/state.json",
79 "/.yui/state.json.tmp",
80 "/.yui/backup/",
81 "config.local.toml",
82];
83
84fn ensure_gitignore_yui_entries(dir: &Utf8Path) -> Result<()> {
90 let path = dir.join(".gitignore");
91 if !path.exists() {
92 std::fs::write(&path, SKELETON_GITIGNORE)?;
93 info!("created: {path}");
94 return Ok(());
95 }
96 let existing = std::fs::read_to_string(&path)?;
97 let missing: Vec<&str> = YUI_REQUIRED_GITIGNORE
98 .iter()
99 .copied()
100 .filter(|entry| !existing.lines().any(|line| line.trim() == *entry))
101 .collect();
102 if missing.is_empty() {
103 return Ok(());
104 }
105 let mut next = existing;
106 if !next.is_empty() && !next.ends_with('\n') {
107 next.push('\n');
108 }
109 if !next.is_empty() {
110 next.push('\n');
111 }
112 next.push_str("# yui per-machine state and backups (added by `yui init`).\n");
113 for entry in &missing {
114 next.push_str(entry);
115 next.push('\n');
116 }
117 std::fs::write(&path, next)?;
118 info!(
119 "updated .gitignore: appended {} yui entr{} ({})",
120 missing.len(),
121 if missing.len() == 1 { "y" } else { "ies" },
122 missing.join(", ")
123 );
124 Ok(())
125}
126
127fn install_git_hooks(source: &Utf8Path) -> Result<()> {
141 let out = std::process::Command::new("git")
142 .args(["rev-parse", "--git-path", "hooks"])
143 .current_dir(source.as_std_path())
144 .output()
145 .with_context(|| format!("git rev-parse --git-path hooks in {source}"))?;
146 if !out.status.success() {
147 let stderr = String::from_utf8_lossy(&out.stderr);
148 anyhow::bail!(
149 "--git-hooks: {source} doesn't look like a git repo \
150 (run `git init` first). git: {}",
151 stderr.trim()
152 );
153 }
154 let raw = String::from_utf8(out.stdout)?;
155 let hooks_dir = {
156 let p = Utf8PathBuf::from(raw.trim());
157 if p.is_absolute() { p } else { source.join(p) }
158 };
159 std::fs::create_dir_all(&hooks_dir).with_context(|| format!("mkdir -p {hooks_dir}"))?;
160
161 for (name, body) in [("pre-commit", PRE_COMMIT_HOOK), ("pre-push", PRE_PUSH_HOOK)] {
162 let path = hooks_dir.join(name);
163 if path.exists() {
164 warn!("--git-hooks: {path} already exists — leaving it alone");
165 continue;
166 }
167 std::fs::write(&path, body).with_context(|| format!("write hook {path}"))?;
168 #[cfg(unix)]
169 {
170 use std::os::unix::fs::PermissionsExt;
171 let mut perms = std::fs::metadata(&path)?.permissions();
172 perms.set_mode(0o755);
173 std::fs::set_permissions(&path, perms)?;
174 }
175 info!("installed: {path}");
176 }
177 Ok(())
178}
179
180const PRE_COMMIT_HOOK: &str = r#"#!/bin/sh
181# Installed by `yui init --git-hooks`.
182# Reject the commit if any `*.tera` template would render to something
183# that diverges from the rendered output staged alongside it. Run
184# `yui apply` (or `yui render`) to refresh and re-commit.
185exec yui render --check
186"#;
187
188const PRE_PUSH_HOOK: &str = r#"#!/bin/sh
189# Installed by `yui init --git-hooks`.
190# Same render-drift check as pre-commit, mirrored on push so a
191# `--no-verify` commit doesn't sneak diverged state to the remote.
192exec yui render --check
193"#;
194
195pub fn apply(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
196 let source = resolve_source(source)?;
197 let yui = YuiVars::detect(&source);
198 let config = config::load(&source, &yui)?;
199
200 let mut engine = template::Engine::new();
201 let tera_ctx = template::template_context(&yui, &config.vars);
202
203 hook::run_phase(
206 &config,
207 &source,
208 &yui,
209 &mut engine,
210 &tera_ctx,
211 HookPhase::Pre,
212 dry_run,
213 )?;
214
215 let render_report = render::render_all(&source, &config, &yui, dry_run)?;
217 log_render_report(&render_report);
218 if render_report.has_drift() {
219 anyhow::bail!(
220 "render drift detected ({} file(s)); reflect target edits back into the .tera before re-running apply",
221 render_report.diverged.len()
222 );
223 }
224
225 let mounts = mount::resolve(
227 &config.mount.entry,
228 config.mount.default_strategy,
229 &mut engine,
230 &tera_ctx,
231 )?;
232
233 let backup_root = source.join(&config.backup.dir);
234 let ctx = ApplyCtx {
235 config: &config,
236 source: &source,
237 file_mode: resolve_file_mode(config.link.file_mode),
238 dir_mode: resolve_dir_mode(config.link.dir_mode),
239 backup_root: &backup_root,
240 dry_run,
241 };
242
243 info!("source: {source}");
244 info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
245 if dry_run {
246 info!("dry-run: nothing will be written");
247 }
248
249 let mut yuiignore = paths::YuiIgnoreStack::new();
253 yuiignore.push_dir(&source)?;
254 let walk_result = (|| -> Result<()> {
255 for m in &mounts {
256 info!("mount: {} → {}", m.src, m.dst);
257 process_mount(&source, m, &ctx, &mut engine, &tera_ctx, &mut yuiignore)?;
258 }
259 Ok(())
260 })();
261 yuiignore.pop_dir(&source);
262 walk_result?;
263
264 hook::run_phase(
266 &config,
267 &source,
268 &yui,
269 &mut engine,
270 &tera_ctx,
271 HookPhase::Post,
272 dry_run,
273 )?;
274 Ok(())
275}
276
277fn log_render_report(r: &RenderReport) {
278 if !r.written.is_empty() {
279 info!("rendered {} new file(s)", r.written.len());
280 }
281 if !r.unchanged.is_empty() {
282 info!("rendered {} file(s) unchanged", r.unchanged.len());
283 }
284 if !r.skipped_when_false.is_empty() {
285 info!(
286 "skipped {} template(s) (when=false)",
287 r.skipped_when_false.len()
288 );
289 }
290 for d in &r.diverged {
291 warn!("rendered file diverged from template: {d}");
292 }
293}
294
295struct ApplyCtx<'a> {
302 config: &'a Config,
303 source: &'a Utf8Path,
305 file_mode: EffectiveFileMode,
306 dir_mode: EffectiveDirMode,
307 backup_root: &'a Utf8Path,
308 dry_run: bool,
309}
310
311pub fn list(
317 source: Option<Utf8PathBuf>,
318 all: bool,
319 icons_override: Option<IconsMode>,
320 no_color: bool,
321) -> Result<()> {
322 let source = resolve_source(source)?;
323 let yui = YuiVars::detect(&source);
324 let config = config::load(&source, &yui)?;
325
326 let icons_mode = icons_override.unwrap_or(config.ui.icons);
327 let icons = Icons::for_mode(icons_mode);
328 let color = !no_color && supports_color_stdout();
329
330 let items = collect_list_items(&source, &config, &yui)?;
331 let displayed: Vec<&ListItem> = if all {
332 items.iter().collect()
333 } else {
334 items.iter().filter(|i| i.active).collect()
335 };
336
337 print_list_table(&displayed, icons, color);
338
339 let total = items.len();
340 let active = items.iter().filter(|i| i.active).count();
341 let inactive = total - active;
342 println!();
343 if all {
344 println!(" {total} entries · {active} active · {inactive} inactive");
345 } else {
346 println!(
347 " {} of {} entries shown ({} inactive hidden — use --all)",
348 active, total, inactive
349 );
350 }
351 Ok(())
352}
353
354#[derive(Debug)]
355struct ListItem {
356 src: Utf8PathBuf,
357 dst: String,
358 when: Option<String>,
359 active: bool,
360}
361
362fn collect_list_items(source: &Utf8Path, config: &Config, yui: &YuiVars) -> Result<Vec<ListItem>> {
363 let mut engine = template::Engine::new();
364 let tera_ctx = template::template_context(yui, &config.vars);
365 let mut items = Vec::new();
366
367 for entry in &config.mount.entry {
369 let active = match &entry.when {
370 None => true,
371 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
372 };
373 let dst = engine
374 .render(&entry.dst, &tera_ctx)
375 .map(|s| paths::expand_tilde(s.trim()).to_string())
376 .unwrap_or_else(|_| entry.dst.clone());
377 items.push(ListItem {
378 src: entry.src.clone(),
379 dst,
380 when: entry.when.clone(),
381 active,
382 });
383 }
384
385 let walker = paths::source_walker(source).build();
387 let marker_filename = &config.mount.marker_filename;
388 for entry in walker {
389 let entry = match entry {
390 Ok(e) => e,
391 Err(_) => continue,
392 };
393 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
394 continue;
395 }
396 if entry.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
397 continue;
398 }
399 let dir = match entry.path().parent() {
400 Some(d) => d,
401 None => continue,
402 };
403 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
404 Ok(p) => p,
405 Err(_) => continue,
406 };
407 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
411 Some(s) => s,
412 None => continue,
413 };
414 let MarkerSpec::Explicit { links } = spec else {
415 continue; };
417 let rel = dir_utf8
418 .strip_prefix(source)
419 .map(Utf8PathBuf::from)
420 .unwrap_or(dir_utf8);
421 for link in &links {
422 let active = match &link.when {
423 None => true,
424 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
425 };
426 let dst = engine
427 .render(&link.dst, &tera_ctx)
428 .map(|s| paths::expand_tilde(s.trim()).to_string())
429 .unwrap_or_else(|_| link.dst.clone());
430 let src_display = match &link.src {
435 Some(filename) => rel.join(filename),
436 None => rel.clone(),
437 };
438 items.push(ListItem {
439 src: src_display,
440 dst,
441 when: link.when.clone(),
442 active,
443 });
444 }
445 }
446
447 items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
448 Ok(items)
449}
450
451fn supports_color_stdout() -> bool {
452 use std::io::IsTerminal;
453 std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
454}
455
456fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
457 let src_w = items
458 .iter()
459 .map(|i| i.src.as_str().chars().count())
460 .max()
461 .unwrap_or(0)
462 .max("SRC".len());
463 let dst_w = items
464 .iter()
465 .map(|i| i.dst.chars().count())
466 .max()
467 .unwrap_or(0)
468 .max("DST".len());
469
470 let status_w = "STATUS".len();
471 let arrow_w = icons.arrow.chars().count();
472
473 print_header(status_w, src_w, arrow_w, dst_w, color);
475
476 let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
478 if color {
479 use owo_colors::OwoColorize as _;
480 println!("{}", sep.dimmed());
481 } else {
482 println!("{sep}");
483 }
484
485 for item in items {
487 print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
488 }
489}
490
491fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
492 use owo_colors::OwoColorize as _;
493 let mut line = String::new();
494 let _ = write!(
495 &mut line,
496 " {:<status_w$} {:<src_w$} {:<arrow_w$} {:<dst_w$} WHEN",
497 "STATUS", "SRC", "", "DST"
498 );
499 if color {
500 println!("{}", line.bold());
501 } else {
502 println!("{line}");
503 }
504}
505
506fn render_separator(
507 sep_ch: char,
508 status_w: usize,
509 src_w: usize,
510 arrow_w: usize,
511 dst_w: usize,
512) -> String {
513 let bar = |n: usize| sep_ch.to_string().repeat(n);
514 format!(
515 " {} {} {} {} {}",
516 bar(status_w),
517 bar(src_w),
518 bar(arrow_w),
519 bar(dst_w),
520 bar("WHEN".len())
521 )
522}
523
524fn print_row(
525 item: &ListItem,
526 icons: Icons,
527 status_w: usize,
528 src_w: usize,
529 arrow_w: usize,
530 dst_w: usize,
531 color: bool,
532) {
533 use owo_colors::OwoColorize as _;
534 let status = if item.active {
535 icons.active
536 } else {
537 icons.inactive
538 };
539 let when_str = item
540 .when
541 .as_deref()
542 .map(strip_braces)
543 .unwrap_or_else(|| "(always)".to_string());
544
545 let src_display = item.src.as_str().replace('\\', "/");
547 let src = src_display.as_str();
548 let dst = &item.dst;
549 let arrow = icons.arrow;
550
551 let cell_status = format!("{:<status_w$}", status);
556 let cell_src = format!("{:<src_w$}", src);
557 let cell_arrow = format!("{:<arrow_w$}", arrow);
558 let cell_dst = format!("{:<dst_w$}", dst);
559
560 if !color {
561 println!(" {cell_status} {cell_src} {cell_arrow} {cell_dst} {when_str}");
562 return;
563 }
564
565 if item.active {
566 println!(
567 " {} {} {} {} {}",
568 cell_status.green(),
569 cell_src.cyan(),
570 cell_arrow.dimmed(),
571 cell_dst.green(),
572 when_str.dimmed()
573 );
574 } else {
575 println!(
576 " {} {} {} {} {}",
577 cell_status.red().dimmed(),
578 cell_src.dimmed(),
579 cell_arrow.dimmed(),
580 cell_dst.dimmed(),
581 when_str.dimmed()
582 );
583 }
584}
585
586fn strip_braces(expr: &str) -> String {
589 let trimmed = expr.trim();
590 if let Some(inner) = trimmed
591 .strip_prefix("{{")
592 .and_then(|s| s.strip_suffix("}}"))
593 {
594 inner.trim().to_string()
595 } else {
596 trimmed.to_string()
597 }
598}
599
600pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
601 let source = resolve_source(source)?;
602 let yui = YuiVars::detect(&source);
603 let config = config::load(&source, &yui)?;
604 let report = render::render_all(&source, &config, &yui, dry_run || check)?;
606 log_render_report(&report);
607 if check && report.has_drift() {
608 anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
609 }
610 Ok(())
611}
612
613pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
614 apply(source, dry_run)
616}
617
618pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
619 let _source = resolve_source(source)?;
620 if paths_arg.is_empty() {
621 anyhow::bail!("yui unlink: provide at least one target path");
622 }
623 for p in paths_arg {
624 let abs = absolutize(&p)?;
625 info!("unlink: {abs}");
626 link::unlink(&abs)?;
627 }
628 Ok(())
629}
630
631pub fn status(
644 source: Option<Utf8PathBuf>,
645 icons_override: Option<IconsMode>,
646 no_color: bool,
647) -> Result<()> {
648 let source = resolve_source(source)?;
649 let yui = YuiVars::detect(&source);
650 let config = config::load(&source, &yui)?;
651
652 let mut engine = template::Engine::new();
653 let tera_ctx = template::template_context(&yui, &config.vars);
654 let mounts = mount::resolve(
655 &config.mount.entry,
656 config.mount.default_strategy,
657 &mut engine,
658 &tera_ctx,
659 )?;
660
661 let icons_mode = icons_override.unwrap_or(config.ui.icons);
662 let icons = Icons::for_mode(icons_mode);
663 let color = !no_color && supports_color_stdout();
664
665 let mut report: Vec<StatusItem> = Vec::new();
666
667 let render_report = render::render_all(&source, &config, &yui, true)?;
670 for rendered in &render_report.diverged {
671 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
675 report.push(StatusItem {
676 src: relative_for_display(&source, &tera_path),
677 dst: rendered.clone(),
678 state: StatusState::RenderDrift,
679 });
680 }
681
682 let mut yuiignore = paths::YuiIgnoreStack::new();
686 yuiignore.push_dir(&source)?;
687 let walk_result = (|| -> Result<()> {
688 for m in &mounts {
689 let src_root = source.join(&m.src);
690 if !src_root.is_dir() {
691 warn!("mount src missing: {src_root}");
692 continue;
693 }
694 classify_walk(
695 &src_root,
696 &m.dst,
697 &config,
698 m.strategy,
699 &mut engine,
700 &tera_ctx,
701 &source,
702 &mut yuiignore,
703 &mut report,
704 )?;
705 }
706 Ok(())
707 })();
708 yuiignore.pop_dir(&source);
709 walk_result?;
710
711 report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
712
713 print_status_table(&report, icons, color);
714
715 let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
716
717 println!();
718 let total = report.len();
719 let in_sync = total - drift;
720 if drift == 0 {
721 println!(" {total} entries · all in sync");
722 Ok(())
723 } else {
724 println!(" {total} entries · {in_sync} in sync · {drift} diverged");
725 anyhow::bail!("status: {drift} entries diverged from source")
726 }
727}
728
729#[derive(Debug)]
730struct StatusItem {
731 src: Utf8PathBuf,
733 dst: Utf8PathBuf,
735 state: StatusState,
736}
737
738#[derive(Debug, Clone, Copy)]
739enum StatusState {
740 Link(absorb::AbsorbDecision),
741 RenderDrift,
744}
745
746impl StatusState {
747 fn is_in_sync(self) -> bool {
748 matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
749 }
750}
751
752#[allow(clippy::too_many_arguments)]
753fn classify_walk(
754 src_dir: &Utf8Path,
755 dst_dir: &Utf8Path,
756 config: &Config,
757 strategy: MountStrategy,
758 engine: &mut template::Engine,
759 tera_ctx: &TeraContext,
760 source_root: &Utf8Path,
761 yuiignore: &mut paths::YuiIgnoreStack,
762 report: &mut Vec<StatusItem>,
763) -> Result<()> {
764 classify_walk_inner(
765 src_dir,
766 dst_dir,
767 config,
768 strategy,
769 engine,
770 tera_ctx,
771 source_root,
772 yuiignore,
773 report,
774 false,
775 )
776}
777
778#[allow(clippy::too_many_arguments)]
779fn classify_walk_inner(
780 src_dir: &Utf8Path,
781 dst_dir: &Utf8Path,
782 config: &Config,
783 strategy: MountStrategy,
784 engine: &mut template::Engine,
785 tera_ctx: &TeraContext,
786 source_root: &Utf8Path,
787 yuiignore: &mut paths::YuiIgnoreStack,
788 report: &mut Vec<StatusItem>,
789 parent_covered: bool,
790) -> Result<()> {
791 if yuiignore.is_ignored(src_dir, true) {
792 return Ok(());
793 }
794 yuiignore.push_dir(src_dir)?;
797 let result = classify_walk_inner_body(
798 src_dir,
799 dst_dir,
800 config,
801 strategy,
802 engine,
803 tera_ctx,
804 source_root,
805 yuiignore,
806 report,
807 parent_covered,
808 );
809 yuiignore.pop_dir(src_dir);
810 result
811}
812
813#[allow(clippy::too_many_arguments)]
814fn classify_walk_inner_body(
815 src_dir: &Utf8Path,
816 dst_dir: &Utf8Path,
817 config: &Config,
818 strategy: MountStrategy,
819 engine: &mut template::Engine,
820 tera_ctx: &TeraContext,
821 source_root: &Utf8Path,
822 yuiignore: &mut paths::YuiIgnoreStack,
823 report: &mut Vec<StatusItem>,
824 parent_covered: bool,
825) -> Result<()> {
826 let marker_filename = &config.mount.marker_filename;
827 let mut covered = parent_covered;
828
829 if strategy == MountStrategy::Marker {
830 match marker::read_spec(src_dir, marker_filename)? {
831 None => {}
832 Some(MarkerSpec::PassThrough) => {
833 let decision = absorb::classify(src_dir, dst_dir)?;
834 report.push(StatusItem {
835 src: relative_for_display(source_root, src_dir),
836 dst: dst_dir.to_path_buf(),
837 state: StatusState::Link(decision),
838 });
839 covered = true;
840 }
841 Some(MarkerSpec::Explicit { links }) => {
842 let mut emitted_dir_link = false;
843 for link in &links {
844 if let Some(when) = &link.when {
845 if !template::eval_truthy(when, engine, tera_ctx)? {
846 continue;
847 }
848 }
849 let dst_str = engine.render(&link.dst, tera_ctx)?;
850 let dst = paths::expand_tilde(dst_str.trim());
851 if let Some(filename) = &link.src {
852 let file_src = src_dir.join(filename);
853 if !file_src.is_file() {
854 anyhow::bail!(
855 "marker at {src_dir}: [[link]] src={filename:?} \
856 not found"
857 );
858 }
859 let decision = absorb::classify(&file_src, &dst)?;
860 report.push(StatusItem {
861 src: relative_for_display(source_root, &file_src),
862 dst,
863 state: StatusState::Link(decision),
864 });
865 } else {
866 let decision = absorb::classify(src_dir, &dst)?;
867 report.push(StatusItem {
868 src: relative_for_display(source_root, src_dir),
869 dst,
870 state: StatusState::Link(decision),
871 });
872 emitted_dir_link = true;
873 }
874 }
875 if emitted_dir_link {
876 covered = true;
877 }
878 }
879 }
880 }
881
882 for entry in std::fs::read_dir(src_dir)? {
883 let entry = entry?;
884 let name_os = entry.file_name();
885 let Some(name) = name_os.to_str() else {
886 continue;
887 };
888 if name == marker_filename || name.ends_with(".tera") {
889 continue;
890 }
891 let src_path = src_dir.join(name);
892 let dst_path = dst_dir.join(name);
893 let ft = entry.file_type()?;
894 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
895 continue;
896 }
897 if ft.is_dir() {
898 classify_walk_inner(
899 &src_path,
900 &dst_path,
901 config,
902 strategy,
903 engine,
904 tera_ctx,
905 source_root,
906 yuiignore,
907 report,
908 covered,
909 )?;
910 } else if ft.is_file() && !covered {
911 let decision = absorb::classify(&src_path, &dst_path)?;
912 report.push(StatusItem {
913 src: relative_for_display(source_root, &src_path),
914 dst: dst_path,
915 state: StatusState::Link(decision),
916 });
917 }
918 }
919 Ok(())
920}
921
922fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
923 p.strip_prefix(source_root)
924 .map(Utf8PathBuf::from)
925 .unwrap_or_else(|_| p.to_path_buf())
926}
927
928fn print_status_table(items: &[StatusItem], icons: Icons, color: bool) {
929 let src_w = items
930 .iter()
931 .map(|i| i.src.as_str().chars().count())
932 .max()
933 .unwrap_or(0)
934 .max("SRC".len());
935 let dst_w = items
936 .iter()
937 .map(|i| i.dst.as_str().chars().count())
938 .max()
939 .unwrap_or(0)
940 .max("DST".len());
941 let state_label_w = items
943 .iter()
944 .map(|i| state_label(i.state).len())
945 .max()
946 .unwrap_or(0)
947 .max("STATE".len() - 2); let state_w = state_label_w + 2; print_status_header(state_w, src_w, dst_w, color);
951 let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
952 if color {
953 use owo_colors::OwoColorize as _;
954 println!("{}", sep.dimmed());
955 } else {
956 println!("{sep}");
957 }
958 for item in items {
959 print_status_row(item, icons, state_w, src_w, dst_w, color);
960 }
961}
962
963fn state_label(s: StatusState) -> &'static str {
964 use absorb::AbsorbDecision::*;
965 match s {
966 StatusState::Link(InSync) => "in-sync",
967 StatusState::Link(RelinkOnly) => "relink",
968 StatusState::Link(AutoAbsorb) => "drift (auto)",
969 StatusState::Link(NeedsConfirm) => "drift (anomaly)",
970 StatusState::Link(Restore) => "missing",
971 StatusState::RenderDrift => "render drift",
972 }
973}
974
975fn state_icon(s: StatusState, icons: Icons) -> &'static str {
976 use absorb::AbsorbDecision::*;
977 match s {
978 StatusState::Link(InSync) => icons.ok,
979 StatusState::Link(RelinkOnly) => icons.warn,
980 StatusState::Link(AutoAbsorb) => icons.warn,
981 StatusState::Link(NeedsConfirm) => icons.error,
982 StatusState::Link(Restore) => icons.info,
983 StatusState::RenderDrift => icons.error,
984 }
985}
986
987fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
988 use owo_colors::OwoColorize as _;
989 let line = format!(
992 " {:<state_w$} {:<src_w$} {:<dst_w$}",
993 "STATE", "SRC", "DST"
994 );
995 if color {
996 println!("{}", line.bold());
997 } else {
998 println!("{line}");
999 }
1000}
1001
1002fn render_status_separator(
1003 sep_ch: char,
1004 state_w: usize,
1005 src_w: usize,
1006 dst_w: usize,
1007 arrow: &str,
1008) -> String {
1009 let bar = |n: usize| sep_ch.to_string().repeat(n);
1010 format!(
1011 " {} {} {} {}",
1012 bar(state_w),
1013 bar(src_w),
1014 bar(arrow.chars().count()),
1015 bar(dst_w)
1016 )
1017}
1018
1019fn print_status_row(
1020 item: &StatusItem,
1021 icons: Icons,
1022 state_w: usize,
1023 src_w: usize,
1024 dst_w: usize,
1025 color: bool,
1026) {
1027 use owo_colors::OwoColorize as _;
1028 let icon = state_icon(item.state, icons);
1029 let label = state_label(item.state);
1030 let state_text = format!("{icon} {label}");
1031 let src_display = item.src.as_str().replace('\\', "/");
1032 let dst_display = item.dst.as_str().replace('\\', "/");
1033 let arrow = icons.arrow;
1034
1035 let cell_state = format!("{:<state_w$}", state_text);
1036 let cell_src = format!("{:<src_w$}", src_display);
1037 let cell_dst = format!("{:<dst_w$}", dst_display);
1038
1039 if !color {
1040 println!(" {cell_state} {cell_src} {arrow} {cell_dst}");
1041 return;
1042 }
1043
1044 use absorb::AbsorbDecision::*;
1045 let state_colored = match item.state {
1046 StatusState::Link(InSync) => cell_state.green().to_string(),
1047 StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
1048 cell_state.yellow().to_string()
1049 }
1050 StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
1051 StatusState::Link(Restore) => cell_state.cyan().to_string(),
1052 StatusState::RenderDrift => cell_state.red().to_string(),
1053 };
1054 let src_colored = cell_src.cyan().to_string();
1055 let arrow_colored = arrow.dimmed().to_string();
1056 let dst_colored = cell_dst.dimmed().to_string();
1057 println!(" {state_colored} {src_colored} {arrow_colored} {dst_colored}");
1058}
1059
1060pub fn absorb(source: Option<Utf8PathBuf>, target: Utf8PathBuf, dry_run: bool) -> Result<()> {
1069 let source = resolve_source(source)?;
1070 let target = absolutize(&target)?;
1071 let yui = YuiVars::detect(&source);
1072 let config = config::load(&source, &yui)?;
1073
1074 let mut engine = template::Engine::new();
1075 let tera_ctx = template::template_context(&yui, &config.vars);
1076
1077 let src_path = match find_source_for_target(&source, &config, &target, &mut engine, &tera_ctx)?
1078 {
1079 Some(s) => s,
1080 None => anyhow::bail!(
1081 "no mount entry / .yuilink override claims target {target}; \
1082 pass a path inside a known dst"
1083 ),
1084 };
1085
1086 info!("source for {target}: {src_path}");
1087
1088 if dry_run {
1089 info!("[dry-run] would absorb {target} → {src_path}");
1090 return Ok(());
1091 }
1092
1093 let backup_root = source.join(&config.backup.dir);
1094 let ctx = ApplyCtx {
1095 config: &config,
1096 source: &source,
1097 file_mode: resolve_file_mode(config.link.file_mode),
1098 dir_mode: resolve_dir_mode(config.link.dir_mode),
1099 backup_root: &backup_root,
1100 dry_run: false,
1101 };
1102
1103 absorb_target_into_source(&src_path, &target, &ctx)
1106}
1107
1108fn find_source_for_target(
1112 source: &Utf8Path,
1113 config: &Config,
1114 target: &Utf8Path,
1115 engine: &mut template::Engine,
1116 tera_ctx: &TeraContext,
1117) -> Result<Option<Utf8PathBuf>> {
1118 for entry in &config.mount.entry {
1120 if let Some(when) = &entry.when {
1121 if !template::eval_truthy(when, engine, tera_ctx)? {
1122 continue;
1123 }
1124 }
1125 let dst_str = engine.render(&entry.dst, tera_ctx)?;
1126 let dst_root = paths::expand_tilde(dst_str.trim());
1127 if let Ok(rel) = target.strip_prefix(&dst_root) {
1128 let candidate = source.join(&entry.src).join(rel);
1129 if paths::is_ignored_at(source, &candidate, candidate.is_dir())? {
1134 continue;
1135 }
1136 return Ok(Some(candidate));
1137 }
1138 }
1139
1140 let walker = paths::source_walker(source).build();
1146 let marker_filename = &config.mount.marker_filename;
1147 for ent in walker {
1148 let ent = match ent {
1149 Ok(e) => e,
1150 Err(_) => continue,
1151 };
1152 if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
1153 continue;
1154 }
1155 if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
1156 continue;
1157 }
1158 let dir = match ent.path().parent() {
1159 Some(d) => d,
1160 None => continue,
1161 };
1162 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
1163 Ok(p) => p,
1164 Err(_) => continue,
1165 };
1166 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
1167 Some(s) => s,
1168 None => continue,
1169 };
1170 let MarkerSpec::Explicit { links } = spec else {
1171 continue;
1172 };
1173 for link in &links {
1174 if let Some(when) = &link.when {
1175 if !template::eval_truthy(when, engine, tera_ctx)? {
1176 continue;
1177 }
1178 }
1179 let dst_str = engine.render(&link.dst, tera_ctx)?;
1180 let dst = paths::expand_tilde(dst_str.trim());
1181 if let Some(filename) = &link.src {
1188 let file_src = dir_utf8.join(filename);
1189 if !file_src.is_file() {
1190 anyhow::bail!(
1191 "marker at {dir_utf8}: [[link]] src={filename:?} \
1192 not found"
1193 );
1194 }
1195 if target == dst {
1196 return Ok(Some(file_src));
1197 }
1198 continue;
1199 }
1200 if target == dst {
1201 return Ok(Some(dir_utf8));
1202 }
1203 if let Ok(rel) = target.strip_prefix(&dst) {
1204 return Ok(Some(dir_utf8.join(rel)));
1205 }
1206 }
1207 }
1208
1209 Ok(None)
1210}
1211
1212pub fn doctor(
1213 source: Option<Utf8PathBuf>,
1214 icons_override: Option<IconsMode>,
1215 no_color: bool,
1216) -> Result<()> {
1217 use owo_colors::OwoColorize as _;
1218
1219 let resolved_source = resolve_source(source);
1224
1225 let yui = match &resolved_source {
1230 Ok(s) => YuiVars::detect(s),
1231 Err(_) => YuiVars::detect(Utf8Path::new(".")),
1232 };
1233
1234 let cfg_res = match &resolved_source {
1239 Ok(s) => Some(config::load(s, &yui)),
1240 Err(_) => None,
1241 };
1242 let cfg = cfg_res.as_ref().and_then(|r| r.as_ref().ok());
1243 let icons_mode = icons_override
1244 .or_else(|| cfg.map(|c| c.ui.icons))
1245 .unwrap_or_default();
1246 let icons = Icons::for_mode(icons_mode);
1247 let color = !no_color && supports_color_stdout();
1248
1249 let mut probes: Vec<Probe> = Vec::new();
1250
1251 probes.push(Probe::group("identity"));
1253 probes.push(Probe::ok("os/arch", format!("{} / {}", yui.os, yui.arch)));
1254 probes.push(Probe::ok("user@host", format!("{}@{}", yui.user, yui.host)));
1255
1256 probes.push(Probe::group("repo"));
1258 let mut have_source = false;
1259 match &resolved_source {
1260 Ok(s) => {
1261 have_source = true;
1262 probes.push(Probe::ok("source", s.to_string()));
1263 match cfg_res.as_ref().expect("cfg_res set when source is Ok") {
1264 Ok(c) => {
1265 probes.push(Probe::ok(
1266 "config",
1267 format!(
1268 "{} mount{} · {} hook{} · {} render rule{}",
1269 c.mount.entry.len(),
1270 plural(c.mount.entry.len()),
1271 c.hook.len(),
1272 plural(c.hook.len()),
1273 c.render.rule.len(),
1274 plural(c.render.rule.len()),
1275 ),
1276 ));
1277 }
1278 Err(e) => probes.push(Probe::error("config", format!("{e}"))),
1279 }
1280 match crate::git::is_clean(s) {
1284 Ok(true) => probes.push(Probe::ok("git", "clean")),
1285 Ok(false) => probes.push(Probe::warn(
1286 "git",
1287 "uncommitted changes — `[absorb] require_clean_git` will defer auto-absorb",
1288 )),
1289 Err(_) => probes.push(Probe::warn(
1290 "git",
1291 "no git repo (auto-absorb still works; commit history won't track drift)",
1292 )),
1293 }
1294 }
1295 Err(e) => {
1296 probes.push(Probe::error("source", format!("not found — {e}")));
1297 }
1298 }
1299
1300 probes.push(Probe::group("links"));
1302 if cfg!(windows) {
1303 probes.push(Probe::ok(
1304 "default mode",
1305 "files=hardlink, dirs=junction (no admin needed)",
1306 ));
1307 } else {
1308 probes.push(Probe::ok("default mode", "files=symlink, dirs=symlink"));
1309 }
1310
1311 if have_source {
1313 if let (Ok(s), Some(c)) = (&resolved_source, cfg) {
1314 probes.push(Probe::group("hooks"));
1315 if c.hook.is_empty() {
1316 probes.push(Probe::ok("hooks", "(none configured)"));
1317 } else {
1318 let mut missing = 0usize;
1319 for h in &c.hook {
1320 if !s.join(&h.script).is_file() {
1321 missing += 1;
1322 probes.push(Probe::error(
1323 format!("hook[{}]", h.name),
1324 format!("script not found at {}", h.script),
1325 ));
1326 }
1327 }
1328 if missing == 0 {
1329 probes.push(Probe::ok(
1330 "scripts",
1331 format!(
1332 "{} hook{} configured, all scripts present",
1333 c.hook.len(),
1334 plural(c.hook.len())
1335 ),
1336 ));
1337 }
1338 }
1339 }
1340 }
1341
1342 if let Some(home) = paths::home_dir() {
1344 let chezmoi_src = home.join(".local/share/chezmoi");
1345 if chezmoi_src.is_dir() {
1346 probes.push(Probe::group("chezmoi"));
1347 probes.push(Probe::warn(
1348 "legacy source",
1349 format!(
1350 "{chezmoi_src} still exists — yui doesn't use it, safe to archive once your migration has settled"
1351 ),
1352 ));
1353 }
1354 }
1355
1356 println!();
1358 if color {
1359 println!(" {}", "yui doctor".bold().underline());
1360 } else {
1361 println!(" yui doctor");
1362 }
1363 println!();
1364 for probe in &probes {
1365 probe.print(&icons, color);
1366 }
1367
1368 let errors = probes.iter().filter(|p| p.is_error()).count();
1369 let warns = probes.iter().filter(|p| p.is_warn()).count();
1370 let oks = probes.iter().filter(|p| p.is_ok()).count();
1371 println!();
1372 let summary = format!("{oks} ok · {warns} warn · {errors} error");
1373 if color {
1374 if errors > 0 {
1375 println!(" {}", summary.red().bold());
1376 } else if warns > 0 {
1377 println!(" {}", summary.yellow());
1378 } else {
1379 println!(" {}", summary.green());
1380 }
1381 } else {
1382 println!(" {summary}");
1383 }
1384
1385 if errors > 0 {
1386 anyhow::bail!("doctor: {errors} probe(s) failed");
1387 }
1388 Ok(())
1389}
1390
1391#[derive(Debug)]
1392enum Probe {
1393 Group(&'static str),
1395 Ok {
1396 label: String,
1397 detail: String,
1398 },
1399 Warn {
1400 label: String,
1401 detail: String,
1402 },
1403 Error {
1404 label: String,
1405 detail: String,
1406 },
1407}
1408
1409impl Probe {
1410 fn group(label: &'static str) -> Self {
1411 Self::Group(label)
1412 }
1413 fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
1414 Self::Ok {
1415 label: label.into(),
1416 detail: detail.into(),
1417 }
1418 }
1419 fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
1420 Self::Warn {
1421 label: label.into(),
1422 detail: detail.into(),
1423 }
1424 }
1425 fn error(label: impl Into<String>, detail: impl Into<String>) -> Self {
1426 Self::Error {
1427 label: label.into(),
1428 detail: detail.into(),
1429 }
1430 }
1431 fn is_ok(&self) -> bool {
1432 matches!(self, Self::Ok { .. })
1433 }
1434 fn is_warn(&self) -> bool {
1435 matches!(self, Self::Warn { .. })
1436 }
1437 fn is_error(&self) -> bool {
1438 matches!(self, Self::Error { .. })
1439 }
1440 fn print(&self, icons: &Icons, color: bool) {
1441 use owo_colors::OwoColorize as _;
1442 match self {
1443 Self::Group(name) => {
1444 println!();
1445 if color {
1446 println!(" {}", name.cyan().bold());
1447 } else {
1448 println!(" {name}");
1449 }
1450 }
1451 Self::Ok { label, detail } => {
1452 let icon = icons.ok;
1453 let padded = format!("{label:<14}");
1457 if color {
1458 println!(
1459 " {} {} {}",
1460 icon.green(),
1461 padded.bold(),
1462 detail.dimmed()
1463 );
1464 } else {
1465 println!(" {icon} {padded} {detail}");
1466 }
1467 }
1468 Self::Warn { label, detail } => {
1469 let icon = icons.warn;
1470 let padded = format!("{label:<14}");
1471 if color {
1472 println!(
1473 " {} {} {}",
1474 icon.yellow(),
1475 padded.bold().yellow(),
1476 detail
1477 );
1478 } else {
1479 println!(" {icon} {padded} {detail}");
1480 }
1481 }
1482 Self::Error { label, detail } => {
1483 let icon = icons.error;
1484 let padded = format!("{label:<14}");
1485 if color {
1486 println!(
1487 " {} {} {}",
1488 icon.red().bold(),
1489 padded.bold().red(),
1490 detail.red()
1491 );
1492 } else {
1493 println!(" {icon} {padded} {detail}");
1494 }
1495 }
1496 }
1497 }
1498}
1499
1500fn plural(n: usize) -> &'static str {
1501 if n == 1 { "" } else { "s" }
1502}
1503
1504pub fn gc_backup(
1524 source: Option<Utf8PathBuf>,
1525 older_than: Option<String>,
1526 dry_run: bool,
1527 icons_override: Option<IconsMode>,
1528 no_color: bool,
1529) -> Result<()> {
1530 let source = resolve_source(source)?;
1531 let yui = YuiVars::detect(&source);
1532 let config = config::load(&source, &yui)?;
1533 let backup_root = source.join(&config.backup.dir);
1534 let icons_mode = icons_override.unwrap_or(config.ui.icons);
1535 let icons = Icons::for_mode(icons_mode);
1536 let color = !no_color && supports_color_stdout();
1537
1538 if !backup_root.is_dir() {
1539 println!(" no backup tree at {backup_root}");
1540 return Ok(());
1541 }
1542
1543 let mut entries = walk_gc_backups(&backup_root)?;
1544 if entries.is_empty() {
1545 println!(" no yui-stamped backups under {backup_root}");
1546 return Ok(());
1547 }
1548 entries.sort_by_key(|e| e.ts);
1550 let now = jiff::Zoned::now();
1551
1552 match older_than {
1553 None => {
1554 let refs: Vec<&BackupEntry> = entries.iter().collect();
1555 print_gc_table(&refs, &backup_root, &now, icons, color);
1556 println!();
1557 println!(
1558 " {} entries · {} total — pass --older-than DUR (e.g. 30d) to delete",
1559 entries.len(),
1560 format_bytes(entries.iter().map(|e| e.size_bytes).sum())
1561 );
1562 Ok(())
1563 }
1564 Some(dur_str) => {
1565 let span = parse_human_duration(&dur_str)?;
1566 let cutoff = now
1567 .checked_sub(span)
1568 .map_err(|e| anyhow::anyhow!("invalid duration {dur_str:?}: {e}"))?;
1569 let cutoff_dt = cutoff.datetime();
1570
1571 let total_before: u64 = entries.iter().map(|e| e.size_bytes).sum();
1572 let to_delete: Vec<&BackupEntry> =
1573 entries.iter().filter(|e| e.ts < cutoff_dt).collect();
1574
1575 if to_delete.is_empty() {
1576 println!(
1577 " no backups older than {dur_str} (oldest: {})",
1578 format_age(entries[0].ts, &now)
1579 );
1580 return Ok(());
1581 }
1582
1583 print_gc_table(&to_delete, &backup_root, &now, icons, color);
1584 println!();
1585 let total_freed: u64 = to_delete.iter().map(|e| e.size_bytes).sum();
1586
1587 if dry_run {
1588 println!(
1589 " [dry-run] would remove {} of {} entries · would free {} of {}",
1590 to_delete.len(),
1591 entries.len(),
1592 format_bytes(total_freed),
1593 format_bytes(total_before),
1594 );
1595 return Ok(());
1596 }
1597
1598 for entry in &to_delete {
1599 match entry.kind {
1600 BackupKind::File => std::fs::remove_file(&entry.path)?,
1601 BackupKind::Dir => std::fs::remove_dir_all(&entry.path)?,
1602 }
1603 if let Some(parent) = entry.path.parent() {
1604 cleanup_empty_parents(parent, &backup_root);
1605 }
1606 }
1607 println!(
1608 " removed {} of {} entries · freed {} (was {}, now {})",
1609 to_delete.len(),
1610 entries.len(),
1611 format_bytes(total_freed),
1612 format_bytes(total_before),
1613 format_bytes(total_before - total_freed),
1614 );
1615 Ok(())
1616 }
1617 }
1618}
1619
1620#[derive(Debug)]
1621struct BackupEntry {
1622 path: Utf8PathBuf,
1623 ts: jiff::civil::DateTime,
1624 kind: BackupKind,
1625 size_bytes: u64,
1626}
1627
1628#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1629enum BackupKind {
1630 File,
1631 Dir,
1632}
1633
1634fn walk_gc_backups(root: &Utf8Path) -> Result<Vec<BackupEntry>> {
1639 let mut out = Vec::new();
1640 walk_gc_backups_rec(root, &mut out)?;
1641 Ok(out)
1642}
1643
1644fn walk_gc_backups_rec(dir: &Utf8Path, out: &mut Vec<BackupEntry>) -> Result<()> {
1645 for entry in std::fs::read_dir(dir)? {
1646 let entry = entry?;
1647 let name_os = entry.file_name();
1648 let Some(name) = name_os.to_str() else {
1649 continue;
1650 };
1651 let path = dir.join(name);
1652 let ft = entry.file_type()?;
1653 if ft.is_dir() {
1654 if let Some(ts) = parse_backup_suffix(name) {
1655 let size = dir_size(&path)?;
1656 out.push(BackupEntry {
1657 path,
1658 ts,
1659 kind: BackupKind::Dir,
1660 size_bytes: size,
1661 });
1662 } else {
1663 walk_gc_backups_rec(&path, out)?;
1664 }
1665 } else if ft.is_file() {
1666 if let Some(ts) = parse_backup_suffix(name) {
1669 let size = entry.metadata()?.len();
1670 out.push(BackupEntry {
1671 path,
1672 ts,
1673 kind: BackupKind::File,
1674 size_bytes: size,
1675 });
1676 }
1677 }
1678 }
1679 Ok(())
1680}
1681
1682fn dir_size(dir: &Utf8Path) -> Result<u64> {
1683 let mut total: u64 = 0;
1684 for entry in std::fs::read_dir(dir)? {
1685 let entry = entry?;
1686 let ft = entry.file_type()?;
1687 if ft.is_dir() {
1688 let p = match Utf8PathBuf::from_path_buf(entry.path()) {
1689 Ok(p) => p,
1690 Err(_) => continue,
1691 };
1692 total = total.saturating_add(dir_size(&p)?);
1693 } else if ft.is_file() {
1694 total = total.saturating_add(entry.metadata()?.len());
1695 }
1696 }
1697 Ok(total)
1698}
1699
1700fn cleanup_empty_parents(start: &Utf8Path, root: &Utf8Path) {
1704 let mut cur = start.to_path_buf();
1705 loop {
1706 if cur == *root {
1707 return;
1708 }
1709 if std::fs::remove_dir(&cur).is_err() {
1711 return;
1712 }
1713 match cur.parent() {
1714 Some(p) => cur = p.to_path_buf(),
1715 None => return,
1716 }
1717 }
1718}
1719
1720fn parse_backup_suffix(name: &str) -> Option<jiff::civil::DateTime> {
1726 if let Some(ts) = parse_ts_at_end(name) {
1727 return Some(ts);
1728 }
1729 if let Some((before, _ext)) = name.rsplit_once('.') {
1732 if let Some(ts) = parse_ts_at_end(before) {
1733 return Some(ts);
1734 }
1735 }
1736 None
1737}
1738
1739fn parse_ts_at_end(s: &str) -> Option<jiff::civil::DateTime> {
1740 if s.len() < 20 {
1742 return None;
1743 }
1744 let split_at = s.len() - 19;
1745 if s.as_bytes()[split_at] != b'_' {
1746 return None;
1747 }
1748 parse_ts(&s[split_at + 1..])
1749}
1750
1751fn parse_ts(s: &str) -> Option<jiff::civil::DateTime> {
1753 if s.len() != 18 || s.as_bytes()[8] != b'_' {
1754 return None;
1755 }
1756 for (i, &b) in s.as_bytes().iter().enumerate() {
1757 if i == 8 {
1758 continue;
1759 }
1760 if !b.is_ascii_digit() {
1761 return None;
1762 }
1763 }
1764 let year: i16 = s[0..4].parse().ok()?;
1765 let month: i8 = s[4..6].parse().ok()?;
1766 let day: i8 = s[6..8].parse().ok()?;
1767 let hour: i8 = s[9..11].parse().ok()?;
1768 let minute: i8 = s[11..13].parse().ok()?;
1769 let second: i8 = s[13..15].parse().ok()?;
1770 let ms: i32 = s[15..18].parse().ok()?;
1771 jiff::civil::DateTime::new(year, month, day, hour, minute, second, ms * 1_000_000).ok()
1772}
1773
1774fn parse_human_duration(s: &str) -> Result<jiff::Span> {
1783 let s = s.trim();
1784 let split = s
1785 .bytes()
1786 .position(|b| b.is_ascii_alphabetic())
1787 .ok_or_else(|| anyhow::anyhow!("invalid duration {s:?}: missing unit (e.g. 30d, 2w)"))?;
1788 let n: i64 = s[..split]
1789 .trim()
1790 .parse()
1791 .map_err(|_| anyhow::anyhow!("invalid duration {s:?}: bad leading number"))?;
1792 if n < 0 {
1793 anyhow::bail!("invalid duration {s:?}: negative durations don't make sense");
1794 }
1795 let unit = s[split..].to_ascii_lowercase();
1796 let span = match unit.as_str() {
1797 "y" | "yr" | "year" | "years" => jiff::Span::new().years(n),
1798 "mo" | "month" | "months" => jiff::Span::new().months(n),
1799 "w" | "wk" | "week" | "weeks" => jiff::Span::new().weeks(n),
1800 "d" | "day" | "days" => jiff::Span::new().days(n),
1801 "h" | "hr" | "hour" | "hours" => jiff::Span::new().hours(n),
1802 "m" | "min" | "minute" | "minutes" => jiff::Span::new().minutes(n),
1803 other => {
1804 anyhow::bail!(
1805 "invalid duration {s:?}: unknown unit {other:?} \
1806 (use y / mo / w / d / h / m)"
1807 )
1808 }
1809 };
1810 Ok(span)
1811}
1812
1813fn format_bytes(n: u64) -> String {
1814 const KIB: u64 = 1024;
1815 const MIB: u64 = KIB * 1024;
1816 const GIB: u64 = MIB * 1024;
1817 if n >= GIB {
1818 format!("{:.1} GiB", n as f64 / GIB as f64)
1819 } else if n >= MIB {
1820 format!("{:.1} MiB", n as f64 / MIB as f64)
1821 } else if n >= KIB {
1822 format!("{:.1} KiB", n as f64 / KIB as f64)
1823 } else {
1824 format!("{n} B")
1825 }
1826}
1827
1828fn format_age(ts: jiff::civil::DateTime, now: &jiff::Zoned) -> String {
1829 let Ok(ts_zoned) = ts.to_zoned(now.time_zone().clone()) else {
1830 return "?".into();
1831 };
1832 let secs = match (now - &ts_zoned).total(jiff::Unit::Second) {
1833 Ok(s) => s as i64,
1834 Err(_) => return "?".into(),
1835 };
1836 if secs < 0 {
1837 return "future".into();
1838 }
1839 if secs < 60 {
1840 format!("{secs}s")
1841 } else if secs < 3600 {
1842 format!("{}m", secs / 60)
1843 } else if secs < 86_400 {
1844 format!("{}h", secs / 3600)
1845 } else if secs < 86_400 * 30 {
1846 format!("{}d", secs / 86_400)
1847 } else if secs < 86_400 * 365 {
1848 format!("{}mo", secs / (86_400 * 30))
1849 } else {
1850 format!("{}y", secs / (86_400 * 365))
1851 }
1852}
1853
1854fn print_gc_table(
1861 entries: &[&BackupEntry],
1862 backup_root: &Utf8Path,
1863 now: &jiff::Zoned,
1864 _icons: Icons,
1865 color: bool,
1866) {
1867 use owo_colors::OwoColorize as _;
1868
1869 let rows: Vec<(String, String, String)> = entries
1870 .iter()
1871 .map(|e| {
1872 let rel = e
1873 .path
1874 .strip_prefix(backup_root)
1875 .map(Utf8PathBuf::from)
1876 .unwrap_or_else(|_| e.path.clone());
1877 let path_disp = match e.kind {
1878 BackupKind::Dir => format!("{rel}/"),
1879 BackupKind::File => rel.to_string(),
1880 };
1881 (format_age(e.ts, now), format_bytes(e.size_bytes), path_disp)
1882 })
1883 .collect();
1884
1885 let age_w = rows.iter().map(|r| r.0.len()).max().unwrap_or(3);
1886 let size_w = rows.iter().map(|r| r.1.len()).max().unwrap_or(4);
1887
1888 if color {
1889 println!(
1890 " {:<age_w$} {:>size_w$} {}",
1891 "AGE".dimmed(),
1892 "SIZE".dimmed(),
1893 "PATH".dimmed(),
1894 );
1895 } else {
1896 println!(" {:<age_w$} {:>size_w$} PATH", "AGE", "SIZE");
1897 }
1898 for (age, size, path) in &rows {
1899 if color {
1900 println!(
1901 " {:<age_w$} {:>size_w$} {}",
1902 age.yellow(),
1903 size,
1904 path.cyan(),
1905 );
1906 } else {
1907 println!(" {:<age_w$} {:>size_w$} {}", age, size, path);
1908 }
1909 }
1910}
1911
1912pub fn hooks_list(
1914 source: Option<Utf8PathBuf>,
1915 icons_override: Option<IconsMode>,
1916 no_color: bool,
1917) -> Result<()> {
1918 let source = resolve_source(source)?;
1919 let yui = YuiVars::detect(&source);
1920 let config = config::load(&source, &yui)?;
1921 let state = hook::State::load(&source)?;
1922
1923 let icons_mode = icons_override.unwrap_or(config.ui.icons);
1924 let icons = Icons::for_mode(icons_mode);
1925 let color = !no_color && supports_color_stdout();
1926
1927 if config.hook.is_empty() {
1928 println!("(no [[hook]] entries in config)");
1929 return Ok(());
1930 }
1931
1932 let mut engine = template::Engine::new();
1936 let tera_ctx = template::template_context(&yui, &config.vars);
1937 let rows: Vec<HookRow> = config
1938 .hook
1939 .iter()
1940 .map(|h| -> Result<HookRow> {
1941 let active = match &h.when {
1945 None => true,
1946 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
1947 };
1948 let last_run_at = state.hooks.get(&h.name).and_then(|s| s.last_run_at.clone());
1949 Ok(HookRow {
1950 name: h.name.clone(),
1951 phase: match h.phase {
1952 HookPhase::Pre => "pre",
1953 HookPhase::Post => "post",
1954 },
1955 when_run: match h.when_run {
1956 config::WhenRun::Once => "once",
1957 config::WhenRun::Onchange => "onchange",
1958 config::WhenRun::Every => "every",
1959 },
1960 last_run_at,
1961 when: h.when.clone(),
1962 active,
1963 })
1964 })
1965 .collect::<Result<Vec<_>>>()?;
1966
1967 print_hooks_table(&rows, icons, color);
1968
1969 let total = rows.len();
1970 let active = rows.iter().filter(|r| r.active).count();
1971 let inactive = total - active;
1972 let ran = rows.iter().filter(|r| r.last_run_at.is_some()).count();
1973 let never = total - ran;
1974 println!();
1975 println!(
1976 " {total} hooks · {active} active · {inactive} inactive · {ran} ran · {never} never run"
1977 );
1978
1979 Ok(())
1980}
1981
1982#[derive(Debug)]
1983struct HookRow {
1984 name: String,
1985 phase: &'static str,
1986 when_run: &'static str,
1987 last_run_at: Option<String>,
1988 when: Option<String>,
1989 active: bool,
1990}
1991
1992fn print_hooks_table(rows: &[HookRow], icons: Icons, color: bool) {
1993 use owo_colors::OwoColorize as _;
1994 use std::fmt::Write as _;
1995
1996 let name_w = rows
1997 .iter()
1998 .map(|r| r.name.chars().count())
1999 .max()
2000 .unwrap_or(0)
2001 .max("NAME".len());
2002 let phase_w = rows
2003 .iter()
2004 .map(|r| r.phase.len())
2005 .max()
2006 .unwrap_or(0)
2007 .max("PHASE".len());
2008 let when_run_w = rows
2009 .iter()
2010 .map(|r| r.when_run.len())
2011 .max()
2012 .unwrap_or(0)
2013 .max("WHEN_RUN".len());
2014 let last_w = rows
2015 .iter()
2016 .map(|r| {
2017 r.last_run_at
2018 .as_deref()
2019 .map(|s| s.chars().count())
2020 .unwrap_or("(never)".len())
2021 })
2022 .max()
2023 .unwrap_or(0)
2024 .max("LAST_RUN".len());
2025 let status_w = "STATUS".len();
2026
2027 let mut header = String::new();
2029 let _ = write!(
2030 &mut header,
2031 " {:<status_w$} {:<name_w$} {:<phase_w$} {:<when_run_w$} {:<last_w$} WHEN",
2032 "STATUS", "NAME", "PHASE", "WHEN_RUN", "LAST_RUN"
2033 );
2034 if color {
2035 println!("{}", header.bold());
2036 } else {
2037 println!("{header}");
2038 }
2039
2040 let bar = |n: usize| icons.sep.to_string().repeat(n);
2042 let sep = format!(
2043 " {} {} {} {} {} {}",
2044 bar(status_w),
2045 bar(name_w),
2046 bar(phase_w),
2047 bar(when_run_w),
2048 bar(last_w),
2049 bar("WHEN".len())
2050 );
2051 if color {
2052 println!("{}", sep.dimmed());
2053 } else {
2054 println!("{sep}");
2055 }
2056
2057 for r in rows {
2059 let (icon, ran) = match (r.active, r.last_run_at.is_some()) {
2064 (false, _) => (icons.inactive, false),
2065 (true, true) => (icons.active, true),
2066 (true, false) => (icons.info, false),
2067 };
2068 let last = r.last_run_at.as_deref().unwrap_or("(never)");
2069 let when_str = r
2070 .when
2071 .as_deref()
2072 .map(strip_braces)
2073 .unwrap_or_else(|| "(always)".to_string());
2074
2075 let cell_status = format!("{icon:<status_w$}");
2076 let cell_name = format!("{:<name_w$}", r.name);
2077 let cell_phase = format!("{:<phase_w$}", r.phase);
2078 let cell_when_run = format!("{:<when_run_w$}", r.when_run);
2079 let cell_last = format!("{last:<last_w$}");
2080
2081 if !color {
2082 println!(
2083 " {cell_status} {cell_name} {cell_phase} {cell_when_run} {cell_last} {when_str}"
2084 );
2085 continue;
2086 }
2087
2088 if !r.active {
2092 println!(
2093 " {} {} {} {} {} {}",
2094 cell_status.dimmed(),
2095 cell_name.dimmed(),
2096 cell_phase.dimmed(),
2097 cell_when_run.dimmed(),
2098 cell_last.dimmed(),
2099 when_str.dimmed()
2100 );
2101 } else if ran {
2102 println!(
2103 " {} {} {} {} {} {}",
2104 cell_status.green(),
2105 cell_name.cyan().bold(),
2106 cell_phase.dimmed(),
2107 cell_when_run.dimmed(),
2108 cell_last.green(),
2109 when_str.dimmed()
2110 );
2111 } else {
2112 println!(
2113 " {} {} {} {} {} {}",
2114 cell_status.yellow(),
2115 cell_name.cyan().bold(),
2116 cell_phase.dimmed(),
2117 cell_when_run.dimmed(),
2118 cell_last.yellow(),
2119 when_str.dimmed()
2120 );
2121 }
2122 }
2123}
2124
2125pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
2129 let source = resolve_source(source)?;
2130 let yui = YuiVars::detect(&source);
2131 let config = config::load(&source, &yui)?;
2132 let mut engine = template::Engine::new();
2133 let tera_ctx = template::template_context(&yui, &config.vars);
2134
2135 let targets: Vec<&config::HookConfig> = match &name {
2136 Some(want) => {
2137 let m = config
2138 .hook
2139 .iter()
2140 .find(|h| &h.name == want)
2141 .ok_or_else(|| {
2142 anyhow::anyhow!(
2143 "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
2144 )
2145 })?;
2146 vec![m]
2147 }
2148 None => config.hook.iter().collect(),
2149 };
2150
2151 let mut state = hook::State::load(&source)?;
2152 for h in targets {
2153 let outcome = hook::run_hook(
2154 h,
2155 &source,
2156 &yui,
2157 &config.vars,
2158 &mut engine,
2159 &tera_ctx,
2160 &mut state,
2161 false,
2162 force,
2163 )?;
2164 let label = match outcome {
2165 HookOutcome::Ran => "ran",
2166 HookOutcome::SkippedOnce => "skipped (once: already ran)",
2167 HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
2168 HookOutcome::SkippedWhenFalse => "skipped (when=false)",
2169 HookOutcome::DryRun => "would run (dry-run)",
2170 };
2171 info!("hook[{}]: {label}", h.name);
2172 if outcome == HookOutcome::Ran {
2173 state.save(&source)?;
2174 }
2175 }
2176 Ok(())
2177}
2178
2179#[allow(clippy::too_many_arguments)]
2184fn process_mount(
2185 source: &Utf8Path,
2186 m: &ResolvedMount,
2187 ctx: &ApplyCtx<'_>,
2188 engine: &mut template::Engine,
2189 tera_ctx: &TeraContext,
2190 yuiignore: &mut paths::YuiIgnoreStack,
2191) -> Result<()> {
2192 let src_root = source.join(&m.src);
2193 if !src_root.is_dir() {
2194 warn!("mount src missing: {src_root}");
2195 return Ok(());
2196 }
2197 walk_and_link(
2198 &src_root, &m.dst, ctx, m.strategy, engine, tera_ctx, yuiignore, false,
2199 )
2200}
2201
2202#[allow(clippy::too_many_arguments)]
2203fn walk_and_link(
2204 src_dir: &Utf8Path,
2205 dst_dir: &Utf8Path,
2206 ctx: &ApplyCtx<'_>,
2207 strategy: MountStrategy,
2208 engine: &mut template::Engine,
2209 tera_ctx: &TeraContext,
2210 yuiignore: &mut paths::YuiIgnoreStack,
2211 parent_covered: bool,
2212) -> Result<()> {
2213 if yuiignore.is_ignored(src_dir, true) {
2216 return Ok(());
2217 }
2218 yuiignore.push_dir(src_dir)?;
2221 let result = walk_and_link_body(
2222 src_dir,
2223 dst_dir,
2224 ctx,
2225 strategy,
2226 engine,
2227 tera_ctx,
2228 yuiignore,
2229 parent_covered,
2230 );
2231 yuiignore.pop_dir(src_dir);
2232 result
2233}
2234
2235#[allow(clippy::too_many_arguments)]
2236fn walk_and_link_body(
2237 src_dir: &Utf8Path,
2238 dst_dir: &Utf8Path,
2239 ctx: &ApplyCtx<'_>,
2240 strategy: MountStrategy,
2241 engine: &mut template::Engine,
2242 tera_ctx: &TeraContext,
2243 yuiignore: &mut paths::YuiIgnoreStack,
2244 parent_covered: bool,
2245) -> Result<()> {
2246 let marker_filename = &ctx.config.mount.marker_filename;
2247 let mut covered = parent_covered;
2248
2249 if strategy == MountStrategy::Marker {
2250 match marker::read_spec(src_dir, marker_filename)? {
2251 None => {} Some(MarkerSpec::PassThrough) => {
2253 link_dir_with_backup(src_dir, dst_dir, ctx)?;
2257 covered = true;
2258 }
2259 Some(MarkerSpec::Explicit { links }) => {
2260 let mut emitted_dir_link = false;
2261 let mut emitted_any = false;
2262 for link in &links {
2263 if let Some(when) = &link.when {
2266 if !template::eval_truthy(when, engine, tera_ctx)? {
2267 continue;
2268 }
2269 }
2270 let dst_str = engine.render(&link.dst, tera_ctx)?;
2271 let dst = paths::expand_tilde(dst_str.trim());
2272 if let Some(filename) = &link.src {
2273 let file_src = src_dir.join(filename);
2274 if !file_src.is_file() {
2275 anyhow::bail!(
2276 "marker at {src_dir}: [[link]] src={filename:?} \
2277 not found"
2278 );
2279 }
2280 link_file_with_backup(&file_src, &dst, ctx)?;
2281 } else {
2282 link_dir_with_backup(src_dir, &dst, ctx)?;
2283 emitted_dir_link = true;
2284 }
2285 emitted_any = true;
2286 }
2287 if !emitted_any {
2288 info!(
2293 "marker at {src_dir} had no active links \
2294 — falling back to defaults"
2295 );
2296 }
2297 if emitted_dir_link {
2298 covered = true;
2299 }
2300 }
2301 }
2302 }
2303
2304 for entry in std::fs::read_dir(src_dir)? {
2305 let entry = entry?;
2306 let name_os = entry.file_name();
2307 let Some(name) = name_os.to_str() else {
2308 continue;
2309 };
2310 if name == marker_filename {
2311 continue;
2312 }
2313 if name.ends_with(".tera") {
2314 continue;
2316 }
2317 let src_path = src_dir.join(name);
2318 let dst_path = dst_dir.join(name);
2319 let ft = entry.file_type()?;
2320
2321 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
2322 continue;
2323 }
2324
2325 if ft.is_dir() {
2326 walk_and_link(
2327 &src_path, &dst_path, ctx, strategy, engine, tera_ctx, yuiignore, covered,
2328 )?;
2329 } else if ft.is_file() {
2330 if !covered {
2336 link_file_with_backup(&src_path, &dst_path, ctx)?;
2337 }
2338 }
2339 }
2340 Ok(())
2341}
2342
2343fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2344 use absorb::AbsorbDecision::*;
2345
2346 let decision = absorb::classify(src, dst)?;
2347
2348 if ctx.dry_run {
2349 info!("[dry-run] {decision:?}: {src} → {dst}");
2350 return Ok(());
2351 }
2352
2353 match decision {
2354 InSync => {
2355 Ok(())
2357 }
2358 Restore => {
2359 info!("link: {src} → {dst}");
2360 link::link_file(src, dst, ctx.file_mode)?;
2361 Ok(())
2362 }
2363 RelinkOnly => {
2364 info!("relink: {src} → {dst}");
2367 link::unlink(dst)?;
2368 link::link_file(src, dst, ctx.file_mode)?;
2369 Ok(())
2370 }
2371 AutoAbsorb => {
2372 if !ctx.config.absorb.auto {
2375 return handle_anomaly(
2376 src,
2377 dst,
2378 ctx,
2379 "absorb.auto = false; treating divergence as anomaly",
2380 );
2381 }
2382 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
2383 return handle_anomaly(
2384 src,
2385 dst,
2386 ctx,
2387 "source repo is dirty; deferring auto-absorb",
2388 );
2389 }
2390 absorb_target_into_source(src, dst, ctx)
2391 }
2392 NeedsConfirm => handle_anomaly(
2393 src,
2394 dst,
2395 ctx,
2396 "anomaly: source equals/newer than target but content differs",
2397 ),
2398 }
2399}
2400
2401fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2405 info!("absorb: {dst} → {src}");
2406 backup_existing(src, ctx.backup_root, false)?;
2407 std::fs::copy(dst, src)?;
2408 link::unlink(dst)?;
2409 link::link_file(src, dst, ctx.file_mode)?;
2410 Ok(())
2411}
2412
2413fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
2419 use crate::config::AnomalyAction::*;
2420 match ctx.config.absorb.on_anomaly {
2421 Skip => {
2422 warn!("anomaly skip: {dst} ({reason})");
2423 Ok(())
2424 }
2425 Force => {
2426 warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
2427 absorb_target_into_source(src, dst, ctx)
2428 }
2429 Ask => {
2430 use std::io::IsTerminal;
2431 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
2432 if prompt_absorb_with_diff(src, dst, reason)? {
2433 absorb_target_into_source(src, dst, ctx)
2434 } else {
2435 warn!("anomaly skipped by user: {dst}");
2436 Ok(())
2437 }
2438 } else {
2439 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
2440 Ok(())
2441 }
2442 }
2443 }
2444}
2445
2446fn prompt_absorb_with_diff(src: &Utf8Path, dst: &Utf8Path, reason: &str) -> Result<bool> {
2447 use std::io::Write as _;
2448 let src_content = std::fs::read_to_string(src).unwrap_or_default();
2449 let dst_content = std::fs::read_to_string(dst).unwrap_or_default();
2450 eprintln!();
2451 eprintln!("anomaly: {reason}");
2452 eprintln!(" src: {src}");
2453 eprintln!(" dst: {dst}");
2454 eprintln!();
2455 eprintln!("--- diff (- source, + target) ---");
2456 let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
2457 for change in diff.iter_all_changes() {
2458 let sign = match change.tag() {
2459 similar::ChangeTag::Delete => "-",
2460 similar::ChangeTag::Insert => "+",
2461 similar::ChangeTag::Equal => " ",
2462 };
2463 eprint!("{sign}{change}");
2464 }
2465 eprintln!();
2466 eprint!("absorb target into source? [y/N]: ");
2467 std::io::stderr().flush().ok();
2472 let mut input = String::new();
2473 std::io::stdin().read_line(&mut input)?;
2474 let answer = input.trim();
2475 Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
2476}
2477
2478fn source_repo_is_clean(source: &Utf8Path) -> bool {
2483 match crate::git::is_clean(source) {
2484 Ok(b) => b,
2485 Err(e) => {
2486 warn!("git clean check failed at {source}: {e} — treating as clean");
2487 true
2488 }
2489 }
2490}
2491
2492fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2493 use absorb::AbsorbDecision::*;
2494 let decision = absorb::classify(src, dst)?;
2495
2496 if ctx.dry_run {
2497 info!("[dry-run] dir {decision:?}: {src} → {dst}");
2498 return Ok(());
2499 }
2500
2501 match decision {
2502 InSync => Ok(()),
2503 Restore => {
2504 info!("link dir: {src} → {dst}");
2505 link::link_dir(src, dst, ctx.dir_mode)?;
2506 Ok(())
2507 }
2508 RelinkOnly => {
2509 info!("relink dir: {src} → {dst}");
2514 remove_dir_link_or_real(dst)?;
2515 link::link_dir(src, dst, ctx.dir_mode)?;
2516 Ok(())
2517 }
2518 AutoAbsorb | NeedsConfirm => {
2519 if !ctx.config.absorb.auto {
2540 return handle_anomaly_dir(
2541 src,
2542 dst,
2543 ctx,
2544 "absorb.auto = false; treating divergence as anomaly",
2545 );
2546 }
2547 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
2548 return handle_anomaly_dir(
2549 src,
2550 dst,
2551 ctx,
2552 "source repo is dirty; deferring auto-absorb",
2553 );
2554 }
2555 absorb_target_dir_into_source(src, dst, ctx)
2556 }
2557 }
2558}
2559
2560fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
2570 if let Err(unlink_err) = link::unlink(dst) {
2571 let meta = std::fs::symlink_metadata(dst)
2572 .with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
2573 let ft = meta.file_type();
2574 if ft.is_dir() && !ft.is_symlink() {
2575 std::fs::remove_dir_all(dst).with_context(|| {
2576 format!(
2577 "remove_dir_all({dst}) after link::unlink failed: \
2578 {unlink_err}"
2579 )
2580 })?;
2581 } else {
2582 return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
2583 }
2584 }
2585 Ok(())
2586}
2587
2588fn merge_dir_target_into_source(
2598 target: &Utf8Path,
2599 source: &Utf8Path,
2600 ctx: &ApplyCtx<'_>,
2601) -> Result<()> {
2602 for entry in std::fs::read_dir(target)? {
2603 let entry = entry?;
2604 let name_os = entry.file_name();
2605 let Some(name) = name_os.to_str() else {
2606 continue;
2607 };
2608 let target_path = target.join(name);
2609 let source_path = source.join(name);
2610 let ft = entry.file_type()?;
2611
2612 if ft.is_dir() && !ft.is_symlink() {
2613 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
2619 let sft = src_meta.file_type();
2620 if !sft.is_dir() || sft.is_symlink() {
2621 link::unlink(&source_path).with_context(|| {
2622 format!("remove conflicting source entry before dir merge: {source_path}")
2623 })?;
2624 }
2625 }
2626 if !source_path.exists() {
2627 std::fs::create_dir_all(&source_path).with_context(|| {
2628 format!("create_dir_all({source_path}) during target→source merge")
2629 })?;
2630 }
2631 merge_dir_target_into_source(&target_path, &source_path, ctx)?;
2632 } else if ft.is_file() {
2633 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
2637 let sft = src_meta.file_type();
2638 if sft.is_dir() && !sft.is_symlink() {
2639 remove_dir_link_or_real(&source_path).with_context(|| {
2640 format!("remove conflicting source dir before file merge: {source_path}")
2641 })?;
2642 } else if sft.is_symlink() {
2643 link::unlink(&source_path).with_context(|| {
2644 format!(
2645 "remove conflicting source symlink before file merge: {source_path}"
2646 )
2647 })?;
2648 }
2649 }
2650 if let Some(parent) = source_path.parent() {
2651 if !parent.exists() {
2652 std::fs::create_dir_all(parent)?;
2653 }
2654 }
2655 if source_path.is_file() {
2669 merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
2670 } else {
2671 std::fs::copy(&target_path, &source_path)
2672 .with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
2673 }
2674 } else {
2675 warn!(
2676 "merge: skipping non-regular entry {target_path} \
2677 (symlink / junction / special — content not copied)"
2678 );
2679 }
2680 }
2681 Ok(())
2682}
2683
2684fn merge_resolve_file_conflict(
2698 target_path: &Utf8Path,
2699 source_path: &Utf8Path,
2700 ctx: &ApplyCtx<'_>,
2701) -> Result<()> {
2702 use absorb::AbsorbDecision::*;
2703 let decision = absorb::classify(source_path, target_path)?;
2704 match decision {
2705 InSync | RelinkOnly => Ok(()),
2706 AutoAbsorb => {
2707 std::fs::copy(target_path, source_path).with_context(|| {
2708 format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
2709 })?;
2710 Ok(())
2711 }
2712 Restore => {
2713 unreachable!(
2720 "merge_resolve_file_conflict reached with both files present, \
2721 but classify returned Restore (target {target_path} / source {source_path})"
2722 )
2723 }
2724 NeedsConfirm => {
2725 use crate::config::AnomalyAction::*;
2726 match ctx.config.absorb.on_anomaly {
2727 Skip => {
2728 warn!(
2729 "merge anomaly skip: {target_path} (source-newer / content drift) \
2730 — keeping source version, target version dropped"
2731 );
2732 Ok(())
2733 }
2734 Force => {
2735 warn!(
2736 "merge anomaly force: {target_path} \
2737 (source-newer / content drift) — overwriting source"
2738 );
2739 std::fs::copy(target_path, source_path)?;
2740 Ok(())
2741 }
2742 Ask => {
2743 use std::io::IsTerminal;
2744 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
2745 if prompt_absorb_with_diff(
2746 source_path,
2747 target_path,
2748 "merge: file content differs and source is newer",
2749 )? {
2750 std::fs::copy(target_path, source_path)?;
2751 } else {
2752 warn!("merge: kept source version by user choice: {source_path}");
2753 }
2754 Ok(())
2755 } else {
2756 warn!(
2757 "merge anomaly skip (non-TTY ask mode): {target_path} \
2758 — keeping source version"
2759 );
2760 Ok(())
2761 }
2762 }
2763 }
2764 }
2765 }
2766}
2767
2768fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2775 info!("absorb dir: {dst} → {src}");
2776 backup_existing(src, ctx.backup_root, true)?;
2777 merge_dir_target_into_source(dst, src, ctx)?;
2778 remove_dir_link_or_real(dst)?;
2781 link::link_dir(src, dst, ctx.dir_mode)?;
2782 Ok(())
2783}
2784
2785fn handle_anomaly_dir(
2789 src: &Utf8Path,
2790 dst: &Utf8Path,
2791 ctx: &ApplyCtx<'_>,
2792 reason: &str,
2793) -> Result<()> {
2794 use crate::config::AnomalyAction::*;
2795 match ctx.config.absorb.on_anomaly {
2796 Skip => {
2797 warn!("anomaly skip dir: {dst} ({reason})");
2798 Ok(())
2799 }
2800 Force => {
2801 warn!(
2802 "anomaly force dir: {dst} ({reason}) \
2803 — absorbing target into source"
2804 );
2805 absorb_target_dir_into_source(src, dst, ctx)
2806 }
2807 Ask => {
2808 use std::io::IsTerminal;
2809 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
2810 eprintln!();
2811 eprintln!("anomaly: {dst}");
2812 eprintln!(" {reason}");
2813 eprintln!(" source: {src}");
2814 eprint!(" absorb target dir into source? (y/N) ");
2815 use std::io::{BufRead as _, Write as _};
2816 std::io::stderr().flush().ok();
2817 let mut buf = String::new();
2818 std::io::stdin().lock().read_line(&mut buf)?;
2819 let answer = buf.trim();
2820 if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
2821 absorb_target_dir_into_source(src, dst, ctx)
2822 } else {
2823 warn!("anomaly skipped by user: {dst}");
2824 Ok(())
2825 }
2826 } else {
2827 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
2828 Ok(())
2829 }
2830 }
2831 }
2832}
2833
2834fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
2835 let abs_target = absolutize(target)?;
2836 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
2837 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
2838 info!("backup → {bp}");
2839 if is_dir {
2840 backup::backup_dir(target, &bp)?;
2841 } else {
2842 backup::backup_file(target, &bp)?;
2843 }
2844 Ok(())
2845}
2846
2847fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
2848 if let Some(s) = source {
2849 return absolutize(&s);
2850 }
2851 if let Ok(s) = std::env::var("YUI_SOURCE") {
2852 return absolutize(Utf8Path::new(&s));
2853 }
2854 let cwd = current_dir_utf8()?;
2855 for ancestor in cwd.ancestors() {
2856 if ancestor.join("config.toml").is_file() {
2857 return Ok(ancestor.to_path_buf());
2858 }
2859 }
2860 if let Some(home) = paths::home_dir() {
2861 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
2862 let p = home.join(c);
2863 if p.join("config.toml").is_file() {
2864 return Ok(p);
2865 }
2866 }
2867 }
2868 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
2869}
2870
2871fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
2872 let expanded = paths::expand_tilde(p.as_str());
2874 if expanded.is_absolute() {
2875 return Ok(expanded);
2876 }
2877 let cwd = current_dir_utf8()?;
2878 Ok(cwd.join(expanded))
2879}
2880
2881fn current_dir_utf8() -> Result<Utf8PathBuf> {
2882 let cwd = std::env::current_dir().context("getting cwd")?;
2883 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
2884}
2885
2886const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
2890
2891[vars]
2892# user-defined values; templates can reference these as {{ vars.foo }}
2893
2894# [link]
2895# file_mode = "auto" # auto | symlink | hardlink
2896# dir_mode = "auto" # auto | symlink | junction
2897
2898[mount]
2899default_strategy = "marker"
2900
2901[[mount.entry]]
2902src = "home"
2903# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
2904dst = "~"
2905
2906# [[mount.entry]]
2907# src = "appdata"
2908# dst = "{{ env(name='APPDATA') }}"
2909# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
2910# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
2911# when = "yui.os == 'windows'"
2912"#;
2913
2914const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
2915# .yui/bin/ is intentionally tracked — it holds your hook scripts.
2916/.yui/state.json
2917/.yui/state.json.tmp
2918/.yui/backup/
2919
2920# >>> yui rendered (auto-managed, do not edit) >>>
2921# <<< yui rendered (auto-managed) <<<
2922
2923# config.local.toml is per-machine; commit a config.local.example.toml instead.
2924config.local.toml
2925"#;
2926
2927#[cfg(test)]
2928mod tests {
2929 use super::*;
2930 use tempfile::TempDir;
2931
2932 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
2933 Utf8PathBuf::from_path_buf(p).unwrap()
2934 }
2935
2936 fn toml_path(p: &Utf8Path) -> String {
2938 p.as_str().replace('\\', "/")
2939 }
2940
2941 #[test]
2942 fn apply_links_a_raw_file() {
2943 let tmp = TempDir::new().unwrap();
2944 let source = utf8(tmp.path().join("dotfiles"));
2945 let target = utf8(tmp.path().join("target"));
2946 std::fs::create_dir_all(source.join("home")).unwrap();
2947 std::fs::create_dir_all(&target).unwrap();
2948 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2949
2950 let cfg = format!(
2951 r#"
2952[[mount.entry]]
2953src = "home"
2954dst = "{}"
2955"#,
2956 toml_path(&target)
2957 );
2958 std::fs::write(source.join("config.toml"), cfg).unwrap();
2959
2960 apply(Some(source), false).unwrap();
2961
2962 let linked = target.join(".bashrc");
2963 assert!(linked.exists(), "expected {linked} to exist");
2964 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
2965 }
2966
2967 #[test]
2968 fn apply_with_marker_links_whole_directory() {
2969 let tmp = TempDir::new().unwrap();
2970 let source = utf8(tmp.path().join("dotfiles"));
2971 let target = utf8(tmp.path().join("target"));
2972 let nvim_src = source.join("home/nvim");
2973 std::fs::create_dir_all(&nvim_src).unwrap();
2974 std::fs::create_dir_all(&target).unwrap();
2975 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
2976 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
2977 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
2978
2979 let cfg = format!(
2980 r#"
2981[[mount.entry]]
2982src = "home"
2983dst = "{}"
2984"#,
2985 toml_path(&target)
2986 );
2987 std::fs::write(source.join("config.toml"), cfg).unwrap();
2988
2989 apply(Some(source.clone()), false).unwrap();
2990
2991 let nvim_dst = target.join("nvim");
2992 assert!(nvim_dst.exists());
2993 assert_eq!(
2994 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
2995 "-- hi\n"
2996 );
2997 }
3001
3002 #[test]
3003 fn apply_dry_run_does_not_write() {
3004 let tmp = TempDir::new().unwrap();
3005 let source = utf8(tmp.path().join("dotfiles"));
3006 let target = utf8(tmp.path().join("target"));
3007 std::fs::create_dir_all(source.join("home")).unwrap();
3008 std::fs::create_dir_all(&target).unwrap();
3009 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
3010
3011 let cfg = format!(
3012 r#"
3013[[mount.entry]]
3014src = "home"
3015dst = "{}"
3016"#,
3017 toml_path(&target)
3018 );
3019 std::fs::write(source.join("config.toml"), cfg).unwrap();
3020
3021 apply(Some(source), true).unwrap();
3022
3023 assert!(!target.join(".bashrc").exists());
3024 }
3025
3026 #[test]
3027 fn apply_renders_templates_then_links_rendered_outputs() {
3028 let tmp = TempDir::new().unwrap();
3029 let source = utf8(tmp.path().join("dotfiles"));
3030 let target = utf8(tmp.path().join("target"));
3031 std::fs::create_dir_all(source.join("home")).unwrap();
3032 std::fs::create_dir_all(&target).unwrap();
3033 std::fs::write(
3034 source.join("home/.gitconfig.tera"),
3035 "[user]\n os = {{ yui.os }}\n",
3036 )
3037 .unwrap();
3038 std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
3039
3040 let cfg = format!(
3041 r#"
3042[[mount.entry]]
3043src = "home"
3044dst = "{}"
3045"#,
3046 toml_path(&target)
3047 );
3048 std::fs::write(source.join("config.toml"), cfg).unwrap();
3049
3050 apply(Some(source.clone()), false).unwrap();
3051
3052 assert!(target.join(".bashrc").exists());
3054 assert!(source.join("home/.gitconfig").exists());
3056 assert!(target.join(".gitconfig").exists());
3057 assert!(!target.join(".gitconfig.tera").exists());
3059 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
3061 assert!(linked.contains("os = "));
3062 }
3063
3064 #[test]
3065 fn apply_marker_override_links_to_custom_dst() {
3066 let tmp = TempDir::new().unwrap();
3067 let source = utf8(tmp.path().join("dotfiles"));
3068 let target_a = utf8(tmp.path().join("target_a"));
3069 let target_b = utf8(tmp.path().join("target_b"));
3070 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3071 std::fs::create_dir_all(&target_a).unwrap();
3072 std::fs::create_dir_all(&target_b).unwrap();
3073 std::fs::write(
3074 source.join("home/.config/nvim/init.lua"),
3075 "-- nvim config\n",
3076 )
3077 .unwrap();
3078
3079 std::fs::write(
3082 source.join("home/.config/nvim/.yuilink"),
3083 format!(
3084 r#"
3085[[link]]
3086dst = "{}/nvim"
3087
3088[[link]]
3089dst = "{}/nvim"
3090when = "{{{{ yui.os == '{}' }}}}"
3091"#,
3092 toml_path(&target_a),
3093 toml_path(&target_b),
3094 std::env::consts::OS
3095 ),
3096 )
3097 .unwrap();
3098
3099 let parent_target = utf8(tmp.path().join("parent_target"));
3100 std::fs::create_dir_all(&parent_target).unwrap();
3101 let cfg = format!(
3102 r#"
3103[[mount.entry]]
3104src = "home"
3105dst = "{}"
3106"#,
3107 toml_path(&parent_target)
3108 );
3109 std::fs::write(source.join("config.toml"), cfg).unwrap();
3110
3111 apply(Some(source.clone()), false).unwrap();
3112
3113 assert!(
3115 target_a.join("nvim/init.lua").exists(),
3116 "target_a/nvim/init.lua should be reachable through the link"
3117 );
3118 assert!(
3119 target_b.join("nvim/init.lua").exists(),
3120 "target_b/nvim/init.lua should be reachable through the link"
3121 );
3122 assert!(
3125 !parent_target.join(".config/nvim").exists(),
3126 "parent mount should have skipped the marker-claimed sub-dir"
3127 );
3128 }
3129
3130 #[test]
3131 fn apply_marker_inactive_link_falls_through_to_default() {
3132 let tmp = TempDir::new().unwrap();
3137 let source = utf8(tmp.path().join("dotfiles"));
3138 let target_inactive = utf8(tmp.path().join("inactive"));
3139 let parent_target = utf8(tmp.path().join("parent"));
3140 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3141 std::fs::create_dir_all(&parent_target).unwrap();
3142 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
3143
3144 std::fs::write(
3146 source.join("home/.config/nvim/.yuilink"),
3147 format!(
3148 r#"
3149[[link]]
3150dst = "{}/nvim"
3151when = "{{{{ yui.os == 'no-such-os' }}}}"
3152"#,
3153 toml_path(&target_inactive)
3154 ),
3155 )
3156 .unwrap();
3157
3158 let cfg = format!(
3159 r#"
3160[[mount.entry]]
3161src = "home"
3162dst = "{}"
3163"#,
3164 toml_path(&parent_target)
3165 );
3166 std::fs::write(source.join("config.toml"), cfg).unwrap();
3167
3168 apply(Some(source.clone()), false).unwrap();
3169
3170 assert!(!target_inactive.join("nvim").exists());
3172 assert!(parent_target.join(".config/nvim/init.lua").exists());
3175 }
3176
3177 #[test]
3178 fn list_shows_mount_entries_and_marker_overrides() {
3179 let tmp = TempDir::new().unwrap();
3180 let source = utf8(tmp.path().join("dotfiles"));
3181 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3182 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
3183 std::fs::write(
3184 source.join("home/.config/nvim/.yuilink"),
3185 r#"
3186[[link]]
3187dst = "/custom/nvim"
3188"#,
3189 )
3190 .unwrap();
3191 std::fs::write(
3192 source.join("config.toml"),
3193 r#"
3194[[mount.entry]]
3195src = "home"
3196dst = "/h"
3197"#,
3198 )
3199 .unwrap();
3200
3201 list(Some(source), false, None, true).unwrap();
3204 }
3205
3206 #[test]
3207 fn status_reports_in_sync_after_apply() {
3208 let tmp = TempDir::new().unwrap();
3209 let source = utf8(tmp.path().join("dotfiles"));
3210 let target = utf8(tmp.path().join("target"));
3211 std::fs::create_dir_all(source.join("home")).unwrap();
3212 std::fs::create_dir_all(&target).unwrap();
3213 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3214 let cfg = format!(
3215 r#"
3216[[mount.entry]]
3217src = "home"
3218dst = "{}"
3219"#,
3220 toml_path(&target)
3221 );
3222 std::fs::write(source.join("config.toml"), cfg).unwrap();
3223 apply(Some(source.clone()), false).unwrap();
3225 status(Some(source), None, true).unwrap();
3227 }
3228
3229 #[test]
3230 fn status_reports_template_drift() {
3231 let tmp = TempDir::new().unwrap();
3232 let source = utf8(tmp.path().join("dotfiles"));
3233 let target = utf8(tmp.path().join("target"));
3234 std::fs::create_dir_all(source.join("home")).unwrap();
3235 std::fs::create_dir_all(&target).unwrap();
3236 std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
3239 std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
3240
3241 let cfg = format!(
3242 r#"
3243[[mount.entry]]
3244src = "home"
3245dst = "{}"
3246"#,
3247 toml_path(&target)
3248 );
3249 std::fs::write(source.join("config.toml"), cfg).unwrap();
3250
3251 let err = status(Some(source), None, true).unwrap_err();
3252 assert!(format!("{err}").contains("diverged"));
3253 }
3254
3255 #[test]
3256 fn status_fails_when_target_missing() {
3257 let tmp = TempDir::new().unwrap();
3258 let source = utf8(tmp.path().join("dotfiles"));
3259 let target = utf8(tmp.path().join("target"));
3260 std::fs::create_dir_all(source.join("home")).unwrap();
3261 std::fs::create_dir_all(&target).unwrap();
3262 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3263 let cfg = format!(
3264 r#"
3265[[mount.entry]]
3266src = "home"
3267dst = "{}"
3268"#,
3269 toml_path(&target)
3270 );
3271 std::fs::write(source.join("config.toml"), cfg).unwrap();
3272 let err = status(Some(source), None, true).unwrap_err();
3274 assert!(format!("{err}").contains("diverged"));
3275 }
3276
3277 #[test]
3278 fn strip_braces_removes_outer_template_braces() {
3279 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
3280 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
3281 assert_eq!(strip_braces(" {{x}} "), "x");
3282 }
3283
3284 #[test]
3285 fn apply_aborts_on_render_drift() {
3286 let tmp = TempDir::new().unwrap();
3287 let source = utf8(tmp.path().join("dotfiles"));
3288 let target = utf8(tmp.path().join("target"));
3289 std::fs::create_dir_all(source.join("home")).unwrap();
3290 std::fs::create_dir_all(&target).unwrap();
3291 std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
3292 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
3293
3294 let cfg = format!(
3295 r#"
3296[[mount.entry]]
3297src = "home"
3298dst = "{}"
3299"#,
3300 toml_path(&target)
3301 );
3302 std::fs::write(source.join("config.toml"), cfg).unwrap();
3303
3304 let err = apply(Some(source.clone()), false).unwrap_err();
3305 assert!(format!("{err}").contains("drift"));
3306 assert_eq!(
3308 std::fs::read_to_string(source.join("home/foo")).unwrap(),
3309 "manually edited"
3310 );
3311 assert!(!target.join("foo").exists());
3313 }
3314
3315 #[test]
3316 fn init_creates_skeleton_when_dir_empty() {
3317 let tmp = TempDir::new().unwrap();
3318 let dir = utf8(tmp.path().join("new_dotfiles"));
3319 init(Some(dir.clone()), false).unwrap();
3320 assert!(dir.join("config.toml").is_file());
3321 assert!(dir.join(".gitignore").is_file());
3322 }
3323
3324 #[test]
3325 fn init_refuses_to_overwrite_existing_config() {
3326 let tmp = TempDir::new().unwrap();
3327 let dir = utf8(tmp.path().join("dotfiles"));
3328 std::fs::create_dir_all(&dir).unwrap();
3329 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
3330 let err = init(Some(dir), false).unwrap_err();
3331 assert!(format!("{err}").contains("already exists"));
3332 }
3333
3334 #[test]
3340 fn init_appends_missing_gitignore_entries_into_existing_file() {
3341 let tmp = TempDir::new().unwrap();
3342 let dir = utf8(tmp.path().join("dotfiles"));
3343 std::fs::create_dir_all(&dir).unwrap();
3344 let user_gitignore = "# user entries\n*.swp\nnode_modules/\n";
3346 std::fs::write(dir.join(".gitignore"), user_gitignore).unwrap();
3347
3348 init(Some(dir.clone()), false).unwrap();
3349
3350 let body = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
3351 assert!(body.contains("*.swp"));
3353 assert!(body.contains("node_modules/"));
3354 assert!(body.contains("/.yui/state.json"));
3356 assert!(body.contains("/.yui/backup/"));
3357 assert!(body.contains("config.local.toml"));
3358 let before_rerun = body.clone();
3360 std::fs::remove_file(dir.join("config.toml")).unwrap();
3363 init(Some(dir.clone()), false).unwrap();
3364 let after_rerun = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
3365 assert_eq!(
3366 before_rerun, after_rerun,
3367 "init must be idempotent when the gitignore already has every yui entry"
3368 );
3369 }
3370
3371 #[test]
3377 fn init_with_git_hooks_installs_into_existing_repo() {
3378 let tmp = TempDir::new().unwrap();
3379 let dir = utf8(tmp.path().join("dotfiles"));
3380 std::fs::create_dir_all(&dir).unwrap();
3381 let st = std::process::Command::new("git")
3382 .args(["init", "-q"])
3383 .current_dir(dir.as_std_path())
3384 .status()
3385 .expect("git init");
3386 if !st.success() {
3387 return;
3388 }
3389 let user_config = "# user already wrote this\n";
3391 std::fs::write(dir.join("config.toml"), user_config).unwrap();
3392
3393 init(Some(dir.clone()), true).unwrap();
3395
3396 assert_eq!(
3397 std::fs::read_to_string(dir.join("config.toml")).unwrap(),
3398 user_config
3399 );
3400 assert!(dir.join(".git/hooks/pre-commit").is_file());
3401 assert!(dir.join(".git/hooks/pre-push").is_file());
3402 }
3403
3404 #[test]
3409 fn init_with_git_hooks_writes_pre_commit_and_pre_push() {
3410 let tmp = TempDir::new().unwrap();
3411 let dir = utf8(tmp.path().join("dotfiles"));
3412 std::fs::create_dir_all(&dir).unwrap();
3413 let st = std::process::Command::new("git")
3415 .args(["init", "-q"])
3416 .current_dir(dir.as_std_path())
3417 .status()
3418 .expect("git init");
3419 if !st.success() {
3420 eprintln!("skipping: git not available");
3422 return;
3423 }
3424 init(Some(dir.clone()), true).unwrap();
3425
3426 let pre_commit = dir.join(".git/hooks/pre-commit");
3427 let pre_push = dir.join(".git/hooks/pre-push");
3428 assert!(pre_commit.is_file(), "pre-commit hook should be written");
3429 assert!(pre_push.is_file(), "pre-push hook should be written");
3430
3431 let body = std::fs::read_to_string(&pre_commit).unwrap();
3432 assert!(
3433 body.contains("yui render --check"),
3434 "pre-commit hook should call `yui render --check`, got: {body}"
3435 );
3436 }
3437
3438 #[test]
3442 fn init_with_git_hooks_errors_outside_a_git_repo() {
3443 let tmp = TempDir::new().unwrap();
3444 let dir = utf8(tmp.path().join("not-a-repo"));
3445 std::fs::create_dir_all(&dir).unwrap();
3446 let err = init(Some(dir), true).unwrap_err();
3447 let msg = format!("{err:#}");
3448 assert!(
3449 msg.contains("git repo") || msg.contains("git rev-parse"),
3450 "expected error to mention the git issue, got: {msg}"
3451 );
3452 }
3453
3454 #[test]
3457 fn init_with_git_hooks_does_not_clobber_existing_hooks() {
3458 let tmp = TempDir::new().unwrap();
3459 let dir = utf8(tmp.path().join("dotfiles"));
3460 std::fs::create_dir_all(&dir).unwrap();
3461 let st = std::process::Command::new("git")
3462 .args(["init", "-q"])
3463 .current_dir(dir.as_std_path())
3464 .status()
3465 .expect("git init");
3466 if !st.success() {
3467 return;
3468 }
3469 let hooks = dir.join(".git/hooks");
3470 std::fs::create_dir_all(&hooks).unwrap();
3471 std::fs::write(hooks.join("pre-commit"), "#! /bin/sh\nexit 0\n").unwrap();
3472
3473 init(Some(dir.clone()), true).unwrap();
3474
3475 let pc = std::fs::read_to_string(hooks.join("pre-commit")).unwrap();
3477 assert!(
3478 !pc.contains("yui render --check"),
3479 "existing pre-commit must not be overwritten"
3480 );
3481 let pp = std::fs::read_to_string(hooks.join("pre-push")).unwrap();
3482 assert!(
3483 pp.contains("yui render --check"),
3484 "missing pre-push should be written: {pp}"
3485 );
3486 }
3487
3488 fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
3491 let source = utf8(tmp.path().join("dotfiles"));
3492 let target = utf8(tmp.path().join("target"));
3493 std::fs::create_dir_all(source.join("home")).unwrap();
3494 std::fs::create_dir_all(&target).unwrap();
3495 let cfg = format!(
3496 r#"
3497[[mount.entry]]
3498src = "home"
3499dst = "{}"
3500"#,
3501 toml_path(&target)
3502 );
3503 std::fs::write(source.join("config.toml"), cfg).unwrap();
3504 (source, target)
3505 }
3506
3507 fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
3508 std::fs::write(path, body).unwrap();
3509 let f = std::fs::OpenOptions::new()
3510 .write(true)
3511 .open(path)
3512 .expect("open writable");
3513 f.set_modified(when).expect("set_modified");
3514 }
3515
3516 #[test]
3517 fn apply_target_newer_absorbs_target_into_source() {
3518 let tmp = TempDir::new().unwrap();
3522 let (source, target) = setup_minimal_dotfiles(&tmp);
3523
3524 let now = std::time::SystemTime::now();
3525 let past = now - std::time::Duration::from_secs(120);
3526 write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
3527 write_with_mtime(&target.join(".bashrc"), "user's edit", now);
3529
3530 apply(Some(source.clone()), false).unwrap();
3531
3532 assert_eq!(
3534 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3535 "user's edit"
3536 );
3537 assert_eq!(
3539 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3540 "user's edit"
3541 );
3542 let backup_root = source.join(".yui/backup");
3544 let mut found_old = false;
3545 for entry in walkdir(&backup_root) {
3546 if let Ok(s) = std::fs::read_to_string(&entry) {
3547 if s == "default from repo" {
3548 found_old = true;
3549 break;
3550 }
3551 }
3552 }
3553 assert!(found_old, "expected backup containing 'default from repo'");
3554 }
3555
3556 #[test]
3557 fn apply_in_sync_target_is_a_no_op() {
3558 let tmp = TempDir::new().unwrap();
3561 let (source, target) = setup_minimal_dotfiles(&tmp);
3562 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3563 apply(Some(source.clone()), false).unwrap();
3564 let backup_root = source.join(".yui/backup");
3565 let backup_count_after_first = walkdir(&backup_root).len();
3566
3567 apply(Some(source.clone()), false).unwrap();
3569 assert_eq!(
3570 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3571 "echo hi\n"
3572 );
3573 let backup_count_after_second = walkdir(&backup_root).len();
3574 assert_eq!(
3575 backup_count_after_first, backup_count_after_second,
3576 "second apply on an in-sync tree should not produce backups"
3577 );
3578 }
3579
3580 #[test]
3581 fn apply_skip_policy_leaves_anomaly_alone() {
3582 let tmp = TempDir::new().unwrap();
3585 let source = utf8(tmp.path().join("dotfiles"));
3586 let target = utf8(tmp.path().join("target"));
3587 std::fs::create_dir_all(source.join("home")).unwrap();
3588 std::fs::create_dir_all(&target).unwrap();
3589 let cfg = format!(
3590 r#"
3591[absorb]
3592on_anomaly = "skip"
3593
3594[[mount.entry]]
3595src = "home"
3596dst = "{}"
3597"#,
3598 toml_path(&target)
3599 );
3600 std::fs::write(source.join("config.toml"), cfg).unwrap();
3601
3602 let now = std::time::SystemTime::now();
3603 let past = now - std::time::Duration::from_secs(120);
3604 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
3605 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
3606
3607 apply(Some(source.clone()), false).unwrap();
3608
3609 assert_eq!(
3611 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3612 "user's edit (older)"
3613 );
3614 assert_eq!(
3616 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3617 "fresh from upstream"
3618 );
3619 }
3620
3621 #[test]
3622 fn apply_force_policy_absorbs_anomaly_anyway() {
3623 let tmp = TempDir::new().unwrap();
3625 let source = utf8(tmp.path().join("dotfiles"));
3626 let target = utf8(tmp.path().join("target"));
3627 std::fs::create_dir_all(source.join("home")).unwrap();
3628 std::fs::create_dir_all(&target).unwrap();
3629 let cfg = format!(
3630 r#"
3631[absorb]
3632on_anomaly = "force"
3633
3634[[mount.entry]]
3635src = "home"
3636dst = "{}"
3637"#,
3638 toml_path(&target)
3639 );
3640 std::fs::write(source.join("config.toml"), cfg).unwrap();
3641
3642 let now = std::time::SystemTime::now();
3643 let past = now - std::time::Duration::from_secs(120);
3644 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
3645 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
3646
3647 apply(Some(source.clone()), false).unwrap();
3648
3649 assert_eq!(
3651 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3652 "user's edit (older)"
3653 );
3654 assert_eq!(
3655 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3656 "user's edit (older)"
3657 );
3658 }
3659
3660 #[test]
3672 fn apply_absorbs_non_empty_target_dir_target_wins() {
3673 let tmp = TempDir::new().unwrap();
3674 let source = utf8(tmp.path().join("dotfiles"));
3675 let target = utf8(tmp.path().join("target"));
3676 std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
3677 std::fs::create_dir_all(target.join(".config/app")).unwrap();
3678 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3681 std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
3682 std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
3684 std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
3687 std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
3688
3689 let cfg = format!(
3690 r#"
3691[absorb]
3692on_anomaly = "force"
3693
3694[[mount.entry]]
3695src = "home"
3696dst = "{}"
3697"#,
3698 toml_path(&target)
3699 );
3700 std::fs::write(source.join("config.toml"), cfg).unwrap();
3701
3702 apply(Some(source.clone()), false).unwrap();
3704
3705 assert_eq!(
3707 std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
3708 "target side"
3709 );
3710 assert_eq!(
3712 std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
3713 "{}"
3714 );
3715 let backup_root = source.join(".yui/backup");
3718 let mut backup_files: Vec<String> = Vec::new();
3719 for entry in walkdir(&backup_root) {
3720 if let Some(n) = entry.file_name() {
3721 backup_files.push(n.to_string());
3722 }
3723 }
3724 assert!(
3725 backup_files.iter().any(|f| f == "config.toml"),
3726 "expected source's config.toml to land in the backup tree, got {backup_files:?}"
3727 );
3728 assert!(
3730 source.join("home/.config/app/source-only.toml").exists(),
3731 "source-only file should survive a target-wins merge"
3732 );
3733 assert!(
3735 source.join("home/.config/app/state.json").exists(),
3736 "target-only state.json should be merged into source"
3737 );
3738 }
3739
3740 #[test]
3746 fn marker_dir_absorbs_with_default_ask_policy() {
3747 let tmp = TempDir::new().unwrap();
3748 let source = utf8(tmp.path().join("dotfiles"));
3749 let target = utf8(tmp.path().join("target"));
3750 std::fs::create_dir_all(source.join("home/.config")).unwrap();
3751 std::fs::create_dir_all(target.join(".config/gh")).unwrap();
3752 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3754 std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\n").unwrap();
3756
3757 let cfg = format!(
3761 r#"
3762[[mount.entry]]
3763src = "home"
3764dst = "{}"
3765"#,
3766 toml_path(&target)
3767 );
3768 std::fs::write(source.join("config.toml"), cfg).unwrap();
3769
3770 apply(Some(source.clone()), false).unwrap();
3774
3775 assert!(target.join(".config/gh/hosts.yml").exists());
3778 assert!(source.join("home/.config/gh/hosts.yml").exists());
3779 }
3780
3781 #[test]
3787 fn merge_handles_file_vs_dir_collisions_target_wins() {
3788 let tmp = TempDir::new().unwrap();
3789 let source = utf8(tmp.path().join("dotfiles"));
3790 let target = utf8(tmp.path().join("target"));
3791 std::fs::create_dir_all(source.join("home/.config/foo")).unwrap();
3792 std::fs::create_dir_all(target.join(".config")).unwrap();
3793 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3794
3795 std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
3797 std::fs::write(target.join(".config/foo"), "target file body").unwrap();
3798 std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
3800 std::fs::create_dir_all(target.join(".config/bar")).unwrap();
3801 std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
3802
3803 let cfg = format!(
3804 r#"
3805[absorb]
3806on_anomaly = "force"
3807
3808[[mount.entry]]
3809src = "home"
3810dst = "{}"
3811"#,
3812 toml_path(&target)
3813 );
3814 std::fs::write(source.join("config.toml"), cfg).unwrap();
3815 apply(Some(source.clone()), false).unwrap();
3816
3817 let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
3821 assert!(foo_meta.file_type().is_file(), "foo should be a file");
3822 assert_eq!(
3823 std::fs::read_to_string(target.join(".config/foo")).unwrap(),
3824 "target file body"
3825 );
3826 let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
3828 assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
3829 assert_eq!(
3830 std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
3831 "target nested"
3832 );
3833 }
3834
3835 #[test]
3839 fn merge_per_file_target_newer_auto_absorbs() {
3840 let tmp = TempDir::new().unwrap();
3841 let source = utf8(tmp.path().join("dotfiles"));
3842 let target = utf8(tmp.path().join("target"));
3843 std::fs::create_dir_all(source.join("home/.config")).unwrap();
3844 std::fs::create_dir_all(target.join(".config")).unwrap();
3845 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3846
3847 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3849 write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
3850 std::fs::write(target.join(".config/app.toml"), "user's live edit").unwrap();
3851
3852 let cfg = format!(
3856 r#"
3857[[mount.entry]]
3858src = "home"
3859dst = "{}"
3860"#,
3861 toml_path(&target)
3862 );
3863 std::fs::write(source.join("config.toml"), cfg).unwrap();
3864 apply(Some(source.clone()), false).unwrap();
3865
3866 assert_eq!(
3868 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3869 "user's live edit"
3870 );
3871 }
3872
3873 #[test]
3879 fn merge_per_file_source_newer_skip_keeps_source() {
3880 let tmp = TempDir::new().unwrap();
3881 let source = utf8(tmp.path().join("dotfiles"));
3882 let target = utf8(tmp.path().join("target"));
3883 std::fs::create_dir_all(source.join("home/.config")).unwrap();
3884 std::fs::create_dir_all(target.join(".config")).unwrap();
3885 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3886
3887 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3889 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
3890 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
3891
3892 let cfg = format!(
3893 r#"
3894[absorb]
3895on_anomaly = "skip"
3896
3897[[mount.entry]]
3898src = "home"
3899dst = "{}"
3900"#,
3901 toml_path(&target)
3902 );
3903 std::fs::write(source.join("config.toml"), cfg).unwrap();
3904 apply(Some(source.clone()), false).unwrap();
3905
3906 assert_eq!(
3909 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3910 "fresh source"
3911 );
3912 }
3913
3914 #[test]
3917 fn merge_per_file_source_newer_force_overwrites_source() {
3918 let tmp = TempDir::new().unwrap();
3919 let source = utf8(tmp.path().join("dotfiles"));
3920 let target = utf8(tmp.path().join("target"));
3921 std::fs::create_dir_all(source.join("home/.config")).unwrap();
3922 std::fs::create_dir_all(target.join(".config")).unwrap();
3923 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3924
3925 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3926 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
3927 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
3928
3929 let cfg = format!(
3930 r#"
3931[absorb]
3932on_anomaly = "force"
3933
3934[[mount.entry]]
3935src = "home"
3936dst = "{}"
3937"#,
3938 toml_path(&target)
3939 );
3940 std::fs::write(source.join("config.toml"), cfg).unwrap();
3941 apply(Some(source.clone()), false).unwrap();
3942
3943 assert_eq!(
3945 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3946 "old target"
3947 );
3948 }
3949
3950 #[test]
3955 fn merge_per_file_identical_content_is_noop() {
3956 let tmp = TempDir::new().unwrap();
3957 let source = utf8(tmp.path().join("dotfiles"));
3958 let target = utf8(tmp.path().join("target"));
3959 std::fs::create_dir_all(source.join("home/.config")).unwrap();
3960 std::fs::create_dir_all(target.join(".config")).unwrap();
3961 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3962 std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
3963 std::fs::write(target.join(".config/app.toml"), "same").unwrap();
3964
3965 let cfg = format!(
3968 r#"
3969[[mount.entry]]
3970src = "home"
3971dst = "{}"
3972"#,
3973 toml_path(&target)
3974 );
3975 std::fs::write(source.join("config.toml"), cfg).unwrap();
3976 apply(Some(source.clone()), false).unwrap();
3977
3978 assert_eq!(
3979 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3980 "same"
3981 );
3982 }
3983
3984 #[test]
3985 fn manual_absorb_command_pulls_target_into_source() {
3986 let tmp = TempDir::new().unwrap();
3988 let source = utf8(tmp.path().join("dotfiles"));
3989 let target = utf8(tmp.path().join("target"));
3990 std::fs::create_dir_all(source.join("home")).unwrap();
3991 std::fs::create_dir_all(&target).unwrap();
3992 let cfg = format!(
3994 r#"
3995[absorb]
3996on_anomaly = "skip"
3997
3998[[mount.entry]]
3999src = "home"
4000dst = "{}"
4001"#,
4002 toml_path(&target)
4003 );
4004 std::fs::write(source.join("config.toml"), cfg).unwrap();
4005 std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
4006 std::fs::write(source.join("home/.bashrc"), "default").unwrap();
4007
4008 absorb(
4010 Some(source.clone()),
4011 target.join(".bashrc"),
4012 false,
4013 )
4014 .unwrap();
4015
4016 assert_eq!(
4018 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4019 "user picked this"
4020 );
4021 }
4022
4023 #[test]
4024 fn manual_absorb_errors_when_target_outside_known_mounts() {
4025 let tmp = TempDir::new().unwrap();
4026 let (source, _target) = setup_minimal_dotfiles(&tmp);
4027 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
4028 let stranger = utf8(tmp.path().join("not-managed/foo"));
4029 std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
4030 std::fs::write(&stranger, "not yui's").unwrap();
4031 let err = absorb(Some(source), stranger, false).unwrap_err();
4032 assert!(format!("{err}").contains("no mount entry"));
4033 }
4034
4035 #[test]
4036 fn yuiignore_excludes_file_from_linking() {
4037 let tmp = TempDir::new().unwrap();
4038 let (source, target) = setup_minimal_dotfiles(&tmp);
4039 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
4040 std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
4041 std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
4043 apply(Some(source.clone()), false).unwrap();
4044 assert!(target.join(".bashrc").exists());
4045 assert!(
4046 !target.join("lock.json").exists(),
4047 "yuiignore should keep lock.json out of target"
4048 );
4049 }
4050
4051 #[test]
4052 fn yuiignore_excludes_directory_subtree() {
4053 let tmp = TempDir::new().unwrap();
4054 let (source, target) = setup_minimal_dotfiles(&tmp);
4055 std::fs::create_dir_all(source.join("home/cache")).unwrap();
4056 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
4057 std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
4058 std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
4059 std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
4061 apply(Some(source.clone()), false).unwrap();
4062 assert!(target.join(".bashrc").exists());
4063 assert!(
4064 !target.join("cache").exists(),
4065 "yuiignore'd subtree should not appear in target"
4066 );
4067 }
4068
4069 #[test]
4070 fn yuiignore_negation_re_includes_file() {
4071 let tmp = TempDir::new().unwrap();
4072 let (source, target) = setup_minimal_dotfiles(&tmp);
4073 std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
4074 std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
4075 std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
4077 apply(Some(source.clone()), false).unwrap();
4078 assert!(target.join("keep.cache").exists());
4079 assert!(!target.join("drop.cache").exists());
4080 }
4081
4082 #[test]
4087 fn nested_yuiignore_only_affects_its_subtree() {
4088 let tmp = TempDir::new().unwrap();
4089 let (source, target) = setup_minimal_dotfiles(&tmp);
4090 std::fs::create_dir_all(source.join("home/inner")).unwrap();
4091 std::fs::write(source.join("home/secret.txt"), "outer keep").unwrap();
4092 std::fs::write(source.join("home/inner/secret.txt"), "inner drop").unwrap();
4093 std::fs::write(source.join("home/inner/keep.txt"), "inner keep").unwrap();
4094 std::fs::write(source.join("home/inner/.yuiignore"), "secret*\n").unwrap();
4096 apply(Some(source.clone()), false).unwrap();
4097 assert!(
4098 target.join("secret.txt").exists(),
4099 "outer secret.txt is outside the nested .yuiignore scope"
4100 );
4101 assert!(target.join("inner/keep.txt").exists());
4102 assert!(
4103 !target.join("inner/secret.txt").exists(),
4104 "inner secret.txt should be excluded by the nested .yuiignore"
4105 );
4106 }
4107
4108 #[test]
4112 fn nested_yuiignore_negation_overrides_root_rule() {
4113 let tmp = TempDir::new().unwrap();
4114 let (source, target) = setup_minimal_dotfiles(&tmp);
4115 std::fs::create_dir_all(source.join("home/keepers")).unwrap();
4116 std::fs::write(source.join("home/drop.lock"), "outer drop").unwrap();
4117 std::fs::write(source.join("home/keepers/wanted.lock"), "inner keep").unwrap();
4118 std::fs::write(source.join(".yuiignore"), "*.lock\n").unwrap();
4119 std::fs::write(source.join("home/keepers/.yuiignore"), "!*.lock\n").unwrap();
4121 apply(Some(source.clone()), false).unwrap();
4122 assert!(
4123 !target.join("drop.lock").exists(),
4124 "root rule still drops outer .lock file"
4125 );
4126 assert!(
4127 target.join("keepers/wanted.lock").exists(),
4128 "nested negation re-includes .lock under keepers/"
4129 );
4130 }
4131
4132 #[test]
4136 fn nested_yuiignore_status_walk_scoped() {
4137 let tmp = TempDir::new().unwrap();
4138 let (source, _target) = setup_minimal_dotfiles(&tmp);
4139 std::fs::create_dir_all(source.join("home/a")).unwrap();
4140 std::fs::create_dir_all(source.join("home/b")).unwrap();
4141 std::fs::write(source.join("home/a/foo.txt"), "a-foo").unwrap();
4142 std::fs::write(source.join("home/b/foo.txt"), "b-foo").unwrap();
4143 std::fs::write(source.join("home/a/.yuiignore"), "foo.txt\n").unwrap();
4145 apply(Some(source.clone()), false).unwrap();
4146 let res = status(Some(source), None, true);
4148 assert!(res.is_ok() || matches!(&res, Err(e) if format!("{e}").contains("diverged")));
4149 }
4150
4151 #[test]
4152 fn yuiignore_skips_template_in_render() {
4153 let tmp = TempDir::new().unwrap();
4154 let source = utf8(tmp.path().join("dotfiles"));
4155 let target = utf8(tmp.path().join("target"));
4156 std::fs::create_dir_all(source.join("home")).unwrap();
4157 std::fs::create_dir_all(&target).unwrap();
4158 std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
4159 std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
4160 let cfg = format!(
4161 r#"
4162[[mount.entry]]
4163src = "home"
4164dst = "{}"
4165"#,
4166 toml_path(&target)
4167 );
4168 std::fs::write(source.join("config.toml"), cfg).unwrap();
4169 apply(Some(source.clone()), false).unwrap();
4170 assert!(!source.join("home/note").exists());
4172 assert!(!target.join("note").exists());
4173 assert!(!target.join("note.tera").exists());
4174 }
4175
4176 #[test]
4180 fn nested_marker_accumulates_extra_dst() {
4181 let tmp = TempDir::new().unwrap();
4182 let source = utf8(tmp.path().join("dotfiles"));
4183 let parent_target = utf8(tmp.path().join("home"));
4184 let extra_target = utf8(tmp.path().join("extra"));
4185 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
4186 std::fs::create_dir_all(&parent_target).unwrap();
4187 std::fs::create_dir_all(&extra_target).unwrap();
4188 std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
4189
4190 std::fs::write(
4192 source.join("home/.config/.yuilink"),
4193 format!(
4194 r#"
4195[[link]]
4196dst = "{}/.config"
4197"#,
4198 toml_path(&parent_target)
4199 ),
4200 )
4201 .unwrap();
4202 std::fs::write(
4205 source.join("home/.config/nvim/.yuilink"),
4206 format!(
4207 r#"
4208[[link]]
4209dst = "{}/nvim"
4210when = "{{{{ yui.os == '{}' }}}}"
4211"#,
4212 toml_path(&extra_target),
4213 std::env::consts::OS
4214 ),
4215 )
4216 .unwrap();
4217
4218 let cfg = format!(
4219 r#"
4220[[mount.entry]]
4221src = "home"
4222dst = "{}"
4223"#,
4224 toml_path(&parent_target)
4225 );
4226 std::fs::write(source.join("config.toml"), cfg).unwrap();
4227
4228 apply(Some(source.clone()), false).unwrap();
4229
4230 assert!(parent_target.join(".config/nvim/init.lua").exists());
4233 assert!(extra_target.join("nvim/init.lua").exists());
4234 }
4235
4236 #[test]
4241 fn marker_file_link_targets_specific_file() {
4242 let tmp = TempDir::new().unwrap();
4243 let source = utf8(tmp.path().join("dotfiles"));
4244 let parent_target = utf8(tmp.path().join("home"));
4245 let docs_target = utf8(tmp.path().join("docs"));
4246 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
4247 std::fs::create_dir_all(&parent_target).unwrap();
4248 std::fs::create_dir_all(&docs_target).unwrap();
4249 std::fs::write(
4250 source.join("home/.config/powershell/profile.ps1"),
4251 "# profile\n",
4252 )
4253 .unwrap();
4254 std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
4255
4256 std::fs::write(
4259 source.join("home/.config/powershell/.yuilink"),
4260 format!(
4261 r#"
4262[[link]]
4263src = "profile.ps1"
4264dst = "{}/Microsoft.PowerShell_profile.ps1"
4265"#,
4266 toml_path(&docs_target)
4267 ),
4268 )
4269 .unwrap();
4270
4271 let cfg = format!(
4272 r#"
4273[[mount.entry]]
4274src = "home"
4275dst = "{}"
4276"#,
4277 toml_path(&parent_target)
4278 );
4279 std::fs::write(source.join("config.toml"), cfg).unwrap();
4280
4281 apply(Some(source.clone()), false).unwrap();
4282
4283 assert!(
4285 docs_target
4286 .join("Microsoft.PowerShell_profile.ps1")
4287 .exists()
4288 );
4289 assert!(
4292 parent_target
4293 .join(".config/powershell/profile.ps1")
4294 .exists()
4295 );
4296 assert!(parent_target.join(".config/powershell/extra.txt").exists());
4297 }
4298
4299 #[test]
4302 fn marker_file_link_missing_src_errors() {
4303 let tmp = TempDir::new().unwrap();
4304 let source = utf8(tmp.path().join("dotfiles"));
4305 let parent_target = utf8(tmp.path().join("home"));
4306 let docs_target = utf8(tmp.path().join("docs"));
4307 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
4308 std::fs::create_dir_all(&parent_target).unwrap();
4309 std::fs::create_dir_all(&docs_target).unwrap();
4310
4311 std::fs::write(
4312 source.join("home/.config/powershell/.yuilink"),
4313 format!(
4314 r#"
4315[[link]]
4316src = "missing.ps1"
4317dst = "{}/profile.ps1"
4318"#,
4319 toml_path(&docs_target)
4320 ),
4321 )
4322 .unwrap();
4323
4324 let cfg = format!(
4325 r#"
4326[[mount.entry]]
4327src = "home"
4328dst = "{}"
4329"#,
4330 toml_path(&parent_target)
4331 );
4332 std::fs::write(source.join("config.toml"), cfg).unwrap();
4333
4334 let err = apply(Some(source.clone()), false).unwrap_err();
4335 assert!(format!("{err:#}").contains("missing.ps1"));
4336 }
4337
4338 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
4339 let mut out = Vec::new();
4340 let mut stack = vec![root.to_path_buf()];
4341 while let Some(dir) = stack.pop() {
4342 let Ok(entries) = std::fs::read_dir(&dir) else {
4343 continue;
4344 };
4345 for e in entries.flatten() {
4346 let p = utf8(e.path());
4347 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
4348 stack.push(p);
4349 } else {
4350 out.push(p);
4351 }
4352 }
4353 }
4354 out
4355 }
4356
4357 #[test]
4362 fn parse_backup_suffix_recognises_file_with_extension() {
4363 let dt = parse_backup_suffix("foo_20260429_143022123.yml").unwrap();
4364 assert_eq!(dt.year(), 2026);
4365 assert_eq!(dt.month(), 4);
4366 assert_eq!(dt.day(), 29);
4367 assert_eq!(dt.hour(), 14);
4368 assert_eq!(dt.minute(), 30);
4369 assert_eq!(dt.second(), 22);
4370 }
4371
4372 #[test]
4373 fn parse_backup_suffix_recognises_dotfile_no_extension() {
4374 let dt = parse_backup_suffix(".gitconfig_20260429_143022123").unwrap();
4375 assert_eq!(dt.year(), 2026);
4376 }
4377
4378 #[test]
4379 fn parse_backup_suffix_recognises_directory_form() {
4380 let dt = parse_backup_suffix("nvim_20260429_143022123").unwrap();
4381 assert_eq!(dt.day(), 29);
4382 }
4383
4384 #[test]
4385 fn parse_backup_suffix_recognises_multi_dot_filename() {
4386 let dt = parse_backup_suffix("archive.tar.gz_20260429_143022123.gz").unwrap();
4388 assert_eq!(dt.month(), 4);
4389 }
4390
4391 #[test]
4392 fn parse_backup_suffix_rejects_non_yui_names() {
4393 assert!(parse_backup_suffix("README.md").is_none());
4394 assert!(parse_backup_suffix("notes_2026.txt").is_none());
4395 assert!(parse_backup_suffix("almost_20260429_14302212").is_none()); assert!(parse_backup_suffix("almost_20260429-143022123").is_none()); assert!(parse_backup_suffix("_20260429_143022123").is_none());
4399 }
4400
4401 #[test]
4402 fn parse_human_duration_basic_units() {
4403 let s = parse_human_duration("30d").unwrap();
4404 assert_eq!(s.get_days(), 30);
4405 let s = parse_human_duration("2w").unwrap();
4406 assert_eq!(s.get_weeks(), 2);
4407 let s = parse_human_duration("12h").unwrap();
4408 assert_eq!(s.get_hours(), 12);
4409 let s = parse_human_duration("5m").unwrap();
4411 assert_eq!(s.get_minutes(), 5);
4412 let s = parse_human_duration("6mo").unwrap();
4413 assert_eq!(s.get_months(), 6);
4414 let s = parse_human_duration("1y").unwrap();
4415 assert_eq!(s.get_years(), 1);
4416 }
4417
4418 #[test]
4419 fn parse_human_duration_case_insensitive_and_whitespace() {
4420 let s = parse_human_duration(" 90D ").unwrap();
4421 assert_eq!(s.get_days(), 90);
4422 let s = parse_human_duration("3WEEKS").unwrap();
4423 assert_eq!(s.get_weeks(), 3);
4424 }
4425
4426 #[test]
4427 fn parse_human_duration_rejects_garbage() {
4428 assert!(parse_human_duration("").is_err());
4429 assert!(parse_human_duration("d30").is_err());
4430 assert!(parse_human_duration("30").is_err()); assert!(parse_human_duration("30x").is_err()); assert!(parse_human_duration("-1d").is_err()); }
4434
4435 #[test]
4439 fn walk_gc_backups_collects_files_and_dir_snapshots() {
4440 let tmp = TempDir::new().unwrap();
4441 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
4442 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
4443 std::fs::write(
4445 root.join("C/Users/u/.config/foo_20260429_143022123.yml"),
4446 "old yml",
4447 )
4448 .unwrap();
4449 std::fs::create_dir_all(root.join("C/Users/u/nvim_20260101_000000000/lua")).unwrap();
4451 std::fs::write(
4452 root.join("C/Users/u/nvim_20260101_000000000/init.lua"),
4453 "ok",
4454 )
4455 .unwrap();
4456 std::fs::write(
4457 root.join("C/Users/u/nvim_20260101_000000000/lua/x.lua"),
4458 "kk",
4459 )
4460 .unwrap();
4461 std::fs::write(root.join("C/Users/u/.config/README.md"), "user note").unwrap();
4463
4464 let entries = walk_gc_backups(&root).unwrap();
4465 assert_eq!(entries.len(), 2, "two backup roots, not three");
4466 let kinds: Vec<_> = entries.iter().map(|e| e.kind).collect();
4467 assert!(kinds.contains(&BackupKind::File));
4468 assert!(kinds.contains(&BackupKind::Dir));
4469 let dir_entry = entries.iter().find(|e| e.kind == BackupKind::Dir).unwrap();
4471 assert!(dir_entry.size_bytes >= 4); }
4473
4474 #[test]
4475 fn cleanup_empty_parents_stops_at_root_and_at_non_empty() {
4476 let tmp = TempDir::new().unwrap();
4477 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
4478 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
4479 std::fs::write(root.join("C/Users/u/sibling_keep"), "x").unwrap();
4480
4481 cleanup_empty_parents(&root.join("C/Users/u/.config"), &root);
4485
4486 assert!(!root.join("C/Users/u/.config").exists(), "empty leaf gone");
4487 assert!(root.join("C/Users/u").exists(), "stops at non-empty parent");
4488 assert!(root.exists(), "backup root preserved");
4489 }
4490
4491 #[test]
4493 fn gc_backup_survey_keeps_all_entries() {
4494 let tmp = TempDir::new().unwrap();
4495 let source = utf8(tmp.path().join("dotfiles"));
4496 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
4497 std::fs::write(source.join("config.toml"), "").unwrap();
4498 let backup = source.join(".yui/backup");
4499 std::fs::write(backup.join("a_20260101_000000000.txt"), "old").unwrap();
4500 std::fs::write(backup.join("b_20260415_120000000.txt"), "fresh").unwrap();
4501
4502 gc_backup(Some(source.clone()), None, false, None, true).unwrap();
4503
4504 assert!(backup.join("a_20260101_000000000.txt").exists());
4506 assert!(backup.join("b_20260415_120000000.txt").exists());
4507 }
4508
4509 #[test]
4512 fn gc_backup_prune_removes_old_files_only() {
4513 let tmp = TempDir::new().unwrap();
4514 let source = utf8(tmp.path().join("dotfiles"));
4515 std::fs::create_dir_all(source.join(".yui/backup/sub")).unwrap();
4516 std::fs::write(source.join("config.toml"), "").unwrap();
4517 let backup = source.join(".yui/backup");
4518
4519 std::fs::write(backup.join("sub/old_20200101_000000000.txt"), "old").unwrap();
4521 let tomorrow = jiff::Zoned::now()
4523 .checked_add(jiff::Span::new().days(1))
4524 .unwrap();
4525 let bdt = jiff::fmt::strtime::BrokenDownTime::from(&tomorrow);
4526 let future_ts = bdt.to_string("%Y%m%d_%H%M%S%3f").unwrap();
4527 std::fs::write(backup.join(format!("fresh_{future_ts}.txt")), "fresh").unwrap();
4528 std::fs::write(backup.join("notes.md"), "mine").unwrap();
4530
4531 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
4532
4533 assert!(!backup.join("sub/old_20200101_000000000.txt").exists());
4534 assert!(!backup.join("sub").exists(), "empty parent removed");
4536 assert!(backup.exists());
4538 assert!(backup.join(format!("fresh_{future_ts}.txt")).exists());
4539 assert!(backup.join("notes.md").exists(), "user file untouched");
4540 }
4541
4542 #[test]
4544 fn gc_backup_dry_run_does_not_delete() {
4545 let tmp = TempDir::new().unwrap();
4546 let source = utf8(tmp.path().join("dotfiles"));
4547 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
4548 std::fs::write(source.join("config.toml"), "").unwrap();
4549 let backup = source.join(".yui/backup");
4550 std::fs::write(backup.join("old_20200101_000000000.txt"), "old").unwrap();
4551
4552 gc_backup(Some(source.clone()), Some("30d".into()), true, None, true).unwrap();
4553
4554 assert!(
4555 backup.join("old_20200101_000000000.txt").exists(),
4556 "dry-run keeps everything in place"
4557 );
4558 }
4559
4560 #[test]
4564 fn gc_backup_prune_handles_directory_snapshot() {
4565 let tmp = TempDir::new().unwrap();
4566 let source = utf8(tmp.path().join("dotfiles"));
4567 std::fs::create_dir_all(source.join(".yui/backup/mirror/u")).unwrap();
4568 std::fs::write(source.join("config.toml"), "").unwrap();
4569 let backup = source.join(".yui/backup");
4570 let snap = backup.join("mirror/u/nvim_20200101_000000000");
4571 std::fs::create_dir_all(snap.join("lua")).unwrap();
4572 std::fs::write(snap.join("init.lua"), "x").unwrap();
4573 std::fs::write(snap.join("lua/y.lua"), "y").unwrap();
4574
4575 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
4576
4577 assert!(!snap.exists(), "dir snapshot removed wholesale");
4578 assert!(!backup.join("mirror").exists(), "empty mirror chain pruned");
4579 assert!(backup.exists(), "backup root preserved");
4580 }
4581}