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 &source,
227 &config.mount.entry,
228 config.mount.default_strategy,
229 &mut engine,
230 &tera_ctx,
231 )?;
232
233 let backup_root = source.join(&config.backup.dir);
234 let ctx = ApplyCtx {
235 config: &config,
236 source: &source,
237 file_mode: resolve_file_mode(config.link.file_mode),
238 dir_mode: resolve_dir_mode(config.link.dir_mode),
239 backup_root: &backup_root,
240 dry_run,
241 };
242
243 info!("source: {source}");
244 info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
245 if dry_run {
246 info!("dry-run: nothing will be written");
247 }
248
249 let mut yuiignore = paths::YuiIgnoreStack::new();
253 yuiignore.push_dir(&source)?;
254 let walk_result = (|| -> Result<()> {
255 for m in &mounts {
256 info!("mount: {} → {}", m.src, m.dst);
257 process_mount(m, &ctx, &mut engine, &tera_ctx, &mut yuiignore)?;
258 }
259 Ok(())
260 })();
261 yuiignore.pop_dir(&source);
262 walk_result?;
263
264 hook::run_phase(
266 &config,
267 &source,
268 &yui,
269 &mut engine,
270 &tera_ctx,
271 HookPhase::Post,
272 dry_run,
273 )?;
274 Ok(())
275}
276
277fn log_render_report(r: &RenderReport) {
278 if !r.written.is_empty() {
279 info!("rendered {} new file(s)", r.written.len());
280 }
281 if !r.unchanged.is_empty() {
282 info!("rendered {} file(s) unchanged", r.unchanged.len());
283 }
284 if !r.skipped_when_false.is_empty() {
285 info!(
286 "skipped {} template(s) (when=false)",
287 r.skipped_when_false.len()
288 );
289 }
290 for d in &r.diverged {
291 warn!("rendered file diverged from template: {d}");
292 }
293}
294
295struct ApplyCtx<'a> {
302 config: &'a Config,
303 source: &'a Utf8Path,
305 file_mode: EffectiveFileMode,
306 dir_mode: EffectiveDirMode,
307 backup_root: &'a Utf8Path,
308 dry_run: bool,
309}
310
311pub fn list(
317 source: Option<Utf8PathBuf>,
318 all: bool,
319 icons_override: Option<IconsMode>,
320 no_color: bool,
321) -> Result<()> {
322 let source = resolve_source(source)?;
323 let yui = YuiVars::detect(&source);
324 let config = config::load(&source, &yui)?;
325
326 let icons_mode = icons_override.unwrap_or(config.ui.icons);
327 let icons = Icons::for_mode(icons_mode);
328 let color = !no_color && supports_color_stdout();
329
330 let items = collect_list_items(&source, &config, &yui)?;
331 let displayed: Vec<&ListItem> = if all {
332 items.iter().collect()
333 } else {
334 items.iter().filter(|i| i.active).collect()
335 };
336
337 print_list_table(&displayed, icons, color);
338
339 let total = items.len();
340 let active = items.iter().filter(|i| i.active).count();
341 let inactive = total - active;
342 println!();
343 if all {
344 println!(" {total} entries · {active} active · {inactive} inactive");
345 } else {
346 println!(
347 " {} of {} entries shown ({} inactive hidden — use --all)",
348 active, total, inactive
349 );
350 }
351 Ok(())
352}
353
354#[derive(Debug)]
355struct ListItem {
356 src: Utf8PathBuf,
357 dst: String,
358 when: Option<String>,
359 active: bool,
360}
361
362fn collect_list_items(source: &Utf8Path, config: &Config, yui: &YuiVars) -> Result<Vec<ListItem>> {
363 let mut engine = template::Engine::new();
364 let tera_ctx = template::template_context(yui, &config.vars);
365 let mut items = Vec::new();
366
367 for entry in &config.mount.entry {
369 let active = match &entry.when {
370 None => true,
371 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
372 };
373 let dst = engine
374 .render(&entry.dst, &tera_ctx)
375 .map(|s| paths::expand_tilde(s.trim()).to_string())
376 .unwrap_or_else(|_| entry.dst.clone());
377 items.push(ListItem {
378 src: entry.src.clone(),
379 dst,
380 when: entry.when.clone(),
381 active,
382 });
383 }
384
385 let walker = paths::source_walker(source).build();
387 let marker_filename = &config.mount.marker_filename;
388 for entry in walker {
389 let entry = match entry {
390 Ok(e) => e,
391 Err(_) => continue,
392 };
393 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
394 continue;
395 }
396 if entry.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
397 continue;
398 }
399 let dir = match entry.path().parent() {
400 Some(d) => d,
401 None => continue,
402 };
403 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
404 Ok(p) => p,
405 Err(_) => continue,
406 };
407 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
411 Some(s) => s,
412 None => continue,
413 };
414 let MarkerSpec::Explicit { links } = spec else {
415 continue; };
417 let rel = dir_utf8
418 .strip_prefix(source)
419 .map(Utf8PathBuf::from)
420 .unwrap_or(dir_utf8);
421 for link in &links {
422 let active = match &link.when {
423 None => true,
424 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
425 };
426 let dst = engine
427 .render(&link.dst, &tera_ctx)
428 .map(|s| paths::expand_tilde(s.trim()).to_string())
429 .unwrap_or_else(|_| link.dst.clone());
430 let src_display = match &link.src {
435 Some(filename) => rel.join(filename),
436 None => rel.clone(),
437 };
438 items.push(ListItem {
439 src: src_display,
440 dst,
441 when: link.when.clone(),
442 active,
443 });
444 }
445 }
446
447 items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
448 Ok(items)
449}
450
451fn supports_color_stdout() -> bool {
452 use std::io::IsTerminal;
453 std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
454}
455
456fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
457 let src_w = items
458 .iter()
459 .map(|i| i.src.as_str().chars().count())
460 .max()
461 .unwrap_or(0)
462 .max("SRC".len());
463 let dst_w = items
464 .iter()
465 .map(|i| i.dst.chars().count())
466 .max()
467 .unwrap_or(0)
468 .max("DST".len());
469
470 let status_w = "STATUS".len();
471 let arrow_w = icons.arrow.chars().count();
472
473 print_header(status_w, src_w, arrow_w, dst_w, color);
475
476 let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
478 if color {
479 use owo_colors::OwoColorize as _;
480 println!("{}", sep.dimmed());
481 } else {
482 println!("{sep}");
483 }
484
485 for item in items {
487 print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
488 }
489}
490
491fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
492 use owo_colors::OwoColorize as _;
493 let mut line = String::new();
494 let _ = write!(
495 &mut line,
496 " {:<status_w$} {:<src_w$} {:<arrow_w$} {:<dst_w$} WHEN",
497 "STATUS", "SRC", "", "DST"
498 );
499 if color {
500 println!("{}", line.bold());
501 } else {
502 println!("{line}");
503 }
504}
505
506fn render_separator(
507 sep_ch: char,
508 status_w: usize,
509 src_w: usize,
510 arrow_w: usize,
511 dst_w: usize,
512) -> String {
513 let bar = |n: usize| sep_ch.to_string().repeat(n);
514 format!(
515 " {} {} {} {} {}",
516 bar(status_w),
517 bar(src_w),
518 bar(arrow_w),
519 bar(dst_w),
520 bar("WHEN".len())
521 )
522}
523
524fn print_row(
525 item: &ListItem,
526 icons: Icons,
527 status_w: usize,
528 src_w: usize,
529 arrow_w: usize,
530 dst_w: usize,
531 color: bool,
532) {
533 use owo_colors::OwoColorize as _;
534 let status = if item.active {
535 icons.active
536 } else {
537 icons.inactive
538 };
539 let when_str = item
540 .when
541 .as_deref()
542 .map(strip_braces)
543 .unwrap_or_else(|| "(always)".to_string());
544
545 let src_display = item.src.as_str().replace('\\', "/");
547 let src = src_display.as_str();
548 let dst = &item.dst;
549 let arrow = icons.arrow;
550
551 let cell_status = format!("{:<status_w$}", status);
556 let cell_src = format!("{:<src_w$}", src);
557 let cell_arrow = format!("{:<arrow_w$}", arrow);
558 let cell_dst = format!("{:<dst_w$}", dst);
559
560 if !color {
561 println!(" {cell_status} {cell_src} {cell_arrow} {cell_dst} {when_str}");
562 return;
563 }
564
565 if item.active {
566 println!(
567 " {} {} {} {} {}",
568 cell_status.green(),
569 cell_src.cyan(),
570 cell_arrow.dimmed(),
571 cell_dst.green(),
572 when_str.dimmed()
573 );
574 } else {
575 println!(
576 " {} {} {} {} {}",
577 cell_status.red().dimmed(),
578 cell_src.dimmed(),
579 cell_arrow.dimmed(),
580 cell_dst.dimmed(),
581 when_str.dimmed()
582 );
583 }
584}
585
586fn strip_braces(expr: &str) -> String {
589 let trimmed = expr.trim();
590 if let Some(inner) = trimmed
591 .strip_prefix("{{")
592 .and_then(|s| s.strip_suffix("}}"))
593 {
594 inner.trim().to_string()
595 } else {
596 trimmed.to_string()
597 }
598}
599
600pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
601 let source = resolve_source(source)?;
602 let yui = YuiVars::detect(&source);
603 let config = config::load(&source, &yui)?;
604 let report = render::render_all(&source, &config, &yui, dry_run || check)?;
606 log_render_report(&report);
607 if check && report.has_drift() {
608 anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
609 }
610 Ok(())
611}
612
613pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
614 apply(source, dry_run)
616}
617
618pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
619 let _source = resolve_source(source)?;
620 if paths_arg.is_empty() {
621 anyhow::bail!("yui unlink: provide at least one target path");
622 }
623 for p in paths_arg {
624 let abs = absolutize(&p)?;
625 info!("unlink: {abs}");
626 link::unlink(&abs)?;
627 }
628 Ok(())
629}
630
631pub fn update(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
642 let source = resolve_source(source)?;
643 if !crate::git::is_clean(&source)? {
644 anyhow::bail!(
645 "source repo {source} has uncommitted changes — \
646 commit or stash before `yui update` (or run \
647 `git pull` + `yui apply` manually if you know what \
648 you're doing)"
649 );
650 }
651 info!("git pull --ff-only at {source}");
652 let status = std::process::Command::new("git")
653 .arg("-C")
654 .arg(source.as_str())
655 .arg("pull")
656 .arg("--ff-only")
657 .status()
658 .map_err(|e| anyhow::anyhow!("invoking git: {e}"))?;
659 if !status.success() {
660 anyhow::bail!("git pull --ff-only failed at {source}");
661 }
662 apply(Some(source), dry_run)
663}
664
665pub fn unmanaged(
676 source: Option<Utf8PathBuf>,
677 icons_override: Option<IconsMode>,
678 no_color: bool,
679) -> Result<()> {
680 let source = resolve_source(source)?;
681 let yui = YuiVars::detect(&source);
682 let config = config::load(&source, &yui)?;
683
684 let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
685 let color = !no_color && supports_color_stdout();
686
687 let mut engine = template::Engine::new();
702 let tera_ctx = template::template_context(&yui, &config.vars);
703 let mount_srcs: Vec<Utf8PathBuf> = config
704 .mount
705 .entry
706 .iter()
707 .map(|e| -> Result<Utf8PathBuf> {
708 let rendered = engine.render(e.src.as_str(), &tera_ctx)?;
709 Ok(paths::resolve_mount_src(&source, rendered.trim()))
710 })
711 .collect::<Result<_>>()?;
712
713 let mut items: Vec<Utf8PathBuf> = Vec::new();
714 let walker = paths::source_walker(&source).build();
715 for entry in walker {
716 let entry = match entry {
717 Ok(e) => e,
718 Err(_) => continue,
719 };
720 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
721 continue;
722 }
723 let std_path = entry.path();
724 let path = match Utf8PathBuf::from_path_buf(std_path.to_path_buf()) {
725 Ok(p) => p,
726 Err(_) => continue,
727 };
728 if is_repo_meta(&path, &source, &config.mount.marker_filename) {
732 continue;
733 }
734 if mount_srcs.iter().any(|m| path.starts_with(m)) {
735 continue;
736 }
737 items.push(path);
738 }
739 items.sort();
740
741 if items.is_empty() {
742 println!(" no unmanaged files under {source}");
743 return Ok(());
744 }
745
746 print_unmanaged_table(&items, &source, color);
747 println!();
748 println!(" {} unmanaged file(s)", items.len());
749 Ok(())
750}
751
752fn is_repo_meta(path: &Utf8Path, source: &Utf8Path, marker_filename: &str) -> bool {
768 let Some(name) = path.file_name() else {
769 return false;
770 };
771 if name.ends_with(".tera") {
772 return true;
773 }
774 if name == marker_filename || name == ".yuiignore" {
775 return true;
776 }
777 let parent = path.parent().unwrap_or(Utf8Path::new(""));
778 let at_root = parent == source;
779 if at_root && name == ".gitignore" {
780 return true;
781 }
782 if at_root && (name == "config.toml" || name == "config.local.toml") {
783 return true;
784 }
785 if at_root
786 && name.starts_with("config.")
787 && (name.ends_with(".toml") || name.ends_with(".example.toml"))
788 {
789 return true;
790 }
791 false
792}
793
794fn print_unmanaged_table(items: &[Utf8PathBuf], source: &Utf8Path, color: bool) {
795 use owo_colors::OwoColorize as _;
796 if color {
797 println!(" {}", "PATH (relative to source)".dimmed());
798 } else {
799 println!(" PATH (relative to source)");
800 }
801 for p in items {
802 let rel = p
803 .strip_prefix(source)
804 .map(Utf8PathBuf::from)
805 .unwrap_or_else(|_| p.clone());
806 if color {
807 println!(" {}", rel.cyan());
808 } else {
809 println!(" {rel}");
810 }
811 }
812}
813
814pub fn diff(
822 source: Option<Utf8PathBuf>,
823 icons_override: Option<IconsMode>,
824 no_color: bool,
825) -> Result<()> {
826 let source = resolve_source(source)?;
827 let yui = YuiVars::detect(&source);
828 let config = config::load(&source, &yui)?;
829 let mut engine = template::Engine::new();
830 let tera_ctx = template::template_context(&yui, &config.vars);
831 let mounts = mount::resolve(
832 &source,
833 &config.mount.entry,
834 config.mount.default_strategy,
835 &mut engine,
836 &tera_ctx,
837 )?;
838
839 let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
840 let color = !no_color && supports_color_stdout();
841
842 let mut report: Vec<StatusItem> = Vec::new();
844 let mut yuiignore = paths::YuiIgnoreStack::new();
845 yuiignore.push_dir(&source)?;
846 let walk_result = (|| -> Result<()> {
847 for m in &mounts {
848 let src_root = m.src.clone();
849 if !src_root.is_dir() {
850 continue;
851 }
852 classify_walk(
853 &src_root,
854 &m.dst,
855 &config,
856 m.strategy,
857 &mut engine,
858 &tera_ctx,
859 &source,
860 &mut yuiignore,
861 &mut report,
862 )?;
863 }
864 Ok(())
865 })();
866 yuiignore.pop_dir(&source);
867 walk_result?;
868
869 let render_report = render::render_all(&source, &config, &yui, true)?;
871 for rendered in &render_report.diverged {
872 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
873 report.push(StatusItem {
874 src: tera_path,
875 dst: rendered.clone(),
876 state: StatusState::RenderDrift,
877 });
878 }
879
880 let mut printed = 0usize;
881 for item in &report {
882 if !diff_worth_printing(&item.state) {
883 continue;
884 }
885 let src_abs = resolve_diff_src(item, &source);
886 print_unified_diff(
887 &src_abs,
888 &item.dst,
889 &item.state,
890 &source,
891 &config,
892 &yui,
893 color,
894 );
895 printed += 1;
896 }
897
898 if printed == 0 {
899 println!(" no diff — every entry is in sync (or only needs a relink)");
900 } else {
901 println!();
902 println!(
903 " {printed} entr{} with content drift",
904 if printed == 1 { "y" } else { "ies" }
905 );
906 }
907 Ok(())
908}
909
910fn resolve_diff_src(item: &StatusItem, source: &Utf8Path) -> Utf8PathBuf {
922 match item.state {
923 StatusState::RenderDrift => item.src.clone(),
924 StatusState::Link(_) => source.join(&item.src),
925 }
926}
927
928fn diff_worth_printing(state: &StatusState) -> bool {
929 use absorb::AbsorbDecision::*;
930 match state {
931 StatusState::Link(InSync) => false,
932 StatusState::Link(Restore) => false, StatusState::Link(RelinkOnly) => false, StatusState::Link(_) => true,
935 StatusState::RenderDrift => true,
936 }
937}
938
939fn print_unified_diff(
947 src: &Utf8Path,
948 dst: &Utf8Path,
949 state: &StatusState,
950 source_root: &Utf8Path,
951 config: &Config,
952 yui: &YuiVars,
953 color: bool,
954) {
955 use owo_colors::OwoColorize as _;
956
957 let header = match state {
958 StatusState::RenderDrift => format!("--- render drift: {src} (template) vs {dst}"),
959 _ => format!("--- {src} → {dst}"),
960 };
961 if color {
962 println!("{}", header.bold());
963 } else {
964 println!("{header}");
965 }
966
967 if src.is_dir() || dst.is_dir() {
968 println!("(directory entry — content listing skipped)");
969 println!();
970 return;
971 }
972
973 let src_content = match state {
978 StatusState::RenderDrift => match render::render_to_string(src, source_root, config, yui) {
979 Ok(Some(s)) => s,
980 Ok(None) => {
981 println!(
982 "(template would be skipped on this host — drift will resolve on next render)"
983 );
984 println!();
985 return;
986 }
987 Err(e) => {
988 println!("(error rendering template: {e})");
989 println!();
990 return;
991 }
992 },
993 _ => match read_text_for_diff(src) {
994 DiffSide::Text(s) => s,
995 DiffSide::Binary => {
996 println!("(binary file or non-UTF-8 content — diff skipped)");
997 println!();
998 return;
999 }
1000 },
1001 };
1002 let dst_content = match read_text_for_diff(dst) {
1003 DiffSide::Text(s) => s,
1004 DiffSide::Binary => {
1005 println!("(binary file or non-UTF-8 content — diff skipped)");
1006 println!();
1007 return;
1008 }
1009 };
1010 print_unified_text_diff(
1011 &src_content,
1012 &dst_content,
1013 src.as_str(),
1014 dst.as_str(),
1015 color,
1016 );
1017 println!();
1018}
1019
1020fn print_unified_text_diff(src: &str, dst: &str, src_label: &str, dst_label: &str, color: bool) {
1029 use owo_colors::OwoColorize as _;
1030 let diff = similar::TextDiff::from_lines(src, dst);
1031 let formatted = diff.unified_diff().header(src_label, dst_label).to_string();
1032 for line in formatted.lines() {
1033 if !color {
1034 println!("{line}");
1035 } else if line.starts_with("+++") || line.starts_with("---") {
1036 println!("{}", line.dimmed());
1037 } else if line.starts_with("@@") {
1038 println!("{}", line.cyan());
1039 } else if line.starts_with('+') {
1040 println!("{}", line.green());
1041 } else if line.starts_with('-') {
1042 println!("{}", line.red());
1043 } else {
1044 println!("{line}");
1045 }
1046 }
1047}
1048
1049enum DiffSide {
1055 Text(String),
1056 Binary,
1057}
1058
1059fn read_text_for_diff(p: &Utf8Path) -> DiffSide {
1060 match std::fs::read_to_string(p) {
1061 Ok(s) => DiffSide::Text(s),
1062 Err(e) if e.kind() == std::io::ErrorKind::InvalidData => DiffSide::Binary,
1063 Err(_) => DiffSide::Text(String::new()),
1064 }
1065}
1066
1067pub fn status(
1080 source: Option<Utf8PathBuf>,
1081 icons_override: Option<IconsMode>,
1082 no_color: bool,
1083) -> Result<()> {
1084 let source = resolve_source(source)?;
1085 let yui = YuiVars::detect(&source);
1086 let config = config::load(&source, &yui)?;
1087
1088 let mut engine = template::Engine::new();
1089 let tera_ctx = template::template_context(&yui, &config.vars);
1090 let mounts = mount::resolve(
1091 &source,
1092 &config.mount.entry,
1093 config.mount.default_strategy,
1094 &mut engine,
1095 &tera_ctx,
1096 )?;
1097
1098 let icons_mode = icons_override.unwrap_or(config.ui.icons);
1099 let icons = Icons::for_mode(icons_mode);
1100 let color = !no_color && supports_color_stdout();
1101
1102 let mut report: Vec<StatusItem> = Vec::new();
1103
1104 let render_report = render::render_all(&source, &config, &yui, true)?;
1107 for rendered in &render_report.diverged {
1108 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
1112 report.push(StatusItem {
1113 src: relative_for_display(&source, &tera_path),
1114 dst: rendered.clone(),
1115 state: StatusState::RenderDrift,
1116 });
1117 }
1118
1119 let mut yuiignore = paths::YuiIgnoreStack::new();
1123 yuiignore.push_dir(&source)?;
1124 let walk_result = (|| -> Result<()> {
1125 for m in &mounts {
1126 let src_root = m.src.clone();
1127 if !src_root.is_dir() {
1128 warn!("mount src missing: {src_root}");
1129 continue;
1130 }
1131 classify_walk(
1132 &src_root,
1133 &m.dst,
1134 &config,
1135 m.strategy,
1136 &mut engine,
1137 &tera_ctx,
1138 &source,
1139 &mut yuiignore,
1140 &mut report,
1141 )?;
1142 }
1143 Ok(())
1144 })();
1145 yuiignore.pop_dir(&source);
1146 walk_result?;
1147
1148 report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
1149
1150 print_status_table(&report, icons, color);
1151
1152 let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
1153
1154 println!();
1155 let total = report.len();
1156 let in_sync = total - drift;
1157 if drift == 0 {
1158 println!(" {total} entries · all in sync");
1159 Ok(())
1160 } else {
1161 println!(" {total} entries · {in_sync} in sync · {drift} diverged");
1162 anyhow::bail!("status: {drift} entries diverged from source")
1163 }
1164}
1165
1166#[derive(Debug)]
1167struct StatusItem {
1168 src: Utf8PathBuf,
1170 dst: Utf8PathBuf,
1172 state: StatusState,
1173}
1174
1175#[derive(Debug, Clone, Copy)]
1176enum StatusState {
1177 Link(absorb::AbsorbDecision),
1178 RenderDrift,
1181}
1182
1183impl StatusState {
1184 fn is_in_sync(self) -> bool {
1185 matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
1186 }
1187}
1188
1189#[allow(clippy::too_many_arguments)]
1190fn classify_walk(
1191 src_dir: &Utf8Path,
1192 dst_dir: &Utf8Path,
1193 config: &Config,
1194 strategy: MountStrategy,
1195 engine: &mut template::Engine,
1196 tera_ctx: &TeraContext,
1197 source_root: &Utf8Path,
1198 yuiignore: &mut paths::YuiIgnoreStack,
1199 report: &mut Vec<StatusItem>,
1200) -> Result<()> {
1201 classify_walk_inner(
1202 src_dir,
1203 dst_dir,
1204 config,
1205 strategy,
1206 engine,
1207 tera_ctx,
1208 source_root,
1209 yuiignore,
1210 report,
1211 false,
1212 )
1213}
1214
1215#[allow(clippy::too_many_arguments)]
1216fn classify_walk_inner(
1217 src_dir: &Utf8Path,
1218 dst_dir: &Utf8Path,
1219 config: &Config,
1220 strategy: MountStrategy,
1221 engine: &mut template::Engine,
1222 tera_ctx: &TeraContext,
1223 source_root: &Utf8Path,
1224 yuiignore: &mut paths::YuiIgnoreStack,
1225 report: &mut Vec<StatusItem>,
1226 parent_covered: bool,
1227) -> Result<()> {
1228 if yuiignore.is_ignored(src_dir, true) {
1229 return Ok(());
1230 }
1231 yuiignore.push_dir(src_dir)?;
1234 let result = classify_walk_inner_body(
1235 src_dir,
1236 dst_dir,
1237 config,
1238 strategy,
1239 engine,
1240 tera_ctx,
1241 source_root,
1242 yuiignore,
1243 report,
1244 parent_covered,
1245 );
1246 yuiignore.pop_dir(src_dir);
1247 result
1248}
1249
1250#[allow(clippy::too_many_arguments)]
1251fn classify_walk_inner_body(
1252 src_dir: &Utf8Path,
1253 dst_dir: &Utf8Path,
1254 config: &Config,
1255 strategy: MountStrategy,
1256 engine: &mut template::Engine,
1257 tera_ctx: &TeraContext,
1258 source_root: &Utf8Path,
1259 yuiignore: &mut paths::YuiIgnoreStack,
1260 report: &mut Vec<StatusItem>,
1261 parent_covered: bool,
1262) -> Result<()> {
1263 let marker_filename = &config.mount.marker_filename;
1264 let mut covered = parent_covered;
1265
1266 if strategy == MountStrategy::Marker {
1267 match marker::read_spec(src_dir, marker_filename)? {
1268 None => {}
1269 Some(MarkerSpec::PassThrough) => {
1270 let decision = absorb::classify(src_dir, dst_dir)?;
1271 report.push(StatusItem {
1272 src: relative_for_display(source_root, src_dir),
1273 dst: dst_dir.to_path_buf(),
1274 state: StatusState::Link(decision),
1275 });
1276 covered = true;
1277 }
1278 Some(MarkerSpec::Explicit { links }) => {
1279 let mut emitted_dir_link = false;
1280 for link in &links {
1281 if let Some(when) = &link.when {
1282 if !template::eval_truthy(when, engine, tera_ctx)? {
1283 continue;
1284 }
1285 }
1286 let dst_str = engine.render(&link.dst, tera_ctx)?;
1287 let dst = paths::expand_tilde(dst_str.trim());
1288 if let Some(filename) = &link.src {
1289 let file_src = src_dir.join(filename);
1290 if !file_src.is_file() {
1291 anyhow::bail!(
1292 "marker at {src_dir}: [[link]] src={filename:?} \
1293 not found"
1294 );
1295 }
1296 let decision = absorb::classify(&file_src, &dst)?;
1297 report.push(StatusItem {
1298 src: relative_for_display(source_root, &file_src),
1299 dst,
1300 state: StatusState::Link(decision),
1301 });
1302 } else {
1303 let decision = absorb::classify(src_dir, &dst)?;
1304 report.push(StatusItem {
1305 src: relative_for_display(source_root, src_dir),
1306 dst,
1307 state: StatusState::Link(decision),
1308 });
1309 emitted_dir_link = true;
1310 }
1311 }
1312 if emitted_dir_link {
1313 covered = true;
1314 }
1315 }
1316 }
1317 }
1318
1319 for entry in std::fs::read_dir(src_dir)? {
1320 let entry = entry?;
1321 let name_os = entry.file_name();
1322 let Some(name) = name_os.to_str() else {
1323 continue;
1324 };
1325 if name == marker_filename || name.ends_with(".tera") {
1326 continue;
1327 }
1328 let src_path = src_dir.join(name);
1329 let dst_path = dst_dir.join(name);
1330 let ft = entry.file_type()?;
1331 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
1332 continue;
1333 }
1334 if ft.is_dir() {
1335 classify_walk_inner(
1336 &src_path,
1337 &dst_path,
1338 config,
1339 strategy,
1340 engine,
1341 tera_ctx,
1342 source_root,
1343 yuiignore,
1344 report,
1345 covered,
1346 )?;
1347 } else if ft.is_file() && !covered {
1348 let decision = absorb::classify(&src_path, &dst_path)?;
1349 report.push(StatusItem {
1350 src: relative_for_display(source_root, &src_path),
1351 dst: dst_path,
1352 state: StatusState::Link(decision),
1353 });
1354 }
1355 }
1356 Ok(())
1357}
1358
1359fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
1360 p.strip_prefix(source_root)
1361 .map(Utf8PathBuf::from)
1362 .unwrap_or_else(|_| p.to_path_buf())
1363}
1364
1365fn print_status_table(items: &[StatusItem], icons: Icons, color: bool) {
1366 let src_w = items
1367 .iter()
1368 .map(|i| i.src.as_str().chars().count())
1369 .max()
1370 .unwrap_or(0)
1371 .max("SRC".len());
1372 let dst_w = items
1373 .iter()
1374 .map(|i| i.dst.as_str().chars().count())
1375 .max()
1376 .unwrap_or(0)
1377 .max("DST".len());
1378 let state_label_w = items
1380 .iter()
1381 .map(|i| state_label(i.state).len())
1382 .max()
1383 .unwrap_or(0)
1384 .max("STATE".len() - 2); let state_w = state_label_w + 2; print_status_header(state_w, src_w, dst_w, color);
1388 let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
1389 if color {
1390 use owo_colors::OwoColorize as _;
1391 println!("{}", sep.dimmed());
1392 } else {
1393 println!("{sep}");
1394 }
1395 for item in items {
1396 print_status_row(item, icons, state_w, src_w, dst_w, color);
1397 }
1398}
1399
1400fn state_label(s: StatusState) -> &'static str {
1401 use absorb::AbsorbDecision::*;
1402 match s {
1403 StatusState::Link(InSync) => "in-sync",
1404 StatusState::Link(RelinkOnly) => "relink",
1405 StatusState::Link(AutoAbsorb) => "drift (auto)",
1406 StatusState::Link(NeedsConfirm) => "drift (anomaly)",
1407 StatusState::Link(Restore) => "missing",
1408 StatusState::RenderDrift => "render drift",
1409 }
1410}
1411
1412fn state_icon(s: StatusState, icons: Icons) -> &'static str {
1413 use absorb::AbsorbDecision::*;
1414 match s {
1415 StatusState::Link(InSync) => icons.ok,
1416 StatusState::Link(RelinkOnly) => icons.warn,
1417 StatusState::Link(AutoAbsorb) => icons.warn,
1418 StatusState::Link(NeedsConfirm) => icons.error,
1419 StatusState::Link(Restore) => icons.info,
1420 StatusState::RenderDrift => icons.error,
1421 }
1422}
1423
1424fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
1425 use owo_colors::OwoColorize as _;
1426 let line = format!(
1429 " {:<state_w$} {:<src_w$} {:<dst_w$}",
1430 "STATE", "SRC", "DST"
1431 );
1432 if color {
1433 println!("{}", line.bold());
1434 } else {
1435 println!("{line}");
1436 }
1437}
1438
1439fn render_status_separator(
1440 sep_ch: char,
1441 state_w: usize,
1442 src_w: usize,
1443 dst_w: usize,
1444 arrow: &str,
1445) -> String {
1446 let bar = |n: usize| sep_ch.to_string().repeat(n);
1447 format!(
1448 " {} {} {} {}",
1449 bar(state_w),
1450 bar(src_w),
1451 bar(arrow.chars().count()),
1452 bar(dst_w)
1453 )
1454}
1455
1456fn print_status_row(
1457 item: &StatusItem,
1458 icons: Icons,
1459 state_w: usize,
1460 src_w: usize,
1461 dst_w: usize,
1462 color: bool,
1463) {
1464 use owo_colors::OwoColorize as _;
1465 let icon = state_icon(item.state, icons);
1466 let label = state_label(item.state);
1467 let state_text = format!("{icon} {label}");
1468 let src_display = item.src.as_str().replace('\\', "/");
1469 let dst_display = item.dst.as_str().replace('\\', "/");
1470 let arrow = icons.arrow;
1471
1472 let cell_state = format!("{:<state_w$}", state_text);
1473 let cell_src = format!("{:<src_w$}", src_display);
1474 let cell_dst = format!("{:<dst_w$}", dst_display);
1475
1476 if !color {
1477 println!(" {cell_state} {cell_src} {arrow} {cell_dst}");
1478 return;
1479 }
1480
1481 use absorb::AbsorbDecision::*;
1482 let state_colored = match item.state {
1483 StatusState::Link(InSync) => cell_state.green().to_string(),
1484 StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
1485 cell_state.yellow().to_string()
1486 }
1487 StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
1488 StatusState::Link(Restore) => cell_state.cyan().to_string(),
1489 StatusState::RenderDrift => cell_state.red().to_string(),
1490 };
1491 let src_colored = cell_src.cyan().to_string();
1492 let arrow_colored = arrow.dimmed().to_string();
1493 let dst_colored = cell_dst.dimmed().to_string();
1494 println!(" {state_colored} {src_colored} {arrow_colored} {dst_colored}");
1495}
1496
1497pub fn absorb(
1511 source: Option<Utf8PathBuf>,
1512 target: Utf8PathBuf,
1513 dry_run: bool,
1514 yes: bool,
1515) -> Result<()> {
1516 let source = resolve_source(source)?;
1517 let target = absolutize(&target)?;
1518 let yui = YuiVars::detect(&source);
1519 let config = config::load(&source, &yui)?;
1520
1521 let mut engine = template::Engine::new();
1522 let tera_ctx = template::template_context(&yui, &config.vars);
1523
1524 let src_path = match find_source_for_target(&source, &config, &target, &mut engine, &tera_ctx)?
1525 {
1526 Some(s) => s,
1527 None => anyhow::bail!(
1528 "no mount entry / .yuilink override claims target {target}; \
1529 pass a path inside a known dst"
1530 ),
1531 };
1532
1533 info!("source for {target}: {src_path}");
1534
1535 print_absorb_diff(&src_path, &target);
1540
1541 if dry_run {
1542 info!("[dry-run] would absorb {target} → {src_path}");
1543 return Ok(());
1544 }
1545
1546 if !yes {
1547 use std::io::IsTerminal;
1548 if !std::io::stdin().is_terminal() {
1549 anyhow::bail!(
1550 "manual absorb refuses to run off-TTY without --yes \
1551 (would silently overwrite {src_path})"
1552 );
1553 }
1554 if !prompt_yes_no("absorb target into source?")? {
1555 warn!("manual absorb cancelled by user: {target}");
1556 return Ok(());
1557 }
1558 }
1559
1560 let backup_root = source.join(&config.backup.dir);
1561 let ctx = ApplyCtx {
1562 config: &config,
1563 source: &source,
1564 file_mode: resolve_file_mode(config.link.file_mode),
1565 dir_mode: resolve_dir_mode(config.link.dir_mode),
1566 backup_root: &backup_root,
1567 dry_run: false,
1568 };
1569
1570 absorb_target_into_source(&src_path, &target, &ctx)
1573}
1574
1575fn print_absorb_diff(src: &Utf8Path, dst: &Utf8Path) {
1580 eprintln!();
1581 eprintln!("--- diff (- source, + target) ---");
1582 eprintln!(" src: {src}");
1583 eprintln!(" dst: {dst}");
1584 eprintln!();
1585 if src.is_dir() || dst.is_dir() {
1586 eprintln!("(directory absorb — content listing skipped)");
1587 eprintln!();
1588 return;
1589 }
1590 let src_content = match read_text_for_diff(src) {
1591 DiffSide::Text(s) => s,
1592 DiffSide::Binary => {
1593 eprintln!("(binary file or non-UTF-8 content — diff skipped)");
1594 eprintln!();
1595 return;
1596 }
1597 };
1598 let dst_content = match read_text_for_diff(dst) {
1599 DiffSide::Text(s) => s,
1600 DiffSide::Binary => {
1601 eprintln!("(binary file or non-UTF-8 content — diff skipped)");
1602 eprintln!();
1603 return;
1604 }
1605 };
1606 let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
1607 let formatted = diff
1608 .unified_diff()
1609 .header(src.as_str(), dst.as_str())
1610 .to_string();
1611 eprint!("{formatted}");
1612 eprintln!();
1613}
1614
1615fn prompt_yes_no(question: &str) -> Result<bool> {
1616 use std::io::Write as _;
1617 eprint!("{question} [y/N]: ");
1618 std::io::stderr().flush().ok();
1619 let mut input = String::new();
1620 std::io::stdin().read_line(&mut input)?;
1621 let answer = input.trim();
1622 Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
1623}
1624
1625fn find_source_for_target(
1629 source: &Utf8Path,
1630 config: &Config,
1631 target: &Utf8Path,
1632 engine: &mut template::Engine,
1633 tera_ctx: &TeraContext,
1634) -> Result<Option<Utf8PathBuf>> {
1635 for entry in &config.mount.entry {
1637 if let Some(when) = &entry.when {
1638 if !template::eval_truthy(when, engine, tera_ctx)? {
1639 continue;
1640 }
1641 }
1642 let dst_str = engine.render(&entry.dst, tera_ctx)?;
1643 let dst_root = paths::expand_tilde(dst_str.trim());
1644 if let Ok(rel) = target.strip_prefix(&dst_root) {
1645 let src_str = engine.render(entry.src.as_str(), tera_ctx)?;
1646 let candidate = paths::resolve_mount_src(source, src_str.trim()).join(rel);
1647 if paths::is_ignored_at(source, &candidate, candidate.is_dir())? {
1652 continue;
1653 }
1654 return Ok(Some(candidate));
1655 }
1656 }
1657
1658 let walker = paths::source_walker(source).build();
1664 let marker_filename = &config.mount.marker_filename;
1665 for ent in walker {
1666 let ent = match ent {
1667 Ok(e) => e,
1668 Err(_) => continue,
1669 };
1670 if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
1671 continue;
1672 }
1673 if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
1674 continue;
1675 }
1676 let dir = match ent.path().parent() {
1677 Some(d) => d,
1678 None => continue,
1679 };
1680 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
1681 Ok(p) => p,
1682 Err(_) => continue,
1683 };
1684 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
1685 Some(s) => s,
1686 None => continue,
1687 };
1688 let MarkerSpec::Explicit { links } = spec else {
1689 continue;
1690 };
1691 for link in &links {
1692 if let Some(when) = &link.when {
1693 if !template::eval_truthy(when, engine, tera_ctx)? {
1694 continue;
1695 }
1696 }
1697 let dst_str = engine.render(&link.dst, tera_ctx)?;
1698 let dst = paths::expand_tilde(dst_str.trim());
1699 if let Some(filename) = &link.src {
1706 let file_src = dir_utf8.join(filename);
1707 if !file_src.is_file() {
1708 anyhow::bail!(
1709 "marker at {dir_utf8}: [[link]] src={filename:?} \
1710 not found"
1711 );
1712 }
1713 if target == dst {
1714 return Ok(Some(file_src));
1715 }
1716 continue;
1717 }
1718 if target == dst {
1719 return Ok(Some(dir_utf8));
1720 }
1721 if let Ok(rel) = target.strip_prefix(&dst) {
1722 return Ok(Some(dir_utf8.join(rel)));
1723 }
1724 }
1725 }
1726
1727 Ok(None)
1728}
1729
1730pub fn doctor(
1731 source: Option<Utf8PathBuf>,
1732 icons_override: Option<IconsMode>,
1733 no_color: bool,
1734) -> Result<()> {
1735 use owo_colors::OwoColorize as _;
1736
1737 let resolved_source = resolve_source(source);
1742
1743 let yui = match &resolved_source {
1748 Ok(s) => YuiVars::detect(s),
1749 Err(_) => YuiVars::detect(Utf8Path::new(".")),
1750 };
1751
1752 let cfg_res = match &resolved_source {
1757 Ok(s) => Some(config::load(s, &yui)),
1758 Err(_) => None,
1759 };
1760 let cfg = cfg_res.as_ref().and_then(|r| r.as_ref().ok());
1761 let icons_mode = icons_override
1762 .or_else(|| cfg.map(|c| c.ui.icons))
1763 .unwrap_or_default();
1764 let icons = Icons::for_mode(icons_mode);
1765 let color = !no_color && supports_color_stdout();
1766
1767 let mut probes: Vec<Probe> = Vec::new();
1768
1769 probes.push(Probe::group("identity"));
1771 probes.push(Probe::ok("os/arch", format!("{} / {}", yui.os, yui.arch)));
1772 probes.push(Probe::ok("user@host", format!("{}@{}", yui.user, yui.host)));
1773
1774 probes.push(Probe::group("repo"));
1776 let mut have_source = false;
1777 match &resolved_source {
1778 Ok(s) => {
1779 have_source = true;
1780 probes.push(Probe::ok("source", s.to_string()));
1781 match cfg_res.as_ref().expect("cfg_res set when source is Ok") {
1782 Ok(c) => {
1783 probes.push(Probe::ok(
1784 "config",
1785 format!(
1786 "{} mount{} · {} hook{} · {} render rule{}",
1787 c.mount.entry.len(),
1788 plural(c.mount.entry.len()),
1789 c.hook.len(),
1790 plural(c.hook.len()),
1791 c.render.rule.len(),
1792 plural(c.render.rule.len()),
1793 ),
1794 ));
1795 }
1796 Err(e) => probes.push(Probe::error("config", format!("{e}"))),
1797 }
1798 match crate::git::is_clean(s) {
1802 Ok(true) => probes.push(Probe::ok("git", "clean")),
1803 Ok(false) => probes.push(Probe::warn(
1804 "git",
1805 "uncommitted changes — `[absorb] require_clean_git` will defer auto-absorb",
1806 )),
1807 Err(_) => probes.push(Probe::warn(
1808 "git",
1809 "no git repo (auto-absorb still works; commit history won't track drift)",
1810 )),
1811 }
1812 }
1813 Err(e) => {
1814 probes.push(Probe::error("source", format!("not found — {e}")));
1815 }
1816 }
1817
1818 probes.push(Probe::group("links"));
1820 if cfg!(windows) {
1821 probes.push(Probe::ok(
1822 "default mode",
1823 "files=hardlink, dirs=junction (no admin needed)",
1824 ));
1825 } else {
1826 probes.push(Probe::ok("default mode", "files=symlink, dirs=symlink"));
1827 }
1828
1829 if have_source {
1831 if let (Ok(s), Some(c)) = (&resolved_source, cfg) {
1832 probes.push(Probe::group("hooks"));
1833 if c.hook.is_empty() {
1834 probes.push(Probe::ok("hooks", "(none configured)"));
1835 } else {
1836 let mut missing = 0usize;
1837 for h in &c.hook {
1838 if !s.join(&h.script).is_file() {
1839 missing += 1;
1840 probes.push(Probe::error(
1841 format!("hook[{}]", h.name),
1842 format!("script not found at {}", h.script),
1843 ));
1844 }
1845 }
1846 if missing == 0 {
1847 probes.push(Probe::ok(
1848 "scripts",
1849 format!(
1850 "{} hook{} configured, all scripts present",
1851 c.hook.len(),
1852 plural(c.hook.len())
1853 ),
1854 ));
1855 }
1856 }
1857 }
1858 }
1859
1860 if let Some(home) = paths::home_dir() {
1862 let chezmoi_src = home.join(".local/share/chezmoi");
1863 if chezmoi_src.is_dir() {
1864 probes.push(Probe::group("chezmoi"));
1865 probes.push(Probe::warn(
1866 "legacy source",
1867 format!(
1868 "{chezmoi_src} still exists — yui doesn't use it, safe to archive once your migration has settled"
1869 ),
1870 ));
1871 }
1872 }
1873
1874 println!();
1876 if color {
1877 println!(" {}", "yui doctor".bold().underline());
1878 } else {
1879 println!(" yui doctor");
1880 }
1881 println!();
1882 for probe in &probes {
1883 probe.print(&icons, color);
1884 }
1885
1886 let errors = probes.iter().filter(|p| p.is_error()).count();
1887 let warns = probes.iter().filter(|p| p.is_warn()).count();
1888 let oks = probes.iter().filter(|p| p.is_ok()).count();
1889 println!();
1890 let summary = format!("{oks} ok · {warns} warn · {errors} error");
1891 if color {
1892 if errors > 0 {
1893 println!(" {}", summary.red().bold());
1894 } else if warns > 0 {
1895 println!(" {}", summary.yellow());
1896 } else {
1897 println!(" {}", summary.green());
1898 }
1899 } else {
1900 println!(" {summary}");
1901 }
1902
1903 if errors > 0 {
1904 anyhow::bail!("doctor: {errors} probe(s) failed");
1905 }
1906 Ok(())
1907}
1908
1909#[derive(Debug)]
1910enum Probe {
1911 Group(&'static str),
1913 Ok {
1914 label: String,
1915 detail: String,
1916 },
1917 Warn {
1918 label: String,
1919 detail: String,
1920 },
1921 Error {
1922 label: String,
1923 detail: String,
1924 },
1925}
1926
1927impl Probe {
1928 fn group(label: &'static str) -> Self {
1929 Self::Group(label)
1930 }
1931 fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
1932 Self::Ok {
1933 label: label.into(),
1934 detail: detail.into(),
1935 }
1936 }
1937 fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
1938 Self::Warn {
1939 label: label.into(),
1940 detail: detail.into(),
1941 }
1942 }
1943 fn error(label: impl Into<String>, detail: impl Into<String>) -> Self {
1944 Self::Error {
1945 label: label.into(),
1946 detail: detail.into(),
1947 }
1948 }
1949 fn is_ok(&self) -> bool {
1950 matches!(self, Self::Ok { .. })
1951 }
1952 fn is_warn(&self) -> bool {
1953 matches!(self, Self::Warn { .. })
1954 }
1955 fn is_error(&self) -> bool {
1956 matches!(self, Self::Error { .. })
1957 }
1958 fn print(&self, icons: &Icons, color: bool) {
1959 use owo_colors::OwoColorize as _;
1960 match self {
1961 Self::Group(name) => {
1962 println!();
1963 if color {
1964 println!(" {}", name.cyan().bold());
1965 } else {
1966 println!(" {name}");
1967 }
1968 }
1969 Self::Ok { label, detail } => {
1970 let icon = icons.ok;
1971 let padded = format!("{label:<14}");
1975 if color {
1976 println!(
1977 " {} {} {}",
1978 icon.green(),
1979 padded.bold(),
1980 detail.dimmed()
1981 );
1982 } else {
1983 println!(" {icon} {padded} {detail}");
1984 }
1985 }
1986 Self::Warn { label, detail } => {
1987 let icon = icons.warn;
1988 let padded = format!("{label:<14}");
1989 if color {
1990 println!(
1991 " {} {} {}",
1992 icon.yellow(),
1993 padded.bold().yellow(),
1994 detail
1995 );
1996 } else {
1997 println!(" {icon} {padded} {detail}");
1998 }
1999 }
2000 Self::Error { label, detail } => {
2001 let icon = icons.error;
2002 let padded = format!("{label:<14}");
2003 if color {
2004 println!(
2005 " {} {} {}",
2006 icon.red().bold(),
2007 padded.bold().red(),
2008 detail.red()
2009 );
2010 } else {
2011 println!(" {icon} {padded} {detail}");
2012 }
2013 }
2014 }
2015 }
2016}
2017
2018fn plural(n: usize) -> &'static str {
2019 if n == 1 { "" } else { "s" }
2020}
2021
2022pub fn gc_backup(
2042 source: Option<Utf8PathBuf>,
2043 older_than: Option<String>,
2044 dry_run: bool,
2045 icons_override: Option<IconsMode>,
2046 no_color: bool,
2047) -> Result<()> {
2048 let source = resolve_source(source)?;
2049 let yui = YuiVars::detect(&source);
2050 let config = config::load(&source, &yui)?;
2051 let backup_root = source.join(&config.backup.dir);
2052 let icons_mode = icons_override.unwrap_or(config.ui.icons);
2053 let icons = Icons::for_mode(icons_mode);
2054 let color = !no_color && supports_color_stdout();
2055
2056 if !backup_root.is_dir() {
2057 println!(" no backup tree at {backup_root}");
2058 return Ok(());
2059 }
2060
2061 let mut entries = walk_gc_backups(&backup_root)?;
2062 if entries.is_empty() {
2063 println!(" no yui-stamped backups under {backup_root}");
2064 return Ok(());
2065 }
2066 entries.sort_by_key(|e| e.ts);
2068 let now = jiff::Zoned::now();
2069
2070 match older_than {
2071 None => {
2072 let refs: Vec<&BackupEntry> = entries.iter().collect();
2073 print_gc_table(&refs, &backup_root, &now, icons, color);
2074 println!();
2075 println!(
2076 " {} entries · {} total — pass --older-than DUR (e.g. 30d) to delete",
2077 entries.len(),
2078 format_bytes(entries.iter().map(|e| e.size_bytes).sum())
2079 );
2080 Ok(())
2081 }
2082 Some(dur_str) => {
2083 let span = parse_human_duration(&dur_str)?;
2084 let cutoff = now
2085 .checked_sub(span)
2086 .map_err(|e| anyhow::anyhow!("invalid duration {dur_str:?}: {e}"))?;
2087 let cutoff_dt = cutoff.datetime();
2088
2089 let total_before: u64 = entries.iter().map(|e| e.size_bytes).sum();
2090 let to_delete: Vec<&BackupEntry> =
2091 entries.iter().filter(|e| e.ts < cutoff_dt).collect();
2092
2093 if to_delete.is_empty() {
2094 println!(
2095 " no backups older than {dur_str} (oldest: {})",
2096 format_age(entries[0].ts, &now)
2097 );
2098 return Ok(());
2099 }
2100
2101 print_gc_table(&to_delete, &backup_root, &now, icons, color);
2102 println!();
2103 let total_freed: u64 = to_delete.iter().map(|e| e.size_bytes).sum();
2104
2105 if dry_run {
2106 println!(
2107 " [dry-run] would remove {} of {} entries · would free {} of {}",
2108 to_delete.len(),
2109 entries.len(),
2110 format_bytes(total_freed),
2111 format_bytes(total_before),
2112 );
2113 return Ok(());
2114 }
2115
2116 for entry in &to_delete {
2117 match entry.kind {
2118 BackupKind::File => std::fs::remove_file(&entry.path)?,
2119 BackupKind::Dir => std::fs::remove_dir_all(&entry.path)?,
2120 }
2121 if let Some(parent) = entry.path.parent() {
2122 cleanup_empty_parents(parent, &backup_root);
2123 }
2124 }
2125 println!(
2126 " removed {} of {} entries · freed {} (was {}, now {})",
2127 to_delete.len(),
2128 entries.len(),
2129 format_bytes(total_freed),
2130 format_bytes(total_before),
2131 format_bytes(total_before - total_freed),
2132 );
2133 Ok(())
2134 }
2135 }
2136}
2137
2138#[derive(Debug)]
2139struct BackupEntry {
2140 path: Utf8PathBuf,
2141 ts: jiff::civil::DateTime,
2142 kind: BackupKind,
2143 size_bytes: u64,
2144}
2145
2146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2147enum BackupKind {
2148 File,
2149 Dir,
2150}
2151
2152fn walk_gc_backups(root: &Utf8Path) -> Result<Vec<BackupEntry>> {
2157 let mut out = Vec::new();
2158 walk_gc_backups_rec(root, &mut out)?;
2159 Ok(out)
2160}
2161
2162fn walk_gc_backups_rec(dir: &Utf8Path, out: &mut Vec<BackupEntry>) -> Result<()> {
2163 for entry in std::fs::read_dir(dir)? {
2164 let entry = entry?;
2165 let name_os = entry.file_name();
2166 let Some(name) = name_os.to_str() else {
2167 continue;
2168 };
2169 let path = dir.join(name);
2170 let ft = entry.file_type()?;
2171 if ft.is_dir() {
2172 if let Some(ts) = parse_backup_suffix(name) {
2173 let size = dir_size(&path)?;
2174 out.push(BackupEntry {
2175 path,
2176 ts,
2177 kind: BackupKind::Dir,
2178 size_bytes: size,
2179 });
2180 } else {
2181 walk_gc_backups_rec(&path, out)?;
2182 }
2183 } else if ft.is_file() {
2184 if let Some(ts) = parse_backup_suffix(name) {
2187 let size = entry.metadata()?.len();
2188 out.push(BackupEntry {
2189 path,
2190 ts,
2191 kind: BackupKind::File,
2192 size_bytes: size,
2193 });
2194 }
2195 }
2196 }
2197 Ok(())
2198}
2199
2200fn dir_size(dir: &Utf8Path) -> Result<u64> {
2201 let mut total: u64 = 0;
2202 for entry in std::fs::read_dir(dir)? {
2203 let entry = entry?;
2204 let ft = entry.file_type()?;
2205 if ft.is_dir() {
2206 let p = match Utf8PathBuf::from_path_buf(entry.path()) {
2207 Ok(p) => p,
2208 Err(_) => continue,
2209 };
2210 total = total.saturating_add(dir_size(&p)?);
2211 } else if ft.is_file() {
2212 total = total.saturating_add(entry.metadata()?.len());
2213 }
2214 }
2215 Ok(total)
2216}
2217
2218fn cleanup_empty_parents(start: &Utf8Path, root: &Utf8Path) {
2222 let mut cur = start.to_path_buf();
2223 loop {
2224 if cur == *root {
2225 return;
2226 }
2227 if std::fs::remove_dir(&cur).is_err() {
2229 return;
2230 }
2231 match cur.parent() {
2232 Some(p) => cur = p.to_path_buf(),
2233 None => return,
2234 }
2235 }
2236}
2237
2238fn parse_backup_suffix(name: &str) -> Option<jiff::civil::DateTime> {
2244 if let Some(ts) = parse_ts_at_end(name) {
2245 return Some(ts);
2246 }
2247 if let Some((before, _ext)) = name.rsplit_once('.') {
2250 if let Some(ts) = parse_ts_at_end(before) {
2251 return Some(ts);
2252 }
2253 }
2254 None
2255}
2256
2257fn parse_ts_at_end(s: &str) -> Option<jiff::civil::DateTime> {
2258 if s.len() < 20 {
2260 return None;
2261 }
2262 let split_at = s.len() - 19;
2263 if s.as_bytes()[split_at] != b'_' {
2264 return None;
2265 }
2266 parse_ts(&s[split_at + 1..])
2267}
2268
2269fn parse_ts(s: &str) -> Option<jiff::civil::DateTime> {
2271 if s.len() != 18 || s.as_bytes()[8] != b'_' {
2272 return None;
2273 }
2274 for (i, &b) in s.as_bytes().iter().enumerate() {
2275 if i == 8 {
2276 continue;
2277 }
2278 if !b.is_ascii_digit() {
2279 return None;
2280 }
2281 }
2282 let year: i16 = s[0..4].parse().ok()?;
2283 let month: i8 = s[4..6].parse().ok()?;
2284 let day: i8 = s[6..8].parse().ok()?;
2285 let hour: i8 = s[9..11].parse().ok()?;
2286 let minute: i8 = s[11..13].parse().ok()?;
2287 let second: i8 = s[13..15].parse().ok()?;
2288 let ms: i32 = s[15..18].parse().ok()?;
2289 jiff::civil::DateTime::new(year, month, day, hour, minute, second, ms * 1_000_000).ok()
2290}
2291
2292fn parse_human_duration(s: &str) -> Result<jiff::Span> {
2301 let s = s.trim();
2302 let split = s
2303 .bytes()
2304 .position(|b| b.is_ascii_alphabetic())
2305 .ok_or_else(|| anyhow::anyhow!("invalid duration {s:?}: missing unit (e.g. 30d, 2w)"))?;
2306 let n: i64 = s[..split]
2307 .trim()
2308 .parse()
2309 .map_err(|_| anyhow::anyhow!("invalid duration {s:?}: bad leading number"))?;
2310 if n < 0 {
2311 anyhow::bail!("invalid duration {s:?}: negative durations don't make sense");
2312 }
2313 let unit = s[split..].to_ascii_lowercase();
2314 let span = match unit.as_str() {
2315 "y" | "yr" | "year" | "years" => jiff::Span::new().years(n),
2316 "mo" | "month" | "months" => jiff::Span::new().months(n),
2317 "w" | "wk" | "week" | "weeks" => jiff::Span::new().weeks(n),
2318 "d" | "day" | "days" => jiff::Span::new().days(n),
2319 "h" | "hr" | "hour" | "hours" => jiff::Span::new().hours(n),
2320 "m" | "min" | "minute" | "minutes" => jiff::Span::new().minutes(n),
2321 other => {
2322 anyhow::bail!(
2323 "invalid duration {s:?}: unknown unit {other:?} \
2324 (use y / mo / w / d / h / m)"
2325 )
2326 }
2327 };
2328 Ok(span)
2329}
2330
2331fn format_bytes(n: u64) -> String {
2332 const KIB: u64 = 1024;
2333 const MIB: u64 = KIB * 1024;
2334 const GIB: u64 = MIB * 1024;
2335 if n >= GIB {
2336 format!("{:.1} GiB", n as f64 / GIB as f64)
2337 } else if n >= MIB {
2338 format!("{:.1} MiB", n as f64 / MIB as f64)
2339 } else if n >= KIB {
2340 format!("{:.1} KiB", n as f64 / KIB as f64)
2341 } else {
2342 format!("{n} B")
2343 }
2344}
2345
2346fn format_age(ts: jiff::civil::DateTime, now: &jiff::Zoned) -> String {
2347 let Ok(ts_zoned) = ts.to_zoned(now.time_zone().clone()) else {
2348 return "?".into();
2349 };
2350 let secs = match (now - &ts_zoned).total(jiff::Unit::Second) {
2351 Ok(s) => s as i64,
2352 Err(_) => return "?".into(),
2353 };
2354 if secs < 0 {
2355 return "future".into();
2356 }
2357 if secs < 60 {
2358 format!("{secs}s")
2359 } else if secs < 3600 {
2360 format!("{}m", secs / 60)
2361 } else if secs < 86_400 {
2362 format!("{}h", secs / 3600)
2363 } else if secs < 86_400 * 30 {
2364 format!("{}d", secs / 86_400)
2365 } else if secs < 86_400 * 365 {
2366 format!("{}mo", secs / (86_400 * 30))
2367 } else {
2368 format!("{}y", secs / (86_400 * 365))
2369 }
2370}
2371
2372fn print_gc_table(
2379 entries: &[&BackupEntry],
2380 backup_root: &Utf8Path,
2381 now: &jiff::Zoned,
2382 _icons: Icons,
2383 color: bool,
2384) {
2385 use owo_colors::OwoColorize as _;
2386
2387 let rows: Vec<(String, String, String)> = entries
2388 .iter()
2389 .map(|e| {
2390 let rel = e
2391 .path
2392 .strip_prefix(backup_root)
2393 .map(Utf8PathBuf::from)
2394 .unwrap_or_else(|_| e.path.clone());
2395 let path_disp = match e.kind {
2396 BackupKind::Dir => format!("{rel}/"),
2397 BackupKind::File => rel.to_string(),
2398 };
2399 (format_age(e.ts, now), format_bytes(e.size_bytes), path_disp)
2400 })
2401 .collect();
2402
2403 let age_w = rows.iter().map(|r| r.0.len()).max().unwrap_or(3);
2404 let size_w = rows.iter().map(|r| r.1.len()).max().unwrap_or(4);
2405
2406 if color {
2407 println!(
2408 " {:<age_w$} {:>size_w$} {}",
2409 "AGE".dimmed(),
2410 "SIZE".dimmed(),
2411 "PATH".dimmed(),
2412 );
2413 } else {
2414 println!(" {:<age_w$} {:>size_w$} PATH", "AGE", "SIZE");
2415 }
2416 for (age, size, path) in &rows {
2417 if color {
2418 println!(
2419 " {:<age_w$} {:>size_w$} {}",
2420 age.yellow(),
2421 size,
2422 path.cyan(),
2423 );
2424 } else {
2425 println!(" {:<age_w$} {:>size_w$} {}", age, size, path);
2426 }
2427 }
2428}
2429
2430pub fn hooks_list(
2432 source: Option<Utf8PathBuf>,
2433 icons_override: Option<IconsMode>,
2434 no_color: bool,
2435) -> Result<()> {
2436 let source = resolve_source(source)?;
2437 let yui = YuiVars::detect(&source);
2438 let config = config::load(&source, &yui)?;
2439 let state = hook::State::load(&source)?;
2440
2441 let icons_mode = icons_override.unwrap_or(config.ui.icons);
2442 let icons = Icons::for_mode(icons_mode);
2443 let color = !no_color && supports_color_stdout();
2444
2445 if config.hook.is_empty() {
2446 println!("(no [[hook]] entries in config)");
2447 return Ok(());
2448 }
2449
2450 let mut engine = template::Engine::new();
2454 let tera_ctx = template::template_context(&yui, &config.vars);
2455 let rows: Vec<HookRow> = config
2456 .hook
2457 .iter()
2458 .map(|h| -> Result<HookRow> {
2459 let active = match &h.when {
2463 None => true,
2464 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
2465 };
2466 let last_run_at = state.hooks.get(&h.name).and_then(|s| s.last_run_at.clone());
2467 Ok(HookRow {
2468 name: h.name.clone(),
2469 phase: match h.phase {
2470 HookPhase::Pre => "pre",
2471 HookPhase::Post => "post",
2472 },
2473 when_run: match h.when_run {
2474 config::WhenRun::Once => "once",
2475 config::WhenRun::Onchange => "onchange",
2476 config::WhenRun::Every => "every",
2477 },
2478 last_run_at,
2479 when: h.when.clone(),
2480 active,
2481 })
2482 })
2483 .collect::<Result<Vec<_>>>()?;
2484
2485 print_hooks_table(&rows, icons, color);
2486
2487 let total = rows.len();
2488 let active = rows.iter().filter(|r| r.active).count();
2489 let inactive = total - active;
2490 let ran = rows.iter().filter(|r| r.last_run_at.is_some()).count();
2491 let never = total - ran;
2492 println!();
2493 println!(
2494 " {total} hooks · {active} active · {inactive} inactive · {ran} ran · {never} never run"
2495 );
2496
2497 Ok(())
2498}
2499
2500#[derive(Debug)]
2501struct HookRow {
2502 name: String,
2503 phase: &'static str,
2504 when_run: &'static str,
2505 last_run_at: Option<String>,
2506 when: Option<String>,
2507 active: bool,
2508}
2509
2510fn print_hooks_table(rows: &[HookRow], icons: Icons, color: bool) {
2511 use owo_colors::OwoColorize as _;
2512 use std::fmt::Write as _;
2513
2514 let name_w = rows
2515 .iter()
2516 .map(|r| r.name.chars().count())
2517 .max()
2518 .unwrap_or(0)
2519 .max("NAME".len());
2520 let phase_w = rows
2521 .iter()
2522 .map(|r| r.phase.len())
2523 .max()
2524 .unwrap_or(0)
2525 .max("PHASE".len());
2526 let when_run_w = rows
2527 .iter()
2528 .map(|r| r.when_run.len())
2529 .max()
2530 .unwrap_or(0)
2531 .max("WHEN_RUN".len());
2532 let last_w = rows
2533 .iter()
2534 .map(|r| {
2535 r.last_run_at
2536 .as_deref()
2537 .map(|s| s.chars().count())
2538 .unwrap_or("(never)".len())
2539 })
2540 .max()
2541 .unwrap_or(0)
2542 .max("LAST_RUN".len());
2543 let status_w = "STATUS".len();
2544
2545 let mut header = String::new();
2547 let _ = write!(
2548 &mut header,
2549 " {:<status_w$} {:<name_w$} {:<phase_w$} {:<when_run_w$} {:<last_w$} WHEN",
2550 "STATUS", "NAME", "PHASE", "WHEN_RUN", "LAST_RUN"
2551 );
2552 if color {
2553 println!("{}", header.bold());
2554 } else {
2555 println!("{header}");
2556 }
2557
2558 let bar = |n: usize| icons.sep.to_string().repeat(n);
2560 let sep = format!(
2561 " {} {} {} {} {} {}",
2562 bar(status_w),
2563 bar(name_w),
2564 bar(phase_w),
2565 bar(when_run_w),
2566 bar(last_w),
2567 bar("WHEN".len())
2568 );
2569 if color {
2570 println!("{}", sep.dimmed());
2571 } else {
2572 println!("{sep}");
2573 }
2574
2575 for r in rows {
2577 let (icon, ran) = match (r.active, r.last_run_at.is_some()) {
2582 (false, _) => (icons.inactive, false),
2583 (true, true) => (icons.active, true),
2584 (true, false) => (icons.info, false),
2585 };
2586 let last = r.last_run_at.as_deref().unwrap_or("(never)");
2587 let when_str = r
2588 .when
2589 .as_deref()
2590 .map(strip_braces)
2591 .unwrap_or_else(|| "(always)".to_string());
2592
2593 let cell_status = format!("{icon:<status_w$}");
2594 let cell_name = format!("{:<name_w$}", r.name);
2595 let cell_phase = format!("{:<phase_w$}", r.phase);
2596 let cell_when_run = format!("{:<when_run_w$}", r.when_run);
2597 let cell_last = format!("{last:<last_w$}");
2598
2599 if !color {
2600 println!(
2601 " {cell_status} {cell_name} {cell_phase} {cell_when_run} {cell_last} {when_str}"
2602 );
2603 continue;
2604 }
2605
2606 if !r.active {
2610 println!(
2611 " {} {} {} {} {} {}",
2612 cell_status.dimmed(),
2613 cell_name.dimmed(),
2614 cell_phase.dimmed(),
2615 cell_when_run.dimmed(),
2616 cell_last.dimmed(),
2617 when_str.dimmed()
2618 );
2619 } else if ran {
2620 println!(
2621 " {} {} {} {} {} {}",
2622 cell_status.green(),
2623 cell_name.cyan().bold(),
2624 cell_phase.dimmed(),
2625 cell_when_run.dimmed(),
2626 cell_last.green(),
2627 when_str.dimmed()
2628 );
2629 } else {
2630 println!(
2631 " {} {} {} {} {} {}",
2632 cell_status.yellow(),
2633 cell_name.cyan().bold(),
2634 cell_phase.dimmed(),
2635 cell_when_run.dimmed(),
2636 cell_last.yellow(),
2637 when_str.dimmed()
2638 );
2639 }
2640 }
2641}
2642
2643pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
2647 let source = resolve_source(source)?;
2648 let yui = YuiVars::detect(&source);
2649 let config = config::load(&source, &yui)?;
2650 let mut engine = template::Engine::new();
2651 let tera_ctx = template::template_context(&yui, &config.vars);
2652
2653 let targets: Vec<&config::HookConfig> = match &name {
2654 Some(want) => {
2655 let m = config
2656 .hook
2657 .iter()
2658 .find(|h| &h.name == want)
2659 .ok_or_else(|| {
2660 anyhow::anyhow!(
2661 "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
2662 )
2663 })?;
2664 vec![m]
2665 }
2666 None => config.hook.iter().collect(),
2667 };
2668
2669 let mut state = hook::State::load(&source)?;
2670 for h in targets {
2671 let outcome = hook::run_hook(
2672 h,
2673 &source,
2674 &yui,
2675 &config.vars,
2676 &mut engine,
2677 &tera_ctx,
2678 &mut state,
2679 false,
2680 force,
2681 )?;
2682 let label = match outcome {
2683 HookOutcome::Ran => "ran",
2684 HookOutcome::SkippedOnce => "skipped (once: already ran)",
2685 HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
2686 HookOutcome::SkippedWhenFalse => "skipped (when=false)",
2687 HookOutcome::DryRun => "would run (dry-run)",
2688 };
2689 info!("hook[{}]: {label}", h.name);
2690 if outcome == HookOutcome::Ran {
2691 state.save(&source)?;
2692 }
2693 }
2694 Ok(())
2695}
2696
2697#[allow(clippy::too_many_arguments)]
2702fn process_mount(
2703 m: &ResolvedMount,
2704 ctx: &ApplyCtx<'_>,
2705 engine: &mut template::Engine,
2706 tera_ctx: &TeraContext,
2707 yuiignore: &mut paths::YuiIgnoreStack,
2708) -> Result<()> {
2709 let src_root = m.src.clone();
2712 if !src_root.is_dir() {
2713 warn!("mount src missing: {src_root}");
2714 return Ok(());
2715 }
2716 walk_and_link(
2717 &src_root, &m.dst, ctx, m.strategy, engine, tera_ctx, yuiignore, false,
2718 )
2719}
2720
2721#[allow(clippy::too_many_arguments)]
2722fn walk_and_link(
2723 src_dir: &Utf8Path,
2724 dst_dir: &Utf8Path,
2725 ctx: &ApplyCtx<'_>,
2726 strategy: MountStrategy,
2727 engine: &mut template::Engine,
2728 tera_ctx: &TeraContext,
2729 yuiignore: &mut paths::YuiIgnoreStack,
2730 parent_covered: bool,
2731) -> Result<()> {
2732 if yuiignore.is_ignored(src_dir, true) {
2735 return Ok(());
2736 }
2737 yuiignore.push_dir(src_dir)?;
2740 let result = walk_and_link_body(
2741 src_dir,
2742 dst_dir,
2743 ctx,
2744 strategy,
2745 engine,
2746 tera_ctx,
2747 yuiignore,
2748 parent_covered,
2749 );
2750 yuiignore.pop_dir(src_dir);
2751 result
2752}
2753
2754#[allow(clippy::too_many_arguments)]
2755fn walk_and_link_body(
2756 src_dir: &Utf8Path,
2757 dst_dir: &Utf8Path,
2758 ctx: &ApplyCtx<'_>,
2759 strategy: MountStrategy,
2760 engine: &mut template::Engine,
2761 tera_ctx: &TeraContext,
2762 yuiignore: &mut paths::YuiIgnoreStack,
2763 parent_covered: bool,
2764) -> Result<()> {
2765 let marker_filename = &ctx.config.mount.marker_filename;
2766 let mut covered = parent_covered;
2767
2768 if strategy == MountStrategy::Marker {
2769 match marker::read_spec(src_dir, marker_filename)? {
2770 None => {} Some(MarkerSpec::PassThrough) => {
2772 link_dir_with_backup(src_dir, dst_dir, ctx)?;
2776 covered = true;
2777 }
2778 Some(MarkerSpec::Explicit { links }) => {
2779 let mut emitted_dir_link = false;
2780 let mut emitted_any = false;
2781 for link in &links {
2782 if let Some(when) = &link.when {
2785 if !template::eval_truthy(when, engine, tera_ctx)? {
2786 continue;
2787 }
2788 }
2789 let dst_str = engine.render(&link.dst, tera_ctx)?;
2790 let dst = paths::expand_tilde(dst_str.trim());
2791 if let Some(filename) = &link.src {
2792 let file_src = src_dir.join(filename);
2793 if !file_src.is_file() {
2794 anyhow::bail!(
2795 "marker at {src_dir}: [[link]] src={filename:?} \
2796 not found"
2797 );
2798 }
2799 link_file_with_backup(&file_src, &dst, ctx)?;
2800 } else {
2801 link_dir_with_backup(src_dir, &dst, ctx)?;
2802 emitted_dir_link = true;
2803 }
2804 emitted_any = true;
2805 }
2806 if !emitted_any {
2807 info!(
2812 "marker at {src_dir} had no active links \
2813 — falling back to defaults"
2814 );
2815 }
2816 if emitted_dir_link {
2817 covered = true;
2818 }
2819 }
2820 }
2821 }
2822
2823 for entry in std::fs::read_dir(src_dir)? {
2824 let entry = entry?;
2825 let name_os = entry.file_name();
2826 let Some(name) = name_os.to_str() else {
2827 continue;
2828 };
2829 if name == marker_filename {
2830 continue;
2831 }
2832 if name.ends_with(".tera") {
2833 continue;
2835 }
2836 let src_path = src_dir.join(name);
2837 let dst_path = dst_dir.join(name);
2838 let ft = entry.file_type()?;
2839
2840 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
2841 continue;
2842 }
2843
2844 if ft.is_dir() {
2845 walk_and_link(
2846 &src_path, &dst_path, ctx, strategy, engine, tera_ctx, yuiignore, covered,
2847 )?;
2848 } else if ft.is_file() {
2849 if !covered {
2855 link_file_with_backup(&src_path, &dst_path, ctx)?;
2856 }
2857 }
2858 }
2859 Ok(())
2860}
2861
2862fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2863 use absorb::AbsorbDecision::*;
2864
2865 let decision = absorb::classify(src, dst)?;
2866
2867 if ctx.dry_run {
2868 info!("[dry-run] {decision:?}: {src} → {dst}");
2869 return Ok(());
2870 }
2871
2872 match decision {
2873 InSync => {
2874 Ok(())
2876 }
2877 Restore => {
2878 info!("link: {src} → {dst}");
2879 link::link_file(src, dst, ctx.file_mode)?;
2880 Ok(())
2881 }
2882 RelinkOnly => {
2883 info!("relink: {src} → {dst}");
2886 link::unlink(dst)?;
2887 link::link_file(src, dst, ctx.file_mode)?;
2888 Ok(())
2889 }
2890 AutoAbsorb => {
2891 if !ctx.config.absorb.auto {
2894 return handle_anomaly(
2895 src,
2896 dst,
2897 ctx,
2898 "absorb.auto = false; treating divergence as anomaly",
2899 );
2900 }
2901 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
2902 return handle_anomaly(
2903 src,
2904 dst,
2905 ctx,
2906 "source repo is dirty; deferring auto-absorb",
2907 );
2908 }
2909 absorb_target_into_source(src, dst, ctx)
2910 }
2911 NeedsConfirm => handle_anomaly(
2912 src,
2913 dst,
2914 ctx,
2915 "anomaly: source equals/newer than target but content differs",
2916 ),
2917 }
2918}
2919
2920fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2924 info!("absorb: {dst} → {src}");
2925 backup_existing(src, ctx.backup_root, false)?;
2926 std::fs::copy(dst, src)?;
2927 link::unlink(dst)?;
2928 link::link_file(src, dst, ctx.file_mode)?;
2929 Ok(())
2930}
2931
2932fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
2938 use crate::config::AnomalyAction::*;
2939 match ctx.config.absorb.on_anomaly {
2940 Skip => {
2941 warn!("anomaly skip: {dst} ({reason})");
2942 Ok(())
2943 }
2944 Force => {
2945 warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
2946 absorb_target_into_source(src, dst, ctx)
2947 }
2948 Ask => {
2949 use std::io::IsTerminal;
2950 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
2951 if prompt_absorb_with_diff(src, dst, reason)? {
2952 absorb_target_into_source(src, dst, ctx)
2953 } else {
2954 warn!("anomaly skipped by user: {dst}");
2955 Ok(())
2956 }
2957 } else {
2958 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
2959 Ok(())
2960 }
2961 }
2962 }
2963}
2964
2965fn prompt_absorb_with_diff(src: &Utf8Path, dst: &Utf8Path, reason: &str) -> Result<bool> {
2966 eprintln!();
2967 eprintln!("anomaly: {reason}");
2968 print_absorb_diff(src, dst);
2969 prompt_yes_no("absorb target into source?")
2970}
2971
2972fn source_repo_is_clean(source: &Utf8Path) -> bool {
2977 match crate::git::is_clean(source) {
2978 Ok(b) => b,
2979 Err(e) => {
2980 warn!("git clean check failed at {source}: {e} — treating as clean");
2981 true
2982 }
2983 }
2984}
2985
2986fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2987 use absorb::AbsorbDecision::*;
2988 let decision = absorb::classify(src, dst)?;
2989
2990 if ctx.dry_run {
2991 info!("[dry-run] dir {decision:?}: {src} → {dst}");
2992 return Ok(());
2993 }
2994
2995 match decision {
2996 InSync => Ok(()),
2997 Restore => {
2998 info!("link dir: {src} → {dst}");
2999 link::link_dir(src, dst, ctx.dir_mode)?;
3000 Ok(())
3001 }
3002 RelinkOnly => {
3003 info!("relink dir: {src} → {dst}");
3008 remove_dir_link_or_real(dst)?;
3009 link::link_dir(src, dst, ctx.dir_mode)?;
3010 Ok(())
3011 }
3012 AutoAbsorb | NeedsConfirm => {
3013 if !ctx.config.absorb.auto {
3034 return handle_anomaly_dir(
3035 src,
3036 dst,
3037 ctx,
3038 "absorb.auto = false; treating divergence as anomaly",
3039 );
3040 }
3041 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
3042 return handle_anomaly_dir(
3043 src,
3044 dst,
3045 ctx,
3046 "source repo is dirty; deferring auto-absorb",
3047 );
3048 }
3049 absorb_target_dir_into_source(src, dst, ctx)
3050 }
3051 }
3052}
3053
3054fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
3064 if let Err(unlink_err) = link::unlink(dst) {
3065 let meta = std::fs::symlink_metadata(dst)
3066 .with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
3067 let ft = meta.file_type();
3068 if ft.is_dir() && !ft.is_symlink() {
3069 std::fs::remove_dir_all(dst).with_context(|| {
3070 format!(
3071 "remove_dir_all({dst}) after link::unlink failed: \
3072 {unlink_err}"
3073 )
3074 })?;
3075 } else {
3076 return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
3077 }
3078 }
3079 Ok(())
3080}
3081
3082fn merge_dir_target_into_source(
3092 target: &Utf8Path,
3093 source: &Utf8Path,
3094 ctx: &ApplyCtx<'_>,
3095) -> Result<()> {
3096 for entry in std::fs::read_dir(target)? {
3097 let entry = entry?;
3098 let name_os = entry.file_name();
3099 let Some(name) = name_os.to_str() else {
3100 continue;
3101 };
3102 let target_path = target.join(name);
3103 let source_path = source.join(name);
3104 let ft = entry.file_type()?;
3105
3106 if ft.is_dir() && !ft.is_symlink() {
3107 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
3113 let sft = src_meta.file_type();
3114 if !sft.is_dir() || sft.is_symlink() {
3115 link::unlink(&source_path).with_context(|| {
3116 format!("remove conflicting source entry before dir merge: {source_path}")
3117 })?;
3118 }
3119 }
3120 if !source_path.exists() {
3121 std::fs::create_dir_all(&source_path).with_context(|| {
3122 format!("create_dir_all({source_path}) during target→source merge")
3123 })?;
3124 }
3125 merge_dir_target_into_source(&target_path, &source_path, ctx)?;
3126 } else if ft.is_file() {
3127 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
3131 let sft = src_meta.file_type();
3132 if sft.is_dir() && !sft.is_symlink() {
3133 remove_dir_link_or_real(&source_path).with_context(|| {
3134 format!("remove conflicting source dir before file merge: {source_path}")
3135 })?;
3136 } else if sft.is_symlink() {
3137 link::unlink(&source_path).with_context(|| {
3138 format!(
3139 "remove conflicting source symlink before file merge: {source_path}"
3140 )
3141 })?;
3142 }
3143 }
3144 if let Some(parent) = source_path.parent() {
3145 if !parent.exists() {
3146 std::fs::create_dir_all(parent)?;
3147 }
3148 }
3149 if source_path.is_file() {
3163 merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
3164 } else {
3165 std::fs::copy(&target_path, &source_path)
3166 .with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
3167 }
3168 } else {
3169 warn!(
3170 "merge: skipping non-regular entry {target_path} \
3171 (symlink / junction / special — content not copied)"
3172 );
3173 }
3174 }
3175 Ok(())
3176}
3177
3178fn merge_resolve_file_conflict(
3192 target_path: &Utf8Path,
3193 source_path: &Utf8Path,
3194 ctx: &ApplyCtx<'_>,
3195) -> Result<()> {
3196 use absorb::AbsorbDecision::*;
3197 let decision = absorb::classify(source_path, target_path)?;
3198 match decision {
3199 InSync | RelinkOnly => Ok(()),
3200 AutoAbsorb => {
3201 std::fs::copy(target_path, source_path).with_context(|| {
3202 format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
3203 })?;
3204 Ok(())
3205 }
3206 Restore => {
3207 unreachable!(
3214 "merge_resolve_file_conflict reached with both files present, \
3215 but classify returned Restore (target {target_path} / source {source_path})"
3216 )
3217 }
3218 NeedsConfirm => {
3219 use crate::config::AnomalyAction::*;
3220 match ctx.config.absorb.on_anomaly {
3221 Skip => {
3222 warn!(
3223 "merge anomaly skip: {target_path} (source-newer / content drift) \
3224 — keeping source version, target version dropped"
3225 );
3226 Ok(())
3227 }
3228 Force => {
3229 warn!(
3230 "merge anomaly force: {target_path} \
3231 (source-newer / content drift) — overwriting source"
3232 );
3233 std::fs::copy(target_path, source_path)?;
3234 Ok(())
3235 }
3236 Ask => {
3237 use std::io::IsTerminal;
3238 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
3239 if prompt_absorb_with_diff(
3240 source_path,
3241 target_path,
3242 "merge: file content differs and source is newer",
3243 )? {
3244 std::fs::copy(target_path, source_path)?;
3245 } else {
3246 warn!("merge: kept source version by user choice: {source_path}");
3247 }
3248 Ok(())
3249 } else {
3250 warn!(
3251 "merge anomaly skip (non-TTY ask mode): {target_path} \
3252 — keeping source version"
3253 );
3254 Ok(())
3255 }
3256 }
3257 }
3258 }
3259 }
3260}
3261
3262fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3269 info!("absorb dir: {dst} → {src}");
3270 backup_existing(src, ctx.backup_root, true)?;
3271 merge_dir_target_into_source(dst, src, ctx)?;
3272 remove_dir_link_or_real(dst)?;
3275 link::link_dir(src, dst, ctx.dir_mode)?;
3276 Ok(())
3277}
3278
3279fn handle_anomaly_dir(
3283 src: &Utf8Path,
3284 dst: &Utf8Path,
3285 ctx: &ApplyCtx<'_>,
3286 reason: &str,
3287) -> Result<()> {
3288 use crate::config::AnomalyAction::*;
3289 match ctx.config.absorb.on_anomaly {
3290 Skip => {
3291 warn!("anomaly skip dir: {dst} ({reason})");
3292 Ok(())
3293 }
3294 Force => {
3295 warn!(
3296 "anomaly force dir: {dst} ({reason}) \
3297 — absorbing target into source"
3298 );
3299 absorb_target_dir_into_source(src, dst, ctx)
3300 }
3301 Ask => {
3302 use std::io::IsTerminal;
3303 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
3304 eprintln!();
3305 eprintln!("anomaly: {dst}");
3306 eprintln!(" {reason}");
3307 eprintln!(" source: {src}");
3308 eprint!(" absorb target dir into source? (y/N) ");
3309 use std::io::{BufRead as _, Write as _};
3310 std::io::stderr().flush().ok();
3311 let mut buf = String::new();
3312 std::io::stdin().lock().read_line(&mut buf)?;
3313 let answer = buf.trim();
3314 if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
3315 absorb_target_dir_into_source(src, dst, ctx)
3316 } else {
3317 warn!("anomaly skipped by user: {dst}");
3318 Ok(())
3319 }
3320 } else {
3321 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
3322 Ok(())
3323 }
3324 }
3325 }
3326}
3327
3328fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
3329 let abs_target = absolutize(target)?;
3330 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
3331 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
3332 info!("backup → {bp}");
3333 if is_dir {
3334 backup::backup_dir(target, &bp)?;
3335 } else {
3336 backup::backup_file(target, &bp)?;
3337 }
3338 Ok(())
3339}
3340
3341fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
3342 if let Some(s) = source {
3343 return absolutize(&s);
3344 }
3345 if let Ok(s) = std::env::var("YUI_SOURCE") {
3346 return absolutize(Utf8Path::new(&s));
3347 }
3348 let cwd = current_dir_utf8()?;
3349 for ancestor in cwd.ancestors() {
3350 if ancestor.join("config.toml").is_file() {
3351 return Ok(ancestor.to_path_buf());
3352 }
3353 }
3354 if let Some(home) = paths::home_dir() {
3355 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
3356 let p = home.join(c);
3357 if p.join("config.toml").is_file() {
3358 return Ok(p);
3359 }
3360 }
3361 }
3362 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
3363}
3364
3365fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
3366 let expanded = paths::expand_tilde(p.as_str());
3368 if expanded.is_absolute() {
3369 return Ok(expanded);
3370 }
3371 let cwd = current_dir_utf8()?;
3372 Ok(cwd.join(expanded))
3373}
3374
3375fn current_dir_utf8() -> Result<Utf8PathBuf> {
3376 let cwd = std::env::current_dir().context("getting cwd")?;
3377 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
3378}
3379
3380const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
3384
3385[vars]
3386# user-defined values; templates can reference these as {{ vars.foo }}
3387
3388# [link]
3389# file_mode = "auto" # auto | symlink | hardlink
3390# dir_mode = "auto" # auto | symlink | junction
3391
3392[mount]
3393default_strategy = "marker"
3394
3395[[mount.entry]]
3396src = "home"
3397# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
3398dst = "~"
3399
3400# [[mount.entry]]
3401# src = "appdata"
3402# dst = "{{ env(name='APPDATA') }}"
3403# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
3404# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
3405# when = "yui.os == 'windows'"
3406"#;
3407
3408const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
3409# .yui/bin/ is intentionally tracked — it holds your hook scripts.
3410/.yui/state.json
3411/.yui/state.json.tmp
3412/.yui/backup/
3413
3414# >>> yui rendered (auto-managed, do not edit) >>>
3415# <<< yui rendered (auto-managed) <<<
3416
3417# config.local.toml is per-machine; commit a config.local.example.toml instead.
3418config.local.toml
3419"#;
3420
3421#[cfg(test)]
3422mod tests {
3423 use super::*;
3424 use tempfile::TempDir;
3425
3426 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
3427 Utf8PathBuf::from_path_buf(p).unwrap()
3428 }
3429
3430 fn toml_path(p: &Utf8Path) -> String {
3432 p.as_str().replace('\\', "/")
3433 }
3434
3435 #[test]
3436 fn apply_links_a_raw_file() {
3437 let tmp = TempDir::new().unwrap();
3438 let source = utf8(tmp.path().join("dotfiles"));
3439 let target = utf8(tmp.path().join("target"));
3440 std::fs::create_dir_all(source.join("home")).unwrap();
3441 std::fs::create_dir_all(&target).unwrap();
3442 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3443
3444 let cfg = format!(
3445 r#"
3446[[mount.entry]]
3447src = "home"
3448dst = "{}"
3449"#,
3450 toml_path(&target)
3451 );
3452 std::fs::write(source.join("config.toml"), cfg).unwrap();
3453
3454 apply(Some(source), false).unwrap();
3455
3456 let linked = target.join(".bashrc");
3457 assert!(linked.exists(), "expected {linked} to exist");
3458 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
3459 }
3460
3461 #[test]
3462 fn apply_with_marker_links_whole_directory() {
3463 let tmp = TempDir::new().unwrap();
3464 let source = utf8(tmp.path().join("dotfiles"));
3465 let target = utf8(tmp.path().join("target"));
3466 let nvim_src = source.join("home/nvim");
3467 std::fs::create_dir_all(&nvim_src).unwrap();
3468 std::fs::create_dir_all(&target).unwrap();
3469 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
3470 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
3471 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
3472
3473 let cfg = format!(
3474 r#"
3475[[mount.entry]]
3476src = "home"
3477dst = "{}"
3478"#,
3479 toml_path(&target)
3480 );
3481 std::fs::write(source.join("config.toml"), cfg).unwrap();
3482
3483 apply(Some(source.clone()), false).unwrap();
3484
3485 let nvim_dst = target.join("nvim");
3486 assert!(nvim_dst.exists());
3487 assert_eq!(
3488 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
3489 "-- hi\n"
3490 );
3491 }
3495
3496 #[test]
3497 fn apply_dry_run_does_not_write() {
3498 let tmp = TempDir::new().unwrap();
3499 let source = utf8(tmp.path().join("dotfiles"));
3500 let target = utf8(tmp.path().join("target"));
3501 std::fs::create_dir_all(source.join("home")).unwrap();
3502 std::fs::create_dir_all(&target).unwrap();
3503 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
3504
3505 let cfg = format!(
3506 r#"
3507[[mount.entry]]
3508src = "home"
3509dst = "{}"
3510"#,
3511 toml_path(&target)
3512 );
3513 std::fs::write(source.join("config.toml"), cfg).unwrap();
3514
3515 apply(Some(source), true).unwrap();
3516
3517 assert!(!target.join(".bashrc").exists());
3518 }
3519
3520 #[test]
3521 fn apply_renders_templates_then_links_rendered_outputs() {
3522 let tmp = TempDir::new().unwrap();
3523 let source = utf8(tmp.path().join("dotfiles"));
3524 let target = utf8(tmp.path().join("target"));
3525 std::fs::create_dir_all(source.join("home")).unwrap();
3526 std::fs::create_dir_all(&target).unwrap();
3527 std::fs::write(
3528 source.join("home/.gitconfig.tera"),
3529 "[user]\n os = {{ yui.os }}\n",
3530 )
3531 .unwrap();
3532 std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
3533
3534 let cfg = format!(
3535 r#"
3536[[mount.entry]]
3537src = "home"
3538dst = "{}"
3539"#,
3540 toml_path(&target)
3541 );
3542 std::fs::write(source.join("config.toml"), cfg).unwrap();
3543
3544 apply(Some(source.clone()), false).unwrap();
3545
3546 assert!(target.join(".bashrc").exists());
3548 assert!(source.join("home/.gitconfig").exists());
3550 assert!(target.join(".gitconfig").exists());
3551 assert!(!target.join(".gitconfig.tera").exists());
3553 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
3555 assert!(linked.contains("os = "));
3556 }
3557
3558 #[test]
3559 fn apply_marker_override_links_to_custom_dst() {
3560 let tmp = TempDir::new().unwrap();
3561 let source = utf8(tmp.path().join("dotfiles"));
3562 let target_a = utf8(tmp.path().join("target_a"));
3563 let target_b = utf8(tmp.path().join("target_b"));
3564 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3565 std::fs::create_dir_all(&target_a).unwrap();
3566 std::fs::create_dir_all(&target_b).unwrap();
3567 std::fs::write(
3568 source.join("home/.config/nvim/init.lua"),
3569 "-- nvim config\n",
3570 )
3571 .unwrap();
3572
3573 std::fs::write(
3576 source.join("home/.config/nvim/.yuilink"),
3577 format!(
3578 r#"
3579[[link]]
3580dst = "{}/nvim"
3581
3582[[link]]
3583dst = "{}/nvim"
3584when = "{{{{ yui.os == '{}' }}}}"
3585"#,
3586 toml_path(&target_a),
3587 toml_path(&target_b),
3588 std::env::consts::OS
3589 ),
3590 )
3591 .unwrap();
3592
3593 let parent_target = utf8(tmp.path().join("parent_target"));
3594 std::fs::create_dir_all(&parent_target).unwrap();
3595 let cfg = format!(
3596 r#"
3597[[mount.entry]]
3598src = "home"
3599dst = "{}"
3600"#,
3601 toml_path(&parent_target)
3602 );
3603 std::fs::write(source.join("config.toml"), cfg).unwrap();
3604
3605 apply(Some(source.clone()), false).unwrap();
3606
3607 assert!(
3609 target_a.join("nvim/init.lua").exists(),
3610 "target_a/nvim/init.lua should be reachable through the link"
3611 );
3612 assert!(
3613 target_b.join("nvim/init.lua").exists(),
3614 "target_b/nvim/init.lua should be reachable through the link"
3615 );
3616 assert!(
3619 !parent_target.join(".config/nvim").exists(),
3620 "parent mount should have skipped the marker-claimed sub-dir"
3621 );
3622 }
3623
3624 #[test]
3625 fn apply_marker_inactive_link_falls_through_to_default() {
3626 let tmp = TempDir::new().unwrap();
3631 let source = utf8(tmp.path().join("dotfiles"));
3632 let target_inactive = utf8(tmp.path().join("inactive"));
3633 let parent_target = utf8(tmp.path().join("parent"));
3634 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3635 std::fs::create_dir_all(&parent_target).unwrap();
3636 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
3637
3638 std::fs::write(
3640 source.join("home/.config/nvim/.yuilink"),
3641 format!(
3642 r#"
3643[[link]]
3644dst = "{}/nvim"
3645when = "{{{{ yui.os == 'no-such-os' }}}}"
3646"#,
3647 toml_path(&target_inactive)
3648 ),
3649 )
3650 .unwrap();
3651
3652 let cfg = format!(
3653 r#"
3654[[mount.entry]]
3655src = "home"
3656dst = "{}"
3657"#,
3658 toml_path(&parent_target)
3659 );
3660 std::fs::write(source.join("config.toml"), cfg).unwrap();
3661
3662 apply(Some(source.clone()), false).unwrap();
3663
3664 assert!(!target_inactive.join("nvim").exists());
3666 assert!(parent_target.join(".config/nvim/init.lua").exists());
3669 }
3670
3671 #[test]
3672 fn list_shows_mount_entries_and_marker_overrides() {
3673 let tmp = TempDir::new().unwrap();
3674 let source = utf8(tmp.path().join("dotfiles"));
3675 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3676 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
3677 std::fs::write(
3678 source.join("home/.config/nvim/.yuilink"),
3679 r#"
3680[[link]]
3681dst = "/custom/nvim"
3682"#,
3683 )
3684 .unwrap();
3685 std::fs::write(
3686 source.join("config.toml"),
3687 r#"
3688[[mount.entry]]
3689src = "home"
3690dst = "/h"
3691"#,
3692 )
3693 .unwrap();
3694
3695 list(Some(source), false, None, true).unwrap();
3698 }
3699
3700 #[test]
3701 fn status_reports_in_sync_after_apply() {
3702 let tmp = TempDir::new().unwrap();
3703 let source = utf8(tmp.path().join("dotfiles"));
3704 let target = utf8(tmp.path().join("target"));
3705 std::fs::create_dir_all(source.join("home")).unwrap();
3706 std::fs::create_dir_all(&target).unwrap();
3707 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3708 let cfg = format!(
3709 r#"
3710[[mount.entry]]
3711src = "home"
3712dst = "{}"
3713"#,
3714 toml_path(&target)
3715 );
3716 std::fs::write(source.join("config.toml"), cfg).unwrap();
3717 apply(Some(source.clone()), false).unwrap();
3719 status(Some(source), None, true).unwrap();
3721 }
3722
3723 #[test]
3724 fn status_reports_template_drift() {
3725 let tmp = TempDir::new().unwrap();
3726 let source = utf8(tmp.path().join("dotfiles"));
3727 let target = utf8(tmp.path().join("target"));
3728 std::fs::create_dir_all(source.join("home")).unwrap();
3729 std::fs::create_dir_all(&target).unwrap();
3730 std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
3733 std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
3734
3735 let cfg = format!(
3736 r#"
3737[[mount.entry]]
3738src = "home"
3739dst = "{}"
3740"#,
3741 toml_path(&target)
3742 );
3743 std::fs::write(source.join("config.toml"), cfg).unwrap();
3744
3745 let err = status(Some(source), None, true).unwrap_err();
3746 assert!(format!("{err}").contains("diverged"));
3747 }
3748
3749 #[test]
3750 fn status_fails_when_target_missing() {
3751 let tmp = TempDir::new().unwrap();
3752 let source = utf8(tmp.path().join("dotfiles"));
3753 let target = utf8(tmp.path().join("target"));
3754 std::fs::create_dir_all(source.join("home")).unwrap();
3755 std::fs::create_dir_all(&target).unwrap();
3756 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3757 let cfg = format!(
3758 r#"
3759[[mount.entry]]
3760src = "home"
3761dst = "{}"
3762"#,
3763 toml_path(&target)
3764 );
3765 std::fs::write(source.join("config.toml"), cfg).unwrap();
3766 let err = status(Some(source), None, true).unwrap_err();
3768 assert!(format!("{err}").contains("diverged"));
3769 }
3770
3771 #[test]
3772 fn strip_braces_removes_outer_template_braces() {
3773 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
3774 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
3775 assert_eq!(strip_braces(" {{x}} "), "x");
3776 }
3777
3778 #[test]
3779 fn apply_aborts_on_render_drift() {
3780 let tmp = TempDir::new().unwrap();
3781 let source = utf8(tmp.path().join("dotfiles"));
3782 let target = utf8(tmp.path().join("target"));
3783 std::fs::create_dir_all(source.join("home")).unwrap();
3784 std::fs::create_dir_all(&target).unwrap();
3785 std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
3786 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
3787
3788 let cfg = format!(
3789 r#"
3790[[mount.entry]]
3791src = "home"
3792dst = "{}"
3793"#,
3794 toml_path(&target)
3795 );
3796 std::fs::write(source.join("config.toml"), cfg).unwrap();
3797
3798 let err = apply(Some(source.clone()), false).unwrap_err();
3799 assert!(format!("{err}").contains("drift"));
3800 assert_eq!(
3802 std::fs::read_to_string(source.join("home/foo")).unwrap(),
3803 "manually edited"
3804 );
3805 assert!(!target.join("foo").exists());
3807 }
3808
3809 #[test]
3810 fn init_creates_skeleton_when_dir_empty() {
3811 let tmp = TempDir::new().unwrap();
3812 let dir = utf8(tmp.path().join("new_dotfiles"));
3813 init(Some(dir.clone()), false).unwrap();
3814 assert!(dir.join("config.toml").is_file());
3815 assert!(dir.join(".gitignore").is_file());
3816 }
3817
3818 #[test]
3819 fn init_refuses_to_overwrite_existing_config() {
3820 let tmp = TempDir::new().unwrap();
3821 let dir = utf8(tmp.path().join("dotfiles"));
3822 std::fs::create_dir_all(&dir).unwrap();
3823 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
3824 let err = init(Some(dir), false).unwrap_err();
3825 assert!(format!("{err}").contains("already exists"));
3826 }
3827
3828 #[test]
3834 fn init_appends_missing_gitignore_entries_into_existing_file() {
3835 let tmp = TempDir::new().unwrap();
3836 let dir = utf8(tmp.path().join("dotfiles"));
3837 std::fs::create_dir_all(&dir).unwrap();
3838 let user_gitignore = "# user entries\n*.swp\nnode_modules/\n";
3840 std::fs::write(dir.join(".gitignore"), user_gitignore).unwrap();
3841
3842 init(Some(dir.clone()), false).unwrap();
3843
3844 let body = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
3845 assert!(body.contains("*.swp"));
3847 assert!(body.contains("node_modules/"));
3848 assert!(body.contains("/.yui/state.json"));
3850 assert!(body.contains("/.yui/backup/"));
3851 assert!(body.contains("config.local.toml"));
3852 let before_rerun = body.clone();
3854 std::fs::remove_file(dir.join("config.toml")).unwrap();
3857 init(Some(dir.clone()), false).unwrap();
3858 let after_rerun = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
3859 assert_eq!(
3860 before_rerun, after_rerun,
3861 "init must be idempotent when the gitignore already has every yui entry"
3862 );
3863 }
3864
3865 #[test]
3871 fn init_with_git_hooks_installs_into_existing_repo() {
3872 let tmp = TempDir::new().unwrap();
3873 let dir = utf8(tmp.path().join("dotfiles"));
3874 std::fs::create_dir_all(&dir).unwrap();
3875 let st = std::process::Command::new("git")
3876 .args(["init", "-q"])
3877 .current_dir(dir.as_std_path())
3878 .status()
3879 .expect("git init");
3880 if !st.success() {
3881 return;
3882 }
3883 let user_config = "# user already wrote this\n";
3885 std::fs::write(dir.join("config.toml"), user_config).unwrap();
3886
3887 init(Some(dir.clone()), true).unwrap();
3889
3890 assert_eq!(
3891 std::fs::read_to_string(dir.join("config.toml")).unwrap(),
3892 user_config
3893 );
3894 assert!(dir.join(".git/hooks/pre-commit").is_file());
3895 assert!(dir.join(".git/hooks/pre-push").is_file());
3896 }
3897
3898 #[test]
3903 fn init_with_git_hooks_writes_pre_commit_and_pre_push() {
3904 let tmp = TempDir::new().unwrap();
3905 let dir = utf8(tmp.path().join("dotfiles"));
3906 std::fs::create_dir_all(&dir).unwrap();
3907 let st = std::process::Command::new("git")
3909 .args(["init", "-q"])
3910 .current_dir(dir.as_std_path())
3911 .status()
3912 .expect("git init");
3913 if !st.success() {
3914 eprintln!("skipping: git not available");
3916 return;
3917 }
3918 init(Some(dir.clone()), true).unwrap();
3919
3920 let pre_commit = dir.join(".git/hooks/pre-commit");
3921 let pre_push = dir.join(".git/hooks/pre-push");
3922 assert!(pre_commit.is_file(), "pre-commit hook should be written");
3923 assert!(pre_push.is_file(), "pre-push hook should be written");
3924
3925 let body = std::fs::read_to_string(&pre_commit).unwrap();
3926 assert!(
3927 body.contains("yui render --check"),
3928 "pre-commit hook should call `yui render --check`, got: {body}"
3929 );
3930 }
3931
3932 #[test]
3936 fn init_with_git_hooks_errors_outside_a_git_repo() {
3937 let tmp = TempDir::new().unwrap();
3938 let dir = utf8(tmp.path().join("not-a-repo"));
3939 std::fs::create_dir_all(&dir).unwrap();
3940 let err = init(Some(dir), true).unwrap_err();
3941 let msg = format!("{err:#}");
3942 assert!(
3943 msg.contains("git repo") || msg.contains("git rev-parse"),
3944 "expected error to mention the git issue, got: {msg}"
3945 );
3946 }
3947
3948 #[test]
3951 fn init_with_git_hooks_does_not_clobber_existing_hooks() {
3952 let tmp = TempDir::new().unwrap();
3953 let dir = utf8(tmp.path().join("dotfiles"));
3954 std::fs::create_dir_all(&dir).unwrap();
3955 let st = std::process::Command::new("git")
3956 .args(["init", "-q"])
3957 .current_dir(dir.as_std_path())
3958 .status()
3959 .expect("git init");
3960 if !st.success() {
3961 return;
3962 }
3963 let hooks = dir.join(".git/hooks");
3964 std::fs::create_dir_all(&hooks).unwrap();
3965 std::fs::write(hooks.join("pre-commit"), "#! /bin/sh\nexit 0\n").unwrap();
3966
3967 init(Some(dir.clone()), true).unwrap();
3968
3969 let pc = std::fs::read_to_string(hooks.join("pre-commit")).unwrap();
3971 assert!(
3972 !pc.contains("yui render --check"),
3973 "existing pre-commit must not be overwritten"
3974 );
3975 let pp = std::fs::read_to_string(hooks.join("pre-push")).unwrap();
3976 assert!(
3977 pp.contains("yui render --check"),
3978 "missing pre-push should be written: {pp}"
3979 );
3980 }
3981
3982 fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
3985 let source = utf8(tmp.path().join("dotfiles"));
3986 let target = utf8(tmp.path().join("target"));
3987 std::fs::create_dir_all(source.join("home")).unwrap();
3988 std::fs::create_dir_all(&target).unwrap();
3989 let cfg = format!(
3990 r#"
3991[[mount.entry]]
3992src = "home"
3993dst = "{}"
3994"#,
3995 toml_path(&target)
3996 );
3997 std::fs::write(source.join("config.toml"), cfg).unwrap();
3998 (source, target)
3999 }
4000
4001 fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
4002 std::fs::write(path, body).unwrap();
4003 let f = std::fs::OpenOptions::new()
4004 .write(true)
4005 .open(path)
4006 .expect("open writable");
4007 f.set_modified(when).expect("set_modified");
4008 }
4009
4010 #[test]
4011 fn apply_target_newer_absorbs_target_into_source() {
4012 let tmp = TempDir::new().unwrap();
4016 let (source, target) = setup_minimal_dotfiles(&tmp);
4017
4018 let now = std::time::SystemTime::now();
4019 let past = now - std::time::Duration::from_secs(120);
4020 write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
4021 write_with_mtime(&target.join(".bashrc"), "user's edit", now);
4023
4024 apply(Some(source.clone()), false).unwrap();
4025
4026 assert_eq!(
4028 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4029 "user's edit"
4030 );
4031 assert_eq!(
4033 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4034 "user's edit"
4035 );
4036 let backup_root = source.join(".yui/backup");
4038 let mut found_old = false;
4039 for entry in walkdir(&backup_root) {
4040 if let Ok(s) = std::fs::read_to_string(&entry) {
4041 if s == "default from repo" {
4042 found_old = true;
4043 break;
4044 }
4045 }
4046 }
4047 assert!(found_old, "expected backup containing 'default from repo'");
4048 }
4049
4050 #[test]
4051 fn apply_in_sync_target_is_a_no_op() {
4052 let tmp = TempDir::new().unwrap();
4055 let (source, target) = setup_minimal_dotfiles(&tmp);
4056 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4057 apply(Some(source.clone()), false).unwrap();
4058 let backup_root = source.join(".yui/backup");
4059 let backup_count_after_first = walkdir(&backup_root).len();
4060
4061 apply(Some(source.clone()), false).unwrap();
4063 assert_eq!(
4064 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4065 "echo hi\n"
4066 );
4067 let backup_count_after_second = walkdir(&backup_root).len();
4068 assert_eq!(
4069 backup_count_after_first, backup_count_after_second,
4070 "second apply on an in-sync tree should not produce backups"
4071 );
4072 }
4073
4074 #[test]
4075 fn apply_skip_policy_leaves_anomaly_alone() {
4076 let tmp = TempDir::new().unwrap();
4079 let source = utf8(tmp.path().join("dotfiles"));
4080 let target = utf8(tmp.path().join("target"));
4081 std::fs::create_dir_all(source.join("home")).unwrap();
4082 std::fs::create_dir_all(&target).unwrap();
4083 let cfg = format!(
4084 r#"
4085[absorb]
4086on_anomaly = "skip"
4087
4088[[mount.entry]]
4089src = "home"
4090dst = "{}"
4091"#,
4092 toml_path(&target)
4093 );
4094 std::fs::write(source.join("config.toml"), cfg).unwrap();
4095
4096 let now = std::time::SystemTime::now();
4097 let past = now - std::time::Duration::from_secs(120);
4098 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4099 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4100
4101 apply(Some(source.clone()), false).unwrap();
4102
4103 assert_eq!(
4105 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4106 "user's edit (older)"
4107 );
4108 assert_eq!(
4110 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4111 "fresh from upstream"
4112 );
4113 }
4114
4115 #[test]
4116 fn apply_force_policy_absorbs_anomaly_anyway() {
4117 let tmp = TempDir::new().unwrap();
4119 let source = utf8(tmp.path().join("dotfiles"));
4120 let target = utf8(tmp.path().join("target"));
4121 std::fs::create_dir_all(source.join("home")).unwrap();
4122 std::fs::create_dir_all(&target).unwrap();
4123 let cfg = format!(
4124 r#"
4125[absorb]
4126on_anomaly = "force"
4127
4128[[mount.entry]]
4129src = "home"
4130dst = "{}"
4131"#,
4132 toml_path(&target)
4133 );
4134 std::fs::write(source.join("config.toml"), cfg).unwrap();
4135
4136 let now = std::time::SystemTime::now();
4137 let past = now - std::time::Duration::from_secs(120);
4138 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4139 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4140
4141 apply(Some(source.clone()), false).unwrap();
4142
4143 assert_eq!(
4145 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4146 "user's edit (older)"
4147 );
4148 assert_eq!(
4149 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4150 "user's edit (older)"
4151 );
4152 }
4153
4154 #[test]
4166 fn apply_absorbs_non_empty_target_dir_target_wins() {
4167 let tmp = TempDir::new().unwrap();
4168 let source = utf8(tmp.path().join("dotfiles"));
4169 let target = utf8(tmp.path().join("target"));
4170 std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
4171 std::fs::create_dir_all(target.join(".config/app")).unwrap();
4172 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4175 std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
4176 std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
4178 std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
4181 std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
4182
4183 let cfg = format!(
4184 r#"
4185[absorb]
4186on_anomaly = "force"
4187
4188[[mount.entry]]
4189src = "home"
4190dst = "{}"
4191"#,
4192 toml_path(&target)
4193 );
4194 std::fs::write(source.join("config.toml"), cfg).unwrap();
4195
4196 apply(Some(source.clone()), false).unwrap();
4198
4199 assert_eq!(
4201 std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
4202 "target side"
4203 );
4204 assert_eq!(
4206 std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
4207 "{}"
4208 );
4209 let backup_root = source.join(".yui/backup");
4212 let mut backup_files: Vec<String> = Vec::new();
4213 for entry in walkdir(&backup_root) {
4214 if let Some(n) = entry.file_name() {
4215 backup_files.push(n.to_string());
4216 }
4217 }
4218 assert!(
4219 backup_files.iter().any(|f| f == "config.toml"),
4220 "expected source's config.toml to land in the backup tree, got {backup_files:?}"
4221 );
4222 assert!(
4224 source.join("home/.config/app/source-only.toml").exists(),
4225 "source-only file should survive a target-wins merge"
4226 );
4227 assert!(
4229 source.join("home/.config/app/state.json").exists(),
4230 "target-only state.json should be merged into source"
4231 );
4232 }
4233
4234 #[test]
4240 fn marker_dir_absorbs_with_default_ask_policy() {
4241 let tmp = TempDir::new().unwrap();
4242 let source = utf8(tmp.path().join("dotfiles"));
4243 let target = utf8(tmp.path().join("target"));
4244 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4245 std::fs::create_dir_all(target.join(".config/gh")).unwrap();
4246 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4248 std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\n").unwrap();
4250
4251 let cfg = format!(
4255 r#"
4256[[mount.entry]]
4257src = "home"
4258dst = "{}"
4259"#,
4260 toml_path(&target)
4261 );
4262 std::fs::write(source.join("config.toml"), cfg).unwrap();
4263
4264 apply(Some(source.clone()), false).unwrap();
4268
4269 assert!(target.join(".config/gh/hosts.yml").exists());
4272 assert!(source.join("home/.config/gh/hosts.yml").exists());
4273 }
4274
4275 #[test]
4281 fn merge_handles_file_vs_dir_collisions_target_wins() {
4282 let tmp = TempDir::new().unwrap();
4283 let source = utf8(tmp.path().join("dotfiles"));
4284 let target = utf8(tmp.path().join("target"));
4285 std::fs::create_dir_all(source.join("home/.config/foo")).unwrap();
4286 std::fs::create_dir_all(target.join(".config")).unwrap();
4287 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4288
4289 std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
4291 std::fs::write(target.join(".config/foo"), "target file body").unwrap();
4292 std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
4294 std::fs::create_dir_all(target.join(".config/bar")).unwrap();
4295 std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
4296
4297 let cfg = format!(
4298 r#"
4299[absorb]
4300on_anomaly = "force"
4301
4302[[mount.entry]]
4303src = "home"
4304dst = "{}"
4305"#,
4306 toml_path(&target)
4307 );
4308 std::fs::write(source.join("config.toml"), cfg).unwrap();
4309 apply(Some(source.clone()), false).unwrap();
4310
4311 let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
4315 assert!(foo_meta.file_type().is_file(), "foo should be a file");
4316 assert_eq!(
4317 std::fs::read_to_string(target.join(".config/foo")).unwrap(),
4318 "target file body"
4319 );
4320 let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
4322 assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
4323 assert_eq!(
4324 std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
4325 "target nested"
4326 );
4327 }
4328
4329 #[test]
4333 fn merge_per_file_target_newer_auto_absorbs() {
4334 let tmp = TempDir::new().unwrap();
4335 let source = utf8(tmp.path().join("dotfiles"));
4336 let target = utf8(tmp.path().join("target"));
4337 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4338 std::fs::create_dir_all(target.join(".config")).unwrap();
4339 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4340
4341 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4343 write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
4344 std::fs::write(target.join(".config/app.toml"), "user's live edit").unwrap();
4345
4346 let cfg = format!(
4350 r#"
4351[[mount.entry]]
4352src = "home"
4353dst = "{}"
4354"#,
4355 toml_path(&target)
4356 );
4357 std::fs::write(source.join("config.toml"), cfg).unwrap();
4358 apply(Some(source.clone()), false).unwrap();
4359
4360 assert_eq!(
4362 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4363 "user's live edit"
4364 );
4365 }
4366
4367 #[test]
4373 fn merge_per_file_source_newer_skip_keeps_source() {
4374 let tmp = TempDir::new().unwrap();
4375 let source = utf8(tmp.path().join("dotfiles"));
4376 let target = utf8(tmp.path().join("target"));
4377 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4378 std::fs::create_dir_all(target.join(".config")).unwrap();
4379 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4380
4381 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4383 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
4384 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
4385
4386 let cfg = format!(
4387 r#"
4388[absorb]
4389on_anomaly = "skip"
4390
4391[[mount.entry]]
4392src = "home"
4393dst = "{}"
4394"#,
4395 toml_path(&target)
4396 );
4397 std::fs::write(source.join("config.toml"), cfg).unwrap();
4398 apply(Some(source.clone()), false).unwrap();
4399
4400 assert_eq!(
4403 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4404 "fresh source"
4405 );
4406 }
4407
4408 #[test]
4411 fn merge_per_file_source_newer_force_overwrites_source() {
4412 let tmp = TempDir::new().unwrap();
4413 let source = utf8(tmp.path().join("dotfiles"));
4414 let target = utf8(tmp.path().join("target"));
4415 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4416 std::fs::create_dir_all(target.join(".config")).unwrap();
4417 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4418
4419 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4420 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
4421 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
4422
4423 let cfg = format!(
4424 r#"
4425[absorb]
4426on_anomaly = "force"
4427
4428[[mount.entry]]
4429src = "home"
4430dst = "{}"
4431"#,
4432 toml_path(&target)
4433 );
4434 std::fs::write(source.join("config.toml"), cfg).unwrap();
4435 apply(Some(source.clone()), false).unwrap();
4436
4437 assert_eq!(
4439 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4440 "old target"
4441 );
4442 }
4443
4444 #[test]
4449 fn merge_per_file_identical_content_is_noop() {
4450 let tmp = TempDir::new().unwrap();
4451 let source = utf8(tmp.path().join("dotfiles"));
4452 let target = utf8(tmp.path().join("target"));
4453 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4454 std::fs::create_dir_all(target.join(".config")).unwrap();
4455 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4456 std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
4457 std::fs::write(target.join(".config/app.toml"), "same").unwrap();
4458
4459 let cfg = format!(
4462 r#"
4463[[mount.entry]]
4464src = "home"
4465dst = "{}"
4466"#,
4467 toml_path(&target)
4468 );
4469 std::fs::write(source.join("config.toml"), cfg).unwrap();
4470 apply(Some(source.clone()), false).unwrap();
4471
4472 assert_eq!(
4473 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4474 "same"
4475 );
4476 }
4477
4478 #[test]
4479 fn manual_absorb_command_pulls_target_into_source() {
4480 let tmp = TempDir::new().unwrap();
4482 let source = utf8(tmp.path().join("dotfiles"));
4483 let target = utf8(tmp.path().join("target"));
4484 std::fs::create_dir_all(source.join("home")).unwrap();
4485 std::fs::create_dir_all(&target).unwrap();
4486 let cfg = format!(
4488 r#"
4489[absorb]
4490on_anomaly = "skip"
4491
4492[[mount.entry]]
4493src = "home"
4494dst = "{}"
4495"#,
4496 toml_path(&target)
4497 );
4498 std::fs::write(source.join("config.toml"), cfg).unwrap();
4499 std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
4500 std::fs::write(source.join("home/.bashrc"), "default").unwrap();
4501
4502 absorb(
4505 Some(source.clone()),
4506 target.join(".bashrc"),
4507 false,
4508 true,
4509 )
4510 .unwrap();
4511
4512 assert_eq!(
4514 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4515 "user picked this"
4516 );
4517 }
4518
4519 #[test]
4520 fn manual_absorb_errors_when_target_outside_known_mounts() {
4521 let tmp = TempDir::new().unwrap();
4522 let (source, _target) = setup_minimal_dotfiles(&tmp);
4523 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
4524 let stranger = utf8(tmp.path().join("not-managed/foo"));
4525 std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
4526 std::fs::write(&stranger, "not yui's").unwrap();
4527 let err = absorb(Some(source), stranger, false, true).unwrap_err();
4528 assert!(format!("{err}").contains("no mount entry"));
4529 }
4530
4531 #[test]
4532 fn yuiignore_excludes_file_from_linking() {
4533 let tmp = TempDir::new().unwrap();
4534 let (source, target) = setup_minimal_dotfiles(&tmp);
4535 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
4536 std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
4537 std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
4539 apply(Some(source.clone()), false).unwrap();
4540 assert!(target.join(".bashrc").exists());
4541 assert!(
4542 !target.join("lock.json").exists(),
4543 "yuiignore should keep lock.json out of target"
4544 );
4545 }
4546
4547 #[test]
4548 fn yuiignore_excludes_directory_subtree() {
4549 let tmp = TempDir::new().unwrap();
4550 let (source, target) = setup_minimal_dotfiles(&tmp);
4551 std::fs::create_dir_all(source.join("home/cache")).unwrap();
4552 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
4553 std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
4554 std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
4555 std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
4557 apply(Some(source.clone()), false).unwrap();
4558 assert!(target.join(".bashrc").exists());
4559 assert!(
4560 !target.join("cache").exists(),
4561 "yuiignore'd subtree should not appear in target"
4562 );
4563 }
4564
4565 #[test]
4566 fn yuiignore_negation_re_includes_file() {
4567 let tmp = TempDir::new().unwrap();
4568 let (source, target) = setup_minimal_dotfiles(&tmp);
4569 std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
4570 std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
4571 std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
4573 apply(Some(source.clone()), false).unwrap();
4574 assert!(target.join("keep.cache").exists());
4575 assert!(!target.join("drop.cache").exists());
4576 }
4577
4578 #[test]
4583 fn nested_yuiignore_only_affects_its_subtree() {
4584 let tmp = TempDir::new().unwrap();
4585 let (source, target) = setup_minimal_dotfiles(&tmp);
4586 std::fs::create_dir_all(source.join("home/inner")).unwrap();
4587 std::fs::write(source.join("home/secret.txt"), "outer keep").unwrap();
4588 std::fs::write(source.join("home/inner/secret.txt"), "inner drop").unwrap();
4589 std::fs::write(source.join("home/inner/keep.txt"), "inner keep").unwrap();
4590 std::fs::write(source.join("home/inner/.yuiignore"), "secret*\n").unwrap();
4592 apply(Some(source.clone()), false).unwrap();
4593 assert!(
4594 target.join("secret.txt").exists(),
4595 "outer secret.txt is outside the nested .yuiignore scope"
4596 );
4597 assert!(target.join("inner/keep.txt").exists());
4598 assert!(
4599 !target.join("inner/secret.txt").exists(),
4600 "inner secret.txt should be excluded by the nested .yuiignore"
4601 );
4602 }
4603
4604 #[test]
4608 fn nested_yuiignore_negation_overrides_root_rule() {
4609 let tmp = TempDir::new().unwrap();
4610 let (source, target) = setup_minimal_dotfiles(&tmp);
4611 std::fs::create_dir_all(source.join("home/keepers")).unwrap();
4612 std::fs::write(source.join("home/drop.lock"), "outer drop").unwrap();
4613 std::fs::write(source.join("home/keepers/wanted.lock"), "inner keep").unwrap();
4614 std::fs::write(source.join(".yuiignore"), "*.lock\n").unwrap();
4615 std::fs::write(source.join("home/keepers/.yuiignore"), "!*.lock\n").unwrap();
4617 apply(Some(source.clone()), false).unwrap();
4618 assert!(
4619 !target.join("drop.lock").exists(),
4620 "root rule still drops outer .lock file"
4621 );
4622 assert!(
4623 target.join("keepers/wanted.lock").exists(),
4624 "nested negation re-includes .lock under keepers/"
4625 );
4626 }
4627
4628 #[test]
4632 fn nested_yuiignore_status_walk_scoped() {
4633 let tmp = TempDir::new().unwrap();
4634 let (source, _target) = setup_minimal_dotfiles(&tmp);
4635 std::fs::create_dir_all(source.join("home/a")).unwrap();
4636 std::fs::create_dir_all(source.join("home/b")).unwrap();
4637 std::fs::write(source.join("home/a/foo.txt"), "a-foo").unwrap();
4638 std::fs::write(source.join("home/b/foo.txt"), "b-foo").unwrap();
4639 std::fs::write(source.join("home/a/.yuiignore"), "foo.txt\n").unwrap();
4641 apply(Some(source.clone()), false).unwrap();
4642 let res = status(Some(source), None, true);
4644 assert!(res.is_ok() || matches!(&res, Err(e) if format!("{e}").contains("diverged")));
4645 }
4646
4647 #[test]
4648 fn yuiignore_skips_template_in_render() {
4649 let tmp = TempDir::new().unwrap();
4650 let source = utf8(tmp.path().join("dotfiles"));
4651 let target = utf8(tmp.path().join("target"));
4652 std::fs::create_dir_all(source.join("home")).unwrap();
4653 std::fs::create_dir_all(&target).unwrap();
4654 std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
4655 std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
4656 let cfg = format!(
4657 r#"
4658[[mount.entry]]
4659src = "home"
4660dst = "{}"
4661"#,
4662 toml_path(&target)
4663 );
4664 std::fs::write(source.join("config.toml"), cfg).unwrap();
4665 apply(Some(source.clone()), false).unwrap();
4666 assert!(!source.join("home/note").exists());
4668 assert!(!target.join("note").exists());
4669 assert!(!target.join("note.tera").exists());
4670 }
4671
4672 #[test]
4676 fn nested_marker_accumulates_extra_dst() {
4677 let tmp = TempDir::new().unwrap();
4678 let source = utf8(tmp.path().join("dotfiles"));
4679 let parent_target = utf8(tmp.path().join("home"));
4680 let extra_target = utf8(tmp.path().join("extra"));
4681 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
4682 std::fs::create_dir_all(&parent_target).unwrap();
4683 std::fs::create_dir_all(&extra_target).unwrap();
4684 std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
4685
4686 std::fs::write(
4688 source.join("home/.config/.yuilink"),
4689 format!(
4690 r#"
4691[[link]]
4692dst = "{}/.config"
4693"#,
4694 toml_path(&parent_target)
4695 ),
4696 )
4697 .unwrap();
4698 std::fs::write(
4701 source.join("home/.config/nvim/.yuilink"),
4702 format!(
4703 r#"
4704[[link]]
4705dst = "{}/nvim"
4706when = "{{{{ yui.os == '{}' }}}}"
4707"#,
4708 toml_path(&extra_target),
4709 std::env::consts::OS
4710 ),
4711 )
4712 .unwrap();
4713
4714 let cfg = format!(
4715 r#"
4716[[mount.entry]]
4717src = "home"
4718dst = "{}"
4719"#,
4720 toml_path(&parent_target)
4721 );
4722 std::fs::write(source.join("config.toml"), cfg).unwrap();
4723
4724 apply(Some(source.clone()), false).unwrap();
4725
4726 assert!(parent_target.join(".config/nvim/init.lua").exists());
4729 assert!(extra_target.join("nvim/init.lua").exists());
4730 }
4731
4732 #[test]
4737 fn marker_file_link_targets_specific_file() {
4738 let tmp = TempDir::new().unwrap();
4739 let source = utf8(tmp.path().join("dotfiles"));
4740 let parent_target = utf8(tmp.path().join("home"));
4741 let docs_target = utf8(tmp.path().join("docs"));
4742 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
4743 std::fs::create_dir_all(&parent_target).unwrap();
4744 std::fs::create_dir_all(&docs_target).unwrap();
4745 std::fs::write(
4746 source.join("home/.config/powershell/profile.ps1"),
4747 "# profile\n",
4748 )
4749 .unwrap();
4750 std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
4751
4752 std::fs::write(
4755 source.join("home/.config/powershell/.yuilink"),
4756 format!(
4757 r#"
4758[[link]]
4759src = "profile.ps1"
4760dst = "{}/Microsoft.PowerShell_profile.ps1"
4761"#,
4762 toml_path(&docs_target)
4763 ),
4764 )
4765 .unwrap();
4766
4767 let cfg = format!(
4768 r#"
4769[[mount.entry]]
4770src = "home"
4771dst = "{}"
4772"#,
4773 toml_path(&parent_target)
4774 );
4775 std::fs::write(source.join("config.toml"), cfg).unwrap();
4776
4777 apply(Some(source.clone()), false).unwrap();
4778
4779 assert!(
4781 docs_target
4782 .join("Microsoft.PowerShell_profile.ps1")
4783 .exists()
4784 );
4785 assert!(
4788 parent_target
4789 .join(".config/powershell/profile.ps1")
4790 .exists()
4791 );
4792 assert!(parent_target.join(".config/powershell/extra.txt").exists());
4793 }
4794
4795 #[test]
4798 fn marker_file_link_missing_src_errors() {
4799 let tmp = TempDir::new().unwrap();
4800 let source = utf8(tmp.path().join("dotfiles"));
4801 let parent_target = utf8(tmp.path().join("home"));
4802 let docs_target = utf8(tmp.path().join("docs"));
4803 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
4804 std::fs::create_dir_all(&parent_target).unwrap();
4805 std::fs::create_dir_all(&docs_target).unwrap();
4806
4807 std::fs::write(
4808 source.join("home/.config/powershell/.yuilink"),
4809 format!(
4810 r#"
4811[[link]]
4812src = "missing.ps1"
4813dst = "{}/profile.ps1"
4814"#,
4815 toml_path(&docs_target)
4816 ),
4817 )
4818 .unwrap();
4819
4820 let cfg = format!(
4821 r#"
4822[[mount.entry]]
4823src = "home"
4824dst = "{}"
4825"#,
4826 toml_path(&parent_target)
4827 );
4828 std::fs::write(source.join("config.toml"), cfg).unwrap();
4829
4830 let err = apply(Some(source.clone()), false).unwrap_err();
4831 assert!(format!("{err:#}").contains("missing.ps1"));
4832 }
4833
4834 #[test]
4843 fn unmanaged_finds_files_outside_any_mount() {
4844 let tmp = TempDir::new().unwrap();
4845 let (source, _target) = setup_minimal_dotfiles(&tmp);
4846 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
4848 std::fs::write(source.join("orphan.txt"), "y").unwrap();
4850 std::fs::create_dir_all(source.join("notes")).unwrap();
4851 std::fs::write(source.join("notes/scratch.md"), "z").unwrap();
4852
4853 unmanaged(Some(source.clone()), None, true).unwrap();
4855
4856 let yui = YuiVars::detect(&source);
4858 let cfg = config::load(&source, &yui).unwrap();
4859 let mount_srcs: Vec<Utf8PathBuf> = cfg
4860 .mount
4861 .entry
4862 .iter()
4863 .map(|m| source.join(&m.src))
4864 .collect();
4865 let walker = paths::source_walker(&source).build();
4866 let mut unmanaged_paths = Vec::new();
4867 for entry in walker.flatten() {
4868 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
4869 continue;
4870 }
4871 let p = match Utf8PathBuf::from_path_buf(entry.path().to_path_buf()) {
4872 Ok(p) => p,
4873 Err(_) => continue,
4874 };
4875 if is_repo_meta(&p, &source, &cfg.mount.marker_filename) {
4876 continue;
4877 }
4878 if mount_srcs.iter().any(|m| p.starts_with(m)) {
4879 continue;
4880 }
4881 unmanaged_paths.push(p);
4882 }
4883 let names: Vec<String> = unmanaged_paths
4884 .iter()
4885 .filter_map(|p| p.file_name().map(String::from))
4886 .collect();
4887 assert!(names.contains(&"orphan.txt".into()));
4888 assert!(names.contains(&"scratch.md".into()));
4889 assert!(!names.contains(&".bashrc".into()), "mount-claimed file");
4890 assert!(!names.contains(&"config.toml".into()), "repo meta");
4891 }
4892
4893 #[test]
4894 fn is_repo_meta_recognises_yui_scaffold() {
4895 let source = Utf8Path::new("/dot");
4896 assert!(is_repo_meta(
4898 Utf8Path::new("/dot/config.toml"),
4899 source,
4900 ".yuilink",
4901 ));
4902 assert!(is_repo_meta(
4903 Utf8Path::new("/dot/config.local.toml"),
4904 source,
4905 ".yuilink",
4906 ));
4907 assert!(is_repo_meta(
4908 Utf8Path::new("/dot/config.linux.toml"),
4909 source,
4910 ".yuilink",
4911 ));
4912 assert!(is_repo_meta(
4913 Utf8Path::new("/dot/config.local.example.toml"),
4914 source,
4915 ".yuilink",
4916 ));
4917 assert!(is_repo_meta(
4919 Utf8Path::new("/dot/.gitignore"),
4920 source,
4921 ".yuilink",
4922 ));
4923 assert!(is_repo_meta(
4925 Utf8Path::new("/dot/home/.config/foo/.yuilink"),
4926 source,
4927 ".yuilink",
4928 ));
4929 assert!(is_repo_meta(
4930 Utf8Path::new("/dot/home/.gitconfig.tera"),
4931 source,
4932 ".yuilink",
4933 ));
4934 assert!(!is_repo_meta(
4936 Utf8Path::new("/dot/home/.config/myapp/config.toml"),
4937 source,
4938 ".yuilink",
4939 ));
4940 assert!(!is_repo_meta(
4944 Utf8Path::new("/dot/home/.config/git/.gitignore"),
4945 source,
4946 ".yuilink",
4947 ));
4948 }
4949
4950 #[test]
4957 fn unmanaged_respects_inactive_mount_entries() {
4958 let tmp = TempDir::new().unwrap();
4959 let source = utf8(tmp.path().join("dotfiles"));
4960 let target = utf8(tmp.path().join("target"));
4961 std::fs::create_dir_all(source.join("home_active")).unwrap();
4962 std::fs::create_dir_all(source.join("home_other_os")).unwrap();
4963 std::fs::create_dir_all(&target).unwrap();
4964 std::fs::write(source.join("home_active/.bashrc"), "active").unwrap();
4965 std::fs::write(source.join("home_other_os/.bashrc"), "inactive").unwrap();
4966 let cfg = format!(
4968 r#"
4969[[mount.entry]]
4970src = "home_active"
4971dst = "{target}"
4972
4973[[mount.entry]]
4974src = "home_other_os"
4975dst = "{target}"
4976when = "yui.os == 'definitely_not_a_real_os'"
4977"#,
4978 target = toml_path(&target)
4979 );
4980 std::fs::write(source.join("config.toml"), cfg).unwrap();
4981
4982 let yui = YuiVars::detect(&source);
4986 let cfg = config::load(&source, &yui).unwrap();
4987 let mount_srcs: Vec<Utf8PathBuf> = cfg
4988 .mount
4989 .entry
4990 .iter()
4991 .map(|m| source.join(&m.src))
4992 .collect();
4993 let inactive_file = source.join("home_other_os/.bashrc");
4994 let claimed = mount_srcs.iter().any(|m| inactive_file.starts_with(m));
4995 assert!(
4996 claimed,
4997 "raw config.mount.entry should claim files even under inactive mounts"
4998 );
4999 }
5000
5001 #[test]
5006 fn diff_shows_drift_skips_in_sync() {
5007 let tmp = TempDir::new().unwrap();
5008 let (source, target) = setup_minimal_dotfiles(&tmp);
5009 std::fs::write(source.join("home/.bashrc"), "first\nsecond\n").unwrap();
5010 apply(Some(source.clone()), false).unwrap();
5012 std::fs::remove_file(target.join(".bashrc")).unwrap();
5014 std::fs::write(target.join(".bashrc"), "first\nEDITED\n").unwrap();
5015
5016 diff(Some(source.clone()), None, true).unwrap();
5019 }
5020
5021 #[test]
5026 fn read_text_for_diff_classifies_correctly() {
5027 let tmp = TempDir::new().unwrap();
5028 let root = utf8(tmp.path().to_path_buf());
5029 let txt = root.join("a.txt");
5031 std::fs::write(&txt, "hello\n").unwrap();
5032 match read_text_for_diff(&txt) {
5033 DiffSide::Text(s) => assert_eq!(s, "hello\n"),
5034 DiffSide::Binary => panic!("text file misclassified as binary"),
5035 }
5036 let bin = root.join("b.bin");
5038 std::fs::write(&bin, [0xff, 0xfe, 0x00, 0xff]).unwrap();
5039 assert!(matches!(read_text_for_diff(&bin), DiffSide::Binary));
5040 let missing = root.join("missing.txt");
5042 match read_text_for_diff(&missing) {
5043 DiffSide::Text(s) => assert!(s.is_empty()),
5044 DiffSide::Binary => panic!("missing file misclassified as binary"),
5045 }
5046 }
5047
5048 #[test]
5055 fn diff_render_drift_uses_rendered_output_not_raw_template() {
5056 let tmp = TempDir::new().unwrap();
5057 let (source, _target) = setup_minimal_dotfiles(&tmp);
5058 std::fs::write(source.join("home/note.tera"), "os = {{ yui.os }}\n").unwrap();
5061 std::fs::write(source.join("home/note"), "os = ancient\n").unwrap();
5062 let yui = YuiVars::detect(&source);
5064 let cfg = config::load(&source, &yui).unwrap();
5065 let rendered =
5066 render::render_to_string(&source.join("home/note.tera"), &source, &cfg, &yui)
5067 .unwrap()
5068 .expect("template should render on this host");
5069 assert!(rendered.starts_with("os = "));
5070 assert!(
5071 !rendered.contains("{{"),
5072 "rendered output must not contain raw Tera tags"
5073 );
5074 }
5075
5076 #[test]
5084 fn resolve_diff_src_absolutizes_link_rows() {
5085 let source = Utf8Path::new("/dot");
5086 let link_item = StatusItem {
5087 src: Utf8PathBuf::from("home/.bashrc"),
5088 dst: Utf8PathBuf::from("/h/u/.bashrc"),
5089 state: StatusState::Link(absorb::AbsorbDecision::AutoAbsorb),
5090 };
5091 assert_eq!(
5092 resolve_diff_src(&link_item, source),
5093 Utf8PathBuf::from("/dot/home/.bashrc"),
5094 );
5095 let render_item = StatusItem {
5096 src: Utf8PathBuf::from("/dot/home/foo.tera"),
5097 dst: Utf8PathBuf::from("/dot/home/foo"),
5098 state: StatusState::RenderDrift,
5099 };
5100 assert_eq!(
5101 resolve_diff_src(&render_item, source),
5102 Utf8PathBuf::from("/dot/home/foo.tera"),
5103 );
5104 }
5105
5106 #[test]
5107 fn diff_classifier_skips_uninteresting_states() {
5108 use absorb::AbsorbDecision::*;
5109 assert!(!diff_worth_printing(&StatusState::Link(InSync)));
5111 assert!(!diff_worth_printing(&StatusState::Link(Restore)));
5112 assert!(!diff_worth_printing(&StatusState::Link(RelinkOnly)));
5113 assert!(diff_worth_printing(&StatusState::Link(AutoAbsorb)));
5115 assert!(diff_worth_printing(&StatusState::Link(NeedsConfirm)));
5116 assert!(diff_worth_printing(&StatusState::RenderDrift));
5117 }
5118
5119 #[test]
5130 fn update_errors_when_source_is_not_a_git_repo() {
5131 let tmp = TempDir::new().unwrap();
5132 let source = utf8(tmp.path().join("dotfiles"));
5133 std::fs::create_dir_all(&source).unwrap();
5134 std::fs::write(source.join("config.toml"), "").unwrap();
5135 let err = update(Some(source), false).unwrap_err();
5137 let msg = format!("{err:#}");
5138 assert!(
5139 msg.contains("not a git repository")
5140 || msg.contains("uncommitted")
5141 || msg.contains("git"),
5142 "unexpected error: {msg}",
5143 );
5144 }
5145
5146 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
5147 let mut out = Vec::new();
5148 let mut stack = vec![root.to_path_buf()];
5149 while let Some(dir) = stack.pop() {
5150 let Ok(entries) = std::fs::read_dir(&dir) else {
5151 continue;
5152 };
5153 for e in entries.flatten() {
5154 let p = utf8(e.path());
5155 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
5156 stack.push(p);
5157 } else {
5158 out.push(p);
5159 }
5160 }
5161 }
5162 out
5163 }
5164
5165 #[test]
5170 fn parse_backup_suffix_recognises_file_with_extension() {
5171 let dt = parse_backup_suffix("foo_20260429_143022123.yml").unwrap();
5172 assert_eq!(dt.year(), 2026);
5173 assert_eq!(dt.month(), 4);
5174 assert_eq!(dt.day(), 29);
5175 assert_eq!(dt.hour(), 14);
5176 assert_eq!(dt.minute(), 30);
5177 assert_eq!(dt.second(), 22);
5178 }
5179
5180 #[test]
5181 fn parse_backup_suffix_recognises_dotfile_no_extension() {
5182 let dt = parse_backup_suffix(".gitconfig_20260429_143022123").unwrap();
5183 assert_eq!(dt.year(), 2026);
5184 }
5185
5186 #[test]
5187 fn parse_backup_suffix_recognises_directory_form() {
5188 let dt = parse_backup_suffix("nvim_20260429_143022123").unwrap();
5189 assert_eq!(dt.day(), 29);
5190 }
5191
5192 #[test]
5193 fn parse_backup_suffix_recognises_multi_dot_filename() {
5194 let dt = parse_backup_suffix("archive.tar.gz_20260429_143022123.gz").unwrap();
5196 assert_eq!(dt.month(), 4);
5197 }
5198
5199 #[test]
5200 fn parse_backup_suffix_rejects_non_yui_names() {
5201 assert!(parse_backup_suffix("README.md").is_none());
5202 assert!(parse_backup_suffix("notes_2026.txt").is_none());
5203 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());
5207 }
5208
5209 #[test]
5210 fn parse_human_duration_basic_units() {
5211 let s = parse_human_duration("30d").unwrap();
5212 assert_eq!(s.get_days(), 30);
5213 let s = parse_human_duration("2w").unwrap();
5214 assert_eq!(s.get_weeks(), 2);
5215 let s = parse_human_duration("12h").unwrap();
5216 assert_eq!(s.get_hours(), 12);
5217 let s = parse_human_duration("5m").unwrap();
5219 assert_eq!(s.get_minutes(), 5);
5220 let s = parse_human_duration("6mo").unwrap();
5221 assert_eq!(s.get_months(), 6);
5222 let s = parse_human_duration("1y").unwrap();
5223 assert_eq!(s.get_years(), 1);
5224 }
5225
5226 #[test]
5227 fn parse_human_duration_case_insensitive_and_whitespace() {
5228 let s = parse_human_duration(" 90D ").unwrap();
5229 assert_eq!(s.get_days(), 90);
5230 let s = parse_human_duration("3WEEKS").unwrap();
5231 assert_eq!(s.get_weeks(), 3);
5232 }
5233
5234 #[test]
5235 fn parse_human_duration_rejects_garbage() {
5236 assert!(parse_human_duration("").is_err());
5237 assert!(parse_human_duration("d30").is_err());
5238 assert!(parse_human_duration("30").is_err()); assert!(parse_human_duration("30x").is_err()); assert!(parse_human_duration("-1d").is_err()); }
5242
5243 #[test]
5247 fn walk_gc_backups_collects_files_and_dir_snapshots() {
5248 let tmp = TempDir::new().unwrap();
5249 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
5250 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
5251 std::fs::write(
5253 root.join("C/Users/u/.config/foo_20260429_143022123.yml"),
5254 "old yml",
5255 )
5256 .unwrap();
5257 std::fs::create_dir_all(root.join("C/Users/u/nvim_20260101_000000000/lua")).unwrap();
5259 std::fs::write(
5260 root.join("C/Users/u/nvim_20260101_000000000/init.lua"),
5261 "ok",
5262 )
5263 .unwrap();
5264 std::fs::write(
5265 root.join("C/Users/u/nvim_20260101_000000000/lua/x.lua"),
5266 "kk",
5267 )
5268 .unwrap();
5269 std::fs::write(root.join("C/Users/u/.config/README.md"), "user note").unwrap();
5271
5272 let entries = walk_gc_backups(&root).unwrap();
5273 assert_eq!(entries.len(), 2, "two backup roots, not three");
5274 let kinds: Vec<_> = entries.iter().map(|e| e.kind).collect();
5275 assert!(kinds.contains(&BackupKind::File));
5276 assert!(kinds.contains(&BackupKind::Dir));
5277 let dir_entry = entries.iter().find(|e| e.kind == BackupKind::Dir).unwrap();
5279 assert!(dir_entry.size_bytes >= 4); }
5281
5282 #[test]
5283 fn cleanup_empty_parents_stops_at_root_and_at_non_empty() {
5284 let tmp = TempDir::new().unwrap();
5285 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
5286 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
5287 std::fs::write(root.join("C/Users/u/sibling_keep"), "x").unwrap();
5288
5289 cleanup_empty_parents(&root.join("C/Users/u/.config"), &root);
5293
5294 assert!(!root.join("C/Users/u/.config").exists(), "empty leaf gone");
5295 assert!(root.join("C/Users/u").exists(), "stops at non-empty parent");
5296 assert!(root.exists(), "backup root preserved");
5297 }
5298
5299 #[test]
5301 fn gc_backup_survey_keeps_all_entries() {
5302 let tmp = TempDir::new().unwrap();
5303 let source = utf8(tmp.path().join("dotfiles"));
5304 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
5305 std::fs::write(source.join("config.toml"), "").unwrap();
5306 let backup = source.join(".yui/backup");
5307 std::fs::write(backup.join("a_20260101_000000000.txt"), "old").unwrap();
5308 std::fs::write(backup.join("b_20260415_120000000.txt"), "fresh").unwrap();
5309
5310 gc_backup(Some(source.clone()), None, false, None, true).unwrap();
5311
5312 assert!(backup.join("a_20260101_000000000.txt").exists());
5314 assert!(backup.join("b_20260415_120000000.txt").exists());
5315 }
5316
5317 #[test]
5320 fn gc_backup_prune_removes_old_files_only() {
5321 let tmp = TempDir::new().unwrap();
5322 let source = utf8(tmp.path().join("dotfiles"));
5323 std::fs::create_dir_all(source.join(".yui/backup/sub")).unwrap();
5324 std::fs::write(source.join("config.toml"), "").unwrap();
5325 let backup = source.join(".yui/backup");
5326
5327 std::fs::write(backup.join("sub/old_20200101_000000000.txt"), "old").unwrap();
5329 let tomorrow = jiff::Zoned::now()
5331 .checked_add(jiff::Span::new().days(1))
5332 .unwrap();
5333 let bdt = jiff::fmt::strtime::BrokenDownTime::from(&tomorrow);
5334 let future_ts = bdt.to_string("%Y%m%d_%H%M%S%3f").unwrap();
5335 std::fs::write(backup.join(format!("fresh_{future_ts}.txt")), "fresh").unwrap();
5336 std::fs::write(backup.join("notes.md"), "mine").unwrap();
5338
5339 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
5340
5341 assert!(!backup.join("sub/old_20200101_000000000.txt").exists());
5342 assert!(!backup.join("sub").exists(), "empty parent removed");
5344 assert!(backup.exists());
5346 assert!(backup.join(format!("fresh_{future_ts}.txt")).exists());
5347 assert!(backup.join("notes.md").exists(), "user file untouched");
5348 }
5349
5350 #[test]
5352 fn gc_backup_dry_run_does_not_delete() {
5353 let tmp = TempDir::new().unwrap();
5354 let source = utf8(tmp.path().join("dotfiles"));
5355 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
5356 std::fs::write(source.join("config.toml"), "").unwrap();
5357 let backup = source.join(".yui/backup");
5358 std::fs::write(backup.join("old_20200101_000000000.txt"), "old").unwrap();
5359
5360 gc_backup(Some(source.clone()), Some("30d".into()), true, None, true).unwrap();
5361
5362 assert!(
5363 backup.join("old_20200101_000000000.txt").exists(),
5364 "dry-run keeps everything in place"
5365 );
5366 }
5367
5368 #[test]
5372 fn gc_backup_prune_handles_directory_snapshot() {
5373 let tmp = TempDir::new().unwrap();
5374 let source = utf8(tmp.path().join("dotfiles"));
5375 std::fs::create_dir_all(source.join(".yui/backup/mirror/u")).unwrap();
5376 std::fs::write(source.join("config.toml"), "").unwrap();
5377 let backup = source.join(".yui/backup");
5378 let snap = backup.join("mirror/u/nvim_20200101_000000000");
5379 std::fs::create_dir_all(snap.join("lua")).unwrap();
5380 std::fs::write(snap.join("init.lua"), "x").unwrap();
5381 std::fs::write(snap.join("lua/y.lua"), "y").unwrap();
5382
5383 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
5384
5385 assert!(!snap.exists(), "dir snapshot removed wholesale");
5386 assert!(!backup.join("mirror").exists(), "empty mirror chain pruned");
5387 assert!(backup.exists(), "backup root preserved");
5388 }
5389}