1use std::fmt::Write as _;
6
7use anyhow::{Context as _, Result};
8use camino::{Utf8Path, Utf8PathBuf};
9use tera::Context as TeraContext;
10use tracing::{info, warn};
11
12use crate::config::{self, Config, HookPhase, IconsMode, MountStrategy};
13use crate::hook::{self, HookOutcome};
14use crate::icons::Icons;
15use crate::link::{self, EffectiveDirMode, EffectiveFileMode, resolve_dir_mode, resolve_file_mode};
16use crate::marker::{self, MarkerSpec};
17use crate::mount::{self, ResolvedMount};
18use crate::render::{self, RenderReport};
19use crate::template;
20use crate::vars::YuiVars;
21use crate::{absorb, backup, paths};
22
23pub fn init(source: Option<Utf8PathBuf>, git_hooks: bool) -> Result<()> {
30 let dir = match source {
31 Some(s) => absolutize(&s)?,
32 None => current_dir_utf8()?,
33 };
34 std::fs::create_dir_all(&dir)?;
35 let config_path = dir.join("config.toml");
36 let scaffolded = if !config_path.exists() {
37 std::fs::write(&config_path, SKELETON_CONFIG)?;
38 info!("initialized yui source repo at {dir}");
39 info!("created: {config_path}");
40 true
41 } else if git_hooks {
42 info!(
47 "config.toml already exists at {config_path} \
48 — skipping scaffold, installing git hooks only"
49 );
50 false
51 } else {
52 anyhow::bail!("config.toml already exists at {config_path}");
53 };
54
55 ensure_gitignore_yui_entries(&dir)?;
62
63 if git_hooks {
64 install_git_hooks(&dir)?;
65 }
66 if scaffolded {
67 info!("next: edit config.toml, then run `yui apply`");
68 }
69 Ok(())
70}
71
72const YUI_REQUIRED_GITIGNORE: &[&str] = &[
77 "/.yui/state.json",
78 "/.yui/state.json.tmp",
79 "/.yui/backup/",
80 "config.local.toml",
81];
82
83fn ensure_gitignore_yui_entries(dir: &Utf8Path) -> Result<()> {
89 let path = dir.join(".gitignore");
90 if !path.exists() {
91 std::fs::write(&path, SKELETON_GITIGNORE)?;
92 info!("created: {path}");
93 return Ok(());
94 }
95 let existing = std::fs::read_to_string(&path)?;
96 let missing: Vec<&str> = YUI_REQUIRED_GITIGNORE
97 .iter()
98 .copied()
99 .filter(|entry| !existing.lines().any(|line| line.trim() == *entry))
100 .collect();
101 if missing.is_empty() {
102 return Ok(());
103 }
104 let mut next = existing;
105 if !next.is_empty() && !next.ends_with('\n') {
106 next.push('\n');
107 }
108 if !next.is_empty() {
109 next.push('\n');
110 }
111 next.push_str("# yui per-machine state and backups (added by `yui init`).\n");
112 for entry in &missing {
113 next.push_str(entry);
114 next.push('\n');
115 }
116 std::fs::write(&path, next)?;
117 info!(
118 "updated .gitignore: appended {} yui entr{} ({})",
119 missing.len(),
120 if missing.len() == 1 { "y" } else { "ies" },
121 missing.join(", ")
122 );
123 Ok(())
124}
125
126fn install_git_hooks(source: &Utf8Path) -> Result<()> {
140 let out = std::process::Command::new("git")
141 .args(["rev-parse", "--git-path", "hooks"])
142 .current_dir(source.as_std_path())
143 .output()
144 .with_context(|| format!("git rev-parse --git-path hooks in {source}"))?;
145 if !out.status.success() {
146 let stderr = String::from_utf8_lossy(&out.stderr);
147 anyhow::bail!(
148 "--git-hooks: {source} doesn't look like a git repo \
149 (run `git init` first). git: {}",
150 stderr.trim()
151 );
152 }
153 let raw = String::from_utf8(out.stdout)?;
154 let hooks_dir = {
155 let p = Utf8PathBuf::from(raw.trim());
156 if p.is_absolute() { p } else { source.join(p) }
157 };
158 std::fs::create_dir_all(&hooks_dir).with_context(|| format!("mkdir -p {hooks_dir}"))?;
159
160 for (name, body) in [("pre-commit", PRE_COMMIT_HOOK), ("pre-push", PRE_PUSH_HOOK)] {
161 let path = hooks_dir.join(name);
162 if path.exists() {
163 warn!("--git-hooks: {path} already exists — leaving it alone");
164 continue;
165 }
166 std::fs::write(&path, body).with_context(|| format!("write hook {path}"))?;
167 #[cfg(unix)]
168 {
169 use std::os::unix::fs::PermissionsExt;
170 let mut perms = std::fs::metadata(&path)?.permissions();
171 perms.set_mode(0o755);
172 std::fs::set_permissions(&path, perms)?;
173 }
174 info!("installed: {path}");
175 }
176 Ok(())
177}
178
179const PRE_COMMIT_HOOK: &str = r#"#!/bin/sh
180# Installed by `yui init --git-hooks`.
181# Reject the commit if any `*.tera` template would render to something
182# that diverges from the rendered output staged alongside it. Run
183# `yui apply` (or `yui render`) to refresh and re-commit.
184exec yui render --check
185"#;
186
187const PRE_PUSH_HOOK: &str = r#"#!/bin/sh
188# Installed by `yui init --git-hooks`.
189# Same render-drift check as pre-commit, mirrored on push so a
190# `--no-verify` commit doesn't sneak diverged state to the remote.
191exec yui render --check
192"#;
193
194pub fn apply(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
195 let source = resolve_source(source)?;
196 let yui = YuiVars::detect(&source);
197 let config = config::load(&source, &yui)?;
198
199 let mut engine = template::Engine::new();
200 let tera_ctx = template::template_context(&yui, &config.vars);
201
202 hook::run_phase(
205 &config,
206 &source,
207 &yui,
208 &mut engine,
209 &tera_ctx,
210 HookPhase::Pre,
211 dry_run,
212 )?;
213
214 let render_report = render::render_all(&source, &config, &yui, dry_run)?;
216 log_render_report(&render_report);
217 if render_report.has_drift() {
218 anyhow::bail!(
219 "render drift detected ({} file(s)); reflect target edits back into the .tera before re-running apply",
220 render_report.diverged.len()
221 );
222 }
223
224 let mounts = mount::resolve(
226 &config.mount.entry,
227 config.mount.default_strategy,
228 &mut engine,
229 &tera_ctx,
230 )?;
231
232 let backup_root = source.join(&config.backup.dir);
233 let ctx = ApplyCtx {
234 config: &config,
235 source: &source,
236 file_mode: resolve_file_mode(config.link.file_mode),
237 dir_mode: resolve_dir_mode(config.link.dir_mode),
238 backup_root: &backup_root,
239 dry_run,
240 };
241
242 info!("source: {source}");
243 info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
244 if dry_run {
245 info!("dry-run: nothing will be written");
246 }
247
248 let mut yuiignore = paths::YuiIgnoreStack::new();
252 yuiignore.push_dir(&source)?;
253 let walk_result = (|| -> Result<()> {
254 for m in &mounts {
255 info!("mount: {} → {}", m.src, m.dst);
256 process_mount(&source, m, &ctx, &mut engine, &tera_ctx, &mut yuiignore)?;
257 }
258 Ok(())
259 })();
260 yuiignore.pop_dir(&source);
261 walk_result?;
262
263 hook::run_phase(
265 &config,
266 &source,
267 &yui,
268 &mut engine,
269 &tera_ctx,
270 HookPhase::Post,
271 dry_run,
272 )?;
273 Ok(())
274}
275
276fn log_render_report(r: &RenderReport) {
277 if !r.written.is_empty() {
278 info!("rendered {} new file(s)", r.written.len());
279 }
280 if !r.unchanged.is_empty() {
281 info!("rendered {} file(s) unchanged", r.unchanged.len());
282 }
283 if !r.skipped_when_false.is_empty() {
284 info!(
285 "skipped {} template(s) (when=false)",
286 r.skipped_when_false.len()
287 );
288 }
289 for d in &r.diverged {
290 warn!("rendered file diverged from template: {d}");
291 }
292}
293
294struct ApplyCtx<'a> {
301 config: &'a Config,
302 source: &'a Utf8Path,
304 file_mode: EffectiveFileMode,
305 dir_mode: EffectiveDirMode,
306 backup_root: &'a Utf8Path,
307 dry_run: bool,
308}
309
310pub fn list(
316 source: Option<Utf8PathBuf>,
317 all: bool,
318 icons_override: Option<IconsMode>,
319 no_color: bool,
320) -> Result<()> {
321 let source = resolve_source(source)?;
322 let yui = YuiVars::detect(&source);
323 let config = config::load(&source, &yui)?;
324
325 let icons_mode = icons_override.unwrap_or(config.ui.icons);
326 let icons = Icons::for_mode(icons_mode);
327 let color = !no_color && supports_color_stdout();
328
329 let items = collect_list_items(&source, &config, &yui)?;
330 let displayed: Vec<&ListItem> = if all {
331 items.iter().collect()
332 } else {
333 items.iter().filter(|i| i.active).collect()
334 };
335
336 print_list_table(&displayed, icons, color);
337
338 let total = items.len();
339 let active = items.iter().filter(|i| i.active).count();
340 let inactive = total - active;
341 println!();
342 if all {
343 println!(" {total} entries · {active} active · {inactive} inactive");
344 } else {
345 println!(
346 " {} of {} entries shown ({} inactive hidden — use --all)",
347 active, total, inactive
348 );
349 }
350 Ok(())
351}
352
353#[derive(Debug)]
354struct ListItem {
355 src: Utf8PathBuf,
356 dst: String,
357 when: Option<String>,
358 active: bool,
359}
360
361fn collect_list_items(source: &Utf8Path, config: &Config, yui: &YuiVars) -> Result<Vec<ListItem>> {
362 let mut engine = template::Engine::new();
363 let tera_ctx = template::template_context(yui, &config.vars);
364 let mut items = Vec::new();
365
366 for entry in &config.mount.entry {
368 let active = match &entry.when {
369 None => true,
370 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
371 };
372 let dst = engine
373 .render(&entry.dst, &tera_ctx)
374 .map(|s| paths::expand_tilde(s.trim()).to_string())
375 .unwrap_or_else(|_| entry.dst.clone());
376 items.push(ListItem {
377 src: entry.src.clone(),
378 dst,
379 when: entry.when.clone(),
380 active,
381 });
382 }
383
384 let walker = paths::source_walker(source).build();
386 let marker_filename = &config.mount.marker_filename;
387 for entry in walker {
388 let entry = match entry {
389 Ok(e) => e,
390 Err(_) => continue,
391 };
392 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
393 continue;
394 }
395 if entry.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
396 continue;
397 }
398 let dir = match entry.path().parent() {
399 Some(d) => d,
400 None => continue,
401 };
402 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
403 Ok(p) => p,
404 Err(_) => continue,
405 };
406 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
410 Some(s) => s,
411 None => continue,
412 };
413 let MarkerSpec::Explicit { links } = spec else {
414 continue; };
416 let rel = dir_utf8
417 .strip_prefix(source)
418 .map(Utf8PathBuf::from)
419 .unwrap_or(dir_utf8);
420 for link in &links {
421 let active = match &link.when {
422 None => true,
423 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
424 };
425 let dst = engine
426 .render(&link.dst, &tera_ctx)
427 .map(|s| paths::expand_tilde(s.trim()).to_string())
428 .unwrap_or_else(|_| link.dst.clone());
429 let src_display = match &link.src {
434 Some(filename) => rel.join(filename),
435 None => rel.clone(),
436 };
437 items.push(ListItem {
438 src: src_display,
439 dst,
440 when: link.when.clone(),
441 active,
442 });
443 }
444 }
445
446 items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
447 Ok(items)
448}
449
450fn supports_color_stdout() -> bool {
451 use std::io::IsTerminal;
452 std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
453}
454
455fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
456 let src_w = items
457 .iter()
458 .map(|i| i.src.as_str().chars().count())
459 .max()
460 .unwrap_or(0)
461 .max("SRC".len());
462 let dst_w = items
463 .iter()
464 .map(|i| i.dst.chars().count())
465 .max()
466 .unwrap_or(0)
467 .max("DST".len());
468
469 let status_w = "STATUS".len();
470 let arrow_w = icons.arrow.chars().count();
471
472 print_header(status_w, src_w, arrow_w, dst_w, color);
474
475 let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
477 if color {
478 use owo_colors::OwoColorize as _;
479 println!("{}", sep.dimmed());
480 } else {
481 println!("{sep}");
482 }
483
484 for item in items {
486 print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
487 }
488}
489
490fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
491 use owo_colors::OwoColorize as _;
492 let mut line = String::new();
493 let _ = write!(
494 &mut line,
495 " {:<status_w$} {:<src_w$} {:<arrow_w$} {:<dst_w$} WHEN",
496 "STATUS", "SRC", "", "DST"
497 );
498 if color {
499 println!("{}", line.bold());
500 } else {
501 println!("{line}");
502 }
503}
504
505fn render_separator(
506 sep_ch: char,
507 status_w: usize,
508 src_w: usize,
509 arrow_w: usize,
510 dst_w: usize,
511) -> String {
512 let bar = |n: usize| sep_ch.to_string().repeat(n);
513 format!(
514 " {} {} {} {} {}",
515 bar(status_w),
516 bar(src_w),
517 bar(arrow_w),
518 bar(dst_w),
519 bar("WHEN".len())
520 )
521}
522
523fn print_row(
524 item: &ListItem,
525 icons: Icons,
526 status_w: usize,
527 src_w: usize,
528 arrow_w: usize,
529 dst_w: usize,
530 color: bool,
531) {
532 use owo_colors::OwoColorize as _;
533 let status = if item.active {
534 icons.active
535 } else {
536 icons.inactive
537 };
538 let when_str = item
539 .when
540 .as_deref()
541 .map(strip_braces)
542 .unwrap_or_else(|| "(always)".to_string());
543
544 let src_display = item.src.as_str().replace('\\', "/");
546 let src = src_display.as_str();
547 let dst = &item.dst;
548 let arrow = icons.arrow;
549
550 let cell_status = format!("{:<status_w$}", status);
555 let cell_src = format!("{:<src_w$}", src);
556 let cell_arrow = format!("{:<arrow_w$}", arrow);
557 let cell_dst = format!("{:<dst_w$}", dst);
558
559 if !color {
560 println!(" {cell_status} {cell_src} {cell_arrow} {cell_dst} {when_str}");
561 return;
562 }
563
564 if item.active {
565 println!(
566 " {} {} {} {} {}",
567 cell_status.green(),
568 cell_src.cyan(),
569 cell_arrow.dimmed(),
570 cell_dst.green(),
571 when_str.dimmed()
572 );
573 } else {
574 println!(
575 " {} {} {} {} {}",
576 cell_status.red().dimmed(),
577 cell_src.dimmed(),
578 cell_arrow.dimmed(),
579 cell_dst.dimmed(),
580 when_str.dimmed()
581 );
582 }
583}
584
585fn strip_braces(expr: &str) -> String {
588 let trimmed = expr.trim();
589 if let Some(inner) = trimmed
590 .strip_prefix("{{")
591 .and_then(|s| s.strip_suffix("}}"))
592 {
593 inner.trim().to_string()
594 } else {
595 trimmed.to_string()
596 }
597}
598
599pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
600 let source = resolve_source(source)?;
601 let yui = YuiVars::detect(&source);
602 let config = config::load(&source, &yui)?;
603 let report = render::render_all(&source, &config, &yui, dry_run || check)?;
605 log_render_report(&report);
606 if check && report.has_drift() {
607 anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
608 }
609 Ok(())
610}
611
612pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
613 apply(source, dry_run)
615}
616
617pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
618 let _source = resolve_source(source)?;
619 if paths_arg.is_empty() {
620 anyhow::bail!("yui unlink: provide at least one target path");
621 }
622 for p in paths_arg {
623 let abs = absolutize(&p)?;
624 info!("unlink: {abs}");
625 link::unlink(&abs)?;
626 }
627 Ok(())
628}
629
630pub fn update(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
641 let source = resolve_source(source)?;
642 if !crate::git::is_clean(&source)? {
643 anyhow::bail!(
644 "source repo {source} has uncommitted changes — \
645 commit or stash before `yui update` (or run \
646 `git pull` + `yui apply` manually if you know what \
647 you're doing)"
648 );
649 }
650 info!("git pull --ff-only at {source}");
651 let status = std::process::Command::new("git")
652 .arg("-C")
653 .arg(source.as_str())
654 .arg("pull")
655 .arg("--ff-only")
656 .status()
657 .map_err(|e| anyhow::anyhow!("invoking git: {e}"))?;
658 if !status.success() {
659 anyhow::bail!("git pull --ff-only failed at {source}");
660 }
661 apply(Some(source), dry_run)
662}
663
664pub fn unmanaged(
675 source: Option<Utf8PathBuf>,
676 icons_override: Option<IconsMode>,
677 no_color: bool,
678) -> Result<()> {
679 let source = resolve_source(source)?;
680 let yui = YuiVars::detect(&source);
681 let config = config::load(&source, &yui)?;
682
683 let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
684 let color = !no_color && supports_color_stdout();
685
686 let mount_srcs: Vec<Utf8PathBuf> = config
695 .mount
696 .entry
697 .iter()
698 .map(|e| source.join(&e.src))
699 .collect();
700
701 let mut items: Vec<Utf8PathBuf> = Vec::new();
702 let walker = paths::source_walker(&source).build();
703 for entry in walker {
704 let entry = match entry {
705 Ok(e) => e,
706 Err(_) => continue,
707 };
708 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
709 continue;
710 }
711 let std_path = entry.path();
712 let path = match Utf8PathBuf::from_path_buf(std_path.to_path_buf()) {
713 Ok(p) => p,
714 Err(_) => continue,
715 };
716 if is_repo_meta(&path, &source, &config.mount.marker_filename) {
720 continue;
721 }
722 if mount_srcs.iter().any(|m| path.starts_with(m)) {
723 continue;
724 }
725 items.push(path);
726 }
727 items.sort();
728
729 if items.is_empty() {
730 println!(" no unmanaged files under {source}");
731 return Ok(());
732 }
733
734 print_unmanaged_table(&items, &source, color);
735 println!();
736 println!(" {} unmanaged file(s)", items.len());
737 Ok(())
738}
739
740fn is_repo_meta(path: &Utf8Path, source: &Utf8Path, marker_filename: &str) -> bool {
756 let Some(name) = path.file_name() else {
757 return false;
758 };
759 if name.ends_with(".tera") {
760 return true;
761 }
762 if name == marker_filename || name == ".yuiignore" {
763 return true;
764 }
765 let parent = path.parent().unwrap_or(Utf8Path::new(""));
766 let at_root = parent == source;
767 if at_root && name == ".gitignore" {
768 return true;
769 }
770 if at_root && (name == "config.toml" || name == "config.local.toml") {
771 return true;
772 }
773 if at_root
774 && name.starts_with("config.")
775 && (name.ends_with(".toml") || name.ends_with(".example.toml"))
776 {
777 return true;
778 }
779 false
780}
781
782fn print_unmanaged_table(items: &[Utf8PathBuf], source: &Utf8Path, color: bool) {
783 use owo_colors::OwoColorize as _;
784 if color {
785 println!(" {}", "PATH (relative to source)".dimmed());
786 } else {
787 println!(" PATH (relative to source)");
788 }
789 for p in items {
790 let rel = p
791 .strip_prefix(source)
792 .map(Utf8PathBuf::from)
793 .unwrap_or_else(|_| p.clone());
794 if color {
795 println!(" {}", rel.cyan());
796 } else {
797 println!(" {rel}");
798 }
799 }
800}
801
802pub fn diff(
810 source: Option<Utf8PathBuf>,
811 icons_override: Option<IconsMode>,
812 no_color: bool,
813) -> Result<()> {
814 let source = resolve_source(source)?;
815 let yui = YuiVars::detect(&source);
816 let config = config::load(&source, &yui)?;
817 let mut engine = template::Engine::new();
818 let tera_ctx = template::template_context(&yui, &config.vars);
819 let mounts = mount::resolve(
820 &config.mount.entry,
821 config.mount.default_strategy,
822 &mut engine,
823 &tera_ctx,
824 )?;
825
826 let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
827 let color = !no_color && supports_color_stdout();
828
829 let mut report: Vec<StatusItem> = Vec::new();
831 let mut yuiignore = paths::YuiIgnoreStack::new();
832 yuiignore.push_dir(&source)?;
833 let walk_result = (|| -> Result<()> {
834 for m in &mounts {
835 let src_root = source.join(&m.src);
836 if !src_root.is_dir() {
837 continue;
838 }
839 classify_walk(
840 &src_root,
841 &m.dst,
842 &config,
843 m.strategy,
844 &mut engine,
845 &tera_ctx,
846 &source,
847 &mut yuiignore,
848 &mut report,
849 )?;
850 }
851 Ok(())
852 })();
853 yuiignore.pop_dir(&source);
854 walk_result?;
855
856 let render_report = render::render_all(&source, &config, &yui, true)?;
858 for rendered in &render_report.diverged {
859 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
860 report.push(StatusItem {
861 src: tera_path,
862 dst: rendered.clone(),
863 state: StatusState::RenderDrift,
864 });
865 }
866
867 let mut printed = 0usize;
868 for item in &report {
869 if !diff_worth_printing(&item.state) {
870 continue;
871 }
872 let src_abs = resolve_diff_src(item, &source);
873 print_unified_diff(
874 &src_abs,
875 &item.dst,
876 &item.state,
877 &source,
878 &config,
879 &yui,
880 color,
881 );
882 printed += 1;
883 }
884
885 if printed == 0 {
886 println!(" no diff — every entry is in sync (or only needs a relink)");
887 } else {
888 println!();
889 println!(
890 " {printed} entr{} with content drift",
891 if printed == 1 { "y" } else { "ies" }
892 );
893 }
894 Ok(())
895}
896
897fn resolve_diff_src(item: &StatusItem, source: &Utf8Path) -> Utf8PathBuf {
909 match item.state {
910 StatusState::RenderDrift => item.src.clone(),
911 StatusState::Link(_) => source.join(&item.src),
912 }
913}
914
915fn diff_worth_printing(state: &StatusState) -> bool {
916 use absorb::AbsorbDecision::*;
917 match state {
918 StatusState::Link(InSync) => false,
919 StatusState::Link(Restore) => false, StatusState::Link(RelinkOnly) => false, StatusState::Link(_) => true,
922 StatusState::RenderDrift => true,
923 }
924}
925
926fn print_unified_diff(
934 src: &Utf8Path,
935 dst: &Utf8Path,
936 state: &StatusState,
937 source_root: &Utf8Path,
938 config: &Config,
939 yui: &YuiVars,
940 color: bool,
941) {
942 use owo_colors::OwoColorize as _;
943
944 let header = match state {
945 StatusState::RenderDrift => format!("--- render drift: {src} (template) vs {dst}"),
946 _ => format!("--- {src} → {dst}"),
947 };
948 if color {
949 println!("{}", header.bold());
950 } else {
951 println!("{header}");
952 }
953
954 if src.is_dir() || dst.is_dir() {
955 println!("(directory entry — content listing skipped)");
956 println!();
957 return;
958 }
959
960 let src_content = match state {
965 StatusState::RenderDrift => match render::render_to_string(src, source_root, config, yui) {
966 Ok(Some(s)) => s,
967 Ok(None) => {
968 println!(
969 "(template would be skipped on this host — drift will resolve on next render)"
970 );
971 println!();
972 return;
973 }
974 Err(e) => {
975 println!("(error rendering template: {e})");
976 println!();
977 return;
978 }
979 },
980 _ => match read_text_for_diff(src) {
981 DiffSide::Text(s) => s,
982 DiffSide::Binary => {
983 println!("(binary file or non-UTF-8 content — diff skipped)");
984 println!();
985 return;
986 }
987 },
988 };
989 let dst_content = match read_text_for_diff(dst) {
990 DiffSide::Text(s) => s,
991 DiffSide::Binary => {
992 println!("(binary file or non-UTF-8 content — diff skipped)");
993 println!();
994 return;
995 }
996 };
997 print_unified_text_diff(
998 &src_content,
999 &dst_content,
1000 src.as_str(),
1001 dst.as_str(),
1002 color,
1003 );
1004 println!();
1005}
1006
1007fn print_unified_text_diff(src: &str, dst: &str, src_label: &str, dst_label: &str, color: bool) {
1016 use owo_colors::OwoColorize as _;
1017 let diff = similar::TextDiff::from_lines(src, dst);
1018 let formatted = diff.unified_diff().header(src_label, dst_label).to_string();
1019 for line in formatted.lines() {
1020 if !color {
1021 println!("{line}");
1022 } else if line.starts_with("+++") || line.starts_with("---") {
1023 println!("{}", line.dimmed());
1024 } else if line.starts_with("@@") {
1025 println!("{}", line.cyan());
1026 } else if line.starts_with('+') {
1027 println!("{}", line.green());
1028 } else if line.starts_with('-') {
1029 println!("{}", line.red());
1030 } else {
1031 println!("{line}");
1032 }
1033 }
1034}
1035
1036enum DiffSide {
1042 Text(String),
1043 Binary,
1044}
1045
1046fn read_text_for_diff(p: &Utf8Path) -> DiffSide {
1047 match std::fs::read_to_string(p) {
1048 Ok(s) => DiffSide::Text(s),
1049 Err(e) if e.kind() == std::io::ErrorKind::InvalidData => DiffSide::Binary,
1050 Err(_) => DiffSide::Text(String::new()),
1051 }
1052}
1053
1054pub fn status(
1067 source: Option<Utf8PathBuf>,
1068 icons_override: Option<IconsMode>,
1069 no_color: bool,
1070) -> Result<()> {
1071 let source = resolve_source(source)?;
1072 let yui = YuiVars::detect(&source);
1073 let config = config::load(&source, &yui)?;
1074
1075 let mut engine = template::Engine::new();
1076 let tera_ctx = template::template_context(&yui, &config.vars);
1077 let mounts = mount::resolve(
1078 &config.mount.entry,
1079 config.mount.default_strategy,
1080 &mut engine,
1081 &tera_ctx,
1082 )?;
1083
1084 let icons_mode = icons_override.unwrap_or(config.ui.icons);
1085 let icons = Icons::for_mode(icons_mode);
1086 let color = !no_color && supports_color_stdout();
1087
1088 let mut report: Vec<StatusItem> = Vec::new();
1089
1090 let render_report = render::render_all(&source, &config, &yui, true)?;
1093 for rendered in &render_report.diverged {
1094 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
1098 report.push(StatusItem {
1099 src: relative_for_display(&source, &tera_path),
1100 dst: rendered.clone(),
1101 state: StatusState::RenderDrift,
1102 });
1103 }
1104
1105 let mut yuiignore = paths::YuiIgnoreStack::new();
1109 yuiignore.push_dir(&source)?;
1110 let walk_result = (|| -> Result<()> {
1111 for m in &mounts {
1112 let src_root = source.join(&m.src);
1113 if !src_root.is_dir() {
1114 warn!("mount src missing: {src_root}");
1115 continue;
1116 }
1117 classify_walk(
1118 &src_root,
1119 &m.dst,
1120 &config,
1121 m.strategy,
1122 &mut engine,
1123 &tera_ctx,
1124 &source,
1125 &mut yuiignore,
1126 &mut report,
1127 )?;
1128 }
1129 Ok(())
1130 })();
1131 yuiignore.pop_dir(&source);
1132 walk_result?;
1133
1134 report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
1135
1136 print_status_table(&report, icons, color);
1137
1138 let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
1139
1140 println!();
1141 let total = report.len();
1142 let in_sync = total - drift;
1143 if drift == 0 {
1144 println!(" {total} entries · all in sync");
1145 Ok(())
1146 } else {
1147 println!(" {total} entries · {in_sync} in sync · {drift} diverged");
1148 anyhow::bail!("status: {drift} entries diverged from source")
1149 }
1150}
1151
1152#[derive(Debug)]
1153struct StatusItem {
1154 src: Utf8PathBuf,
1156 dst: Utf8PathBuf,
1158 state: StatusState,
1159}
1160
1161#[derive(Debug, Clone, Copy)]
1162enum StatusState {
1163 Link(absorb::AbsorbDecision),
1164 RenderDrift,
1167}
1168
1169impl StatusState {
1170 fn is_in_sync(self) -> bool {
1171 matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
1172 }
1173}
1174
1175#[allow(clippy::too_many_arguments)]
1176fn classify_walk(
1177 src_dir: &Utf8Path,
1178 dst_dir: &Utf8Path,
1179 config: &Config,
1180 strategy: MountStrategy,
1181 engine: &mut template::Engine,
1182 tera_ctx: &TeraContext,
1183 source_root: &Utf8Path,
1184 yuiignore: &mut paths::YuiIgnoreStack,
1185 report: &mut Vec<StatusItem>,
1186) -> Result<()> {
1187 classify_walk_inner(
1188 src_dir,
1189 dst_dir,
1190 config,
1191 strategy,
1192 engine,
1193 tera_ctx,
1194 source_root,
1195 yuiignore,
1196 report,
1197 false,
1198 )
1199}
1200
1201#[allow(clippy::too_many_arguments)]
1202fn classify_walk_inner(
1203 src_dir: &Utf8Path,
1204 dst_dir: &Utf8Path,
1205 config: &Config,
1206 strategy: MountStrategy,
1207 engine: &mut template::Engine,
1208 tera_ctx: &TeraContext,
1209 source_root: &Utf8Path,
1210 yuiignore: &mut paths::YuiIgnoreStack,
1211 report: &mut Vec<StatusItem>,
1212 parent_covered: bool,
1213) -> Result<()> {
1214 if yuiignore.is_ignored(src_dir, true) {
1215 return Ok(());
1216 }
1217 yuiignore.push_dir(src_dir)?;
1220 let result = classify_walk_inner_body(
1221 src_dir,
1222 dst_dir,
1223 config,
1224 strategy,
1225 engine,
1226 tera_ctx,
1227 source_root,
1228 yuiignore,
1229 report,
1230 parent_covered,
1231 );
1232 yuiignore.pop_dir(src_dir);
1233 result
1234}
1235
1236#[allow(clippy::too_many_arguments)]
1237fn classify_walk_inner_body(
1238 src_dir: &Utf8Path,
1239 dst_dir: &Utf8Path,
1240 config: &Config,
1241 strategy: MountStrategy,
1242 engine: &mut template::Engine,
1243 tera_ctx: &TeraContext,
1244 source_root: &Utf8Path,
1245 yuiignore: &mut paths::YuiIgnoreStack,
1246 report: &mut Vec<StatusItem>,
1247 parent_covered: bool,
1248) -> Result<()> {
1249 let marker_filename = &config.mount.marker_filename;
1250 let mut covered = parent_covered;
1251
1252 if strategy == MountStrategy::Marker {
1253 match marker::read_spec(src_dir, marker_filename)? {
1254 None => {}
1255 Some(MarkerSpec::PassThrough) => {
1256 let decision = absorb::classify(src_dir, dst_dir)?;
1257 report.push(StatusItem {
1258 src: relative_for_display(source_root, src_dir),
1259 dst: dst_dir.to_path_buf(),
1260 state: StatusState::Link(decision),
1261 });
1262 covered = true;
1263 }
1264 Some(MarkerSpec::Explicit { links }) => {
1265 let mut emitted_dir_link = false;
1266 for link in &links {
1267 if let Some(when) = &link.when {
1268 if !template::eval_truthy(when, engine, tera_ctx)? {
1269 continue;
1270 }
1271 }
1272 let dst_str = engine.render(&link.dst, tera_ctx)?;
1273 let dst = paths::expand_tilde(dst_str.trim());
1274 if let Some(filename) = &link.src {
1275 let file_src = src_dir.join(filename);
1276 if !file_src.is_file() {
1277 anyhow::bail!(
1278 "marker at {src_dir}: [[link]] src={filename:?} \
1279 not found"
1280 );
1281 }
1282 let decision = absorb::classify(&file_src, &dst)?;
1283 report.push(StatusItem {
1284 src: relative_for_display(source_root, &file_src),
1285 dst,
1286 state: StatusState::Link(decision),
1287 });
1288 } else {
1289 let decision = absorb::classify(src_dir, &dst)?;
1290 report.push(StatusItem {
1291 src: relative_for_display(source_root, src_dir),
1292 dst,
1293 state: StatusState::Link(decision),
1294 });
1295 emitted_dir_link = true;
1296 }
1297 }
1298 if emitted_dir_link {
1299 covered = true;
1300 }
1301 }
1302 }
1303 }
1304
1305 for entry in std::fs::read_dir(src_dir)? {
1306 let entry = entry?;
1307 let name_os = entry.file_name();
1308 let Some(name) = name_os.to_str() else {
1309 continue;
1310 };
1311 if name == marker_filename || name.ends_with(".tera") {
1312 continue;
1313 }
1314 let src_path = src_dir.join(name);
1315 let dst_path = dst_dir.join(name);
1316 let ft = entry.file_type()?;
1317 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
1318 continue;
1319 }
1320 if ft.is_dir() {
1321 classify_walk_inner(
1322 &src_path,
1323 &dst_path,
1324 config,
1325 strategy,
1326 engine,
1327 tera_ctx,
1328 source_root,
1329 yuiignore,
1330 report,
1331 covered,
1332 )?;
1333 } else if ft.is_file() && !covered {
1334 let decision = absorb::classify(&src_path, &dst_path)?;
1335 report.push(StatusItem {
1336 src: relative_for_display(source_root, &src_path),
1337 dst: dst_path,
1338 state: StatusState::Link(decision),
1339 });
1340 }
1341 }
1342 Ok(())
1343}
1344
1345fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
1346 p.strip_prefix(source_root)
1347 .map(Utf8PathBuf::from)
1348 .unwrap_or_else(|_| p.to_path_buf())
1349}
1350
1351fn print_status_table(items: &[StatusItem], icons: Icons, color: bool) {
1352 let src_w = items
1353 .iter()
1354 .map(|i| i.src.as_str().chars().count())
1355 .max()
1356 .unwrap_or(0)
1357 .max("SRC".len());
1358 let dst_w = items
1359 .iter()
1360 .map(|i| i.dst.as_str().chars().count())
1361 .max()
1362 .unwrap_or(0)
1363 .max("DST".len());
1364 let state_label_w = items
1366 .iter()
1367 .map(|i| state_label(i.state).len())
1368 .max()
1369 .unwrap_or(0)
1370 .max("STATE".len() - 2); let state_w = state_label_w + 2; print_status_header(state_w, src_w, dst_w, color);
1374 let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
1375 if color {
1376 use owo_colors::OwoColorize as _;
1377 println!("{}", sep.dimmed());
1378 } else {
1379 println!("{sep}");
1380 }
1381 for item in items {
1382 print_status_row(item, icons, state_w, src_w, dst_w, color);
1383 }
1384}
1385
1386fn state_label(s: StatusState) -> &'static str {
1387 use absorb::AbsorbDecision::*;
1388 match s {
1389 StatusState::Link(InSync) => "in-sync",
1390 StatusState::Link(RelinkOnly) => "relink",
1391 StatusState::Link(AutoAbsorb) => "drift (auto)",
1392 StatusState::Link(NeedsConfirm) => "drift (anomaly)",
1393 StatusState::Link(Restore) => "missing",
1394 StatusState::RenderDrift => "render drift",
1395 }
1396}
1397
1398fn state_icon(s: StatusState, icons: Icons) -> &'static str {
1399 use absorb::AbsorbDecision::*;
1400 match s {
1401 StatusState::Link(InSync) => icons.ok,
1402 StatusState::Link(RelinkOnly) => icons.warn,
1403 StatusState::Link(AutoAbsorb) => icons.warn,
1404 StatusState::Link(NeedsConfirm) => icons.error,
1405 StatusState::Link(Restore) => icons.info,
1406 StatusState::RenderDrift => icons.error,
1407 }
1408}
1409
1410fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
1411 use owo_colors::OwoColorize as _;
1412 let line = format!(
1415 " {:<state_w$} {:<src_w$} {:<dst_w$}",
1416 "STATE", "SRC", "DST"
1417 );
1418 if color {
1419 println!("{}", line.bold());
1420 } else {
1421 println!("{line}");
1422 }
1423}
1424
1425fn render_status_separator(
1426 sep_ch: char,
1427 state_w: usize,
1428 src_w: usize,
1429 dst_w: usize,
1430 arrow: &str,
1431) -> String {
1432 let bar = |n: usize| sep_ch.to_string().repeat(n);
1433 format!(
1434 " {} {} {} {}",
1435 bar(state_w),
1436 bar(src_w),
1437 bar(arrow.chars().count()),
1438 bar(dst_w)
1439 )
1440}
1441
1442fn print_status_row(
1443 item: &StatusItem,
1444 icons: Icons,
1445 state_w: usize,
1446 src_w: usize,
1447 dst_w: usize,
1448 color: bool,
1449) {
1450 use owo_colors::OwoColorize as _;
1451 let icon = state_icon(item.state, icons);
1452 let label = state_label(item.state);
1453 let state_text = format!("{icon} {label}");
1454 let src_display = item.src.as_str().replace('\\', "/");
1455 let dst_display = item.dst.as_str().replace('\\', "/");
1456 let arrow = icons.arrow;
1457
1458 let cell_state = format!("{:<state_w$}", state_text);
1459 let cell_src = format!("{:<src_w$}", src_display);
1460 let cell_dst = format!("{:<dst_w$}", dst_display);
1461
1462 if !color {
1463 println!(" {cell_state} {cell_src} {arrow} {cell_dst}");
1464 return;
1465 }
1466
1467 use absorb::AbsorbDecision::*;
1468 let state_colored = match item.state {
1469 StatusState::Link(InSync) => cell_state.green().to_string(),
1470 StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
1471 cell_state.yellow().to_string()
1472 }
1473 StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
1474 StatusState::Link(Restore) => cell_state.cyan().to_string(),
1475 StatusState::RenderDrift => cell_state.red().to_string(),
1476 };
1477 let src_colored = cell_src.cyan().to_string();
1478 let arrow_colored = arrow.dimmed().to_string();
1479 let dst_colored = cell_dst.dimmed().to_string();
1480 println!(" {state_colored} {src_colored} {arrow_colored} {dst_colored}");
1481}
1482
1483pub fn absorb(
1497 source: Option<Utf8PathBuf>,
1498 target: Utf8PathBuf,
1499 dry_run: bool,
1500 yes: bool,
1501) -> Result<()> {
1502 let source = resolve_source(source)?;
1503 let target = absolutize(&target)?;
1504 let yui = YuiVars::detect(&source);
1505 let config = config::load(&source, &yui)?;
1506
1507 let mut engine = template::Engine::new();
1508 let tera_ctx = template::template_context(&yui, &config.vars);
1509
1510 let src_path = match find_source_for_target(&source, &config, &target, &mut engine, &tera_ctx)?
1511 {
1512 Some(s) => s,
1513 None => anyhow::bail!(
1514 "no mount entry / .yuilink override claims target {target}; \
1515 pass a path inside a known dst"
1516 ),
1517 };
1518
1519 info!("source for {target}: {src_path}");
1520
1521 print_absorb_diff(&src_path, &target);
1526
1527 if dry_run {
1528 info!("[dry-run] would absorb {target} → {src_path}");
1529 return Ok(());
1530 }
1531
1532 if !yes {
1533 use std::io::IsTerminal;
1534 if !std::io::stdin().is_terminal() {
1535 anyhow::bail!(
1536 "manual absorb refuses to run off-TTY without --yes \
1537 (would silently overwrite {src_path})"
1538 );
1539 }
1540 if !prompt_yes_no("absorb target into source?")? {
1541 warn!("manual absorb cancelled by user: {target}");
1542 return Ok(());
1543 }
1544 }
1545
1546 let backup_root = source.join(&config.backup.dir);
1547 let ctx = ApplyCtx {
1548 config: &config,
1549 source: &source,
1550 file_mode: resolve_file_mode(config.link.file_mode),
1551 dir_mode: resolve_dir_mode(config.link.dir_mode),
1552 backup_root: &backup_root,
1553 dry_run: false,
1554 };
1555
1556 absorb_target_into_source(&src_path, &target, &ctx)
1559}
1560
1561fn print_absorb_diff(src: &Utf8Path, dst: &Utf8Path) {
1566 eprintln!();
1567 eprintln!("--- diff (- source, + target) ---");
1568 eprintln!(" src: {src}");
1569 eprintln!(" dst: {dst}");
1570 eprintln!();
1571 if src.is_dir() || dst.is_dir() {
1572 eprintln!("(directory absorb — content listing skipped)");
1573 eprintln!();
1574 return;
1575 }
1576 let src_content = match read_text_for_diff(src) {
1577 DiffSide::Text(s) => s,
1578 DiffSide::Binary => {
1579 eprintln!("(binary file or non-UTF-8 content — diff skipped)");
1580 eprintln!();
1581 return;
1582 }
1583 };
1584 let dst_content = match read_text_for_diff(dst) {
1585 DiffSide::Text(s) => s,
1586 DiffSide::Binary => {
1587 eprintln!("(binary file or non-UTF-8 content — diff skipped)");
1588 eprintln!();
1589 return;
1590 }
1591 };
1592 let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
1593 let formatted = diff
1594 .unified_diff()
1595 .header(src.as_str(), dst.as_str())
1596 .to_string();
1597 eprint!("{formatted}");
1598 eprintln!();
1599}
1600
1601fn prompt_yes_no(question: &str) -> Result<bool> {
1602 use std::io::Write as _;
1603 eprint!("{question} [y/N]: ");
1604 std::io::stderr().flush().ok();
1605 let mut input = String::new();
1606 std::io::stdin().read_line(&mut input)?;
1607 let answer = input.trim();
1608 Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
1609}
1610
1611fn find_source_for_target(
1615 source: &Utf8Path,
1616 config: &Config,
1617 target: &Utf8Path,
1618 engine: &mut template::Engine,
1619 tera_ctx: &TeraContext,
1620) -> Result<Option<Utf8PathBuf>> {
1621 for entry in &config.mount.entry {
1623 if let Some(when) = &entry.when {
1624 if !template::eval_truthy(when, engine, tera_ctx)? {
1625 continue;
1626 }
1627 }
1628 let dst_str = engine.render(&entry.dst, tera_ctx)?;
1629 let dst_root = paths::expand_tilde(dst_str.trim());
1630 if let Ok(rel) = target.strip_prefix(&dst_root) {
1631 let candidate = source.join(&entry.src).join(rel);
1632 if paths::is_ignored_at(source, &candidate, candidate.is_dir())? {
1637 continue;
1638 }
1639 return Ok(Some(candidate));
1640 }
1641 }
1642
1643 let walker = paths::source_walker(source).build();
1649 let marker_filename = &config.mount.marker_filename;
1650 for ent in walker {
1651 let ent = match ent {
1652 Ok(e) => e,
1653 Err(_) => continue,
1654 };
1655 if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
1656 continue;
1657 }
1658 if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
1659 continue;
1660 }
1661 let dir = match ent.path().parent() {
1662 Some(d) => d,
1663 None => continue,
1664 };
1665 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
1666 Ok(p) => p,
1667 Err(_) => continue,
1668 };
1669 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
1670 Some(s) => s,
1671 None => continue,
1672 };
1673 let MarkerSpec::Explicit { links } = spec else {
1674 continue;
1675 };
1676 for link in &links {
1677 if let Some(when) = &link.when {
1678 if !template::eval_truthy(when, engine, tera_ctx)? {
1679 continue;
1680 }
1681 }
1682 let dst_str = engine.render(&link.dst, tera_ctx)?;
1683 let dst = paths::expand_tilde(dst_str.trim());
1684 if let Some(filename) = &link.src {
1691 let file_src = dir_utf8.join(filename);
1692 if !file_src.is_file() {
1693 anyhow::bail!(
1694 "marker at {dir_utf8}: [[link]] src={filename:?} \
1695 not found"
1696 );
1697 }
1698 if target == dst {
1699 return Ok(Some(file_src));
1700 }
1701 continue;
1702 }
1703 if target == dst {
1704 return Ok(Some(dir_utf8));
1705 }
1706 if let Ok(rel) = target.strip_prefix(&dst) {
1707 return Ok(Some(dir_utf8.join(rel)));
1708 }
1709 }
1710 }
1711
1712 Ok(None)
1713}
1714
1715pub fn doctor(
1716 source: Option<Utf8PathBuf>,
1717 icons_override: Option<IconsMode>,
1718 no_color: bool,
1719) -> Result<()> {
1720 use owo_colors::OwoColorize as _;
1721
1722 let resolved_source = resolve_source(source);
1727
1728 let yui = match &resolved_source {
1733 Ok(s) => YuiVars::detect(s),
1734 Err(_) => YuiVars::detect(Utf8Path::new(".")),
1735 };
1736
1737 let cfg_res = match &resolved_source {
1742 Ok(s) => Some(config::load(s, &yui)),
1743 Err(_) => None,
1744 };
1745 let cfg = cfg_res.as_ref().and_then(|r| r.as_ref().ok());
1746 let icons_mode = icons_override
1747 .or_else(|| cfg.map(|c| c.ui.icons))
1748 .unwrap_or_default();
1749 let icons = Icons::for_mode(icons_mode);
1750 let color = !no_color && supports_color_stdout();
1751
1752 let mut probes: Vec<Probe> = Vec::new();
1753
1754 probes.push(Probe::group("identity"));
1756 probes.push(Probe::ok("os/arch", format!("{} / {}", yui.os, yui.arch)));
1757 probes.push(Probe::ok("user@host", format!("{}@{}", yui.user, yui.host)));
1758
1759 probes.push(Probe::group("repo"));
1761 let mut have_source = false;
1762 match &resolved_source {
1763 Ok(s) => {
1764 have_source = true;
1765 probes.push(Probe::ok("source", s.to_string()));
1766 match cfg_res.as_ref().expect("cfg_res set when source is Ok") {
1767 Ok(c) => {
1768 probes.push(Probe::ok(
1769 "config",
1770 format!(
1771 "{} mount{} · {} hook{} · {} render rule{}",
1772 c.mount.entry.len(),
1773 plural(c.mount.entry.len()),
1774 c.hook.len(),
1775 plural(c.hook.len()),
1776 c.render.rule.len(),
1777 plural(c.render.rule.len()),
1778 ),
1779 ));
1780 }
1781 Err(e) => probes.push(Probe::error("config", format!("{e}"))),
1782 }
1783 match crate::git::is_clean(s) {
1787 Ok(true) => probes.push(Probe::ok("git", "clean")),
1788 Ok(false) => probes.push(Probe::warn(
1789 "git",
1790 "uncommitted changes — `[absorb] require_clean_git` will defer auto-absorb",
1791 )),
1792 Err(_) => probes.push(Probe::warn(
1793 "git",
1794 "no git repo (auto-absorb still works; commit history won't track drift)",
1795 )),
1796 }
1797 }
1798 Err(e) => {
1799 probes.push(Probe::error("source", format!("not found — {e}")));
1800 }
1801 }
1802
1803 probes.push(Probe::group("links"));
1805 if cfg!(windows) {
1806 probes.push(Probe::ok(
1807 "default mode",
1808 "files=hardlink, dirs=junction (no admin needed)",
1809 ));
1810 } else {
1811 probes.push(Probe::ok("default mode", "files=symlink, dirs=symlink"));
1812 }
1813
1814 if have_source {
1816 if let (Ok(s), Some(c)) = (&resolved_source, cfg) {
1817 probes.push(Probe::group("hooks"));
1818 if c.hook.is_empty() {
1819 probes.push(Probe::ok("hooks", "(none configured)"));
1820 } else {
1821 let mut missing = 0usize;
1822 for h in &c.hook {
1823 if !s.join(&h.script).is_file() {
1824 missing += 1;
1825 probes.push(Probe::error(
1826 format!("hook[{}]", h.name),
1827 format!("script not found at {}", h.script),
1828 ));
1829 }
1830 }
1831 if missing == 0 {
1832 probes.push(Probe::ok(
1833 "scripts",
1834 format!(
1835 "{} hook{} configured, all scripts present",
1836 c.hook.len(),
1837 plural(c.hook.len())
1838 ),
1839 ));
1840 }
1841 }
1842 }
1843 }
1844
1845 if let Some(home) = paths::home_dir() {
1847 let chezmoi_src = home.join(".local/share/chezmoi");
1848 if chezmoi_src.is_dir() {
1849 probes.push(Probe::group("chezmoi"));
1850 probes.push(Probe::warn(
1851 "legacy source",
1852 format!(
1853 "{chezmoi_src} still exists — yui doesn't use it, safe to archive once your migration has settled"
1854 ),
1855 ));
1856 }
1857 }
1858
1859 println!();
1861 if color {
1862 println!(" {}", "yui doctor".bold().underline());
1863 } else {
1864 println!(" yui doctor");
1865 }
1866 println!();
1867 for probe in &probes {
1868 probe.print(&icons, color);
1869 }
1870
1871 let errors = probes.iter().filter(|p| p.is_error()).count();
1872 let warns = probes.iter().filter(|p| p.is_warn()).count();
1873 let oks = probes.iter().filter(|p| p.is_ok()).count();
1874 println!();
1875 let summary = format!("{oks} ok · {warns} warn · {errors} error");
1876 if color {
1877 if errors > 0 {
1878 println!(" {}", summary.red().bold());
1879 } else if warns > 0 {
1880 println!(" {}", summary.yellow());
1881 } else {
1882 println!(" {}", summary.green());
1883 }
1884 } else {
1885 println!(" {summary}");
1886 }
1887
1888 if errors > 0 {
1889 anyhow::bail!("doctor: {errors} probe(s) failed");
1890 }
1891 Ok(())
1892}
1893
1894#[derive(Debug)]
1895enum Probe {
1896 Group(&'static str),
1898 Ok {
1899 label: String,
1900 detail: String,
1901 },
1902 Warn {
1903 label: String,
1904 detail: String,
1905 },
1906 Error {
1907 label: String,
1908 detail: String,
1909 },
1910}
1911
1912impl Probe {
1913 fn group(label: &'static str) -> Self {
1914 Self::Group(label)
1915 }
1916 fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
1917 Self::Ok {
1918 label: label.into(),
1919 detail: detail.into(),
1920 }
1921 }
1922 fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
1923 Self::Warn {
1924 label: label.into(),
1925 detail: detail.into(),
1926 }
1927 }
1928 fn error(label: impl Into<String>, detail: impl Into<String>) -> Self {
1929 Self::Error {
1930 label: label.into(),
1931 detail: detail.into(),
1932 }
1933 }
1934 fn is_ok(&self) -> bool {
1935 matches!(self, Self::Ok { .. })
1936 }
1937 fn is_warn(&self) -> bool {
1938 matches!(self, Self::Warn { .. })
1939 }
1940 fn is_error(&self) -> bool {
1941 matches!(self, Self::Error { .. })
1942 }
1943 fn print(&self, icons: &Icons, color: bool) {
1944 use owo_colors::OwoColorize as _;
1945 match self {
1946 Self::Group(name) => {
1947 println!();
1948 if color {
1949 println!(" {}", name.cyan().bold());
1950 } else {
1951 println!(" {name}");
1952 }
1953 }
1954 Self::Ok { label, detail } => {
1955 let icon = icons.ok;
1956 let padded = format!("{label:<14}");
1960 if color {
1961 println!(
1962 " {} {} {}",
1963 icon.green(),
1964 padded.bold(),
1965 detail.dimmed()
1966 );
1967 } else {
1968 println!(" {icon} {padded} {detail}");
1969 }
1970 }
1971 Self::Warn { label, detail } => {
1972 let icon = icons.warn;
1973 let padded = format!("{label:<14}");
1974 if color {
1975 println!(
1976 " {} {} {}",
1977 icon.yellow(),
1978 padded.bold().yellow(),
1979 detail
1980 );
1981 } else {
1982 println!(" {icon} {padded} {detail}");
1983 }
1984 }
1985 Self::Error { label, detail } => {
1986 let icon = icons.error;
1987 let padded = format!("{label:<14}");
1988 if color {
1989 println!(
1990 " {} {} {}",
1991 icon.red().bold(),
1992 padded.bold().red(),
1993 detail.red()
1994 );
1995 } else {
1996 println!(" {icon} {padded} {detail}");
1997 }
1998 }
1999 }
2000 }
2001}
2002
2003fn plural(n: usize) -> &'static str {
2004 if n == 1 { "" } else { "s" }
2005}
2006
2007pub fn gc_backup(
2027 source: Option<Utf8PathBuf>,
2028 older_than: Option<String>,
2029 dry_run: bool,
2030 icons_override: Option<IconsMode>,
2031 no_color: bool,
2032) -> Result<()> {
2033 let source = resolve_source(source)?;
2034 let yui = YuiVars::detect(&source);
2035 let config = config::load(&source, &yui)?;
2036 let backup_root = source.join(&config.backup.dir);
2037 let icons_mode = icons_override.unwrap_or(config.ui.icons);
2038 let icons = Icons::for_mode(icons_mode);
2039 let color = !no_color && supports_color_stdout();
2040
2041 if !backup_root.is_dir() {
2042 println!(" no backup tree at {backup_root}");
2043 return Ok(());
2044 }
2045
2046 let mut entries = walk_gc_backups(&backup_root)?;
2047 if entries.is_empty() {
2048 println!(" no yui-stamped backups under {backup_root}");
2049 return Ok(());
2050 }
2051 entries.sort_by_key(|e| e.ts);
2053 let now = jiff::Zoned::now();
2054
2055 match older_than {
2056 None => {
2057 let refs: Vec<&BackupEntry> = entries.iter().collect();
2058 print_gc_table(&refs, &backup_root, &now, icons, color);
2059 println!();
2060 println!(
2061 " {} entries · {} total — pass --older-than DUR (e.g. 30d) to delete",
2062 entries.len(),
2063 format_bytes(entries.iter().map(|e| e.size_bytes).sum())
2064 );
2065 Ok(())
2066 }
2067 Some(dur_str) => {
2068 let span = parse_human_duration(&dur_str)?;
2069 let cutoff = now
2070 .checked_sub(span)
2071 .map_err(|e| anyhow::anyhow!("invalid duration {dur_str:?}: {e}"))?;
2072 let cutoff_dt = cutoff.datetime();
2073
2074 let total_before: u64 = entries.iter().map(|e| e.size_bytes).sum();
2075 let to_delete: Vec<&BackupEntry> =
2076 entries.iter().filter(|e| e.ts < cutoff_dt).collect();
2077
2078 if to_delete.is_empty() {
2079 println!(
2080 " no backups older than {dur_str} (oldest: {})",
2081 format_age(entries[0].ts, &now)
2082 );
2083 return Ok(());
2084 }
2085
2086 print_gc_table(&to_delete, &backup_root, &now, icons, color);
2087 println!();
2088 let total_freed: u64 = to_delete.iter().map(|e| e.size_bytes).sum();
2089
2090 if dry_run {
2091 println!(
2092 " [dry-run] would remove {} of {} entries · would free {} of {}",
2093 to_delete.len(),
2094 entries.len(),
2095 format_bytes(total_freed),
2096 format_bytes(total_before),
2097 );
2098 return Ok(());
2099 }
2100
2101 for entry in &to_delete {
2102 match entry.kind {
2103 BackupKind::File => std::fs::remove_file(&entry.path)?,
2104 BackupKind::Dir => std::fs::remove_dir_all(&entry.path)?,
2105 }
2106 if let Some(parent) = entry.path.parent() {
2107 cleanup_empty_parents(parent, &backup_root);
2108 }
2109 }
2110 println!(
2111 " removed {} of {} entries · freed {} (was {}, now {})",
2112 to_delete.len(),
2113 entries.len(),
2114 format_bytes(total_freed),
2115 format_bytes(total_before),
2116 format_bytes(total_before - total_freed),
2117 );
2118 Ok(())
2119 }
2120 }
2121}
2122
2123#[derive(Debug)]
2124struct BackupEntry {
2125 path: Utf8PathBuf,
2126 ts: jiff::civil::DateTime,
2127 kind: BackupKind,
2128 size_bytes: u64,
2129}
2130
2131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2132enum BackupKind {
2133 File,
2134 Dir,
2135}
2136
2137fn walk_gc_backups(root: &Utf8Path) -> Result<Vec<BackupEntry>> {
2142 let mut out = Vec::new();
2143 walk_gc_backups_rec(root, &mut out)?;
2144 Ok(out)
2145}
2146
2147fn walk_gc_backups_rec(dir: &Utf8Path, out: &mut Vec<BackupEntry>) -> Result<()> {
2148 for entry in std::fs::read_dir(dir)? {
2149 let entry = entry?;
2150 let name_os = entry.file_name();
2151 let Some(name) = name_os.to_str() else {
2152 continue;
2153 };
2154 let path = dir.join(name);
2155 let ft = entry.file_type()?;
2156 if ft.is_dir() {
2157 if let Some(ts) = parse_backup_suffix(name) {
2158 let size = dir_size(&path)?;
2159 out.push(BackupEntry {
2160 path,
2161 ts,
2162 kind: BackupKind::Dir,
2163 size_bytes: size,
2164 });
2165 } else {
2166 walk_gc_backups_rec(&path, out)?;
2167 }
2168 } else if ft.is_file() {
2169 if let Some(ts) = parse_backup_suffix(name) {
2172 let size = entry.metadata()?.len();
2173 out.push(BackupEntry {
2174 path,
2175 ts,
2176 kind: BackupKind::File,
2177 size_bytes: size,
2178 });
2179 }
2180 }
2181 }
2182 Ok(())
2183}
2184
2185fn dir_size(dir: &Utf8Path) -> Result<u64> {
2186 let mut total: u64 = 0;
2187 for entry in std::fs::read_dir(dir)? {
2188 let entry = entry?;
2189 let ft = entry.file_type()?;
2190 if ft.is_dir() {
2191 let p = match Utf8PathBuf::from_path_buf(entry.path()) {
2192 Ok(p) => p,
2193 Err(_) => continue,
2194 };
2195 total = total.saturating_add(dir_size(&p)?);
2196 } else if ft.is_file() {
2197 total = total.saturating_add(entry.metadata()?.len());
2198 }
2199 }
2200 Ok(total)
2201}
2202
2203fn cleanup_empty_parents(start: &Utf8Path, root: &Utf8Path) {
2207 let mut cur = start.to_path_buf();
2208 loop {
2209 if cur == *root {
2210 return;
2211 }
2212 if std::fs::remove_dir(&cur).is_err() {
2214 return;
2215 }
2216 match cur.parent() {
2217 Some(p) => cur = p.to_path_buf(),
2218 None => return,
2219 }
2220 }
2221}
2222
2223fn parse_backup_suffix(name: &str) -> Option<jiff::civil::DateTime> {
2229 if let Some(ts) = parse_ts_at_end(name) {
2230 return Some(ts);
2231 }
2232 if let Some((before, _ext)) = name.rsplit_once('.') {
2235 if let Some(ts) = parse_ts_at_end(before) {
2236 return Some(ts);
2237 }
2238 }
2239 None
2240}
2241
2242fn parse_ts_at_end(s: &str) -> Option<jiff::civil::DateTime> {
2243 if s.len() < 20 {
2245 return None;
2246 }
2247 let split_at = s.len() - 19;
2248 if s.as_bytes()[split_at] != b'_' {
2249 return None;
2250 }
2251 parse_ts(&s[split_at + 1..])
2252}
2253
2254fn parse_ts(s: &str) -> Option<jiff::civil::DateTime> {
2256 if s.len() != 18 || s.as_bytes()[8] != b'_' {
2257 return None;
2258 }
2259 for (i, &b) in s.as_bytes().iter().enumerate() {
2260 if i == 8 {
2261 continue;
2262 }
2263 if !b.is_ascii_digit() {
2264 return None;
2265 }
2266 }
2267 let year: i16 = s[0..4].parse().ok()?;
2268 let month: i8 = s[4..6].parse().ok()?;
2269 let day: i8 = s[6..8].parse().ok()?;
2270 let hour: i8 = s[9..11].parse().ok()?;
2271 let minute: i8 = s[11..13].parse().ok()?;
2272 let second: i8 = s[13..15].parse().ok()?;
2273 let ms: i32 = s[15..18].parse().ok()?;
2274 jiff::civil::DateTime::new(year, month, day, hour, minute, second, ms * 1_000_000).ok()
2275}
2276
2277fn parse_human_duration(s: &str) -> Result<jiff::Span> {
2286 let s = s.trim();
2287 let split = s
2288 .bytes()
2289 .position(|b| b.is_ascii_alphabetic())
2290 .ok_or_else(|| anyhow::anyhow!("invalid duration {s:?}: missing unit (e.g. 30d, 2w)"))?;
2291 let n: i64 = s[..split]
2292 .trim()
2293 .parse()
2294 .map_err(|_| anyhow::anyhow!("invalid duration {s:?}: bad leading number"))?;
2295 if n < 0 {
2296 anyhow::bail!("invalid duration {s:?}: negative durations don't make sense");
2297 }
2298 let unit = s[split..].to_ascii_lowercase();
2299 let span = match unit.as_str() {
2300 "y" | "yr" | "year" | "years" => jiff::Span::new().years(n),
2301 "mo" | "month" | "months" => jiff::Span::new().months(n),
2302 "w" | "wk" | "week" | "weeks" => jiff::Span::new().weeks(n),
2303 "d" | "day" | "days" => jiff::Span::new().days(n),
2304 "h" | "hr" | "hour" | "hours" => jiff::Span::new().hours(n),
2305 "m" | "min" | "minute" | "minutes" => jiff::Span::new().minutes(n),
2306 other => {
2307 anyhow::bail!(
2308 "invalid duration {s:?}: unknown unit {other:?} \
2309 (use y / mo / w / d / h / m)"
2310 )
2311 }
2312 };
2313 Ok(span)
2314}
2315
2316fn format_bytes(n: u64) -> String {
2317 const KIB: u64 = 1024;
2318 const MIB: u64 = KIB * 1024;
2319 const GIB: u64 = MIB * 1024;
2320 if n >= GIB {
2321 format!("{:.1} GiB", n as f64 / GIB as f64)
2322 } else if n >= MIB {
2323 format!("{:.1} MiB", n as f64 / MIB as f64)
2324 } else if n >= KIB {
2325 format!("{:.1} KiB", n as f64 / KIB as f64)
2326 } else {
2327 format!("{n} B")
2328 }
2329}
2330
2331fn format_age(ts: jiff::civil::DateTime, now: &jiff::Zoned) -> String {
2332 let Ok(ts_zoned) = ts.to_zoned(now.time_zone().clone()) else {
2333 return "?".into();
2334 };
2335 let secs = match (now - &ts_zoned).total(jiff::Unit::Second) {
2336 Ok(s) => s as i64,
2337 Err(_) => return "?".into(),
2338 };
2339 if secs < 0 {
2340 return "future".into();
2341 }
2342 if secs < 60 {
2343 format!("{secs}s")
2344 } else if secs < 3600 {
2345 format!("{}m", secs / 60)
2346 } else if secs < 86_400 {
2347 format!("{}h", secs / 3600)
2348 } else if secs < 86_400 * 30 {
2349 format!("{}d", secs / 86_400)
2350 } else if secs < 86_400 * 365 {
2351 format!("{}mo", secs / (86_400 * 30))
2352 } else {
2353 format!("{}y", secs / (86_400 * 365))
2354 }
2355}
2356
2357fn print_gc_table(
2364 entries: &[&BackupEntry],
2365 backup_root: &Utf8Path,
2366 now: &jiff::Zoned,
2367 _icons: Icons,
2368 color: bool,
2369) {
2370 use owo_colors::OwoColorize as _;
2371
2372 let rows: Vec<(String, String, String)> = entries
2373 .iter()
2374 .map(|e| {
2375 let rel = e
2376 .path
2377 .strip_prefix(backup_root)
2378 .map(Utf8PathBuf::from)
2379 .unwrap_or_else(|_| e.path.clone());
2380 let path_disp = match e.kind {
2381 BackupKind::Dir => format!("{rel}/"),
2382 BackupKind::File => rel.to_string(),
2383 };
2384 (format_age(e.ts, now), format_bytes(e.size_bytes), path_disp)
2385 })
2386 .collect();
2387
2388 let age_w = rows.iter().map(|r| r.0.len()).max().unwrap_or(3);
2389 let size_w = rows.iter().map(|r| r.1.len()).max().unwrap_or(4);
2390
2391 if color {
2392 println!(
2393 " {:<age_w$} {:>size_w$} {}",
2394 "AGE".dimmed(),
2395 "SIZE".dimmed(),
2396 "PATH".dimmed(),
2397 );
2398 } else {
2399 println!(" {:<age_w$} {:>size_w$} PATH", "AGE", "SIZE");
2400 }
2401 for (age, size, path) in &rows {
2402 if color {
2403 println!(
2404 " {:<age_w$} {:>size_w$} {}",
2405 age.yellow(),
2406 size,
2407 path.cyan(),
2408 );
2409 } else {
2410 println!(" {:<age_w$} {:>size_w$} {}", age, size, path);
2411 }
2412 }
2413}
2414
2415pub fn hooks_list(
2417 source: Option<Utf8PathBuf>,
2418 icons_override: Option<IconsMode>,
2419 no_color: bool,
2420) -> Result<()> {
2421 let source = resolve_source(source)?;
2422 let yui = YuiVars::detect(&source);
2423 let config = config::load(&source, &yui)?;
2424 let state = hook::State::load(&source)?;
2425
2426 let icons_mode = icons_override.unwrap_or(config.ui.icons);
2427 let icons = Icons::for_mode(icons_mode);
2428 let color = !no_color && supports_color_stdout();
2429
2430 if config.hook.is_empty() {
2431 println!("(no [[hook]] entries in config)");
2432 return Ok(());
2433 }
2434
2435 let mut engine = template::Engine::new();
2439 let tera_ctx = template::template_context(&yui, &config.vars);
2440 let rows: Vec<HookRow> = config
2441 .hook
2442 .iter()
2443 .map(|h| -> Result<HookRow> {
2444 let active = match &h.when {
2448 None => true,
2449 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
2450 };
2451 let last_run_at = state.hooks.get(&h.name).and_then(|s| s.last_run_at.clone());
2452 Ok(HookRow {
2453 name: h.name.clone(),
2454 phase: match h.phase {
2455 HookPhase::Pre => "pre",
2456 HookPhase::Post => "post",
2457 },
2458 when_run: match h.when_run {
2459 config::WhenRun::Once => "once",
2460 config::WhenRun::Onchange => "onchange",
2461 config::WhenRun::Every => "every",
2462 },
2463 last_run_at,
2464 when: h.when.clone(),
2465 active,
2466 })
2467 })
2468 .collect::<Result<Vec<_>>>()?;
2469
2470 print_hooks_table(&rows, icons, color);
2471
2472 let total = rows.len();
2473 let active = rows.iter().filter(|r| r.active).count();
2474 let inactive = total - active;
2475 let ran = rows.iter().filter(|r| r.last_run_at.is_some()).count();
2476 let never = total - ran;
2477 println!();
2478 println!(
2479 " {total} hooks · {active} active · {inactive} inactive · {ran} ran · {never} never run"
2480 );
2481
2482 Ok(())
2483}
2484
2485#[derive(Debug)]
2486struct HookRow {
2487 name: String,
2488 phase: &'static str,
2489 when_run: &'static str,
2490 last_run_at: Option<String>,
2491 when: Option<String>,
2492 active: bool,
2493}
2494
2495fn print_hooks_table(rows: &[HookRow], icons: Icons, color: bool) {
2496 use owo_colors::OwoColorize as _;
2497 use std::fmt::Write as _;
2498
2499 let name_w = rows
2500 .iter()
2501 .map(|r| r.name.chars().count())
2502 .max()
2503 .unwrap_or(0)
2504 .max("NAME".len());
2505 let phase_w = rows
2506 .iter()
2507 .map(|r| r.phase.len())
2508 .max()
2509 .unwrap_or(0)
2510 .max("PHASE".len());
2511 let when_run_w = rows
2512 .iter()
2513 .map(|r| r.when_run.len())
2514 .max()
2515 .unwrap_or(0)
2516 .max("WHEN_RUN".len());
2517 let last_w = rows
2518 .iter()
2519 .map(|r| {
2520 r.last_run_at
2521 .as_deref()
2522 .map(|s| s.chars().count())
2523 .unwrap_or("(never)".len())
2524 })
2525 .max()
2526 .unwrap_or(0)
2527 .max("LAST_RUN".len());
2528 let status_w = "STATUS".len();
2529
2530 let mut header = String::new();
2532 let _ = write!(
2533 &mut header,
2534 " {:<status_w$} {:<name_w$} {:<phase_w$} {:<when_run_w$} {:<last_w$} WHEN",
2535 "STATUS", "NAME", "PHASE", "WHEN_RUN", "LAST_RUN"
2536 );
2537 if color {
2538 println!("{}", header.bold());
2539 } else {
2540 println!("{header}");
2541 }
2542
2543 let bar = |n: usize| icons.sep.to_string().repeat(n);
2545 let sep = format!(
2546 " {} {} {} {} {} {}",
2547 bar(status_w),
2548 bar(name_w),
2549 bar(phase_w),
2550 bar(when_run_w),
2551 bar(last_w),
2552 bar("WHEN".len())
2553 );
2554 if color {
2555 println!("{}", sep.dimmed());
2556 } else {
2557 println!("{sep}");
2558 }
2559
2560 for r in rows {
2562 let (icon, ran) = match (r.active, r.last_run_at.is_some()) {
2567 (false, _) => (icons.inactive, false),
2568 (true, true) => (icons.active, true),
2569 (true, false) => (icons.info, false),
2570 };
2571 let last = r.last_run_at.as_deref().unwrap_or("(never)");
2572 let when_str = r
2573 .when
2574 .as_deref()
2575 .map(strip_braces)
2576 .unwrap_or_else(|| "(always)".to_string());
2577
2578 let cell_status = format!("{icon:<status_w$}");
2579 let cell_name = format!("{:<name_w$}", r.name);
2580 let cell_phase = format!("{:<phase_w$}", r.phase);
2581 let cell_when_run = format!("{:<when_run_w$}", r.when_run);
2582 let cell_last = format!("{last:<last_w$}");
2583
2584 if !color {
2585 println!(
2586 " {cell_status} {cell_name} {cell_phase} {cell_when_run} {cell_last} {when_str}"
2587 );
2588 continue;
2589 }
2590
2591 if !r.active {
2595 println!(
2596 " {} {} {} {} {} {}",
2597 cell_status.dimmed(),
2598 cell_name.dimmed(),
2599 cell_phase.dimmed(),
2600 cell_when_run.dimmed(),
2601 cell_last.dimmed(),
2602 when_str.dimmed()
2603 );
2604 } else if ran {
2605 println!(
2606 " {} {} {} {} {} {}",
2607 cell_status.green(),
2608 cell_name.cyan().bold(),
2609 cell_phase.dimmed(),
2610 cell_when_run.dimmed(),
2611 cell_last.green(),
2612 when_str.dimmed()
2613 );
2614 } else {
2615 println!(
2616 " {} {} {} {} {} {}",
2617 cell_status.yellow(),
2618 cell_name.cyan().bold(),
2619 cell_phase.dimmed(),
2620 cell_when_run.dimmed(),
2621 cell_last.yellow(),
2622 when_str.dimmed()
2623 );
2624 }
2625 }
2626}
2627
2628pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
2632 let source = resolve_source(source)?;
2633 let yui = YuiVars::detect(&source);
2634 let config = config::load(&source, &yui)?;
2635 let mut engine = template::Engine::new();
2636 let tera_ctx = template::template_context(&yui, &config.vars);
2637
2638 let targets: Vec<&config::HookConfig> = match &name {
2639 Some(want) => {
2640 let m = config
2641 .hook
2642 .iter()
2643 .find(|h| &h.name == want)
2644 .ok_or_else(|| {
2645 anyhow::anyhow!(
2646 "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
2647 )
2648 })?;
2649 vec![m]
2650 }
2651 None => config.hook.iter().collect(),
2652 };
2653
2654 let mut state = hook::State::load(&source)?;
2655 for h in targets {
2656 let outcome = hook::run_hook(
2657 h,
2658 &source,
2659 &yui,
2660 &config.vars,
2661 &mut engine,
2662 &tera_ctx,
2663 &mut state,
2664 false,
2665 force,
2666 )?;
2667 let label = match outcome {
2668 HookOutcome::Ran => "ran",
2669 HookOutcome::SkippedOnce => "skipped (once: already ran)",
2670 HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
2671 HookOutcome::SkippedWhenFalse => "skipped (when=false)",
2672 HookOutcome::DryRun => "would run (dry-run)",
2673 };
2674 info!("hook[{}]: {label}", h.name);
2675 if outcome == HookOutcome::Ran {
2676 state.save(&source)?;
2677 }
2678 }
2679 Ok(())
2680}
2681
2682#[allow(clippy::too_many_arguments)]
2687fn process_mount(
2688 source: &Utf8Path,
2689 m: &ResolvedMount,
2690 ctx: &ApplyCtx<'_>,
2691 engine: &mut template::Engine,
2692 tera_ctx: &TeraContext,
2693 yuiignore: &mut paths::YuiIgnoreStack,
2694) -> Result<()> {
2695 let src_root = source.join(&m.src);
2696 if !src_root.is_dir() {
2697 warn!("mount src missing: {src_root}");
2698 return Ok(());
2699 }
2700 walk_and_link(
2701 &src_root, &m.dst, ctx, m.strategy, engine, tera_ctx, yuiignore, false,
2702 )
2703}
2704
2705#[allow(clippy::too_many_arguments)]
2706fn walk_and_link(
2707 src_dir: &Utf8Path,
2708 dst_dir: &Utf8Path,
2709 ctx: &ApplyCtx<'_>,
2710 strategy: MountStrategy,
2711 engine: &mut template::Engine,
2712 tera_ctx: &TeraContext,
2713 yuiignore: &mut paths::YuiIgnoreStack,
2714 parent_covered: bool,
2715) -> Result<()> {
2716 if yuiignore.is_ignored(src_dir, true) {
2719 return Ok(());
2720 }
2721 yuiignore.push_dir(src_dir)?;
2724 let result = walk_and_link_body(
2725 src_dir,
2726 dst_dir,
2727 ctx,
2728 strategy,
2729 engine,
2730 tera_ctx,
2731 yuiignore,
2732 parent_covered,
2733 );
2734 yuiignore.pop_dir(src_dir);
2735 result
2736}
2737
2738#[allow(clippy::too_many_arguments)]
2739fn walk_and_link_body(
2740 src_dir: &Utf8Path,
2741 dst_dir: &Utf8Path,
2742 ctx: &ApplyCtx<'_>,
2743 strategy: MountStrategy,
2744 engine: &mut template::Engine,
2745 tera_ctx: &TeraContext,
2746 yuiignore: &mut paths::YuiIgnoreStack,
2747 parent_covered: bool,
2748) -> Result<()> {
2749 let marker_filename = &ctx.config.mount.marker_filename;
2750 let mut covered = parent_covered;
2751
2752 if strategy == MountStrategy::Marker {
2753 match marker::read_spec(src_dir, marker_filename)? {
2754 None => {} Some(MarkerSpec::PassThrough) => {
2756 link_dir_with_backup(src_dir, dst_dir, ctx)?;
2760 covered = true;
2761 }
2762 Some(MarkerSpec::Explicit { links }) => {
2763 let mut emitted_dir_link = false;
2764 let mut emitted_any = false;
2765 for link in &links {
2766 if let Some(when) = &link.when {
2769 if !template::eval_truthy(when, engine, tera_ctx)? {
2770 continue;
2771 }
2772 }
2773 let dst_str = engine.render(&link.dst, tera_ctx)?;
2774 let dst = paths::expand_tilde(dst_str.trim());
2775 if let Some(filename) = &link.src {
2776 let file_src = src_dir.join(filename);
2777 if !file_src.is_file() {
2778 anyhow::bail!(
2779 "marker at {src_dir}: [[link]] src={filename:?} \
2780 not found"
2781 );
2782 }
2783 link_file_with_backup(&file_src, &dst, ctx)?;
2784 } else {
2785 link_dir_with_backup(src_dir, &dst, ctx)?;
2786 emitted_dir_link = true;
2787 }
2788 emitted_any = true;
2789 }
2790 if !emitted_any {
2791 info!(
2796 "marker at {src_dir} had no active links \
2797 — falling back to defaults"
2798 );
2799 }
2800 if emitted_dir_link {
2801 covered = true;
2802 }
2803 }
2804 }
2805 }
2806
2807 for entry in std::fs::read_dir(src_dir)? {
2808 let entry = entry?;
2809 let name_os = entry.file_name();
2810 let Some(name) = name_os.to_str() else {
2811 continue;
2812 };
2813 if name == marker_filename {
2814 continue;
2815 }
2816 if name.ends_with(".tera") {
2817 continue;
2819 }
2820 let src_path = src_dir.join(name);
2821 let dst_path = dst_dir.join(name);
2822 let ft = entry.file_type()?;
2823
2824 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
2825 continue;
2826 }
2827
2828 if ft.is_dir() {
2829 walk_and_link(
2830 &src_path, &dst_path, ctx, strategy, engine, tera_ctx, yuiignore, covered,
2831 )?;
2832 } else if ft.is_file() {
2833 if !covered {
2839 link_file_with_backup(&src_path, &dst_path, ctx)?;
2840 }
2841 }
2842 }
2843 Ok(())
2844}
2845
2846fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2847 use absorb::AbsorbDecision::*;
2848
2849 let decision = absorb::classify(src, dst)?;
2850
2851 if ctx.dry_run {
2852 info!("[dry-run] {decision:?}: {src} → {dst}");
2853 return Ok(());
2854 }
2855
2856 match decision {
2857 InSync => {
2858 Ok(())
2860 }
2861 Restore => {
2862 info!("link: {src} → {dst}");
2863 link::link_file(src, dst, ctx.file_mode)?;
2864 Ok(())
2865 }
2866 RelinkOnly => {
2867 info!("relink: {src} → {dst}");
2870 link::unlink(dst)?;
2871 link::link_file(src, dst, ctx.file_mode)?;
2872 Ok(())
2873 }
2874 AutoAbsorb => {
2875 if !ctx.config.absorb.auto {
2878 return handle_anomaly(
2879 src,
2880 dst,
2881 ctx,
2882 "absorb.auto = false; treating divergence as anomaly",
2883 );
2884 }
2885 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
2886 return handle_anomaly(
2887 src,
2888 dst,
2889 ctx,
2890 "source repo is dirty; deferring auto-absorb",
2891 );
2892 }
2893 absorb_target_into_source(src, dst, ctx)
2894 }
2895 NeedsConfirm => handle_anomaly(
2896 src,
2897 dst,
2898 ctx,
2899 "anomaly: source equals/newer than target but content differs",
2900 ),
2901 }
2902}
2903
2904fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2908 info!("absorb: {dst} → {src}");
2909 backup_existing(src, ctx.backup_root, false)?;
2910 std::fs::copy(dst, src)?;
2911 link::unlink(dst)?;
2912 link::link_file(src, dst, ctx.file_mode)?;
2913 Ok(())
2914}
2915
2916fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
2922 use crate::config::AnomalyAction::*;
2923 match ctx.config.absorb.on_anomaly {
2924 Skip => {
2925 warn!("anomaly skip: {dst} ({reason})");
2926 Ok(())
2927 }
2928 Force => {
2929 warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
2930 absorb_target_into_source(src, dst, ctx)
2931 }
2932 Ask => {
2933 use std::io::IsTerminal;
2934 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
2935 if prompt_absorb_with_diff(src, dst, reason)? {
2936 absorb_target_into_source(src, dst, ctx)
2937 } else {
2938 warn!("anomaly skipped by user: {dst}");
2939 Ok(())
2940 }
2941 } else {
2942 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
2943 Ok(())
2944 }
2945 }
2946 }
2947}
2948
2949fn prompt_absorb_with_diff(src: &Utf8Path, dst: &Utf8Path, reason: &str) -> Result<bool> {
2950 eprintln!();
2951 eprintln!("anomaly: {reason}");
2952 print_absorb_diff(src, dst);
2953 prompt_yes_no("absorb target into source?")
2954}
2955
2956fn source_repo_is_clean(source: &Utf8Path) -> bool {
2961 match crate::git::is_clean(source) {
2962 Ok(b) => b,
2963 Err(e) => {
2964 warn!("git clean check failed at {source}: {e} — treating as clean");
2965 true
2966 }
2967 }
2968}
2969
2970fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2971 use absorb::AbsorbDecision::*;
2972 let decision = absorb::classify(src, dst)?;
2973
2974 if ctx.dry_run {
2975 info!("[dry-run] dir {decision:?}: {src} → {dst}");
2976 return Ok(());
2977 }
2978
2979 match decision {
2980 InSync => Ok(()),
2981 Restore => {
2982 info!("link dir: {src} → {dst}");
2983 link::link_dir(src, dst, ctx.dir_mode)?;
2984 Ok(())
2985 }
2986 RelinkOnly => {
2987 info!("relink dir: {src} → {dst}");
2992 remove_dir_link_or_real(dst)?;
2993 link::link_dir(src, dst, ctx.dir_mode)?;
2994 Ok(())
2995 }
2996 AutoAbsorb | NeedsConfirm => {
2997 if !ctx.config.absorb.auto {
3018 return handle_anomaly_dir(
3019 src,
3020 dst,
3021 ctx,
3022 "absorb.auto = false; treating divergence as anomaly",
3023 );
3024 }
3025 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
3026 return handle_anomaly_dir(
3027 src,
3028 dst,
3029 ctx,
3030 "source repo is dirty; deferring auto-absorb",
3031 );
3032 }
3033 absorb_target_dir_into_source(src, dst, ctx)
3034 }
3035 }
3036}
3037
3038fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
3048 if let Err(unlink_err) = link::unlink(dst) {
3049 let meta = std::fs::symlink_metadata(dst)
3050 .with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
3051 let ft = meta.file_type();
3052 if ft.is_dir() && !ft.is_symlink() {
3053 std::fs::remove_dir_all(dst).with_context(|| {
3054 format!(
3055 "remove_dir_all({dst}) after link::unlink failed: \
3056 {unlink_err}"
3057 )
3058 })?;
3059 } else {
3060 return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
3061 }
3062 }
3063 Ok(())
3064}
3065
3066fn merge_dir_target_into_source(
3076 target: &Utf8Path,
3077 source: &Utf8Path,
3078 ctx: &ApplyCtx<'_>,
3079) -> Result<()> {
3080 for entry in std::fs::read_dir(target)? {
3081 let entry = entry?;
3082 let name_os = entry.file_name();
3083 let Some(name) = name_os.to_str() else {
3084 continue;
3085 };
3086 let target_path = target.join(name);
3087 let source_path = source.join(name);
3088 let ft = entry.file_type()?;
3089
3090 if ft.is_dir() && !ft.is_symlink() {
3091 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
3097 let sft = src_meta.file_type();
3098 if !sft.is_dir() || sft.is_symlink() {
3099 link::unlink(&source_path).with_context(|| {
3100 format!("remove conflicting source entry before dir merge: {source_path}")
3101 })?;
3102 }
3103 }
3104 if !source_path.exists() {
3105 std::fs::create_dir_all(&source_path).with_context(|| {
3106 format!("create_dir_all({source_path}) during target→source merge")
3107 })?;
3108 }
3109 merge_dir_target_into_source(&target_path, &source_path, ctx)?;
3110 } else if ft.is_file() {
3111 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
3115 let sft = src_meta.file_type();
3116 if sft.is_dir() && !sft.is_symlink() {
3117 remove_dir_link_or_real(&source_path).with_context(|| {
3118 format!("remove conflicting source dir before file merge: {source_path}")
3119 })?;
3120 } else if sft.is_symlink() {
3121 link::unlink(&source_path).with_context(|| {
3122 format!(
3123 "remove conflicting source symlink before file merge: {source_path}"
3124 )
3125 })?;
3126 }
3127 }
3128 if let Some(parent) = source_path.parent() {
3129 if !parent.exists() {
3130 std::fs::create_dir_all(parent)?;
3131 }
3132 }
3133 if source_path.is_file() {
3147 merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
3148 } else {
3149 std::fs::copy(&target_path, &source_path)
3150 .with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
3151 }
3152 } else {
3153 warn!(
3154 "merge: skipping non-regular entry {target_path} \
3155 (symlink / junction / special — content not copied)"
3156 );
3157 }
3158 }
3159 Ok(())
3160}
3161
3162fn merge_resolve_file_conflict(
3176 target_path: &Utf8Path,
3177 source_path: &Utf8Path,
3178 ctx: &ApplyCtx<'_>,
3179) -> Result<()> {
3180 use absorb::AbsorbDecision::*;
3181 let decision = absorb::classify(source_path, target_path)?;
3182 match decision {
3183 InSync | RelinkOnly => Ok(()),
3184 AutoAbsorb => {
3185 std::fs::copy(target_path, source_path).with_context(|| {
3186 format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
3187 })?;
3188 Ok(())
3189 }
3190 Restore => {
3191 unreachable!(
3198 "merge_resolve_file_conflict reached with both files present, \
3199 but classify returned Restore (target {target_path} / source {source_path})"
3200 )
3201 }
3202 NeedsConfirm => {
3203 use crate::config::AnomalyAction::*;
3204 match ctx.config.absorb.on_anomaly {
3205 Skip => {
3206 warn!(
3207 "merge anomaly skip: {target_path} (source-newer / content drift) \
3208 — keeping source version, target version dropped"
3209 );
3210 Ok(())
3211 }
3212 Force => {
3213 warn!(
3214 "merge anomaly force: {target_path} \
3215 (source-newer / content drift) — overwriting source"
3216 );
3217 std::fs::copy(target_path, source_path)?;
3218 Ok(())
3219 }
3220 Ask => {
3221 use std::io::IsTerminal;
3222 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
3223 if prompt_absorb_with_diff(
3224 source_path,
3225 target_path,
3226 "merge: file content differs and source is newer",
3227 )? {
3228 std::fs::copy(target_path, source_path)?;
3229 } else {
3230 warn!("merge: kept source version by user choice: {source_path}");
3231 }
3232 Ok(())
3233 } else {
3234 warn!(
3235 "merge anomaly skip (non-TTY ask mode): {target_path} \
3236 — keeping source version"
3237 );
3238 Ok(())
3239 }
3240 }
3241 }
3242 }
3243 }
3244}
3245
3246fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3253 info!("absorb dir: {dst} → {src}");
3254 backup_existing(src, ctx.backup_root, true)?;
3255 merge_dir_target_into_source(dst, src, ctx)?;
3256 remove_dir_link_or_real(dst)?;
3259 link::link_dir(src, dst, ctx.dir_mode)?;
3260 Ok(())
3261}
3262
3263fn handle_anomaly_dir(
3267 src: &Utf8Path,
3268 dst: &Utf8Path,
3269 ctx: &ApplyCtx<'_>,
3270 reason: &str,
3271) -> Result<()> {
3272 use crate::config::AnomalyAction::*;
3273 match ctx.config.absorb.on_anomaly {
3274 Skip => {
3275 warn!("anomaly skip dir: {dst} ({reason})");
3276 Ok(())
3277 }
3278 Force => {
3279 warn!(
3280 "anomaly force dir: {dst} ({reason}) \
3281 — absorbing target into source"
3282 );
3283 absorb_target_dir_into_source(src, dst, ctx)
3284 }
3285 Ask => {
3286 use std::io::IsTerminal;
3287 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
3288 eprintln!();
3289 eprintln!("anomaly: {dst}");
3290 eprintln!(" {reason}");
3291 eprintln!(" source: {src}");
3292 eprint!(" absorb target dir into source? (y/N) ");
3293 use std::io::{BufRead as _, Write as _};
3294 std::io::stderr().flush().ok();
3295 let mut buf = String::new();
3296 std::io::stdin().lock().read_line(&mut buf)?;
3297 let answer = buf.trim();
3298 if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
3299 absorb_target_dir_into_source(src, dst, ctx)
3300 } else {
3301 warn!("anomaly skipped by user: {dst}");
3302 Ok(())
3303 }
3304 } else {
3305 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
3306 Ok(())
3307 }
3308 }
3309 }
3310}
3311
3312fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
3313 let abs_target = absolutize(target)?;
3314 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
3315 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
3316 info!("backup → {bp}");
3317 if is_dir {
3318 backup::backup_dir(target, &bp)?;
3319 } else {
3320 backup::backup_file(target, &bp)?;
3321 }
3322 Ok(())
3323}
3324
3325fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
3326 if let Some(s) = source {
3327 return absolutize(&s);
3328 }
3329 if let Ok(s) = std::env::var("YUI_SOURCE") {
3330 return absolutize(Utf8Path::new(&s));
3331 }
3332 let cwd = current_dir_utf8()?;
3333 for ancestor in cwd.ancestors() {
3334 if ancestor.join("config.toml").is_file() {
3335 return Ok(ancestor.to_path_buf());
3336 }
3337 }
3338 if let Some(home) = paths::home_dir() {
3339 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
3340 let p = home.join(c);
3341 if p.join("config.toml").is_file() {
3342 return Ok(p);
3343 }
3344 }
3345 }
3346 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
3347}
3348
3349fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
3350 let expanded = paths::expand_tilde(p.as_str());
3352 if expanded.is_absolute() {
3353 return Ok(expanded);
3354 }
3355 let cwd = current_dir_utf8()?;
3356 Ok(cwd.join(expanded))
3357}
3358
3359fn current_dir_utf8() -> Result<Utf8PathBuf> {
3360 let cwd = std::env::current_dir().context("getting cwd")?;
3361 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
3362}
3363
3364const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
3368
3369[vars]
3370# user-defined values; templates can reference these as {{ vars.foo }}
3371
3372# [link]
3373# file_mode = "auto" # auto | symlink | hardlink
3374# dir_mode = "auto" # auto | symlink | junction
3375
3376[mount]
3377default_strategy = "marker"
3378
3379[[mount.entry]]
3380src = "home"
3381# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
3382dst = "~"
3383
3384# [[mount.entry]]
3385# src = "appdata"
3386# dst = "{{ env(name='APPDATA') }}"
3387# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
3388# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
3389# when = "yui.os == 'windows'"
3390"#;
3391
3392const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
3393# .yui/bin/ is intentionally tracked — it holds your hook scripts.
3394/.yui/state.json
3395/.yui/state.json.tmp
3396/.yui/backup/
3397
3398# >>> yui rendered (auto-managed, do not edit) >>>
3399# <<< yui rendered (auto-managed) <<<
3400
3401# config.local.toml is per-machine; commit a config.local.example.toml instead.
3402config.local.toml
3403"#;
3404
3405#[cfg(test)]
3406mod tests {
3407 use super::*;
3408 use tempfile::TempDir;
3409
3410 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
3411 Utf8PathBuf::from_path_buf(p).unwrap()
3412 }
3413
3414 fn toml_path(p: &Utf8Path) -> String {
3416 p.as_str().replace('\\', "/")
3417 }
3418
3419 #[test]
3420 fn apply_links_a_raw_file() {
3421 let tmp = TempDir::new().unwrap();
3422 let source = utf8(tmp.path().join("dotfiles"));
3423 let target = utf8(tmp.path().join("target"));
3424 std::fs::create_dir_all(source.join("home")).unwrap();
3425 std::fs::create_dir_all(&target).unwrap();
3426 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3427
3428 let cfg = format!(
3429 r#"
3430[[mount.entry]]
3431src = "home"
3432dst = "{}"
3433"#,
3434 toml_path(&target)
3435 );
3436 std::fs::write(source.join("config.toml"), cfg).unwrap();
3437
3438 apply(Some(source), false).unwrap();
3439
3440 let linked = target.join(".bashrc");
3441 assert!(linked.exists(), "expected {linked} to exist");
3442 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
3443 }
3444
3445 #[test]
3446 fn apply_with_marker_links_whole_directory() {
3447 let tmp = TempDir::new().unwrap();
3448 let source = utf8(tmp.path().join("dotfiles"));
3449 let target = utf8(tmp.path().join("target"));
3450 let nvim_src = source.join("home/nvim");
3451 std::fs::create_dir_all(&nvim_src).unwrap();
3452 std::fs::create_dir_all(&target).unwrap();
3453 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
3454 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
3455 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
3456
3457 let cfg = format!(
3458 r#"
3459[[mount.entry]]
3460src = "home"
3461dst = "{}"
3462"#,
3463 toml_path(&target)
3464 );
3465 std::fs::write(source.join("config.toml"), cfg).unwrap();
3466
3467 apply(Some(source.clone()), false).unwrap();
3468
3469 let nvim_dst = target.join("nvim");
3470 assert!(nvim_dst.exists());
3471 assert_eq!(
3472 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
3473 "-- hi\n"
3474 );
3475 }
3479
3480 #[test]
3481 fn apply_dry_run_does_not_write() {
3482 let tmp = TempDir::new().unwrap();
3483 let source = utf8(tmp.path().join("dotfiles"));
3484 let target = utf8(tmp.path().join("target"));
3485 std::fs::create_dir_all(source.join("home")).unwrap();
3486 std::fs::create_dir_all(&target).unwrap();
3487 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
3488
3489 let cfg = format!(
3490 r#"
3491[[mount.entry]]
3492src = "home"
3493dst = "{}"
3494"#,
3495 toml_path(&target)
3496 );
3497 std::fs::write(source.join("config.toml"), cfg).unwrap();
3498
3499 apply(Some(source), true).unwrap();
3500
3501 assert!(!target.join(".bashrc").exists());
3502 }
3503
3504 #[test]
3505 fn apply_renders_templates_then_links_rendered_outputs() {
3506 let tmp = TempDir::new().unwrap();
3507 let source = utf8(tmp.path().join("dotfiles"));
3508 let target = utf8(tmp.path().join("target"));
3509 std::fs::create_dir_all(source.join("home")).unwrap();
3510 std::fs::create_dir_all(&target).unwrap();
3511 std::fs::write(
3512 source.join("home/.gitconfig.tera"),
3513 "[user]\n os = {{ yui.os }}\n",
3514 )
3515 .unwrap();
3516 std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
3517
3518 let cfg = format!(
3519 r#"
3520[[mount.entry]]
3521src = "home"
3522dst = "{}"
3523"#,
3524 toml_path(&target)
3525 );
3526 std::fs::write(source.join("config.toml"), cfg).unwrap();
3527
3528 apply(Some(source.clone()), false).unwrap();
3529
3530 assert!(target.join(".bashrc").exists());
3532 assert!(source.join("home/.gitconfig").exists());
3534 assert!(target.join(".gitconfig").exists());
3535 assert!(!target.join(".gitconfig.tera").exists());
3537 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
3539 assert!(linked.contains("os = "));
3540 }
3541
3542 #[test]
3543 fn apply_marker_override_links_to_custom_dst() {
3544 let tmp = TempDir::new().unwrap();
3545 let source = utf8(tmp.path().join("dotfiles"));
3546 let target_a = utf8(tmp.path().join("target_a"));
3547 let target_b = utf8(tmp.path().join("target_b"));
3548 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3549 std::fs::create_dir_all(&target_a).unwrap();
3550 std::fs::create_dir_all(&target_b).unwrap();
3551 std::fs::write(
3552 source.join("home/.config/nvim/init.lua"),
3553 "-- nvim config\n",
3554 )
3555 .unwrap();
3556
3557 std::fs::write(
3560 source.join("home/.config/nvim/.yuilink"),
3561 format!(
3562 r#"
3563[[link]]
3564dst = "{}/nvim"
3565
3566[[link]]
3567dst = "{}/nvim"
3568when = "{{{{ yui.os == '{}' }}}}"
3569"#,
3570 toml_path(&target_a),
3571 toml_path(&target_b),
3572 std::env::consts::OS
3573 ),
3574 )
3575 .unwrap();
3576
3577 let parent_target = utf8(tmp.path().join("parent_target"));
3578 std::fs::create_dir_all(&parent_target).unwrap();
3579 let cfg = format!(
3580 r#"
3581[[mount.entry]]
3582src = "home"
3583dst = "{}"
3584"#,
3585 toml_path(&parent_target)
3586 );
3587 std::fs::write(source.join("config.toml"), cfg).unwrap();
3588
3589 apply(Some(source.clone()), false).unwrap();
3590
3591 assert!(
3593 target_a.join("nvim/init.lua").exists(),
3594 "target_a/nvim/init.lua should be reachable through the link"
3595 );
3596 assert!(
3597 target_b.join("nvim/init.lua").exists(),
3598 "target_b/nvim/init.lua should be reachable through the link"
3599 );
3600 assert!(
3603 !parent_target.join(".config/nvim").exists(),
3604 "parent mount should have skipped the marker-claimed sub-dir"
3605 );
3606 }
3607
3608 #[test]
3609 fn apply_marker_inactive_link_falls_through_to_default() {
3610 let tmp = TempDir::new().unwrap();
3615 let source = utf8(tmp.path().join("dotfiles"));
3616 let target_inactive = utf8(tmp.path().join("inactive"));
3617 let parent_target = utf8(tmp.path().join("parent"));
3618 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3619 std::fs::create_dir_all(&parent_target).unwrap();
3620 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
3621
3622 std::fs::write(
3624 source.join("home/.config/nvim/.yuilink"),
3625 format!(
3626 r#"
3627[[link]]
3628dst = "{}/nvim"
3629when = "{{{{ yui.os == 'no-such-os' }}}}"
3630"#,
3631 toml_path(&target_inactive)
3632 ),
3633 )
3634 .unwrap();
3635
3636 let cfg = format!(
3637 r#"
3638[[mount.entry]]
3639src = "home"
3640dst = "{}"
3641"#,
3642 toml_path(&parent_target)
3643 );
3644 std::fs::write(source.join("config.toml"), cfg).unwrap();
3645
3646 apply(Some(source.clone()), false).unwrap();
3647
3648 assert!(!target_inactive.join("nvim").exists());
3650 assert!(parent_target.join(".config/nvim/init.lua").exists());
3653 }
3654
3655 #[test]
3656 fn list_shows_mount_entries_and_marker_overrides() {
3657 let tmp = TempDir::new().unwrap();
3658 let source = utf8(tmp.path().join("dotfiles"));
3659 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3660 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
3661 std::fs::write(
3662 source.join("home/.config/nvim/.yuilink"),
3663 r#"
3664[[link]]
3665dst = "/custom/nvim"
3666"#,
3667 )
3668 .unwrap();
3669 std::fs::write(
3670 source.join("config.toml"),
3671 r#"
3672[[mount.entry]]
3673src = "home"
3674dst = "/h"
3675"#,
3676 )
3677 .unwrap();
3678
3679 list(Some(source), false, None, true).unwrap();
3682 }
3683
3684 #[test]
3685 fn status_reports_in_sync_after_apply() {
3686 let tmp = TempDir::new().unwrap();
3687 let source = utf8(tmp.path().join("dotfiles"));
3688 let target = utf8(tmp.path().join("target"));
3689 std::fs::create_dir_all(source.join("home")).unwrap();
3690 std::fs::create_dir_all(&target).unwrap();
3691 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3692 let cfg = format!(
3693 r#"
3694[[mount.entry]]
3695src = "home"
3696dst = "{}"
3697"#,
3698 toml_path(&target)
3699 );
3700 std::fs::write(source.join("config.toml"), cfg).unwrap();
3701 apply(Some(source.clone()), false).unwrap();
3703 status(Some(source), None, true).unwrap();
3705 }
3706
3707 #[test]
3708 fn status_reports_template_drift() {
3709 let tmp = TempDir::new().unwrap();
3710 let source = utf8(tmp.path().join("dotfiles"));
3711 let target = utf8(tmp.path().join("target"));
3712 std::fs::create_dir_all(source.join("home")).unwrap();
3713 std::fs::create_dir_all(&target).unwrap();
3714 std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
3717 std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
3718
3719 let cfg = format!(
3720 r#"
3721[[mount.entry]]
3722src = "home"
3723dst = "{}"
3724"#,
3725 toml_path(&target)
3726 );
3727 std::fs::write(source.join("config.toml"), cfg).unwrap();
3728
3729 let err = status(Some(source), None, true).unwrap_err();
3730 assert!(format!("{err}").contains("diverged"));
3731 }
3732
3733 #[test]
3734 fn status_fails_when_target_missing() {
3735 let tmp = TempDir::new().unwrap();
3736 let source = utf8(tmp.path().join("dotfiles"));
3737 let target = utf8(tmp.path().join("target"));
3738 std::fs::create_dir_all(source.join("home")).unwrap();
3739 std::fs::create_dir_all(&target).unwrap();
3740 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3741 let cfg = format!(
3742 r#"
3743[[mount.entry]]
3744src = "home"
3745dst = "{}"
3746"#,
3747 toml_path(&target)
3748 );
3749 std::fs::write(source.join("config.toml"), cfg).unwrap();
3750 let err = status(Some(source), None, true).unwrap_err();
3752 assert!(format!("{err}").contains("diverged"));
3753 }
3754
3755 #[test]
3756 fn strip_braces_removes_outer_template_braces() {
3757 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
3758 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
3759 assert_eq!(strip_braces(" {{x}} "), "x");
3760 }
3761
3762 #[test]
3763 fn apply_aborts_on_render_drift() {
3764 let tmp = TempDir::new().unwrap();
3765 let source = utf8(tmp.path().join("dotfiles"));
3766 let target = utf8(tmp.path().join("target"));
3767 std::fs::create_dir_all(source.join("home")).unwrap();
3768 std::fs::create_dir_all(&target).unwrap();
3769 std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
3770 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
3771
3772 let cfg = format!(
3773 r#"
3774[[mount.entry]]
3775src = "home"
3776dst = "{}"
3777"#,
3778 toml_path(&target)
3779 );
3780 std::fs::write(source.join("config.toml"), cfg).unwrap();
3781
3782 let err = apply(Some(source.clone()), false).unwrap_err();
3783 assert!(format!("{err}").contains("drift"));
3784 assert_eq!(
3786 std::fs::read_to_string(source.join("home/foo")).unwrap(),
3787 "manually edited"
3788 );
3789 assert!(!target.join("foo").exists());
3791 }
3792
3793 #[test]
3794 fn init_creates_skeleton_when_dir_empty() {
3795 let tmp = TempDir::new().unwrap();
3796 let dir = utf8(tmp.path().join("new_dotfiles"));
3797 init(Some(dir.clone()), false).unwrap();
3798 assert!(dir.join("config.toml").is_file());
3799 assert!(dir.join(".gitignore").is_file());
3800 }
3801
3802 #[test]
3803 fn init_refuses_to_overwrite_existing_config() {
3804 let tmp = TempDir::new().unwrap();
3805 let dir = utf8(tmp.path().join("dotfiles"));
3806 std::fs::create_dir_all(&dir).unwrap();
3807 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
3808 let err = init(Some(dir), false).unwrap_err();
3809 assert!(format!("{err}").contains("already exists"));
3810 }
3811
3812 #[test]
3818 fn init_appends_missing_gitignore_entries_into_existing_file() {
3819 let tmp = TempDir::new().unwrap();
3820 let dir = utf8(tmp.path().join("dotfiles"));
3821 std::fs::create_dir_all(&dir).unwrap();
3822 let user_gitignore = "# user entries\n*.swp\nnode_modules/\n";
3824 std::fs::write(dir.join(".gitignore"), user_gitignore).unwrap();
3825
3826 init(Some(dir.clone()), false).unwrap();
3827
3828 let body = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
3829 assert!(body.contains("*.swp"));
3831 assert!(body.contains("node_modules/"));
3832 assert!(body.contains("/.yui/state.json"));
3834 assert!(body.contains("/.yui/backup/"));
3835 assert!(body.contains("config.local.toml"));
3836 let before_rerun = body.clone();
3838 std::fs::remove_file(dir.join("config.toml")).unwrap();
3841 init(Some(dir.clone()), false).unwrap();
3842 let after_rerun = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
3843 assert_eq!(
3844 before_rerun, after_rerun,
3845 "init must be idempotent when the gitignore already has every yui entry"
3846 );
3847 }
3848
3849 #[test]
3855 fn init_with_git_hooks_installs_into_existing_repo() {
3856 let tmp = TempDir::new().unwrap();
3857 let dir = utf8(tmp.path().join("dotfiles"));
3858 std::fs::create_dir_all(&dir).unwrap();
3859 let st = std::process::Command::new("git")
3860 .args(["init", "-q"])
3861 .current_dir(dir.as_std_path())
3862 .status()
3863 .expect("git init");
3864 if !st.success() {
3865 return;
3866 }
3867 let user_config = "# user already wrote this\n";
3869 std::fs::write(dir.join("config.toml"), user_config).unwrap();
3870
3871 init(Some(dir.clone()), true).unwrap();
3873
3874 assert_eq!(
3875 std::fs::read_to_string(dir.join("config.toml")).unwrap(),
3876 user_config
3877 );
3878 assert!(dir.join(".git/hooks/pre-commit").is_file());
3879 assert!(dir.join(".git/hooks/pre-push").is_file());
3880 }
3881
3882 #[test]
3887 fn init_with_git_hooks_writes_pre_commit_and_pre_push() {
3888 let tmp = TempDir::new().unwrap();
3889 let dir = utf8(tmp.path().join("dotfiles"));
3890 std::fs::create_dir_all(&dir).unwrap();
3891 let st = std::process::Command::new("git")
3893 .args(["init", "-q"])
3894 .current_dir(dir.as_std_path())
3895 .status()
3896 .expect("git init");
3897 if !st.success() {
3898 eprintln!("skipping: git not available");
3900 return;
3901 }
3902 init(Some(dir.clone()), true).unwrap();
3903
3904 let pre_commit = dir.join(".git/hooks/pre-commit");
3905 let pre_push = dir.join(".git/hooks/pre-push");
3906 assert!(pre_commit.is_file(), "pre-commit hook should be written");
3907 assert!(pre_push.is_file(), "pre-push hook should be written");
3908
3909 let body = std::fs::read_to_string(&pre_commit).unwrap();
3910 assert!(
3911 body.contains("yui render --check"),
3912 "pre-commit hook should call `yui render --check`, got: {body}"
3913 );
3914 }
3915
3916 #[test]
3920 fn init_with_git_hooks_errors_outside_a_git_repo() {
3921 let tmp = TempDir::new().unwrap();
3922 let dir = utf8(tmp.path().join("not-a-repo"));
3923 std::fs::create_dir_all(&dir).unwrap();
3924 let err = init(Some(dir), true).unwrap_err();
3925 let msg = format!("{err:#}");
3926 assert!(
3927 msg.contains("git repo") || msg.contains("git rev-parse"),
3928 "expected error to mention the git issue, got: {msg}"
3929 );
3930 }
3931
3932 #[test]
3935 fn init_with_git_hooks_does_not_clobber_existing_hooks() {
3936 let tmp = TempDir::new().unwrap();
3937 let dir = utf8(tmp.path().join("dotfiles"));
3938 std::fs::create_dir_all(&dir).unwrap();
3939 let st = std::process::Command::new("git")
3940 .args(["init", "-q"])
3941 .current_dir(dir.as_std_path())
3942 .status()
3943 .expect("git init");
3944 if !st.success() {
3945 return;
3946 }
3947 let hooks = dir.join(".git/hooks");
3948 std::fs::create_dir_all(&hooks).unwrap();
3949 std::fs::write(hooks.join("pre-commit"), "#! /bin/sh\nexit 0\n").unwrap();
3950
3951 init(Some(dir.clone()), true).unwrap();
3952
3953 let pc = std::fs::read_to_string(hooks.join("pre-commit")).unwrap();
3955 assert!(
3956 !pc.contains("yui render --check"),
3957 "existing pre-commit must not be overwritten"
3958 );
3959 let pp = std::fs::read_to_string(hooks.join("pre-push")).unwrap();
3960 assert!(
3961 pp.contains("yui render --check"),
3962 "missing pre-push should be written: {pp}"
3963 );
3964 }
3965
3966 fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
3969 let source = utf8(tmp.path().join("dotfiles"));
3970 let target = utf8(tmp.path().join("target"));
3971 std::fs::create_dir_all(source.join("home")).unwrap();
3972 std::fs::create_dir_all(&target).unwrap();
3973 let cfg = format!(
3974 r#"
3975[[mount.entry]]
3976src = "home"
3977dst = "{}"
3978"#,
3979 toml_path(&target)
3980 );
3981 std::fs::write(source.join("config.toml"), cfg).unwrap();
3982 (source, target)
3983 }
3984
3985 fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
3986 std::fs::write(path, body).unwrap();
3987 let f = std::fs::OpenOptions::new()
3988 .write(true)
3989 .open(path)
3990 .expect("open writable");
3991 f.set_modified(when).expect("set_modified");
3992 }
3993
3994 #[test]
3995 fn apply_target_newer_absorbs_target_into_source() {
3996 let tmp = TempDir::new().unwrap();
4000 let (source, target) = setup_minimal_dotfiles(&tmp);
4001
4002 let now = std::time::SystemTime::now();
4003 let past = now - std::time::Duration::from_secs(120);
4004 write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
4005 write_with_mtime(&target.join(".bashrc"), "user's edit", now);
4007
4008 apply(Some(source.clone()), false).unwrap();
4009
4010 assert_eq!(
4012 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4013 "user's edit"
4014 );
4015 assert_eq!(
4017 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4018 "user's edit"
4019 );
4020 let backup_root = source.join(".yui/backup");
4022 let mut found_old = false;
4023 for entry in walkdir(&backup_root) {
4024 if let Ok(s) = std::fs::read_to_string(&entry) {
4025 if s == "default from repo" {
4026 found_old = true;
4027 break;
4028 }
4029 }
4030 }
4031 assert!(found_old, "expected backup containing 'default from repo'");
4032 }
4033
4034 #[test]
4035 fn apply_in_sync_target_is_a_no_op() {
4036 let tmp = TempDir::new().unwrap();
4039 let (source, target) = setup_minimal_dotfiles(&tmp);
4040 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4041 apply(Some(source.clone()), false).unwrap();
4042 let backup_root = source.join(".yui/backup");
4043 let backup_count_after_first = walkdir(&backup_root).len();
4044
4045 apply(Some(source.clone()), false).unwrap();
4047 assert_eq!(
4048 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4049 "echo hi\n"
4050 );
4051 let backup_count_after_second = walkdir(&backup_root).len();
4052 assert_eq!(
4053 backup_count_after_first, backup_count_after_second,
4054 "second apply on an in-sync tree should not produce backups"
4055 );
4056 }
4057
4058 #[test]
4059 fn apply_skip_policy_leaves_anomaly_alone() {
4060 let tmp = TempDir::new().unwrap();
4063 let source = utf8(tmp.path().join("dotfiles"));
4064 let target = utf8(tmp.path().join("target"));
4065 std::fs::create_dir_all(source.join("home")).unwrap();
4066 std::fs::create_dir_all(&target).unwrap();
4067 let cfg = format!(
4068 r#"
4069[absorb]
4070on_anomaly = "skip"
4071
4072[[mount.entry]]
4073src = "home"
4074dst = "{}"
4075"#,
4076 toml_path(&target)
4077 );
4078 std::fs::write(source.join("config.toml"), cfg).unwrap();
4079
4080 let now = std::time::SystemTime::now();
4081 let past = now - std::time::Duration::from_secs(120);
4082 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4083 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4084
4085 apply(Some(source.clone()), false).unwrap();
4086
4087 assert_eq!(
4089 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4090 "user's edit (older)"
4091 );
4092 assert_eq!(
4094 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4095 "fresh from upstream"
4096 );
4097 }
4098
4099 #[test]
4100 fn apply_force_policy_absorbs_anomaly_anyway() {
4101 let tmp = TempDir::new().unwrap();
4103 let source = utf8(tmp.path().join("dotfiles"));
4104 let target = utf8(tmp.path().join("target"));
4105 std::fs::create_dir_all(source.join("home")).unwrap();
4106 std::fs::create_dir_all(&target).unwrap();
4107 let cfg = format!(
4108 r#"
4109[absorb]
4110on_anomaly = "force"
4111
4112[[mount.entry]]
4113src = "home"
4114dst = "{}"
4115"#,
4116 toml_path(&target)
4117 );
4118 std::fs::write(source.join("config.toml"), cfg).unwrap();
4119
4120 let now = std::time::SystemTime::now();
4121 let past = now - std::time::Duration::from_secs(120);
4122 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4123 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4124
4125 apply(Some(source.clone()), false).unwrap();
4126
4127 assert_eq!(
4129 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4130 "user's edit (older)"
4131 );
4132 assert_eq!(
4133 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4134 "user's edit (older)"
4135 );
4136 }
4137
4138 #[test]
4150 fn apply_absorbs_non_empty_target_dir_target_wins() {
4151 let tmp = TempDir::new().unwrap();
4152 let source = utf8(tmp.path().join("dotfiles"));
4153 let target = utf8(tmp.path().join("target"));
4154 std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
4155 std::fs::create_dir_all(target.join(".config/app")).unwrap();
4156 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4159 std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
4160 std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
4162 std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
4165 std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
4166
4167 let cfg = format!(
4168 r#"
4169[absorb]
4170on_anomaly = "force"
4171
4172[[mount.entry]]
4173src = "home"
4174dst = "{}"
4175"#,
4176 toml_path(&target)
4177 );
4178 std::fs::write(source.join("config.toml"), cfg).unwrap();
4179
4180 apply(Some(source.clone()), false).unwrap();
4182
4183 assert_eq!(
4185 std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
4186 "target side"
4187 );
4188 assert_eq!(
4190 std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
4191 "{}"
4192 );
4193 let backup_root = source.join(".yui/backup");
4196 let mut backup_files: Vec<String> = Vec::new();
4197 for entry in walkdir(&backup_root) {
4198 if let Some(n) = entry.file_name() {
4199 backup_files.push(n.to_string());
4200 }
4201 }
4202 assert!(
4203 backup_files.iter().any(|f| f == "config.toml"),
4204 "expected source's config.toml to land in the backup tree, got {backup_files:?}"
4205 );
4206 assert!(
4208 source.join("home/.config/app/source-only.toml").exists(),
4209 "source-only file should survive a target-wins merge"
4210 );
4211 assert!(
4213 source.join("home/.config/app/state.json").exists(),
4214 "target-only state.json should be merged into source"
4215 );
4216 }
4217
4218 #[test]
4224 fn marker_dir_absorbs_with_default_ask_policy() {
4225 let tmp = TempDir::new().unwrap();
4226 let source = utf8(tmp.path().join("dotfiles"));
4227 let target = utf8(tmp.path().join("target"));
4228 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4229 std::fs::create_dir_all(target.join(".config/gh")).unwrap();
4230 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4232 std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\n").unwrap();
4234
4235 let cfg = format!(
4239 r#"
4240[[mount.entry]]
4241src = "home"
4242dst = "{}"
4243"#,
4244 toml_path(&target)
4245 );
4246 std::fs::write(source.join("config.toml"), cfg).unwrap();
4247
4248 apply(Some(source.clone()), false).unwrap();
4252
4253 assert!(target.join(".config/gh/hosts.yml").exists());
4256 assert!(source.join("home/.config/gh/hosts.yml").exists());
4257 }
4258
4259 #[test]
4265 fn merge_handles_file_vs_dir_collisions_target_wins() {
4266 let tmp = TempDir::new().unwrap();
4267 let source = utf8(tmp.path().join("dotfiles"));
4268 let target = utf8(tmp.path().join("target"));
4269 std::fs::create_dir_all(source.join("home/.config/foo")).unwrap();
4270 std::fs::create_dir_all(target.join(".config")).unwrap();
4271 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4272
4273 std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
4275 std::fs::write(target.join(".config/foo"), "target file body").unwrap();
4276 std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
4278 std::fs::create_dir_all(target.join(".config/bar")).unwrap();
4279 std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
4280
4281 let cfg = format!(
4282 r#"
4283[absorb]
4284on_anomaly = "force"
4285
4286[[mount.entry]]
4287src = "home"
4288dst = "{}"
4289"#,
4290 toml_path(&target)
4291 );
4292 std::fs::write(source.join("config.toml"), cfg).unwrap();
4293 apply(Some(source.clone()), false).unwrap();
4294
4295 let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
4299 assert!(foo_meta.file_type().is_file(), "foo should be a file");
4300 assert_eq!(
4301 std::fs::read_to_string(target.join(".config/foo")).unwrap(),
4302 "target file body"
4303 );
4304 let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
4306 assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
4307 assert_eq!(
4308 std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
4309 "target nested"
4310 );
4311 }
4312
4313 #[test]
4317 fn merge_per_file_target_newer_auto_absorbs() {
4318 let tmp = TempDir::new().unwrap();
4319 let source = utf8(tmp.path().join("dotfiles"));
4320 let target = utf8(tmp.path().join("target"));
4321 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4322 std::fs::create_dir_all(target.join(".config")).unwrap();
4323 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4324
4325 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4327 write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
4328 std::fs::write(target.join(".config/app.toml"), "user's live edit").unwrap();
4329
4330 let cfg = format!(
4334 r#"
4335[[mount.entry]]
4336src = "home"
4337dst = "{}"
4338"#,
4339 toml_path(&target)
4340 );
4341 std::fs::write(source.join("config.toml"), cfg).unwrap();
4342 apply(Some(source.clone()), false).unwrap();
4343
4344 assert_eq!(
4346 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4347 "user's live edit"
4348 );
4349 }
4350
4351 #[test]
4357 fn merge_per_file_source_newer_skip_keeps_source() {
4358 let tmp = TempDir::new().unwrap();
4359 let source = utf8(tmp.path().join("dotfiles"));
4360 let target = utf8(tmp.path().join("target"));
4361 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4362 std::fs::create_dir_all(target.join(".config")).unwrap();
4363 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4364
4365 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4367 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
4368 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
4369
4370 let cfg = format!(
4371 r#"
4372[absorb]
4373on_anomaly = "skip"
4374
4375[[mount.entry]]
4376src = "home"
4377dst = "{}"
4378"#,
4379 toml_path(&target)
4380 );
4381 std::fs::write(source.join("config.toml"), cfg).unwrap();
4382 apply(Some(source.clone()), false).unwrap();
4383
4384 assert_eq!(
4387 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4388 "fresh source"
4389 );
4390 }
4391
4392 #[test]
4395 fn merge_per_file_source_newer_force_overwrites_source() {
4396 let tmp = TempDir::new().unwrap();
4397 let source = utf8(tmp.path().join("dotfiles"));
4398 let target = utf8(tmp.path().join("target"));
4399 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4400 std::fs::create_dir_all(target.join(".config")).unwrap();
4401 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4402
4403 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4404 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
4405 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
4406
4407 let cfg = format!(
4408 r#"
4409[absorb]
4410on_anomaly = "force"
4411
4412[[mount.entry]]
4413src = "home"
4414dst = "{}"
4415"#,
4416 toml_path(&target)
4417 );
4418 std::fs::write(source.join("config.toml"), cfg).unwrap();
4419 apply(Some(source.clone()), false).unwrap();
4420
4421 assert_eq!(
4423 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4424 "old target"
4425 );
4426 }
4427
4428 #[test]
4433 fn merge_per_file_identical_content_is_noop() {
4434 let tmp = TempDir::new().unwrap();
4435 let source = utf8(tmp.path().join("dotfiles"));
4436 let target = utf8(tmp.path().join("target"));
4437 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4438 std::fs::create_dir_all(target.join(".config")).unwrap();
4439 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4440 std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
4441 std::fs::write(target.join(".config/app.toml"), "same").unwrap();
4442
4443 let cfg = format!(
4446 r#"
4447[[mount.entry]]
4448src = "home"
4449dst = "{}"
4450"#,
4451 toml_path(&target)
4452 );
4453 std::fs::write(source.join("config.toml"), cfg).unwrap();
4454 apply(Some(source.clone()), false).unwrap();
4455
4456 assert_eq!(
4457 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4458 "same"
4459 );
4460 }
4461
4462 #[test]
4463 fn manual_absorb_command_pulls_target_into_source() {
4464 let tmp = TempDir::new().unwrap();
4466 let source = utf8(tmp.path().join("dotfiles"));
4467 let target = utf8(tmp.path().join("target"));
4468 std::fs::create_dir_all(source.join("home")).unwrap();
4469 std::fs::create_dir_all(&target).unwrap();
4470 let cfg = format!(
4472 r#"
4473[absorb]
4474on_anomaly = "skip"
4475
4476[[mount.entry]]
4477src = "home"
4478dst = "{}"
4479"#,
4480 toml_path(&target)
4481 );
4482 std::fs::write(source.join("config.toml"), cfg).unwrap();
4483 std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
4484 std::fs::write(source.join("home/.bashrc"), "default").unwrap();
4485
4486 absorb(
4489 Some(source.clone()),
4490 target.join(".bashrc"),
4491 false,
4492 true,
4493 )
4494 .unwrap();
4495
4496 assert_eq!(
4498 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4499 "user picked this"
4500 );
4501 }
4502
4503 #[test]
4504 fn manual_absorb_errors_when_target_outside_known_mounts() {
4505 let tmp = TempDir::new().unwrap();
4506 let (source, _target) = setup_minimal_dotfiles(&tmp);
4507 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
4508 let stranger = utf8(tmp.path().join("not-managed/foo"));
4509 std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
4510 std::fs::write(&stranger, "not yui's").unwrap();
4511 let err = absorb(Some(source), stranger, false, true).unwrap_err();
4512 assert!(format!("{err}").contains("no mount entry"));
4513 }
4514
4515 #[test]
4516 fn yuiignore_excludes_file_from_linking() {
4517 let tmp = TempDir::new().unwrap();
4518 let (source, target) = setup_minimal_dotfiles(&tmp);
4519 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
4520 std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
4521 std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
4523 apply(Some(source.clone()), false).unwrap();
4524 assert!(target.join(".bashrc").exists());
4525 assert!(
4526 !target.join("lock.json").exists(),
4527 "yuiignore should keep lock.json out of target"
4528 );
4529 }
4530
4531 #[test]
4532 fn yuiignore_excludes_directory_subtree() {
4533 let tmp = TempDir::new().unwrap();
4534 let (source, target) = setup_minimal_dotfiles(&tmp);
4535 std::fs::create_dir_all(source.join("home/cache")).unwrap();
4536 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
4537 std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
4538 std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
4539 std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
4541 apply(Some(source.clone()), false).unwrap();
4542 assert!(target.join(".bashrc").exists());
4543 assert!(
4544 !target.join("cache").exists(),
4545 "yuiignore'd subtree should not appear in target"
4546 );
4547 }
4548
4549 #[test]
4550 fn yuiignore_negation_re_includes_file() {
4551 let tmp = TempDir::new().unwrap();
4552 let (source, target) = setup_minimal_dotfiles(&tmp);
4553 std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
4554 std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
4555 std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
4557 apply(Some(source.clone()), false).unwrap();
4558 assert!(target.join("keep.cache").exists());
4559 assert!(!target.join("drop.cache").exists());
4560 }
4561
4562 #[test]
4567 fn nested_yuiignore_only_affects_its_subtree() {
4568 let tmp = TempDir::new().unwrap();
4569 let (source, target) = setup_minimal_dotfiles(&tmp);
4570 std::fs::create_dir_all(source.join("home/inner")).unwrap();
4571 std::fs::write(source.join("home/secret.txt"), "outer keep").unwrap();
4572 std::fs::write(source.join("home/inner/secret.txt"), "inner drop").unwrap();
4573 std::fs::write(source.join("home/inner/keep.txt"), "inner keep").unwrap();
4574 std::fs::write(source.join("home/inner/.yuiignore"), "secret*\n").unwrap();
4576 apply(Some(source.clone()), false).unwrap();
4577 assert!(
4578 target.join("secret.txt").exists(),
4579 "outer secret.txt is outside the nested .yuiignore scope"
4580 );
4581 assert!(target.join("inner/keep.txt").exists());
4582 assert!(
4583 !target.join("inner/secret.txt").exists(),
4584 "inner secret.txt should be excluded by the nested .yuiignore"
4585 );
4586 }
4587
4588 #[test]
4592 fn nested_yuiignore_negation_overrides_root_rule() {
4593 let tmp = TempDir::new().unwrap();
4594 let (source, target) = setup_minimal_dotfiles(&tmp);
4595 std::fs::create_dir_all(source.join("home/keepers")).unwrap();
4596 std::fs::write(source.join("home/drop.lock"), "outer drop").unwrap();
4597 std::fs::write(source.join("home/keepers/wanted.lock"), "inner keep").unwrap();
4598 std::fs::write(source.join(".yuiignore"), "*.lock\n").unwrap();
4599 std::fs::write(source.join("home/keepers/.yuiignore"), "!*.lock\n").unwrap();
4601 apply(Some(source.clone()), false).unwrap();
4602 assert!(
4603 !target.join("drop.lock").exists(),
4604 "root rule still drops outer .lock file"
4605 );
4606 assert!(
4607 target.join("keepers/wanted.lock").exists(),
4608 "nested negation re-includes .lock under keepers/"
4609 );
4610 }
4611
4612 #[test]
4616 fn nested_yuiignore_status_walk_scoped() {
4617 let tmp = TempDir::new().unwrap();
4618 let (source, _target) = setup_minimal_dotfiles(&tmp);
4619 std::fs::create_dir_all(source.join("home/a")).unwrap();
4620 std::fs::create_dir_all(source.join("home/b")).unwrap();
4621 std::fs::write(source.join("home/a/foo.txt"), "a-foo").unwrap();
4622 std::fs::write(source.join("home/b/foo.txt"), "b-foo").unwrap();
4623 std::fs::write(source.join("home/a/.yuiignore"), "foo.txt\n").unwrap();
4625 apply(Some(source.clone()), false).unwrap();
4626 let res = status(Some(source), None, true);
4628 assert!(res.is_ok() || matches!(&res, Err(e) if format!("{e}").contains("diverged")));
4629 }
4630
4631 #[test]
4632 fn yuiignore_skips_template_in_render() {
4633 let tmp = TempDir::new().unwrap();
4634 let source = utf8(tmp.path().join("dotfiles"));
4635 let target = utf8(tmp.path().join("target"));
4636 std::fs::create_dir_all(source.join("home")).unwrap();
4637 std::fs::create_dir_all(&target).unwrap();
4638 std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
4639 std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
4640 let cfg = format!(
4641 r#"
4642[[mount.entry]]
4643src = "home"
4644dst = "{}"
4645"#,
4646 toml_path(&target)
4647 );
4648 std::fs::write(source.join("config.toml"), cfg).unwrap();
4649 apply(Some(source.clone()), false).unwrap();
4650 assert!(!source.join("home/note").exists());
4652 assert!(!target.join("note").exists());
4653 assert!(!target.join("note.tera").exists());
4654 }
4655
4656 #[test]
4660 fn nested_marker_accumulates_extra_dst() {
4661 let tmp = TempDir::new().unwrap();
4662 let source = utf8(tmp.path().join("dotfiles"));
4663 let parent_target = utf8(tmp.path().join("home"));
4664 let extra_target = utf8(tmp.path().join("extra"));
4665 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
4666 std::fs::create_dir_all(&parent_target).unwrap();
4667 std::fs::create_dir_all(&extra_target).unwrap();
4668 std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
4669
4670 std::fs::write(
4672 source.join("home/.config/.yuilink"),
4673 format!(
4674 r#"
4675[[link]]
4676dst = "{}/.config"
4677"#,
4678 toml_path(&parent_target)
4679 ),
4680 )
4681 .unwrap();
4682 std::fs::write(
4685 source.join("home/.config/nvim/.yuilink"),
4686 format!(
4687 r#"
4688[[link]]
4689dst = "{}/nvim"
4690when = "{{{{ yui.os == '{}' }}}}"
4691"#,
4692 toml_path(&extra_target),
4693 std::env::consts::OS
4694 ),
4695 )
4696 .unwrap();
4697
4698 let cfg = format!(
4699 r#"
4700[[mount.entry]]
4701src = "home"
4702dst = "{}"
4703"#,
4704 toml_path(&parent_target)
4705 );
4706 std::fs::write(source.join("config.toml"), cfg).unwrap();
4707
4708 apply(Some(source.clone()), false).unwrap();
4709
4710 assert!(parent_target.join(".config/nvim/init.lua").exists());
4713 assert!(extra_target.join("nvim/init.lua").exists());
4714 }
4715
4716 #[test]
4721 fn marker_file_link_targets_specific_file() {
4722 let tmp = TempDir::new().unwrap();
4723 let source = utf8(tmp.path().join("dotfiles"));
4724 let parent_target = utf8(tmp.path().join("home"));
4725 let docs_target = utf8(tmp.path().join("docs"));
4726 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
4727 std::fs::create_dir_all(&parent_target).unwrap();
4728 std::fs::create_dir_all(&docs_target).unwrap();
4729 std::fs::write(
4730 source.join("home/.config/powershell/profile.ps1"),
4731 "# profile\n",
4732 )
4733 .unwrap();
4734 std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
4735
4736 std::fs::write(
4739 source.join("home/.config/powershell/.yuilink"),
4740 format!(
4741 r#"
4742[[link]]
4743src = "profile.ps1"
4744dst = "{}/Microsoft.PowerShell_profile.ps1"
4745"#,
4746 toml_path(&docs_target)
4747 ),
4748 )
4749 .unwrap();
4750
4751 let cfg = format!(
4752 r#"
4753[[mount.entry]]
4754src = "home"
4755dst = "{}"
4756"#,
4757 toml_path(&parent_target)
4758 );
4759 std::fs::write(source.join("config.toml"), cfg).unwrap();
4760
4761 apply(Some(source.clone()), false).unwrap();
4762
4763 assert!(
4765 docs_target
4766 .join("Microsoft.PowerShell_profile.ps1")
4767 .exists()
4768 );
4769 assert!(
4772 parent_target
4773 .join(".config/powershell/profile.ps1")
4774 .exists()
4775 );
4776 assert!(parent_target.join(".config/powershell/extra.txt").exists());
4777 }
4778
4779 #[test]
4782 fn marker_file_link_missing_src_errors() {
4783 let tmp = TempDir::new().unwrap();
4784 let source = utf8(tmp.path().join("dotfiles"));
4785 let parent_target = utf8(tmp.path().join("home"));
4786 let docs_target = utf8(tmp.path().join("docs"));
4787 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
4788 std::fs::create_dir_all(&parent_target).unwrap();
4789 std::fs::create_dir_all(&docs_target).unwrap();
4790
4791 std::fs::write(
4792 source.join("home/.config/powershell/.yuilink"),
4793 format!(
4794 r#"
4795[[link]]
4796src = "missing.ps1"
4797dst = "{}/profile.ps1"
4798"#,
4799 toml_path(&docs_target)
4800 ),
4801 )
4802 .unwrap();
4803
4804 let cfg = format!(
4805 r#"
4806[[mount.entry]]
4807src = "home"
4808dst = "{}"
4809"#,
4810 toml_path(&parent_target)
4811 );
4812 std::fs::write(source.join("config.toml"), cfg).unwrap();
4813
4814 let err = apply(Some(source.clone()), false).unwrap_err();
4815 assert!(format!("{err:#}").contains("missing.ps1"));
4816 }
4817
4818 #[test]
4827 fn unmanaged_finds_files_outside_any_mount() {
4828 let tmp = TempDir::new().unwrap();
4829 let (source, _target) = setup_minimal_dotfiles(&tmp);
4830 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
4832 std::fs::write(source.join("orphan.txt"), "y").unwrap();
4834 std::fs::create_dir_all(source.join("notes")).unwrap();
4835 std::fs::write(source.join("notes/scratch.md"), "z").unwrap();
4836
4837 unmanaged(Some(source.clone()), None, true).unwrap();
4839
4840 let yui = YuiVars::detect(&source);
4842 let cfg = config::load(&source, &yui).unwrap();
4843 let mount_srcs: Vec<Utf8PathBuf> = cfg
4844 .mount
4845 .entry
4846 .iter()
4847 .map(|m| source.join(&m.src))
4848 .collect();
4849 let walker = paths::source_walker(&source).build();
4850 let mut unmanaged_paths = Vec::new();
4851 for entry in walker.flatten() {
4852 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
4853 continue;
4854 }
4855 let p = match Utf8PathBuf::from_path_buf(entry.path().to_path_buf()) {
4856 Ok(p) => p,
4857 Err(_) => continue,
4858 };
4859 if is_repo_meta(&p, &source, &cfg.mount.marker_filename) {
4860 continue;
4861 }
4862 if mount_srcs.iter().any(|m| p.starts_with(m)) {
4863 continue;
4864 }
4865 unmanaged_paths.push(p);
4866 }
4867 let names: Vec<String> = unmanaged_paths
4868 .iter()
4869 .filter_map(|p| p.file_name().map(String::from))
4870 .collect();
4871 assert!(names.contains(&"orphan.txt".into()));
4872 assert!(names.contains(&"scratch.md".into()));
4873 assert!(!names.contains(&".bashrc".into()), "mount-claimed file");
4874 assert!(!names.contains(&"config.toml".into()), "repo meta");
4875 }
4876
4877 #[test]
4878 fn is_repo_meta_recognises_yui_scaffold() {
4879 let source = Utf8Path::new("/dot");
4880 assert!(is_repo_meta(
4882 Utf8Path::new("/dot/config.toml"),
4883 source,
4884 ".yuilink",
4885 ));
4886 assert!(is_repo_meta(
4887 Utf8Path::new("/dot/config.local.toml"),
4888 source,
4889 ".yuilink",
4890 ));
4891 assert!(is_repo_meta(
4892 Utf8Path::new("/dot/config.linux.toml"),
4893 source,
4894 ".yuilink",
4895 ));
4896 assert!(is_repo_meta(
4897 Utf8Path::new("/dot/config.local.example.toml"),
4898 source,
4899 ".yuilink",
4900 ));
4901 assert!(is_repo_meta(
4903 Utf8Path::new("/dot/.gitignore"),
4904 source,
4905 ".yuilink",
4906 ));
4907 assert!(is_repo_meta(
4909 Utf8Path::new("/dot/home/.config/foo/.yuilink"),
4910 source,
4911 ".yuilink",
4912 ));
4913 assert!(is_repo_meta(
4914 Utf8Path::new("/dot/home/.gitconfig.tera"),
4915 source,
4916 ".yuilink",
4917 ));
4918 assert!(!is_repo_meta(
4920 Utf8Path::new("/dot/home/.config/myapp/config.toml"),
4921 source,
4922 ".yuilink",
4923 ));
4924 assert!(!is_repo_meta(
4928 Utf8Path::new("/dot/home/.config/git/.gitignore"),
4929 source,
4930 ".yuilink",
4931 ));
4932 }
4933
4934 #[test]
4941 fn unmanaged_respects_inactive_mount_entries() {
4942 let tmp = TempDir::new().unwrap();
4943 let source = utf8(tmp.path().join("dotfiles"));
4944 let target = utf8(tmp.path().join("target"));
4945 std::fs::create_dir_all(source.join("home_active")).unwrap();
4946 std::fs::create_dir_all(source.join("home_other_os")).unwrap();
4947 std::fs::create_dir_all(&target).unwrap();
4948 std::fs::write(source.join("home_active/.bashrc"), "active").unwrap();
4949 std::fs::write(source.join("home_other_os/.bashrc"), "inactive").unwrap();
4950 let cfg = format!(
4952 r#"
4953[[mount.entry]]
4954src = "home_active"
4955dst = "{target}"
4956
4957[[mount.entry]]
4958src = "home_other_os"
4959dst = "{target}"
4960when = "yui.os == 'definitely_not_a_real_os'"
4961"#,
4962 target = toml_path(&target)
4963 );
4964 std::fs::write(source.join("config.toml"), cfg).unwrap();
4965
4966 let yui = YuiVars::detect(&source);
4970 let cfg = config::load(&source, &yui).unwrap();
4971 let mount_srcs: Vec<Utf8PathBuf> = cfg
4972 .mount
4973 .entry
4974 .iter()
4975 .map(|m| source.join(&m.src))
4976 .collect();
4977 let inactive_file = source.join("home_other_os/.bashrc");
4978 let claimed = mount_srcs.iter().any(|m| inactive_file.starts_with(m));
4979 assert!(
4980 claimed,
4981 "raw config.mount.entry should claim files even under inactive mounts"
4982 );
4983 }
4984
4985 #[test]
4990 fn diff_shows_drift_skips_in_sync() {
4991 let tmp = TempDir::new().unwrap();
4992 let (source, target) = setup_minimal_dotfiles(&tmp);
4993 std::fs::write(source.join("home/.bashrc"), "first\nsecond\n").unwrap();
4994 apply(Some(source.clone()), false).unwrap();
4996 std::fs::remove_file(target.join(".bashrc")).unwrap();
4998 std::fs::write(target.join(".bashrc"), "first\nEDITED\n").unwrap();
4999
5000 diff(Some(source.clone()), None, true).unwrap();
5003 }
5004
5005 #[test]
5010 fn read_text_for_diff_classifies_correctly() {
5011 let tmp = TempDir::new().unwrap();
5012 let root = utf8(tmp.path().to_path_buf());
5013 let txt = root.join("a.txt");
5015 std::fs::write(&txt, "hello\n").unwrap();
5016 match read_text_for_diff(&txt) {
5017 DiffSide::Text(s) => assert_eq!(s, "hello\n"),
5018 DiffSide::Binary => panic!("text file misclassified as binary"),
5019 }
5020 let bin = root.join("b.bin");
5022 std::fs::write(&bin, [0xff, 0xfe, 0x00, 0xff]).unwrap();
5023 assert!(matches!(read_text_for_diff(&bin), DiffSide::Binary));
5024 let missing = root.join("missing.txt");
5026 match read_text_for_diff(&missing) {
5027 DiffSide::Text(s) => assert!(s.is_empty()),
5028 DiffSide::Binary => panic!("missing file misclassified as binary"),
5029 }
5030 }
5031
5032 #[test]
5039 fn diff_render_drift_uses_rendered_output_not_raw_template() {
5040 let tmp = TempDir::new().unwrap();
5041 let (source, _target) = setup_minimal_dotfiles(&tmp);
5042 std::fs::write(source.join("home/note.tera"), "os = {{ yui.os }}\n").unwrap();
5045 std::fs::write(source.join("home/note"), "os = ancient\n").unwrap();
5046 let yui = YuiVars::detect(&source);
5048 let cfg = config::load(&source, &yui).unwrap();
5049 let rendered =
5050 render::render_to_string(&source.join("home/note.tera"), &source, &cfg, &yui)
5051 .unwrap()
5052 .expect("template should render on this host");
5053 assert!(rendered.starts_with("os = "));
5054 assert!(
5055 !rendered.contains("{{"),
5056 "rendered output must not contain raw Tera tags"
5057 );
5058 }
5059
5060 #[test]
5068 fn resolve_diff_src_absolutizes_link_rows() {
5069 let source = Utf8Path::new("/dot");
5070 let link_item = StatusItem {
5071 src: Utf8PathBuf::from("home/.bashrc"),
5072 dst: Utf8PathBuf::from("/h/u/.bashrc"),
5073 state: StatusState::Link(absorb::AbsorbDecision::AutoAbsorb),
5074 };
5075 assert_eq!(
5076 resolve_diff_src(&link_item, source),
5077 Utf8PathBuf::from("/dot/home/.bashrc"),
5078 );
5079 let render_item = StatusItem {
5080 src: Utf8PathBuf::from("/dot/home/foo.tera"),
5081 dst: Utf8PathBuf::from("/dot/home/foo"),
5082 state: StatusState::RenderDrift,
5083 };
5084 assert_eq!(
5085 resolve_diff_src(&render_item, source),
5086 Utf8PathBuf::from("/dot/home/foo.tera"),
5087 );
5088 }
5089
5090 #[test]
5091 fn diff_classifier_skips_uninteresting_states() {
5092 use absorb::AbsorbDecision::*;
5093 assert!(!diff_worth_printing(&StatusState::Link(InSync)));
5095 assert!(!diff_worth_printing(&StatusState::Link(Restore)));
5096 assert!(!diff_worth_printing(&StatusState::Link(RelinkOnly)));
5097 assert!(diff_worth_printing(&StatusState::Link(AutoAbsorb)));
5099 assert!(diff_worth_printing(&StatusState::Link(NeedsConfirm)));
5100 assert!(diff_worth_printing(&StatusState::RenderDrift));
5101 }
5102
5103 #[test]
5114 fn update_errors_when_source_is_not_a_git_repo() {
5115 let tmp = TempDir::new().unwrap();
5116 let source = utf8(tmp.path().join("dotfiles"));
5117 std::fs::create_dir_all(&source).unwrap();
5118 std::fs::write(source.join("config.toml"), "").unwrap();
5119 let err = update(Some(source), false).unwrap_err();
5121 let msg = format!("{err:#}");
5122 assert!(
5123 msg.contains("not a git repository")
5124 || msg.contains("uncommitted")
5125 || msg.contains("git"),
5126 "unexpected error: {msg}",
5127 );
5128 }
5129
5130 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
5131 let mut out = Vec::new();
5132 let mut stack = vec![root.to_path_buf()];
5133 while let Some(dir) = stack.pop() {
5134 let Ok(entries) = std::fs::read_dir(&dir) else {
5135 continue;
5136 };
5137 for e in entries.flatten() {
5138 let p = utf8(e.path());
5139 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
5140 stack.push(p);
5141 } else {
5142 out.push(p);
5143 }
5144 }
5145 }
5146 out
5147 }
5148
5149 #[test]
5154 fn parse_backup_suffix_recognises_file_with_extension() {
5155 let dt = parse_backup_suffix("foo_20260429_143022123.yml").unwrap();
5156 assert_eq!(dt.year(), 2026);
5157 assert_eq!(dt.month(), 4);
5158 assert_eq!(dt.day(), 29);
5159 assert_eq!(dt.hour(), 14);
5160 assert_eq!(dt.minute(), 30);
5161 assert_eq!(dt.second(), 22);
5162 }
5163
5164 #[test]
5165 fn parse_backup_suffix_recognises_dotfile_no_extension() {
5166 let dt = parse_backup_suffix(".gitconfig_20260429_143022123").unwrap();
5167 assert_eq!(dt.year(), 2026);
5168 }
5169
5170 #[test]
5171 fn parse_backup_suffix_recognises_directory_form() {
5172 let dt = parse_backup_suffix("nvim_20260429_143022123").unwrap();
5173 assert_eq!(dt.day(), 29);
5174 }
5175
5176 #[test]
5177 fn parse_backup_suffix_recognises_multi_dot_filename() {
5178 let dt = parse_backup_suffix("archive.tar.gz_20260429_143022123.gz").unwrap();
5180 assert_eq!(dt.month(), 4);
5181 }
5182
5183 #[test]
5184 fn parse_backup_suffix_rejects_non_yui_names() {
5185 assert!(parse_backup_suffix("README.md").is_none());
5186 assert!(parse_backup_suffix("notes_2026.txt").is_none());
5187 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());
5191 }
5192
5193 #[test]
5194 fn parse_human_duration_basic_units() {
5195 let s = parse_human_duration("30d").unwrap();
5196 assert_eq!(s.get_days(), 30);
5197 let s = parse_human_duration("2w").unwrap();
5198 assert_eq!(s.get_weeks(), 2);
5199 let s = parse_human_duration("12h").unwrap();
5200 assert_eq!(s.get_hours(), 12);
5201 let s = parse_human_duration("5m").unwrap();
5203 assert_eq!(s.get_minutes(), 5);
5204 let s = parse_human_duration("6mo").unwrap();
5205 assert_eq!(s.get_months(), 6);
5206 let s = parse_human_duration("1y").unwrap();
5207 assert_eq!(s.get_years(), 1);
5208 }
5209
5210 #[test]
5211 fn parse_human_duration_case_insensitive_and_whitespace() {
5212 let s = parse_human_duration(" 90D ").unwrap();
5213 assert_eq!(s.get_days(), 90);
5214 let s = parse_human_duration("3WEEKS").unwrap();
5215 assert_eq!(s.get_weeks(), 3);
5216 }
5217
5218 #[test]
5219 fn parse_human_duration_rejects_garbage() {
5220 assert!(parse_human_duration("").is_err());
5221 assert!(parse_human_duration("d30").is_err());
5222 assert!(parse_human_duration("30").is_err()); assert!(parse_human_duration("30x").is_err()); assert!(parse_human_duration("-1d").is_err()); }
5226
5227 #[test]
5231 fn walk_gc_backups_collects_files_and_dir_snapshots() {
5232 let tmp = TempDir::new().unwrap();
5233 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
5234 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
5235 std::fs::write(
5237 root.join("C/Users/u/.config/foo_20260429_143022123.yml"),
5238 "old yml",
5239 )
5240 .unwrap();
5241 std::fs::create_dir_all(root.join("C/Users/u/nvim_20260101_000000000/lua")).unwrap();
5243 std::fs::write(
5244 root.join("C/Users/u/nvim_20260101_000000000/init.lua"),
5245 "ok",
5246 )
5247 .unwrap();
5248 std::fs::write(
5249 root.join("C/Users/u/nvim_20260101_000000000/lua/x.lua"),
5250 "kk",
5251 )
5252 .unwrap();
5253 std::fs::write(root.join("C/Users/u/.config/README.md"), "user note").unwrap();
5255
5256 let entries = walk_gc_backups(&root).unwrap();
5257 assert_eq!(entries.len(), 2, "two backup roots, not three");
5258 let kinds: Vec<_> = entries.iter().map(|e| e.kind).collect();
5259 assert!(kinds.contains(&BackupKind::File));
5260 assert!(kinds.contains(&BackupKind::Dir));
5261 let dir_entry = entries.iter().find(|e| e.kind == BackupKind::Dir).unwrap();
5263 assert!(dir_entry.size_bytes >= 4); }
5265
5266 #[test]
5267 fn cleanup_empty_parents_stops_at_root_and_at_non_empty() {
5268 let tmp = TempDir::new().unwrap();
5269 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
5270 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
5271 std::fs::write(root.join("C/Users/u/sibling_keep"), "x").unwrap();
5272
5273 cleanup_empty_parents(&root.join("C/Users/u/.config"), &root);
5277
5278 assert!(!root.join("C/Users/u/.config").exists(), "empty leaf gone");
5279 assert!(root.join("C/Users/u").exists(), "stops at non-empty parent");
5280 assert!(root.exists(), "backup root preserved");
5281 }
5282
5283 #[test]
5285 fn gc_backup_survey_keeps_all_entries() {
5286 let tmp = TempDir::new().unwrap();
5287 let source = utf8(tmp.path().join("dotfiles"));
5288 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
5289 std::fs::write(source.join("config.toml"), "").unwrap();
5290 let backup = source.join(".yui/backup");
5291 std::fs::write(backup.join("a_20260101_000000000.txt"), "old").unwrap();
5292 std::fs::write(backup.join("b_20260415_120000000.txt"), "fresh").unwrap();
5293
5294 gc_backup(Some(source.clone()), None, false, None, true).unwrap();
5295
5296 assert!(backup.join("a_20260101_000000000.txt").exists());
5298 assert!(backup.join("b_20260415_120000000.txt").exists());
5299 }
5300
5301 #[test]
5304 fn gc_backup_prune_removes_old_files_only() {
5305 let tmp = TempDir::new().unwrap();
5306 let source = utf8(tmp.path().join("dotfiles"));
5307 std::fs::create_dir_all(source.join(".yui/backup/sub")).unwrap();
5308 std::fs::write(source.join("config.toml"), "").unwrap();
5309 let backup = source.join(".yui/backup");
5310
5311 std::fs::write(backup.join("sub/old_20200101_000000000.txt"), "old").unwrap();
5313 let tomorrow = jiff::Zoned::now()
5315 .checked_add(jiff::Span::new().days(1))
5316 .unwrap();
5317 let bdt = jiff::fmt::strtime::BrokenDownTime::from(&tomorrow);
5318 let future_ts = bdt.to_string("%Y%m%d_%H%M%S%3f").unwrap();
5319 std::fs::write(backup.join(format!("fresh_{future_ts}.txt")), "fresh").unwrap();
5320 std::fs::write(backup.join("notes.md"), "mine").unwrap();
5322
5323 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
5324
5325 assert!(!backup.join("sub/old_20200101_000000000.txt").exists());
5326 assert!(!backup.join("sub").exists(), "empty parent removed");
5328 assert!(backup.exists());
5330 assert!(backup.join(format!("fresh_{future_ts}.txt")).exists());
5331 assert!(backup.join("notes.md").exists(), "user file untouched");
5332 }
5333
5334 #[test]
5336 fn gc_backup_dry_run_does_not_delete() {
5337 let tmp = TempDir::new().unwrap();
5338 let source = utf8(tmp.path().join("dotfiles"));
5339 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
5340 std::fs::write(source.join("config.toml"), "").unwrap();
5341 let backup = source.join(".yui/backup");
5342 std::fs::write(backup.join("old_20200101_000000000.txt"), "old").unwrap();
5343
5344 gc_backup(Some(source.clone()), Some("30d".into()), true, None, true).unwrap();
5345
5346 assert!(
5347 backup.join("old_20200101_000000000.txt").exists(),
5348 "dry-run keeps everything in place"
5349 );
5350 }
5351
5352 #[test]
5356 fn gc_backup_prune_handles_directory_snapshot() {
5357 let tmp = TempDir::new().unwrap();
5358 let source = utf8(tmp.path().join("dotfiles"));
5359 std::fs::create_dir_all(source.join(".yui/backup/mirror/u")).unwrap();
5360 std::fs::write(source.join("config.toml"), "").unwrap();
5361 let backup = source.join(".yui/backup");
5362 let snap = backup.join("mirror/u/nvim_20200101_000000000");
5363 std::fs::create_dir_all(snap.join("lua")).unwrap();
5364 std::fs::write(snap.join("init.lua"), "x").unwrap();
5365 std::fs::write(snap.join("lua/y.lua"), "y").unwrap();
5366
5367 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
5368
5369 assert!(!snap.exists(), "dir snapshot removed wholesale");
5370 assert!(!backup.join("mirror").exists(), "empty mirror chain pruned");
5371 assert!(backup.exists(), "backup root preserved");
5372 }
5373}