1use std::fmt::Write as _;
7
8use anyhow::{Context as _, Result};
9use camino::{Utf8Path, Utf8PathBuf};
10use tera::Context as TeraContext;
11use tracing::{info, warn};
12
13use crate::config::{self, Config, HookPhase, IconsMode, MountStrategy};
14use crate::hook::{self, HookOutcome};
15use crate::icons::Icons;
16use crate::link::{self, EffectiveDirMode, EffectiveFileMode, resolve_dir_mode, resolve_file_mode};
17use crate::marker::{self, MarkerSpec};
18use crate::mount::{self, ResolvedMount};
19use crate::render::{self, RenderReport};
20use crate::template;
21use crate::vars::YuiVars;
22use crate::{absorb, backup, paths};
23
24pub fn init(source: Option<Utf8PathBuf>, git_hooks: bool) -> Result<()> {
31 let dir = match source {
32 Some(s) => absolutize(&s)?,
33 None => current_dir_utf8()?,
34 };
35 std::fs::create_dir_all(&dir)?;
36 let config_path = dir.join("config.toml");
37 let scaffolded = if !config_path.exists() {
38 std::fs::write(&config_path, SKELETON_CONFIG)?;
39 info!("initialized yui source repo at {dir}");
40 info!("created: {config_path}");
41 true
42 } else if git_hooks {
43 info!(
48 "config.toml already exists at {config_path} \
49 — skipping scaffold, installing git hooks only"
50 );
51 false
52 } else {
53 anyhow::bail!("config.toml already exists at {config_path}");
54 };
55
56 ensure_gitignore_yui_entries(&dir)?;
63
64 if git_hooks {
65 install_git_hooks(&dir)?;
66 }
67 if scaffolded {
68 info!("next: edit config.toml, then run `yui apply`");
69 }
70 Ok(())
71}
72
73const YUI_REQUIRED_GITIGNORE: &[&str] = &[
78 "/.yui/state.json",
79 "/.yui/state.json.tmp",
80 "/.yui/backup/",
81 "config.local.toml",
82];
83
84fn ensure_gitignore_yui_entries(dir: &Utf8Path) -> Result<()> {
90 let path = dir.join(".gitignore");
91 if !path.exists() {
92 std::fs::write(&path, SKELETON_GITIGNORE)?;
93 info!("created: {path}");
94 return Ok(());
95 }
96 let existing = std::fs::read_to_string(&path)?;
97 let missing: Vec<&str> = YUI_REQUIRED_GITIGNORE
98 .iter()
99 .copied()
100 .filter(|entry| !existing.lines().any(|line| line.trim() == *entry))
101 .collect();
102 if missing.is_empty() {
103 return Ok(());
104 }
105 let mut next = existing;
106 if !next.is_empty() && !next.ends_with('\n') {
107 next.push('\n');
108 }
109 if !next.is_empty() {
110 next.push('\n');
111 }
112 next.push_str("# yui per-machine state and backups (added by `yui init`).\n");
113 for entry in &missing {
114 next.push_str(entry);
115 next.push('\n');
116 }
117 std::fs::write(&path, next)?;
118 info!(
119 "updated .gitignore: appended {} yui entr{} ({})",
120 missing.len(),
121 if missing.len() == 1 { "y" } else { "ies" },
122 missing.join(", ")
123 );
124 Ok(())
125}
126
127fn install_git_hooks(source: &Utf8Path) -> Result<()> {
141 let out = std::process::Command::new("git")
142 .args(["rev-parse", "--git-path", "hooks"])
143 .current_dir(source.as_std_path())
144 .output()
145 .with_context(|| format!("git rev-parse --git-path hooks in {source}"))?;
146 if !out.status.success() {
147 let stderr = String::from_utf8_lossy(&out.stderr);
148 anyhow::bail!(
149 "--git-hooks: {source} doesn't look like a git repo \
150 (run `git init` first). git: {}",
151 stderr.trim()
152 );
153 }
154 let raw = String::from_utf8(out.stdout)?;
155 let hooks_dir = {
156 let p = Utf8PathBuf::from(raw.trim());
157 if p.is_absolute() { p } else { source.join(p) }
158 };
159 std::fs::create_dir_all(&hooks_dir).with_context(|| format!("mkdir -p {hooks_dir}"))?;
160
161 for (name, body) in [("pre-commit", PRE_COMMIT_HOOK), ("pre-push", PRE_PUSH_HOOK)] {
162 let path = hooks_dir.join(name);
163 if path.exists() {
164 warn!("--git-hooks: {path} already exists — leaving it alone");
165 continue;
166 }
167 std::fs::write(&path, body).with_context(|| format!("write hook {path}"))?;
168 #[cfg(unix)]
169 {
170 use std::os::unix::fs::PermissionsExt;
171 let mut perms = std::fs::metadata(&path)?.permissions();
172 perms.set_mode(0o755);
173 std::fs::set_permissions(&path, perms)?;
174 }
175 info!("installed: {path}");
176 }
177 Ok(())
178}
179
180const PRE_COMMIT_HOOK: &str = r#"#!/bin/sh
181# Installed by `yui init --git-hooks`.
182# Reject the commit if any `*.tera` template would render to something
183# that diverges from the rendered output staged alongside it. Run
184# `yui apply` (or `yui render`) to refresh and re-commit.
185exec yui render --check
186"#;
187
188const PRE_PUSH_HOOK: &str = r#"#!/bin/sh
189# Installed by `yui init --git-hooks`.
190# Same render-drift check as pre-commit, mirrored on push so a
191# `--no-verify` commit doesn't sneak diverged state to the remote.
192exec yui render --check
193"#;
194
195pub fn apply(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
196 let source = resolve_source(source)?;
197 let yui = YuiVars::detect(&source);
198 let config = config::load(&source, &yui)?;
199
200 let mut engine = template::Engine::new();
201 let tera_ctx = template::template_context(&yui, &config.vars);
202
203 hook::run_phase(
206 &config,
207 &source,
208 &yui,
209 &mut engine,
210 &tera_ctx,
211 HookPhase::Pre,
212 dry_run,
213 )?;
214
215 let render_report = render::render_all(&source, &config, &yui, dry_run)?;
217 log_render_report(&render_report);
218 if render_report.has_drift() {
219 anyhow::bail!(
220 "render drift detected ({} file(s)); reflect target edits back into the .tera before re-running apply",
221 render_report.diverged.len()
222 );
223 }
224
225 let mounts = mount::resolve(
227 &config.mount.entry,
228 config.mount.default_strategy,
229 &mut engine,
230 &tera_ctx,
231 )?;
232
233 let backup_root = source.join(&config.backup.dir);
234 let ctx = ApplyCtx {
235 config: &config,
236 source: &source,
237 file_mode: resolve_file_mode(config.link.file_mode),
238 dir_mode: resolve_dir_mode(config.link.dir_mode),
239 backup_root: &backup_root,
240 dry_run,
241 };
242
243 info!("source: {source}");
244 info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
245 if dry_run {
246 info!("dry-run: nothing will be written");
247 }
248
249 let mut yuiignore = paths::YuiIgnoreStack::new();
253 yuiignore.push_dir(&source)?;
254 let walk_result = (|| -> Result<()> {
255 for m in &mounts {
256 info!("mount: {} → {}", m.src, m.dst);
257 process_mount(&source, m, &ctx, &mut engine, &tera_ctx, &mut yuiignore)?;
258 }
259 Ok(())
260 })();
261 yuiignore.pop_dir(&source);
262 walk_result?;
263
264 hook::run_phase(
266 &config,
267 &source,
268 &yui,
269 &mut engine,
270 &tera_ctx,
271 HookPhase::Post,
272 dry_run,
273 )?;
274 Ok(())
275}
276
277fn log_render_report(r: &RenderReport) {
278 if !r.written.is_empty() {
279 info!("rendered {} new file(s)", r.written.len());
280 }
281 if !r.unchanged.is_empty() {
282 info!("rendered {} file(s) unchanged", r.unchanged.len());
283 }
284 if !r.skipped_when_false.is_empty() {
285 info!(
286 "skipped {} template(s) (when=false)",
287 r.skipped_when_false.len()
288 );
289 }
290 for d in &r.diverged {
291 warn!("rendered file diverged from template: {d}");
292 }
293}
294
295struct ApplyCtx<'a> {
302 config: &'a Config,
303 source: &'a Utf8Path,
305 file_mode: EffectiveFileMode,
306 dir_mode: EffectiveDirMode,
307 backup_root: &'a Utf8Path,
308 dry_run: bool,
309}
310
311pub fn list(
317 source: Option<Utf8PathBuf>,
318 all: bool,
319 icons_override: Option<IconsMode>,
320 no_color: bool,
321) -> Result<()> {
322 let source = resolve_source(source)?;
323 let yui = YuiVars::detect(&source);
324 let config = config::load(&source, &yui)?;
325
326 let icons_mode = icons_override.unwrap_or(config.ui.icons);
327 let icons = Icons::for_mode(icons_mode);
328 let color = !no_color && supports_color_stdout();
329
330 let items = collect_list_items(&source, &config, &yui)?;
331 let displayed: Vec<&ListItem> = if all {
332 items.iter().collect()
333 } else {
334 items.iter().filter(|i| i.active).collect()
335 };
336
337 print_list_table(&displayed, icons, color);
338
339 let total = items.len();
340 let active = items.iter().filter(|i| i.active).count();
341 let inactive = total - active;
342 println!();
343 if all {
344 println!(" {total} entries · {active} active · {inactive} inactive");
345 } else {
346 println!(
347 " {} of {} entries shown ({} inactive hidden — use --all)",
348 active, total, inactive
349 );
350 }
351 Ok(())
352}
353
354#[derive(Debug)]
355struct ListItem {
356 src: Utf8PathBuf,
357 dst: String,
358 when: Option<String>,
359 active: bool,
360}
361
362fn collect_list_items(source: &Utf8Path, config: &Config, yui: &YuiVars) -> Result<Vec<ListItem>> {
363 let mut engine = template::Engine::new();
364 let tera_ctx = template::template_context(yui, &config.vars);
365 let mut items = Vec::new();
366
367 for entry in &config.mount.entry {
369 let active = match &entry.when {
370 None => true,
371 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
372 };
373 let dst = engine
374 .render(&entry.dst, &tera_ctx)
375 .map(|s| paths::expand_tilde(s.trim()).to_string())
376 .unwrap_or_else(|_| entry.dst.clone());
377 items.push(ListItem {
378 src: entry.src.clone(),
379 dst,
380 when: entry.when.clone(),
381 active,
382 });
383 }
384
385 let walker = paths::source_walker(source).build();
387 let marker_filename = &config.mount.marker_filename;
388 for entry in walker {
389 let entry = match entry {
390 Ok(e) => e,
391 Err(_) => continue,
392 };
393 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
394 continue;
395 }
396 if entry.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
397 continue;
398 }
399 let dir = match entry.path().parent() {
400 Some(d) => d,
401 None => continue,
402 };
403 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
404 Ok(p) => p,
405 Err(_) => continue,
406 };
407 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
411 Some(s) => s,
412 None => continue,
413 };
414 let MarkerSpec::Explicit { links } = spec else {
415 continue; };
417 let rel = dir_utf8
418 .strip_prefix(source)
419 .map(Utf8PathBuf::from)
420 .unwrap_or(dir_utf8);
421 for link in &links {
422 let active = match &link.when {
423 None => true,
424 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
425 };
426 let dst = engine
427 .render(&link.dst, &tera_ctx)
428 .map(|s| paths::expand_tilde(s.trim()).to_string())
429 .unwrap_or_else(|_| link.dst.clone());
430 let src_display = match &link.src {
435 Some(filename) => rel.join(filename),
436 None => rel.clone(),
437 };
438 items.push(ListItem {
439 src: src_display,
440 dst,
441 when: link.when.clone(),
442 active,
443 });
444 }
445 }
446
447 items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
448 Ok(items)
449}
450
451fn supports_color_stdout() -> bool {
452 use std::io::IsTerminal;
453 std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
454}
455
456fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
457 let src_w = items
458 .iter()
459 .map(|i| i.src.as_str().chars().count())
460 .max()
461 .unwrap_or(0)
462 .max("SRC".len());
463 let dst_w = items
464 .iter()
465 .map(|i| i.dst.chars().count())
466 .max()
467 .unwrap_or(0)
468 .max("DST".len());
469
470 let status_w = "STATUS".len();
471 let arrow_w = icons.arrow.chars().count();
472
473 print_header(status_w, src_w, arrow_w, dst_w, color);
475
476 let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
478 if color {
479 use owo_colors::OwoColorize as _;
480 println!("{}", sep.dimmed());
481 } else {
482 println!("{sep}");
483 }
484
485 for item in items {
487 print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
488 }
489}
490
491fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
492 use owo_colors::OwoColorize as _;
493 let mut line = String::new();
494 let _ = write!(
495 &mut line,
496 " {:<status_w$} {:<src_w$} {:<arrow_w$} {:<dst_w$} WHEN",
497 "STATUS", "SRC", "", "DST"
498 );
499 if color {
500 println!("{}", line.bold());
501 } else {
502 println!("{line}");
503 }
504}
505
506fn render_separator(
507 sep_ch: char,
508 status_w: usize,
509 src_w: usize,
510 arrow_w: usize,
511 dst_w: usize,
512) -> String {
513 let bar = |n: usize| sep_ch.to_string().repeat(n);
514 format!(
515 " {} {} {} {} {}",
516 bar(status_w),
517 bar(src_w),
518 bar(arrow_w),
519 bar(dst_w),
520 bar("WHEN".len())
521 )
522}
523
524fn print_row(
525 item: &ListItem,
526 icons: Icons,
527 status_w: usize,
528 src_w: usize,
529 arrow_w: usize,
530 dst_w: usize,
531 color: bool,
532) {
533 use owo_colors::OwoColorize as _;
534 let status = if item.active {
535 icons.active
536 } else {
537 icons.inactive
538 };
539 let when_str = item
540 .when
541 .as_deref()
542 .map(strip_braces)
543 .unwrap_or_else(|| "(always)".to_string());
544
545 let src_display = item.src.as_str().replace('\\', "/");
547 let src = src_display.as_str();
548 let dst = &item.dst;
549 let arrow = icons.arrow;
550
551 let cell_status = format!("{:<status_w$}", status);
556 let cell_src = format!("{:<src_w$}", src);
557 let cell_arrow = format!("{:<arrow_w$}", arrow);
558 let cell_dst = format!("{:<dst_w$}", dst);
559
560 if !color {
561 println!(" {cell_status} {cell_src} {cell_arrow} {cell_dst} {when_str}");
562 return;
563 }
564
565 if item.active {
566 println!(
567 " {} {} {} {} {}",
568 cell_status.green(),
569 cell_src.cyan(),
570 cell_arrow.dimmed(),
571 cell_dst.green(),
572 when_str.dimmed()
573 );
574 } else {
575 println!(
576 " {} {} {} {} {}",
577 cell_status.red().dimmed(),
578 cell_src.dimmed(),
579 cell_arrow.dimmed(),
580 cell_dst.dimmed(),
581 when_str.dimmed()
582 );
583 }
584}
585
586fn strip_braces(expr: &str) -> String {
589 let trimmed = expr.trim();
590 if let Some(inner) = trimmed
591 .strip_prefix("{{")
592 .and_then(|s| s.strip_suffix("}}"))
593 {
594 inner.trim().to_string()
595 } else {
596 trimmed.to_string()
597 }
598}
599
600pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
601 let source = resolve_source(source)?;
602 let yui = YuiVars::detect(&source);
603 let config = config::load(&source, &yui)?;
604 let report = render::render_all(&source, &config, &yui, dry_run || check)?;
606 log_render_report(&report);
607 if check && report.has_drift() {
608 anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
609 }
610 Ok(())
611}
612
613pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
614 apply(source, dry_run)
616}
617
618pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
619 let _source = resolve_source(source)?;
620 if paths_arg.is_empty() {
621 anyhow::bail!("yui unlink: provide at least one target path");
622 }
623 for p in paths_arg {
624 let abs = absolutize(&p)?;
625 info!("unlink: {abs}");
626 link::unlink(&abs)?;
627 }
628 Ok(())
629}
630
631pub fn status(
644 source: Option<Utf8PathBuf>,
645 icons_override: Option<IconsMode>,
646 no_color: bool,
647) -> Result<()> {
648 let source = resolve_source(source)?;
649 let yui = YuiVars::detect(&source);
650 let config = config::load(&source, &yui)?;
651
652 let mut engine = template::Engine::new();
653 let tera_ctx = template::template_context(&yui, &config.vars);
654 let mounts = mount::resolve(
655 &config.mount.entry,
656 config.mount.default_strategy,
657 &mut engine,
658 &tera_ctx,
659 )?;
660
661 let icons_mode = icons_override.unwrap_or(config.ui.icons);
662 let icons = Icons::for_mode(icons_mode);
663 let color = !no_color && supports_color_stdout();
664
665 let mut report: Vec<StatusItem> = Vec::new();
666
667 let render_report = render::render_all(&source, &config, &yui, true)?;
670 for rendered in &render_report.diverged {
671 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
675 report.push(StatusItem {
676 src: relative_for_display(&source, &tera_path),
677 dst: rendered.clone(),
678 state: StatusState::RenderDrift,
679 });
680 }
681
682 let mut yuiignore = paths::YuiIgnoreStack::new();
686 yuiignore.push_dir(&source)?;
687 let walk_result = (|| -> Result<()> {
688 for m in &mounts {
689 let src_root = source.join(&m.src);
690 if !src_root.is_dir() {
691 warn!("mount src missing: {src_root}");
692 continue;
693 }
694 classify_walk(
695 &src_root,
696 &m.dst,
697 &config,
698 m.strategy,
699 &mut engine,
700 &tera_ctx,
701 &source,
702 &mut yuiignore,
703 &mut report,
704 )?;
705 }
706 Ok(())
707 })();
708 yuiignore.pop_dir(&source);
709 walk_result?;
710
711 report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
712
713 print_status_table(&report, icons, color);
714
715 let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
716
717 println!();
718 let total = report.len();
719 let in_sync = total - drift;
720 if drift == 0 {
721 println!(" {total} entries · all in sync");
722 Ok(())
723 } else {
724 println!(" {total} entries · {in_sync} in sync · {drift} diverged");
725 anyhow::bail!("status: {drift} entries diverged from source")
726 }
727}
728
729#[derive(Debug)]
730struct StatusItem {
731 src: Utf8PathBuf,
733 dst: Utf8PathBuf,
735 state: StatusState,
736}
737
738#[derive(Debug, Clone, Copy)]
739enum StatusState {
740 Link(absorb::AbsorbDecision),
741 RenderDrift,
744}
745
746impl StatusState {
747 fn is_in_sync(self) -> bool {
748 matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
749 }
750}
751
752#[allow(clippy::too_many_arguments)]
753fn classify_walk(
754 src_dir: &Utf8Path,
755 dst_dir: &Utf8Path,
756 config: &Config,
757 strategy: MountStrategy,
758 engine: &mut template::Engine,
759 tera_ctx: &TeraContext,
760 source_root: &Utf8Path,
761 yuiignore: &mut paths::YuiIgnoreStack,
762 report: &mut Vec<StatusItem>,
763) -> Result<()> {
764 classify_walk_inner(
765 src_dir,
766 dst_dir,
767 config,
768 strategy,
769 engine,
770 tera_ctx,
771 source_root,
772 yuiignore,
773 report,
774 false,
775 )
776}
777
778#[allow(clippy::too_many_arguments)]
779fn classify_walk_inner(
780 src_dir: &Utf8Path,
781 dst_dir: &Utf8Path,
782 config: &Config,
783 strategy: MountStrategy,
784 engine: &mut template::Engine,
785 tera_ctx: &TeraContext,
786 source_root: &Utf8Path,
787 yuiignore: &mut paths::YuiIgnoreStack,
788 report: &mut Vec<StatusItem>,
789 parent_covered: bool,
790) -> Result<()> {
791 if yuiignore.is_ignored(src_dir, true) {
792 return Ok(());
793 }
794 yuiignore.push_dir(src_dir)?;
797 let result = classify_walk_inner_body(
798 src_dir,
799 dst_dir,
800 config,
801 strategy,
802 engine,
803 tera_ctx,
804 source_root,
805 yuiignore,
806 report,
807 parent_covered,
808 );
809 yuiignore.pop_dir(src_dir);
810 result
811}
812
813#[allow(clippy::too_many_arguments)]
814fn classify_walk_inner_body(
815 src_dir: &Utf8Path,
816 dst_dir: &Utf8Path,
817 config: &Config,
818 strategy: MountStrategy,
819 engine: &mut template::Engine,
820 tera_ctx: &TeraContext,
821 source_root: &Utf8Path,
822 yuiignore: &mut paths::YuiIgnoreStack,
823 report: &mut Vec<StatusItem>,
824 parent_covered: bool,
825) -> Result<()> {
826 let marker_filename = &config.mount.marker_filename;
827 let mut covered = parent_covered;
828
829 if strategy == MountStrategy::Marker {
830 match marker::read_spec(src_dir, marker_filename)? {
831 None => {}
832 Some(MarkerSpec::PassThrough) => {
833 let decision = absorb::classify(src_dir, dst_dir)?;
834 report.push(StatusItem {
835 src: relative_for_display(source_root, src_dir),
836 dst: dst_dir.to_path_buf(),
837 state: StatusState::Link(decision),
838 });
839 covered = true;
840 }
841 Some(MarkerSpec::Explicit { links }) => {
842 let mut emitted_dir_link = false;
843 for link in &links {
844 if let Some(when) = &link.when {
845 if !template::eval_truthy(when, engine, tera_ctx)? {
846 continue;
847 }
848 }
849 let dst_str = engine.render(&link.dst, tera_ctx)?;
850 let dst = paths::expand_tilde(dst_str.trim());
851 if let Some(filename) = &link.src {
852 let file_src = src_dir.join(filename);
853 if !file_src.is_file() {
854 anyhow::bail!(
855 "marker at {src_dir}: [[link]] src={filename:?} \
856 not found"
857 );
858 }
859 let decision = absorb::classify(&file_src, &dst)?;
860 report.push(StatusItem {
861 src: relative_for_display(source_root, &file_src),
862 dst,
863 state: StatusState::Link(decision),
864 });
865 } else {
866 let decision = absorb::classify(src_dir, &dst)?;
867 report.push(StatusItem {
868 src: relative_for_display(source_root, src_dir),
869 dst,
870 state: StatusState::Link(decision),
871 });
872 emitted_dir_link = true;
873 }
874 }
875 if emitted_dir_link {
876 covered = true;
877 }
878 }
879 }
880 }
881
882 for entry in std::fs::read_dir(src_dir)? {
883 let entry = entry?;
884 let name_os = entry.file_name();
885 let Some(name) = name_os.to_str() else {
886 continue;
887 };
888 if name == marker_filename || name.ends_with(".tera") {
889 continue;
890 }
891 let src_path = src_dir.join(name);
892 let dst_path = dst_dir.join(name);
893 let ft = entry.file_type()?;
894 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
895 continue;
896 }
897 if ft.is_dir() {
898 classify_walk_inner(
899 &src_path,
900 &dst_path,
901 config,
902 strategy,
903 engine,
904 tera_ctx,
905 source_root,
906 yuiignore,
907 report,
908 covered,
909 )?;
910 } else if ft.is_file() && !covered {
911 let decision = absorb::classify(&src_path, &dst_path)?;
912 report.push(StatusItem {
913 src: relative_for_display(source_root, &src_path),
914 dst: dst_path,
915 state: StatusState::Link(decision),
916 });
917 }
918 }
919 Ok(())
920}
921
922fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
923 p.strip_prefix(source_root)
924 .map(Utf8PathBuf::from)
925 .unwrap_or_else(|_| p.to_path_buf())
926}
927
928fn print_status_table(items: &[StatusItem], icons: Icons, color: bool) {
929 let src_w = items
930 .iter()
931 .map(|i| i.src.as_str().chars().count())
932 .max()
933 .unwrap_or(0)
934 .max("SRC".len());
935 let dst_w = items
936 .iter()
937 .map(|i| i.dst.as_str().chars().count())
938 .max()
939 .unwrap_or(0)
940 .max("DST".len());
941 let state_label_w = items
943 .iter()
944 .map(|i| state_label(i.state).len())
945 .max()
946 .unwrap_or(0)
947 .max("STATE".len() - 2); let state_w = state_label_w + 2; print_status_header(state_w, src_w, dst_w, color);
951 let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
952 if color {
953 use owo_colors::OwoColorize as _;
954 println!("{}", sep.dimmed());
955 } else {
956 println!("{sep}");
957 }
958 for item in items {
959 print_status_row(item, icons, state_w, src_w, dst_w, color);
960 }
961}
962
963fn state_label(s: StatusState) -> &'static str {
964 use absorb::AbsorbDecision::*;
965 match s {
966 StatusState::Link(InSync) => "in-sync",
967 StatusState::Link(RelinkOnly) => "relink",
968 StatusState::Link(AutoAbsorb) => "drift (auto)",
969 StatusState::Link(NeedsConfirm) => "drift (anomaly)",
970 StatusState::Link(Restore) => "missing",
971 StatusState::RenderDrift => "render drift",
972 }
973}
974
975fn state_icon(s: StatusState, icons: Icons) -> &'static str {
976 use absorb::AbsorbDecision::*;
977 match s {
978 StatusState::Link(InSync) => icons.ok,
979 StatusState::Link(RelinkOnly) => icons.warn,
980 StatusState::Link(AutoAbsorb) => icons.warn,
981 StatusState::Link(NeedsConfirm) => icons.error,
982 StatusState::Link(Restore) => icons.info,
983 StatusState::RenderDrift => icons.error,
984 }
985}
986
987fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
988 use owo_colors::OwoColorize as _;
989 let line = format!(
992 " {:<state_w$} {:<src_w$} {:<dst_w$}",
993 "STATE", "SRC", "DST"
994 );
995 if color {
996 println!("{}", line.bold());
997 } else {
998 println!("{line}");
999 }
1000}
1001
1002fn render_status_separator(
1003 sep_ch: char,
1004 state_w: usize,
1005 src_w: usize,
1006 dst_w: usize,
1007 arrow: &str,
1008) -> String {
1009 let bar = |n: usize| sep_ch.to_string().repeat(n);
1010 format!(
1011 " {} {} {} {}",
1012 bar(state_w),
1013 bar(src_w),
1014 bar(arrow.chars().count()),
1015 bar(dst_w)
1016 )
1017}
1018
1019fn print_status_row(
1020 item: &StatusItem,
1021 icons: Icons,
1022 state_w: usize,
1023 src_w: usize,
1024 dst_w: usize,
1025 color: bool,
1026) {
1027 use owo_colors::OwoColorize as _;
1028 let icon = state_icon(item.state, icons);
1029 let label = state_label(item.state);
1030 let state_text = format!("{icon} {label}");
1031 let src_display = item.src.as_str().replace('\\', "/");
1032 let dst_display = item.dst.as_str().replace('\\', "/");
1033 let arrow = icons.arrow;
1034
1035 let cell_state = format!("{:<state_w$}", state_text);
1036 let cell_src = format!("{:<src_w$}", src_display);
1037 let cell_dst = format!("{:<dst_w$}", dst_display);
1038
1039 if !color {
1040 println!(" {cell_state} {cell_src} {arrow} {cell_dst}");
1041 return;
1042 }
1043
1044 use absorb::AbsorbDecision::*;
1045 let state_colored = match item.state {
1046 StatusState::Link(InSync) => cell_state.green().to_string(),
1047 StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
1048 cell_state.yellow().to_string()
1049 }
1050 StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
1051 StatusState::Link(Restore) => cell_state.cyan().to_string(),
1052 StatusState::RenderDrift => cell_state.red().to_string(),
1053 };
1054 let src_colored = cell_src.cyan().to_string();
1055 let arrow_colored = arrow.dimmed().to_string();
1056 let dst_colored = cell_dst.dimmed().to_string();
1057 println!(" {state_colored} {src_colored} {arrow_colored} {dst_colored}");
1058}
1059
1060pub fn absorb(source: Option<Utf8PathBuf>, target: Utf8PathBuf, dry_run: bool) -> Result<()> {
1069 let source = resolve_source(source)?;
1070 let target = absolutize(&target)?;
1071 let yui = YuiVars::detect(&source);
1072 let config = config::load(&source, &yui)?;
1073
1074 let mut engine = template::Engine::new();
1075 let tera_ctx = template::template_context(&yui, &config.vars);
1076
1077 let src_path = match find_source_for_target(&source, &config, &target, &mut engine, &tera_ctx)?
1078 {
1079 Some(s) => s,
1080 None => anyhow::bail!(
1081 "no mount entry / .yuilink override claims target {target}; \
1082 pass a path inside a known dst"
1083 ),
1084 };
1085
1086 info!("source for {target}: {src_path}");
1087
1088 if dry_run {
1089 info!("[dry-run] would absorb {target} → {src_path}");
1090 return Ok(());
1091 }
1092
1093 let backup_root = source.join(&config.backup.dir);
1094 let ctx = ApplyCtx {
1095 config: &config,
1096 source: &source,
1097 file_mode: resolve_file_mode(config.link.file_mode),
1098 dir_mode: resolve_dir_mode(config.link.dir_mode),
1099 backup_root: &backup_root,
1100 dry_run: false,
1101 };
1102
1103 absorb_target_into_source(&src_path, &target, &ctx)
1106}
1107
1108fn find_source_for_target(
1112 source: &Utf8Path,
1113 config: &Config,
1114 target: &Utf8Path,
1115 engine: &mut template::Engine,
1116 tera_ctx: &TeraContext,
1117) -> Result<Option<Utf8PathBuf>> {
1118 for entry in &config.mount.entry {
1120 if let Some(when) = &entry.when {
1121 if !template::eval_truthy(when, engine, tera_ctx)? {
1122 continue;
1123 }
1124 }
1125 let dst_str = engine.render(&entry.dst, tera_ctx)?;
1126 let dst_root = paths::expand_tilde(dst_str.trim());
1127 if let Ok(rel) = target.strip_prefix(&dst_root) {
1128 let candidate = source.join(&entry.src).join(rel);
1129 if paths::is_ignored_at(source, &candidate, candidate.is_dir())? {
1134 continue;
1135 }
1136 return Ok(Some(candidate));
1137 }
1138 }
1139
1140 let walker = paths::source_walker(source).build();
1146 let marker_filename = &config.mount.marker_filename;
1147 for ent in walker {
1148 let ent = match ent {
1149 Ok(e) => e,
1150 Err(_) => continue,
1151 };
1152 if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
1153 continue;
1154 }
1155 if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
1156 continue;
1157 }
1158 let dir = match ent.path().parent() {
1159 Some(d) => d,
1160 None => continue,
1161 };
1162 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
1163 Ok(p) => p,
1164 Err(_) => continue,
1165 };
1166 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
1167 Some(s) => s,
1168 None => continue,
1169 };
1170 let MarkerSpec::Explicit { links } = spec else {
1171 continue;
1172 };
1173 for link in &links {
1174 if let Some(when) = &link.when {
1175 if !template::eval_truthy(when, engine, tera_ctx)? {
1176 continue;
1177 }
1178 }
1179 let dst_str = engine.render(&link.dst, tera_ctx)?;
1180 let dst = paths::expand_tilde(dst_str.trim());
1181 if let Some(filename) = &link.src {
1188 let file_src = dir_utf8.join(filename);
1189 if !file_src.is_file() {
1190 anyhow::bail!(
1191 "marker at {dir_utf8}: [[link]] src={filename:?} \
1192 not found"
1193 );
1194 }
1195 if target == dst {
1196 return Ok(Some(file_src));
1197 }
1198 continue;
1199 }
1200 if target == dst {
1201 return Ok(Some(dir_utf8));
1202 }
1203 if let Ok(rel) = target.strip_prefix(&dst) {
1204 return Ok(Some(dir_utf8.join(rel)));
1205 }
1206 }
1207 }
1208
1209 Ok(None)
1210}
1211
1212pub fn doctor(
1213 source: Option<Utf8PathBuf>,
1214 icons_override: Option<IconsMode>,
1215 no_color: bool,
1216) -> Result<()> {
1217 use owo_colors::OwoColorize as _;
1218
1219 let resolved_source = resolve_source(source);
1224
1225 let yui = match &resolved_source {
1230 Ok(s) => YuiVars::detect(s),
1231 Err(_) => YuiVars::detect(Utf8Path::new(".")),
1232 };
1233
1234 let cfg_res = match &resolved_source {
1239 Ok(s) => Some(config::load(s, &yui)),
1240 Err(_) => None,
1241 };
1242 let cfg = cfg_res.as_ref().and_then(|r| r.as_ref().ok());
1243 let icons_mode = icons_override
1244 .or_else(|| cfg.map(|c| c.ui.icons))
1245 .unwrap_or_default();
1246 let icons = Icons::for_mode(icons_mode);
1247 let color = !no_color && supports_color_stdout();
1248
1249 let mut probes: Vec<Probe> = Vec::new();
1250
1251 probes.push(Probe::group("identity"));
1253 probes.push(Probe::ok("os/arch", format!("{} / {}", yui.os, yui.arch)));
1254 probes.push(Probe::ok("user@host", format!("{}@{}", yui.user, yui.host)));
1255
1256 probes.push(Probe::group("repo"));
1258 let mut have_source = false;
1259 match &resolved_source {
1260 Ok(s) => {
1261 have_source = true;
1262 probes.push(Probe::ok("source", s.to_string()));
1263 match cfg_res.as_ref().expect("cfg_res set when source is Ok") {
1264 Ok(c) => {
1265 probes.push(Probe::ok(
1266 "config",
1267 format!(
1268 "{} mount{} · {} hook{} · {} render rule{}",
1269 c.mount.entry.len(),
1270 plural(c.mount.entry.len()),
1271 c.hook.len(),
1272 plural(c.hook.len()),
1273 c.render.rule.len(),
1274 plural(c.render.rule.len()),
1275 ),
1276 ));
1277 }
1278 Err(e) => probes.push(Probe::error("config", format!("{e}"))),
1279 }
1280 match crate::git::is_clean(s) {
1284 Ok(true) => probes.push(Probe::ok("git", "clean")),
1285 Ok(false) => probes.push(Probe::warn(
1286 "git",
1287 "uncommitted changes — `[absorb] require_clean_git` will defer auto-absorb",
1288 )),
1289 Err(_) => probes.push(Probe::warn(
1290 "git",
1291 "no git repo (auto-absorb still works; commit history won't track drift)",
1292 )),
1293 }
1294 }
1295 Err(e) => {
1296 probes.push(Probe::error("source", format!("not found — {e}")));
1297 }
1298 }
1299
1300 probes.push(Probe::group("links"));
1302 if cfg!(windows) {
1303 probes.push(Probe::ok(
1304 "default mode",
1305 "files=hardlink, dirs=junction (no admin needed)",
1306 ));
1307 } else {
1308 probes.push(Probe::ok("default mode", "files=symlink, dirs=symlink"));
1309 }
1310
1311 if have_source {
1313 if let (Ok(s), Some(c)) = (&resolved_source, cfg) {
1314 probes.push(Probe::group("hooks"));
1315 if c.hook.is_empty() {
1316 probes.push(Probe::ok("hooks", "(none configured)"));
1317 } else {
1318 let mut missing = 0usize;
1319 for h in &c.hook {
1320 if !s.join(&h.script).is_file() {
1321 missing += 1;
1322 probes.push(Probe::error(
1323 format!("hook[{}]", h.name),
1324 format!("script not found at {}", h.script),
1325 ));
1326 }
1327 }
1328 if missing == 0 {
1329 probes.push(Probe::ok(
1330 "scripts",
1331 format!(
1332 "{} hook{} configured, all scripts present",
1333 c.hook.len(),
1334 plural(c.hook.len())
1335 ),
1336 ));
1337 }
1338 }
1339 }
1340 }
1341
1342 if let Some(home) = paths::home_dir() {
1344 let chezmoi_src = home.join(".local/share/chezmoi");
1345 if chezmoi_src.is_dir() {
1346 probes.push(Probe::group("chezmoi"));
1347 probes.push(Probe::warn(
1348 "legacy source",
1349 format!(
1350 "{chezmoi_src} still exists — yui doesn't use it, safe to archive once your migration has settled"
1351 ),
1352 ));
1353 }
1354 }
1355
1356 println!();
1358 if color {
1359 println!(" {}", "yui doctor".bold().underline());
1360 } else {
1361 println!(" yui doctor");
1362 }
1363 println!();
1364 for probe in &probes {
1365 probe.print(&icons, color);
1366 }
1367
1368 let errors = probes.iter().filter(|p| p.is_error()).count();
1369 let warns = probes.iter().filter(|p| p.is_warn()).count();
1370 let oks = probes.iter().filter(|p| p.is_ok()).count();
1371 println!();
1372 let summary = format!("{oks} ok · {warns} warn · {errors} error");
1373 if color {
1374 if errors > 0 {
1375 println!(" {}", summary.red().bold());
1376 } else if warns > 0 {
1377 println!(" {}", summary.yellow());
1378 } else {
1379 println!(" {}", summary.green());
1380 }
1381 } else {
1382 println!(" {summary}");
1383 }
1384
1385 if errors > 0 {
1386 anyhow::bail!("doctor: {errors} probe(s) failed");
1387 }
1388 Ok(())
1389}
1390
1391#[derive(Debug)]
1392enum Probe {
1393 Group(&'static str),
1395 Ok {
1396 label: String,
1397 detail: String,
1398 },
1399 Warn {
1400 label: String,
1401 detail: String,
1402 },
1403 Error {
1404 label: String,
1405 detail: String,
1406 },
1407}
1408
1409impl Probe {
1410 fn group(label: &'static str) -> Self {
1411 Self::Group(label)
1412 }
1413 fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
1414 Self::Ok {
1415 label: label.into(),
1416 detail: detail.into(),
1417 }
1418 }
1419 fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
1420 Self::Warn {
1421 label: label.into(),
1422 detail: detail.into(),
1423 }
1424 }
1425 fn error(label: impl Into<String>, detail: impl Into<String>) -> Self {
1426 Self::Error {
1427 label: label.into(),
1428 detail: detail.into(),
1429 }
1430 }
1431 fn is_ok(&self) -> bool {
1432 matches!(self, Self::Ok { .. })
1433 }
1434 fn is_warn(&self) -> bool {
1435 matches!(self, Self::Warn { .. })
1436 }
1437 fn is_error(&self) -> bool {
1438 matches!(self, Self::Error { .. })
1439 }
1440 fn print(&self, icons: &Icons, color: bool) {
1441 use owo_colors::OwoColorize as _;
1442 match self {
1443 Self::Group(name) => {
1444 println!();
1445 if color {
1446 println!(" {}", name.cyan().bold());
1447 } else {
1448 println!(" {name}");
1449 }
1450 }
1451 Self::Ok { label, detail } => {
1452 let icon = icons.ok;
1453 let padded = format!("{label:<14}");
1457 if color {
1458 println!(
1459 " {} {} {}",
1460 icon.green(),
1461 padded.bold(),
1462 detail.dimmed()
1463 );
1464 } else {
1465 println!(" {icon} {padded} {detail}");
1466 }
1467 }
1468 Self::Warn { label, detail } => {
1469 let icon = icons.warn;
1470 let padded = format!("{label:<14}");
1471 if color {
1472 println!(
1473 " {} {} {}",
1474 icon.yellow(),
1475 padded.bold().yellow(),
1476 detail
1477 );
1478 } else {
1479 println!(" {icon} {padded} {detail}");
1480 }
1481 }
1482 Self::Error { label, detail } => {
1483 let icon = icons.error;
1484 let padded = format!("{label:<14}");
1485 if color {
1486 println!(
1487 " {} {} {}",
1488 icon.red().bold(),
1489 padded.bold().red(),
1490 detail.red()
1491 );
1492 } else {
1493 println!(" {icon} {padded} {detail}");
1494 }
1495 }
1496 }
1497 }
1498}
1499
1500fn plural(n: usize) -> &'static str {
1501 if n == 1 { "" } else { "s" }
1502}
1503
1504pub fn gc_backup(_source: Option<Utf8PathBuf>, _older_than: Option<String>) -> Result<()> {
1505 todo!("yui gc-backup — clean up old backups")
1506}
1507
1508pub fn hooks_list(
1510 source: Option<Utf8PathBuf>,
1511 icons_override: Option<IconsMode>,
1512 no_color: bool,
1513) -> Result<()> {
1514 let source = resolve_source(source)?;
1515 let yui = YuiVars::detect(&source);
1516 let config = config::load(&source, &yui)?;
1517 let state = hook::State::load(&source)?;
1518
1519 let icons_mode = icons_override.unwrap_or(config.ui.icons);
1520 let icons = Icons::for_mode(icons_mode);
1521 let color = !no_color && supports_color_stdout();
1522
1523 if config.hook.is_empty() {
1524 println!("(no [[hook]] entries in config)");
1525 return Ok(());
1526 }
1527
1528 let mut engine = template::Engine::new();
1532 let tera_ctx = template::template_context(&yui, &config.vars);
1533 let rows: Vec<HookRow> = config
1534 .hook
1535 .iter()
1536 .map(|h| -> Result<HookRow> {
1537 let active = match &h.when {
1541 None => true,
1542 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
1543 };
1544 let last_run_at = state.hooks.get(&h.name).and_then(|s| s.last_run_at.clone());
1545 Ok(HookRow {
1546 name: h.name.clone(),
1547 phase: match h.phase {
1548 HookPhase::Pre => "pre",
1549 HookPhase::Post => "post",
1550 },
1551 when_run: match h.when_run {
1552 config::WhenRun::Once => "once",
1553 config::WhenRun::Onchange => "onchange",
1554 config::WhenRun::Every => "every",
1555 },
1556 last_run_at,
1557 when: h.when.clone(),
1558 active,
1559 })
1560 })
1561 .collect::<Result<Vec<_>>>()?;
1562
1563 print_hooks_table(&rows, icons, color);
1564
1565 let total = rows.len();
1566 let active = rows.iter().filter(|r| r.active).count();
1567 let inactive = total - active;
1568 let ran = rows.iter().filter(|r| r.last_run_at.is_some()).count();
1569 let never = total - ran;
1570 println!();
1571 println!(
1572 " {total} hooks · {active} active · {inactive} inactive · {ran} ran · {never} never run"
1573 );
1574
1575 Ok(())
1576}
1577
1578#[derive(Debug)]
1579struct HookRow {
1580 name: String,
1581 phase: &'static str,
1582 when_run: &'static str,
1583 last_run_at: Option<String>,
1584 when: Option<String>,
1585 active: bool,
1586}
1587
1588fn print_hooks_table(rows: &[HookRow], icons: Icons, color: bool) {
1589 use owo_colors::OwoColorize as _;
1590 use std::fmt::Write as _;
1591
1592 let name_w = rows
1593 .iter()
1594 .map(|r| r.name.chars().count())
1595 .max()
1596 .unwrap_or(0)
1597 .max("NAME".len());
1598 let phase_w = rows
1599 .iter()
1600 .map(|r| r.phase.len())
1601 .max()
1602 .unwrap_or(0)
1603 .max("PHASE".len());
1604 let when_run_w = rows
1605 .iter()
1606 .map(|r| r.when_run.len())
1607 .max()
1608 .unwrap_or(0)
1609 .max("WHEN_RUN".len());
1610 let last_w = rows
1611 .iter()
1612 .map(|r| {
1613 r.last_run_at
1614 .as_deref()
1615 .map(|s| s.chars().count())
1616 .unwrap_or("(never)".len())
1617 })
1618 .max()
1619 .unwrap_or(0)
1620 .max("LAST_RUN".len());
1621 let status_w = "STATUS".len();
1622
1623 let mut header = String::new();
1625 let _ = write!(
1626 &mut header,
1627 " {:<status_w$} {:<name_w$} {:<phase_w$} {:<when_run_w$} {:<last_w$} WHEN",
1628 "STATUS", "NAME", "PHASE", "WHEN_RUN", "LAST_RUN"
1629 );
1630 if color {
1631 println!("{}", header.bold());
1632 } else {
1633 println!("{header}");
1634 }
1635
1636 let bar = |n: usize| icons.sep.to_string().repeat(n);
1638 let sep = format!(
1639 " {} {} {} {} {} {}",
1640 bar(status_w),
1641 bar(name_w),
1642 bar(phase_w),
1643 bar(when_run_w),
1644 bar(last_w),
1645 bar("WHEN".len())
1646 );
1647 if color {
1648 println!("{}", sep.dimmed());
1649 } else {
1650 println!("{sep}");
1651 }
1652
1653 for r in rows {
1655 let (icon, ran) = match (r.active, r.last_run_at.is_some()) {
1660 (false, _) => (icons.inactive, false),
1661 (true, true) => (icons.active, true),
1662 (true, false) => (icons.info, false),
1663 };
1664 let last = r.last_run_at.as_deref().unwrap_or("(never)");
1665 let when_str = r
1666 .when
1667 .as_deref()
1668 .map(strip_braces)
1669 .unwrap_or_else(|| "(always)".to_string());
1670
1671 let cell_status = format!("{icon:<status_w$}");
1672 let cell_name = format!("{:<name_w$}", r.name);
1673 let cell_phase = format!("{:<phase_w$}", r.phase);
1674 let cell_when_run = format!("{:<when_run_w$}", r.when_run);
1675 let cell_last = format!("{last:<last_w$}");
1676
1677 if !color {
1678 println!(
1679 " {cell_status} {cell_name} {cell_phase} {cell_when_run} {cell_last} {when_str}"
1680 );
1681 continue;
1682 }
1683
1684 if !r.active {
1688 println!(
1689 " {} {} {} {} {} {}",
1690 cell_status.dimmed(),
1691 cell_name.dimmed(),
1692 cell_phase.dimmed(),
1693 cell_when_run.dimmed(),
1694 cell_last.dimmed(),
1695 when_str.dimmed()
1696 );
1697 } else if ran {
1698 println!(
1699 " {} {} {} {} {} {}",
1700 cell_status.green(),
1701 cell_name.cyan().bold(),
1702 cell_phase.dimmed(),
1703 cell_when_run.dimmed(),
1704 cell_last.green(),
1705 when_str.dimmed()
1706 );
1707 } else {
1708 println!(
1709 " {} {} {} {} {} {}",
1710 cell_status.yellow(),
1711 cell_name.cyan().bold(),
1712 cell_phase.dimmed(),
1713 cell_when_run.dimmed(),
1714 cell_last.yellow(),
1715 when_str.dimmed()
1716 );
1717 }
1718 }
1719}
1720
1721pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
1725 let source = resolve_source(source)?;
1726 let yui = YuiVars::detect(&source);
1727 let config = config::load(&source, &yui)?;
1728 let mut engine = template::Engine::new();
1729 let tera_ctx = template::template_context(&yui, &config.vars);
1730
1731 let targets: Vec<&config::HookConfig> = match &name {
1732 Some(want) => {
1733 let m = config
1734 .hook
1735 .iter()
1736 .find(|h| &h.name == want)
1737 .ok_or_else(|| {
1738 anyhow::anyhow!(
1739 "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
1740 )
1741 })?;
1742 vec![m]
1743 }
1744 None => config.hook.iter().collect(),
1745 };
1746
1747 let mut state = hook::State::load(&source)?;
1748 for h in targets {
1749 let outcome = hook::run_hook(
1750 h,
1751 &source,
1752 &yui,
1753 &config.vars,
1754 &mut engine,
1755 &tera_ctx,
1756 &mut state,
1757 false,
1758 force,
1759 )?;
1760 let label = match outcome {
1761 HookOutcome::Ran => "ran",
1762 HookOutcome::SkippedOnce => "skipped (once: already ran)",
1763 HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
1764 HookOutcome::SkippedWhenFalse => "skipped (when=false)",
1765 HookOutcome::DryRun => "would run (dry-run)",
1766 };
1767 info!("hook[{}]: {label}", h.name);
1768 if outcome == HookOutcome::Ran {
1769 state.save(&source)?;
1770 }
1771 }
1772 Ok(())
1773}
1774
1775#[allow(clippy::too_many_arguments)]
1780fn process_mount(
1781 source: &Utf8Path,
1782 m: &ResolvedMount,
1783 ctx: &ApplyCtx<'_>,
1784 engine: &mut template::Engine,
1785 tera_ctx: &TeraContext,
1786 yuiignore: &mut paths::YuiIgnoreStack,
1787) -> Result<()> {
1788 let src_root = source.join(&m.src);
1789 if !src_root.is_dir() {
1790 warn!("mount src missing: {src_root}");
1791 return Ok(());
1792 }
1793 walk_and_link(
1794 &src_root, &m.dst, ctx, m.strategy, engine, tera_ctx, yuiignore, false,
1795 )
1796}
1797
1798#[allow(clippy::too_many_arguments)]
1799fn walk_and_link(
1800 src_dir: &Utf8Path,
1801 dst_dir: &Utf8Path,
1802 ctx: &ApplyCtx<'_>,
1803 strategy: MountStrategy,
1804 engine: &mut template::Engine,
1805 tera_ctx: &TeraContext,
1806 yuiignore: &mut paths::YuiIgnoreStack,
1807 parent_covered: bool,
1808) -> Result<()> {
1809 if yuiignore.is_ignored(src_dir, true) {
1812 return Ok(());
1813 }
1814 yuiignore.push_dir(src_dir)?;
1817 let result = walk_and_link_body(
1818 src_dir,
1819 dst_dir,
1820 ctx,
1821 strategy,
1822 engine,
1823 tera_ctx,
1824 yuiignore,
1825 parent_covered,
1826 );
1827 yuiignore.pop_dir(src_dir);
1828 result
1829}
1830
1831#[allow(clippy::too_many_arguments)]
1832fn walk_and_link_body(
1833 src_dir: &Utf8Path,
1834 dst_dir: &Utf8Path,
1835 ctx: &ApplyCtx<'_>,
1836 strategy: MountStrategy,
1837 engine: &mut template::Engine,
1838 tera_ctx: &TeraContext,
1839 yuiignore: &mut paths::YuiIgnoreStack,
1840 parent_covered: bool,
1841) -> Result<()> {
1842 let marker_filename = &ctx.config.mount.marker_filename;
1843 let mut covered = parent_covered;
1844
1845 if strategy == MountStrategy::Marker {
1846 match marker::read_spec(src_dir, marker_filename)? {
1847 None => {} Some(MarkerSpec::PassThrough) => {
1849 link_dir_with_backup(src_dir, dst_dir, ctx)?;
1853 covered = true;
1854 }
1855 Some(MarkerSpec::Explicit { links }) => {
1856 let mut emitted_dir_link = false;
1857 let mut emitted_any = false;
1858 for link in &links {
1859 if let Some(when) = &link.when {
1862 if !template::eval_truthy(when, engine, tera_ctx)? {
1863 continue;
1864 }
1865 }
1866 let dst_str = engine.render(&link.dst, tera_ctx)?;
1867 let dst = paths::expand_tilde(dst_str.trim());
1868 if let Some(filename) = &link.src {
1869 let file_src = src_dir.join(filename);
1870 if !file_src.is_file() {
1871 anyhow::bail!(
1872 "marker at {src_dir}: [[link]] src={filename:?} \
1873 not found"
1874 );
1875 }
1876 link_file_with_backup(&file_src, &dst, ctx)?;
1877 } else {
1878 link_dir_with_backup(src_dir, &dst, ctx)?;
1879 emitted_dir_link = true;
1880 }
1881 emitted_any = true;
1882 }
1883 if !emitted_any {
1884 info!(
1889 "marker at {src_dir} had no active links \
1890 — falling back to defaults"
1891 );
1892 }
1893 if emitted_dir_link {
1894 covered = true;
1895 }
1896 }
1897 }
1898 }
1899
1900 for entry in std::fs::read_dir(src_dir)? {
1901 let entry = entry?;
1902 let name_os = entry.file_name();
1903 let Some(name) = name_os.to_str() else {
1904 continue;
1905 };
1906 if name == marker_filename {
1907 continue;
1908 }
1909 if name.ends_with(".tera") {
1910 continue;
1912 }
1913 let src_path = src_dir.join(name);
1914 let dst_path = dst_dir.join(name);
1915 let ft = entry.file_type()?;
1916
1917 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
1918 continue;
1919 }
1920
1921 if ft.is_dir() {
1922 walk_and_link(
1923 &src_path, &dst_path, ctx, strategy, engine, tera_ctx, yuiignore, covered,
1924 )?;
1925 } else if ft.is_file() {
1926 if !covered {
1932 link_file_with_backup(&src_path, &dst_path, ctx)?;
1933 }
1934 }
1935 }
1936 Ok(())
1937}
1938
1939fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1940 use absorb::AbsorbDecision::*;
1941
1942 let decision = absorb::classify(src, dst)?;
1943
1944 if ctx.dry_run {
1945 info!("[dry-run] {decision:?}: {src} → {dst}");
1946 return Ok(());
1947 }
1948
1949 match decision {
1950 InSync => {
1951 Ok(())
1953 }
1954 Restore => {
1955 info!("link: {src} → {dst}");
1956 link::link_file(src, dst, ctx.file_mode)?;
1957 Ok(())
1958 }
1959 RelinkOnly => {
1960 info!("relink: {src} → {dst}");
1963 link::unlink(dst)?;
1964 link::link_file(src, dst, ctx.file_mode)?;
1965 Ok(())
1966 }
1967 AutoAbsorb => {
1968 if !ctx.config.absorb.auto {
1971 return handle_anomaly(
1972 src,
1973 dst,
1974 ctx,
1975 "absorb.auto = false; treating divergence as anomaly",
1976 );
1977 }
1978 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
1979 return handle_anomaly(
1980 src,
1981 dst,
1982 ctx,
1983 "source repo is dirty; deferring auto-absorb",
1984 );
1985 }
1986 absorb_target_into_source(src, dst, ctx)
1987 }
1988 NeedsConfirm => handle_anomaly(
1989 src,
1990 dst,
1991 ctx,
1992 "anomaly: source equals/newer than target but content differs",
1993 ),
1994 }
1995}
1996
1997fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2001 info!("absorb: {dst} → {src}");
2002 backup_existing(src, ctx.backup_root, false)?;
2003 std::fs::copy(dst, src)?;
2004 link::unlink(dst)?;
2005 link::link_file(src, dst, ctx.file_mode)?;
2006 Ok(())
2007}
2008
2009fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
2015 use crate::config::AnomalyAction::*;
2016 match ctx.config.absorb.on_anomaly {
2017 Skip => {
2018 warn!("anomaly skip: {dst} ({reason})");
2019 Ok(())
2020 }
2021 Force => {
2022 warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
2023 absorb_target_into_source(src, dst, ctx)
2024 }
2025 Ask => {
2026 use std::io::IsTerminal;
2027 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
2028 if prompt_absorb_with_diff(src, dst, reason)? {
2029 absorb_target_into_source(src, dst, ctx)
2030 } else {
2031 warn!("anomaly skipped by user: {dst}");
2032 Ok(())
2033 }
2034 } else {
2035 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
2036 Ok(())
2037 }
2038 }
2039 }
2040}
2041
2042fn prompt_absorb_with_diff(src: &Utf8Path, dst: &Utf8Path, reason: &str) -> Result<bool> {
2043 use std::io::Write as _;
2044 let src_content = std::fs::read_to_string(src).unwrap_or_default();
2045 let dst_content = std::fs::read_to_string(dst).unwrap_or_default();
2046 eprintln!();
2047 eprintln!("anomaly: {reason}");
2048 eprintln!(" src: {src}");
2049 eprintln!(" dst: {dst}");
2050 eprintln!();
2051 eprintln!("--- diff (- source, + target) ---");
2052 let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
2053 for change in diff.iter_all_changes() {
2054 let sign = match change.tag() {
2055 similar::ChangeTag::Delete => "-",
2056 similar::ChangeTag::Insert => "+",
2057 similar::ChangeTag::Equal => " ",
2058 };
2059 eprint!("{sign}{change}");
2060 }
2061 eprintln!();
2062 eprint!("absorb target into source? [y/N]: ");
2063 std::io::stderr().flush().ok();
2068 let mut input = String::new();
2069 std::io::stdin().read_line(&mut input)?;
2070 let answer = input.trim();
2071 Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
2072}
2073
2074fn source_repo_is_clean(source: &Utf8Path) -> bool {
2079 match crate::git::is_clean(source) {
2080 Ok(b) => b,
2081 Err(e) => {
2082 warn!("git clean check failed at {source}: {e} — treating as clean");
2083 true
2084 }
2085 }
2086}
2087
2088fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2089 use absorb::AbsorbDecision::*;
2090 let decision = absorb::classify(src, dst)?;
2091
2092 if ctx.dry_run {
2093 info!("[dry-run] dir {decision:?}: {src} → {dst}");
2094 return Ok(());
2095 }
2096
2097 match decision {
2098 InSync => Ok(()),
2099 Restore => {
2100 info!("link dir: {src} → {dst}");
2101 link::link_dir(src, dst, ctx.dir_mode)?;
2102 Ok(())
2103 }
2104 RelinkOnly => {
2105 info!("relink dir: {src} → {dst}");
2110 remove_dir_link_or_real(dst)?;
2111 link::link_dir(src, dst, ctx.dir_mode)?;
2112 Ok(())
2113 }
2114 AutoAbsorb | NeedsConfirm => {
2115 if !ctx.config.absorb.auto {
2136 return handle_anomaly_dir(
2137 src,
2138 dst,
2139 ctx,
2140 "absorb.auto = false; treating divergence as anomaly",
2141 );
2142 }
2143 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
2144 return handle_anomaly_dir(
2145 src,
2146 dst,
2147 ctx,
2148 "source repo is dirty; deferring auto-absorb",
2149 );
2150 }
2151 absorb_target_dir_into_source(src, dst, ctx)
2152 }
2153 }
2154}
2155
2156fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
2166 if let Err(unlink_err) = link::unlink(dst) {
2167 let meta = std::fs::symlink_metadata(dst)
2168 .with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
2169 let ft = meta.file_type();
2170 if ft.is_dir() && !ft.is_symlink() {
2171 std::fs::remove_dir_all(dst).with_context(|| {
2172 format!(
2173 "remove_dir_all({dst}) after link::unlink failed: \
2174 {unlink_err}"
2175 )
2176 })?;
2177 } else {
2178 return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
2179 }
2180 }
2181 Ok(())
2182}
2183
2184fn merge_dir_target_into_source(
2194 target: &Utf8Path,
2195 source: &Utf8Path,
2196 ctx: &ApplyCtx<'_>,
2197) -> Result<()> {
2198 for entry in std::fs::read_dir(target)? {
2199 let entry = entry?;
2200 let name_os = entry.file_name();
2201 let Some(name) = name_os.to_str() else {
2202 continue;
2203 };
2204 let target_path = target.join(name);
2205 let source_path = source.join(name);
2206 let ft = entry.file_type()?;
2207
2208 if ft.is_dir() && !ft.is_symlink() {
2209 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
2215 let sft = src_meta.file_type();
2216 if !sft.is_dir() || sft.is_symlink() {
2217 link::unlink(&source_path).with_context(|| {
2218 format!("remove conflicting source entry before dir merge: {source_path}")
2219 })?;
2220 }
2221 }
2222 if !source_path.exists() {
2223 std::fs::create_dir_all(&source_path).with_context(|| {
2224 format!("create_dir_all({source_path}) during target→source merge")
2225 })?;
2226 }
2227 merge_dir_target_into_source(&target_path, &source_path, ctx)?;
2228 } else if ft.is_file() {
2229 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
2233 let sft = src_meta.file_type();
2234 if sft.is_dir() && !sft.is_symlink() {
2235 remove_dir_link_or_real(&source_path).with_context(|| {
2236 format!("remove conflicting source dir before file merge: {source_path}")
2237 })?;
2238 } else if sft.is_symlink() {
2239 link::unlink(&source_path).with_context(|| {
2240 format!(
2241 "remove conflicting source symlink before file merge: {source_path}"
2242 )
2243 })?;
2244 }
2245 }
2246 if let Some(parent) = source_path.parent() {
2247 if !parent.exists() {
2248 std::fs::create_dir_all(parent)?;
2249 }
2250 }
2251 if source_path.is_file() {
2265 merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
2266 } else {
2267 std::fs::copy(&target_path, &source_path)
2268 .with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
2269 }
2270 } else {
2271 warn!(
2272 "merge: skipping non-regular entry {target_path} \
2273 (symlink / junction / special — content not copied)"
2274 );
2275 }
2276 }
2277 Ok(())
2278}
2279
2280fn merge_resolve_file_conflict(
2294 target_path: &Utf8Path,
2295 source_path: &Utf8Path,
2296 ctx: &ApplyCtx<'_>,
2297) -> Result<()> {
2298 use absorb::AbsorbDecision::*;
2299 let decision = absorb::classify(source_path, target_path)?;
2300 match decision {
2301 InSync | RelinkOnly => Ok(()),
2302 AutoAbsorb => {
2303 std::fs::copy(target_path, source_path).with_context(|| {
2304 format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
2305 })?;
2306 Ok(())
2307 }
2308 Restore => {
2309 unreachable!(
2316 "merge_resolve_file_conflict reached with both files present, \
2317 but classify returned Restore (target {target_path} / source {source_path})"
2318 )
2319 }
2320 NeedsConfirm => {
2321 use crate::config::AnomalyAction::*;
2322 match ctx.config.absorb.on_anomaly {
2323 Skip => {
2324 warn!(
2325 "merge anomaly skip: {target_path} (source-newer / content drift) \
2326 — keeping source version, target version dropped"
2327 );
2328 Ok(())
2329 }
2330 Force => {
2331 warn!(
2332 "merge anomaly force: {target_path} \
2333 (source-newer / content drift) — overwriting source"
2334 );
2335 std::fs::copy(target_path, source_path)?;
2336 Ok(())
2337 }
2338 Ask => {
2339 use std::io::IsTerminal;
2340 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
2341 if prompt_absorb_with_diff(
2342 source_path,
2343 target_path,
2344 "merge: file content differs and source is newer",
2345 )? {
2346 std::fs::copy(target_path, source_path)?;
2347 } else {
2348 warn!("merge: kept source version by user choice: {source_path}");
2349 }
2350 Ok(())
2351 } else {
2352 warn!(
2353 "merge anomaly skip (non-TTY ask mode): {target_path} \
2354 — keeping source version"
2355 );
2356 Ok(())
2357 }
2358 }
2359 }
2360 }
2361 }
2362}
2363
2364fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2371 info!("absorb dir: {dst} → {src}");
2372 backup_existing(src, ctx.backup_root, true)?;
2373 merge_dir_target_into_source(dst, src, ctx)?;
2374 remove_dir_link_or_real(dst)?;
2377 link::link_dir(src, dst, ctx.dir_mode)?;
2378 Ok(())
2379}
2380
2381fn handle_anomaly_dir(
2385 src: &Utf8Path,
2386 dst: &Utf8Path,
2387 ctx: &ApplyCtx<'_>,
2388 reason: &str,
2389) -> Result<()> {
2390 use crate::config::AnomalyAction::*;
2391 match ctx.config.absorb.on_anomaly {
2392 Skip => {
2393 warn!("anomaly skip dir: {dst} ({reason})");
2394 Ok(())
2395 }
2396 Force => {
2397 warn!(
2398 "anomaly force dir: {dst} ({reason}) \
2399 — absorbing target into source"
2400 );
2401 absorb_target_dir_into_source(src, dst, ctx)
2402 }
2403 Ask => {
2404 use std::io::IsTerminal;
2405 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
2406 eprintln!();
2407 eprintln!("anomaly: {dst}");
2408 eprintln!(" {reason}");
2409 eprintln!(" source: {src}");
2410 eprint!(" absorb target dir into source? (y/N) ");
2411 use std::io::{BufRead as _, Write as _};
2412 std::io::stderr().flush().ok();
2413 let mut buf = String::new();
2414 std::io::stdin().lock().read_line(&mut buf)?;
2415 let answer = buf.trim();
2416 if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
2417 absorb_target_dir_into_source(src, dst, ctx)
2418 } else {
2419 warn!("anomaly skipped by user: {dst}");
2420 Ok(())
2421 }
2422 } else {
2423 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
2424 Ok(())
2425 }
2426 }
2427 }
2428}
2429
2430fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
2431 let abs_target = absolutize(target)?;
2432 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
2433 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
2434 info!("backup → {bp}");
2435 if is_dir {
2436 backup::backup_dir(target, &bp)?;
2437 } else {
2438 backup::backup_file(target, &bp)?;
2439 }
2440 Ok(())
2441}
2442
2443fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
2444 if let Some(s) = source {
2445 return absolutize(&s);
2446 }
2447 if let Ok(s) = std::env::var("YUI_SOURCE") {
2448 return absolutize(Utf8Path::new(&s));
2449 }
2450 let cwd = current_dir_utf8()?;
2451 for ancestor in cwd.ancestors() {
2452 if ancestor.join("config.toml").is_file() {
2453 return Ok(ancestor.to_path_buf());
2454 }
2455 }
2456 if let Some(home) = paths::home_dir() {
2457 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
2458 let p = home.join(c);
2459 if p.join("config.toml").is_file() {
2460 return Ok(p);
2461 }
2462 }
2463 }
2464 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
2465}
2466
2467fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
2468 let expanded = paths::expand_tilde(p.as_str());
2470 if expanded.is_absolute() {
2471 return Ok(expanded);
2472 }
2473 let cwd = current_dir_utf8()?;
2474 Ok(cwd.join(expanded))
2475}
2476
2477fn current_dir_utf8() -> Result<Utf8PathBuf> {
2478 let cwd = std::env::current_dir().context("getting cwd")?;
2479 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
2480}
2481
2482const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
2486
2487[vars]
2488# user-defined values; templates can reference these as {{ vars.foo }}
2489
2490# [link]
2491# file_mode = "auto" # auto | symlink | hardlink
2492# dir_mode = "auto" # auto | symlink | junction
2493
2494[mount]
2495default_strategy = "marker"
2496
2497[[mount.entry]]
2498src = "home"
2499# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
2500dst = "~"
2501
2502# [[mount.entry]]
2503# src = "appdata"
2504# dst = "{{ env(name='APPDATA') }}"
2505# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
2506# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
2507# when = "yui.os == 'windows'"
2508"#;
2509
2510const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
2511# .yui/bin/ is intentionally tracked — it holds your hook scripts.
2512/.yui/state.json
2513/.yui/state.json.tmp
2514/.yui/backup/
2515
2516# >>> yui rendered (auto-managed, do not edit) >>>
2517# <<< yui rendered (auto-managed) <<<
2518
2519# config.local.toml is per-machine; commit a config.local.example.toml instead.
2520config.local.toml
2521"#;
2522
2523#[cfg(test)]
2524mod tests {
2525 use super::*;
2526 use tempfile::TempDir;
2527
2528 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
2529 Utf8PathBuf::from_path_buf(p).unwrap()
2530 }
2531
2532 fn toml_path(p: &Utf8Path) -> String {
2534 p.as_str().replace('\\', "/")
2535 }
2536
2537 #[test]
2538 fn apply_links_a_raw_file() {
2539 let tmp = TempDir::new().unwrap();
2540 let source = utf8(tmp.path().join("dotfiles"));
2541 let target = utf8(tmp.path().join("target"));
2542 std::fs::create_dir_all(source.join("home")).unwrap();
2543 std::fs::create_dir_all(&target).unwrap();
2544 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2545
2546 let cfg = format!(
2547 r#"
2548[[mount.entry]]
2549src = "home"
2550dst = "{}"
2551"#,
2552 toml_path(&target)
2553 );
2554 std::fs::write(source.join("config.toml"), cfg).unwrap();
2555
2556 apply(Some(source), false).unwrap();
2557
2558 let linked = target.join(".bashrc");
2559 assert!(linked.exists(), "expected {linked} to exist");
2560 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
2561 }
2562
2563 #[test]
2564 fn apply_with_marker_links_whole_directory() {
2565 let tmp = TempDir::new().unwrap();
2566 let source = utf8(tmp.path().join("dotfiles"));
2567 let target = utf8(tmp.path().join("target"));
2568 let nvim_src = source.join("home/nvim");
2569 std::fs::create_dir_all(&nvim_src).unwrap();
2570 std::fs::create_dir_all(&target).unwrap();
2571 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
2572 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
2573 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
2574
2575 let cfg = format!(
2576 r#"
2577[[mount.entry]]
2578src = "home"
2579dst = "{}"
2580"#,
2581 toml_path(&target)
2582 );
2583 std::fs::write(source.join("config.toml"), cfg).unwrap();
2584
2585 apply(Some(source.clone()), false).unwrap();
2586
2587 let nvim_dst = target.join("nvim");
2588 assert!(nvim_dst.exists());
2589 assert_eq!(
2590 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
2591 "-- hi\n"
2592 );
2593 }
2597
2598 #[test]
2599 fn apply_dry_run_does_not_write() {
2600 let tmp = TempDir::new().unwrap();
2601 let source = utf8(tmp.path().join("dotfiles"));
2602 let target = utf8(tmp.path().join("target"));
2603 std::fs::create_dir_all(source.join("home")).unwrap();
2604 std::fs::create_dir_all(&target).unwrap();
2605 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
2606
2607 let cfg = format!(
2608 r#"
2609[[mount.entry]]
2610src = "home"
2611dst = "{}"
2612"#,
2613 toml_path(&target)
2614 );
2615 std::fs::write(source.join("config.toml"), cfg).unwrap();
2616
2617 apply(Some(source), true).unwrap();
2618
2619 assert!(!target.join(".bashrc").exists());
2620 }
2621
2622 #[test]
2623 fn apply_renders_templates_then_links_rendered_outputs() {
2624 let tmp = TempDir::new().unwrap();
2625 let source = utf8(tmp.path().join("dotfiles"));
2626 let target = utf8(tmp.path().join("target"));
2627 std::fs::create_dir_all(source.join("home")).unwrap();
2628 std::fs::create_dir_all(&target).unwrap();
2629 std::fs::write(
2630 source.join("home/.gitconfig.tera"),
2631 "[user]\n os = {{ yui.os }}\n",
2632 )
2633 .unwrap();
2634 std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
2635
2636 let cfg = format!(
2637 r#"
2638[[mount.entry]]
2639src = "home"
2640dst = "{}"
2641"#,
2642 toml_path(&target)
2643 );
2644 std::fs::write(source.join("config.toml"), cfg).unwrap();
2645
2646 apply(Some(source.clone()), false).unwrap();
2647
2648 assert!(target.join(".bashrc").exists());
2650 assert!(source.join("home/.gitconfig").exists());
2652 assert!(target.join(".gitconfig").exists());
2653 assert!(!target.join(".gitconfig.tera").exists());
2655 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
2657 assert!(linked.contains("os = "));
2658 }
2659
2660 #[test]
2661 fn apply_marker_override_links_to_custom_dst() {
2662 let tmp = TempDir::new().unwrap();
2663 let source = utf8(tmp.path().join("dotfiles"));
2664 let target_a = utf8(tmp.path().join("target_a"));
2665 let target_b = utf8(tmp.path().join("target_b"));
2666 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2667 std::fs::create_dir_all(&target_a).unwrap();
2668 std::fs::create_dir_all(&target_b).unwrap();
2669 std::fs::write(
2670 source.join("home/.config/nvim/init.lua"),
2671 "-- nvim config\n",
2672 )
2673 .unwrap();
2674
2675 std::fs::write(
2678 source.join("home/.config/nvim/.yuilink"),
2679 format!(
2680 r#"
2681[[link]]
2682dst = "{}/nvim"
2683
2684[[link]]
2685dst = "{}/nvim"
2686when = "{{{{ yui.os == '{}' }}}}"
2687"#,
2688 toml_path(&target_a),
2689 toml_path(&target_b),
2690 std::env::consts::OS
2691 ),
2692 )
2693 .unwrap();
2694
2695 let parent_target = utf8(tmp.path().join("parent_target"));
2696 std::fs::create_dir_all(&parent_target).unwrap();
2697 let cfg = format!(
2698 r#"
2699[[mount.entry]]
2700src = "home"
2701dst = "{}"
2702"#,
2703 toml_path(&parent_target)
2704 );
2705 std::fs::write(source.join("config.toml"), cfg).unwrap();
2706
2707 apply(Some(source.clone()), false).unwrap();
2708
2709 assert!(
2711 target_a.join("nvim/init.lua").exists(),
2712 "target_a/nvim/init.lua should be reachable through the link"
2713 );
2714 assert!(
2715 target_b.join("nvim/init.lua").exists(),
2716 "target_b/nvim/init.lua should be reachable through the link"
2717 );
2718 assert!(
2721 !parent_target.join(".config/nvim").exists(),
2722 "parent mount should have skipped the marker-claimed sub-dir"
2723 );
2724 }
2725
2726 #[test]
2727 fn apply_marker_inactive_link_falls_through_to_default() {
2728 let tmp = TempDir::new().unwrap();
2733 let source = utf8(tmp.path().join("dotfiles"));
2734 let target_inactive = utf8(tmp.path().join("inactive"));
2735 let parent_target = utf8(tmp.path().join("parent"));
2736 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2737 std::fs::create_dir_all(&parent_target).unwrap();
2738 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
2739
2740 std::fs::write(
2742 source.join("home/.config/nvim/.yuilink"),
2743 format!(
2744 r#"
2745[[link]]
2746dst = "{}/nvim"
2747when = "{{{{ yui.os == 'no-such-os' }}}}"
2748"#,
2749 toml_path(&target_inactive)
2750 ),
2751 )
2752 .unwrap();
2753
2754 let cfg = format!(
2755 r#"
2756[[mount.entry]]
2757src = "home"
2758dst = "{}"
2759"#,
2760 toml_path(&parent_target)
2761 );
2762 std::fs::write(source.join("config.toml"), cfg).unwrap();
2763
2764 apply(Some(source.clone()), false).unwrap();
2765
2766 assert!(!target_inactive.join("nvim").exists());
2768 assert!(parent_target.join(".config/nvim/init.lua").exists());
2771 }
2772
2773 #[test]
2774 fn list_shows_mount_entries_and_marker_overrides() {
2775 let tmp = TempDir::new().unwrap();
2776 let source = utf8(tmp.path().join("dotfiles"));
2777 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2778 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
2779 std::fs::write(
2780 source.join("home/.config/nvim/.yuilink"),
2781 r#"
2782[[link]]
2783dst = "/custom/nvim"
2784"#,
2785 )
2786 .unwrap();
2787 std::fs::write(
2788 source.join("config.toml"),
2789 r#"
2790[[mount.entry]]
2791src = "home"
2792dst = "/h"
2793"#,
2794 )
2795 .unwrap();
2796
2797 list(Some(source), false, None, true).unwrap();
2800 }
2801
2802 #[test]
2803 fn status_reports_in_sync_after_apply() {
2804 let tmp = TempDir::new().unwrap();
2805 let source = utf8(tmp.path().join("dotfiles"));
2806 let target = utf8(tmp.path().join("target"));
2807 std::fs::create_dir_all(source.join("home")).unwrap();
2808 std::fs::create_dir_all(&target).unwrap();
2809 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2810 let cfg = format!(
2811 r#"
2812[[mount.entry]]
2813src = "home"
2814dst = "{}"
2815"#,
2816 toml_path(&target)
2817 );
2818 std::fs::write(source.join("config.toml"), cfg).unwrap();
2819 apply(Some(source.clone()), false).unwrap();
2821 status(Some(source), None, true).unwrap();
2823 }
2824
2825 #[test]
2826 fn status_reports_template_drift() {
2827 let tmp = TempDir::new().unwrap();
2828 let source = utf8(tmp.path().join("dotfiles"));
2829 let target = utf8(tmp.path().join("target"));
2830 std::fs::create_dir_all(source.join("home")).unwrap();
2831 std::fs::create_dir_all(&target).unwrap();
2832 std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
2835 std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
2836
2837 let cfg = format!(
2838 r#"
2839[[mount.entry]]
2840src = "home"
2841dst = "{}"
2842"#,
2843 toml_path(&target)
2844 );
2845 std::fs::write(source.join("config.toml"), cfg).unwrap();
2846
2847 let err = status(Some(source), None, true).unwrap_err();
2848 assert!(format!("{err}").contains("diverged"));
2849 }
2850
2851 #[test]
2852 fn status_fails_when_target_missing() {
2853 let tmp = TempDir::new().unwrap();
2854 let source = utf8(tmp.path().join("dotfiles"));
2855 let target = utf8(tmp.path().join("target"));
2856 std::fs::create_dir_all(source.join("home")).unwrap();
2857 std::fs::create_dir_all(&target).unwrap();
2858 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2859 let cfg = format!(
2860 r#"
2861[[mount.entry]]
2862src = "home"
2863dst = "{}"
2864"#,
2865 toml_path(&target)
2866 );
2867 std::fs::write(source.join("config.toml"), cfg).unwrap();
2868 let err = status(Some(source), None, true).unwrap_err();
2870 assert!(format!("{err}").contains("diverged"));
2871 }
2872
2873 #[test]
2874 fn strip_braces_removes_outer_template_braces() {
2875 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
2876 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
2877 assert_eq!(strip_braces(" {{x}} "), "x");
2878 }
2879
2880 #[test]
2881 fn apply_aborts_on_render_drift() {
2882 let tmp = TempDir::new().unwrap();
2883 let source = utf8(tmp.path().join("dotfiles"));
2884 let target = utf8(tmp.path().join("target"));
2885 std::fs::create_dir_all(source.join("home")).unwrap();
2886 std::fs::create_dir_all(&target).unwrap();
2887 std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
2888 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
2889
2890 let cfg = format!(
2891 r#"
2892[[mount.entry]]
2893src = "home"
2894dst = "{}"
2895"#,
2896 toml_path(&target)
2897 );
2898 std::fs::write(source.join("config.toml"), cfg).unwrap();
2899
2900 let err = apply(Some(source.clone()), false).unwrap_err();
2901 assert!(format!("{err}").contains("drift"));
2902 assert_eq!(
2904 std::fs::read_to_string(source.join("home/foo")).unwrap(),
2905 "manually edited"
2906 );
2907 assert!(!target.join("foo").exists());
2909 }
2910
2911 #[test]
2912 fn init_creates_skeleton_when_dir_empty() {
2913 let tmp = TempDir::new().unwrap();
2914 let dir = utf8(tmp.path().join("new_dotfiles"));
2915 init(Some(dir.clone()), false).unwrap();
2916 assert!(dir.join("config.toml").is_file());
2917 assert!(dir.join(".gitignore").is_file());
2918 }
2919
2920 #[test]
2921 fn init_refuses_to_overwrite_existing_config() {
2922 let tmp = TempDir::new().unwrap();
2923 let dir = utf8(tmp.path().join("dotfiles"));
2924 std::fs::create_dir_all(&dir).unwrap();
2925 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
2926 let err = init(Some(dir), false).unwrap_err();
2927 assert!(format!("{err}").contains("already exists"));
2928 }
2929
2930 #[test]
2936 fn init_appends_missing_gitignore_entries_into_existing_file() {
2937 let tmp = TempDir::new().unwrap();
2938 let dir = utf8(tmp.path().join("dotfiles"));
2939 std::fs::create_dir_all(&dir).unwrap();
2940 let user_gitignore = "# user entries\n*.swp\nnode_modules/\n";
2942 std::fs::write(dir.join(".gitignore"), user_gitignore).unwrap();
2943
2944 init(Some(dir.clone()), false).unwrap();
2945
2946 let body = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
2947 assert!(body.contains("*.swp"));
2949 assert!(body.contains("node_modules/"));
2950 assert!(body.contains("/.yui/state.json"));
2952 assert!(body.contains("/.yui/backup/"));
2953 assert!(body.contains("config.local.toml"));
2954 let before_rerun = body.clone();
2956 std::fs::remove_file(dir.join("config.toml")).unwrap();
2959 init(Some(dir.clone()), false).unwrap();
2960 let after_rerun = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
2961 assert_eq!(
2962 before_rerun, after_rerun,
2963 "init must be idempotent when the gitignore already has every yui entry"
2964 );
2965 }
2966
2967 #[test]
2973 fn init_with_git_hooks_installs_into_existing_repo() {
2974 let tmp = TempDir::new().unwrap();
2975 let dir = utf8(tmp.path().join("dotfiles"));
2976 std::fs::create_dir_all(&dir).unwrap();
2977 let st = std::process::Command::new("git")
2978 .args(["init", "-q"])
2979 .current_dir(dir.as_std_path())
2980 .status()
2981 .expect("git init");
2982 if !st.success() {
2983 return;
2984 }
2985 let user_config = "# user already wrote this\n";
2987 std::fs::write(dir.join("config.toml"), user_config).unwrap();
2988
2989 init(Some(dir.clone()), true).unwrap();
2991
2992 assert_eq!(
2993 std::fs::read_to_string(dir.join("config.toml")).unwrap(),
2994 user_config
2995 );
2996 assert!(dir.join(".git/hooks/pre-commit").is_file());
2997 assert!(dir.join(".git/hooks/pre-push").is_file());
2998 }
2999
3000 #[test]
3005 fn init_with_git_hooks_writes_pre_commit_and_pre_push() {
3006 let tmp = TempDir::new().unwrap();
3007 let dir = utf8(tmp.path().join("dotfiles"));
3008 std::fs::create_dir_all(&dir).unwrap();
3009 let st = std::process::Command::new("git")
3011 .args(["init", "-q"])
3012 .current_dir(dir.as_std_path())
3013 .status()
3014 .expect("git init");
3015 if !st.success() {
3016 eprintln!("skipping: git not available");
3018 return;
3019 }
3020 init(Some(dir.clone()), true).unwrap();
3021
3022 let pre_commit = dir.join(".git/hooks/pre-commit");
3023 let pre_push = dir.join(".git/hooks/pre-push");
3024 assert!(pre_commit.is_file(), "pre-commit hook should be written");
3025 assert!(pre_push.is_file(), "pre-push hook should be written");
3026
3027 let body = std::fs::read_to_string(&pre_commit).unwrap();
3028 assert!(
3029 body.contains("yui render --check"),
3030 "pre-commit hook should call `yui render --check`, got: {body}"
3031 );
3032 }
3033
3034 #[test]
3038 fn init_with_git_hooks_errors_outside_a_git_repo() {
3039 let tmp = TempDir::new().unwrap();
3040 let dir = utf8(tmp.path().join("not-a-repo"));
3041 std::fs::create_dir_all(&dir).unwrap();
3042 let err = init(Some(dir), true).unwrap_err();
3043 let msg = format!("{err:#}");
3044 assert!(
3045 msg.contains("git repo") || msg.contains("git rev-parse"),
3046 "expected error to mention the git issue, got: {msg}"
3047 );
3048 }
3049
3050 #[test]
3053 fn init_with_git_hooks_does_not_clobber_existing_hooks() {
3054 let tmp = TempDir::new().unwrap();
3055 let dir = utf8(tmp.path().join("dotfiles"));
3056 std::fs::create_dir_all(&dir).unwrap();
3057 let st = std::process::Command::new("git")
3058 .args(["init", "-q"])
3059 .current_dir(dir.as_std_path())
3060 .status()
3061 .expect("git init");
3062 if !st.success() {
3063 return;
3064 }
3065 let hooks = dir.join(".git/hooks");
3066 std::fs::create_dir_all(&hooks).unwrap();
3067 std::fs::write(hooks.join("pre-commit"), "#! /bin/sh\nexit 0\n").unwrap();
3068
3069 init(Some(dir.clone()), true).unwrap();
3070
3071 let pc = std::fs::read_to_string(hooks.join("pre-commit")).unwrap();
3073 assert!(
3074 !pc.contains("yui render --check"),
3075 "existing pre-commit must not be overwritten"
3076 );
3077 let pp = std::fs::read_to_string(hooks.join("pre-push")).unwrap();
3078 assert!(
3079 pp.contains("yui render --check"),
3080 "missing pre-push should be written: {pp}"
3081 );
3082 }
3083
3084 fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
3087 let source = utf8(tmp.path().join("dotfiles"));
3088 let target = utf8(tmp.path().join("target"));
3089 std::fs::create_dir_all(source.join("home")).unwrap();
3090 std::fs::create_dir_all(&target).unwrap();
3091 let cfg = format!(
3092 r#"
3093[[mount.entry]]
3094src = "home"
3095dst = "{}"
3096"#,
3097 toml_path(&target)
3098 );
3099 std::fs::write(source.join("config.toml"), cfg).unwrap();
3100 (source, target)
3101 }
3102
3103 fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
3104 std::fs::write(path, body).unwrap();
3105 let f = std::fs::OpenOptions::new()
3106 .write(true)
3107 .open(path)
3108 .expect("open writable");
3109 f.set_modified(when).expect("set_modified");
3110 }
3111
3112 #[test]
3113 fn apply_target_newer_absorbs_target_into_source() {
3114 let tmp = TempDir::new().unwrap();
3118 let (source, target) = setup_minimal_dotfiles(&tmp);
3119
3120 let now = std::time::SystemTime::now();
3121 let past = now - std::time::Duration::from_secs(120);
3122 write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
3123 write_with_mtime(&target.join(".bashrc"), "user's edit", now);
3125
3126 apply(Some(source.clone()), false).unwrap();
3127
3128 assert_eq!(
3130 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3131 "user's edit"
3132 );
3133 assert_eq!(
3135 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3136 "user's edit"
3137 );
3138 let backup_root = source.join(".yui/backup");
3140 let mut found_old = false;
3141 for entry in walkdir(&backup_root) {
3142 if let Ok(s) = std::fs::read_to_string(&entry) {
3143 if s == "default from repo" {
3144 found_old = true;
3145 break;
3146 }
3147 }
3148 }
3149 assert!(found_old, "expected backup containing 'default from repo'");
3150 }
3151
3152 #[test]
3153 fn apply_in_sync_target_is_a_no_op() {
3154 let tmp = TempDir::new().unwrap();
3157 let (source, target) = setup_minimal_dotfiles(&tmp);
3158 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3159 apply(Some(source.clone()), false).unwrap();
3160 let backup_root = source.join(".yui/backup");
3161 let backup_count_after_first = walkdir(&backup_root).len();
3162
3163 apply(Some(source.clone()), false).unwrap();
3165 assert_eq!(
3166 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3167 "echo hi\n"
3168 );
3169 let backup_count_after_second = walkdir(&backup_root).len();
3170 assert_eq!(
3171 backup_count_after_first, backup_count_after_second,
3172 "second apply on an in-sync tree should not produce backups"
3173 );
3174 }
3175
3176 #[test]
3177 fn apply_skip_policy_leaves_anomaly_alone() {
3178 let tmp = TempDir::new().unwrap();
3181 let source = utf8(tmp.path().join("dotfiles"));
3182 let target = utf8(tmp.path().join("target"));
3183 std::fs::create_dir_all(source.join("home")).unwrap();
3184 std::fs::create_dir_all(&target).unwrap();
3185 let cfg = format!(
3186 r#"
3187[absorb]
3188on_anomaly = "skip"
3189
3190[[mount.entry]]
3191src = "home"
3192dst = "{}"
3193"#,
3194 toml_path(&target)
3195 );
3196 std::fs::write(source.join("config.toml"), cfg).unwrap();
3197
3198 let now = std::time::SystemTime::now();
3199 let past = now - std::time::Duration::from_secs(120);
3200 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
3201 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
3202
3203 apply(Some(source.clone()), false).unwrap();
3204
3205 assert_eq!(
3207 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3208 "user's edit (older)"
3209 );
3210 assert_eq!(
3212 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3213 "fresh from upstream"
3214 );
3215 }
3216
3217 #[test]
3218 fn apply_force_policy_absorbs_anomaly_anyway() {
3219 let tmp = TempDir::new().unwrap();
3221 let source = utf8(tmp.path().join("dotfiles"));
3222 let target = utf8(tmp.path().join("target"));
3223 std::fs::create_dir_all(source.join("home")).unwrap();
3224 std::fs::create_dir_all(&target).unwrap();
3225 let cfg = format!(
3226 r#"
3227[absorb]
3228on_anomaly = "force"
3229
3230[[mount.entry]]
3231src = "home"
3232dst = "{}"
3233"#,
3234 toml_path(&target)
3235 );
3236 std::fs::write(source.join("config.toml"), cfg).unwrap();
3237
3238 let now = std::time::SystemTime::now();
3239 let past = now - std::time::Duration::from_secs(120);
3240 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
3241 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
3242
3243 apply(Some(source.clone()), false).unwrap();
3244
3245 assert_eq!(
3247 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3248 "user's edit (older)"
3249 );
3250 assert_eq!(
3251 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3252 "user's edit (older)"
3253 );
3254 }
3255
3256 #[test]
3268 fn apply_absorbs_non_empty_target_dir_target_wins() {
3269 let tmp = TempDir::new().unwrap();
3270 let source = utf8(tmp.path().join("dotfiles"));
3271 let target = utf8(tmp.path().join("target"));
3272 std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
3273 std::fs::create_dir_all(target.join(".config/app")).unwrap();
3274 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3277 std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
3278 std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
3280 std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
3283 std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
3284
3285 let cfg = format!(
3286 r#"
3287[absorb]
3288on_anomaly = "force"
3289
3290[[mount.entry]]
3291src = "home"
3292dst = "{}"
3293"#,
3294 toml_path(&target)
3295 );
3296 std::fs::write(source.join("config.toml"), cfg).unwrap();
3297
3298 apply(Some(source.clone()), false).unwrap();
3300
3301 assert_eq!(
3303 std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
3304 "target side"
3305 );
3306 assert_eq!(
3308 std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
3309 "{}"
3310 );
3311 let backup_root = source.join(".yui/backup");
3314 let mut backup_files: Vec<String> = Vec::new();
3315 for entry in walkdir(&backup_root) {
3316 if let Some(n) = entry.file_name() {
3317 backup_files.push(n.to_string());
3318 }
3319 }
3320 assert!(
3321 backup_files.iter().any(|f| f == "config.toml"),
3322 "expected source's config.toml to land in the backup tree, got {backup_files:?}"
3323 );
3324 assert!(
3326 source.join("home/.config/app/source-only.toml").exists(),
3327 "source-only file should survive a target-wins merge"
3328 );
3329 assert!(
3331 source.join("home/.config/app/state.json").exists(),
3332 "target-only state.json should be merged into source"
3333 );
3334 }
3335
3336 #[test]
3342 fn marker_dir_absorbs_with_default_ask_policy() {
3343 let tmp = TempDir::new().unwrap();
3344 let source = utf8(tmp.path().join("dotfiles"));
3345 let target = utf8(tmp.path().join("target"));
3346 std::fs::create_dir_all(source.join("home/.config")).unwrap();
3347 std::fs::create_dir_all(target.join(".config/gh")).unwrap();
3348 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3350 std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\n").unwrap();
3352
3353 let cfg = format!(
3357 r#"
3358[[mount.entry]]
3359src = "home"
3360dst = "{}"
3361"#,
3362 toml_path(&target)
3363 );
3364 std::fs::write(source.join("config.toml"), cfg).unwrap();
3365
3366 apply(Some(source.clone()), false).unwrap();
3370
3371 assert!(target.join(".config/gh/hosts.yml").exists());
3374 assert!(source.join("home/.config/gh/hosts.yml").exists());
3375 }
3376
3377 #[test]
3383 fn merge_handles_file_vs_dir_collisions_target_wins() {
3384 let tmp = TempDir::new().unwrap();
3385 let source = utf8(tmp.path().join("dotfiles"));
3386 let target = utf8(tmp.path().join("target"));
3387 std::fs::create_dir_all(source.join("home/.config/foo")).unwrap();
3388 std::fs::create_dir_all(target.join(".config")).unwrap();
3389 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3390
3391 std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
3393 std::fs::write(target.join(".config/foo"), "target file body").unwrap();
3394 std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
3396 std::fs::create_dir_all(target.join(".config/bar")).unwrap();
3397 std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
3398
3399 let cfg = format!(
3400 r#"
3401[absorb]
3402on_anomaly = "force"
3403
3404[[mount.entry]]
3405src = "home"
3406dst = "{}"
3407"#,
3408 toml_path(&target)
3409 );
3410 std::fs::write(source.join("config.toml"), cfg).unwrap();
3411 apply(Some(source.clone()), false).unwrap();
3412
3413 let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
3417 assert!(foo_meta.file_type().is_file(), "foo should be a file");
3418 assert_eq!(
3419 std::fs::read_to_string(target.join(".config/foo")).unwrap(),
3420 "target file body"
3421 );
3422 let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
3424 assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
3425 assert_eq!(
3426 std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
3427 "target nested"
3428 );
3429 }
3430
3431 #[test]
3435 fn merge_per_file_target_newer_auto_absorbs() {
3436 let tmp = TempDir::new().unwrap();
3437 let source = utf8(tmp.path().join("dotfiles"));
3438 let target = utf8(tmp.path().join("target"));
3439 std::fs::create_dir_all(source.join("home/.config")).unwrap();
3440 std::fs::create_dir_all(target.join(".config")).unwrap();
3441 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3442
3443 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3445 write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
3446 std::fs::write(target.join(".config/app.toml"), "user's live edit").unwrap();
3447
3448 let cfg = format!(
3452 r#"
3453[[mount.entry]]
3454src = "home"
3455dst = "{}"
3456"#,
3457 toml_path(&target)
3458 );
3459 std::fs::write(source.join("config.toml"), cfg).unwrap();
3460 apply(Some(source.clone()), false).unwrap();
3461
3462 assert_eq!(
3464 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3465 "user's live edit"
3466 );
3467 }
3468
3469 #[test]
3475 fn merge_per_file_source_newer_skip_keeps_source() {
3476 let tmp = TempDir::new().unwrap();
3477 let source = utf8(tmp.path().join("dotfiles"));
3478 let target = utf8(tmp.path().join("target"));
3479 std::fs::create_dir_all(source.join("home/.config")).unwrap();
3480 std::fs::create_dir_all(target.join(".config")).unwrap();
3481 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3482
3483 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3485 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
3486 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
3487
3488 let cfg = format!(
3489 r#"
3490[absorb]
3491on_anomaly = "skip"
3492
3493[[mount.entry]]
3494src = "home"
3495dst = "{}"
3496"#,
3497 toml_path(&target)
3498 );
3499 std::fs::write(source.join("config.toml"), cfg).unwrap();
3500 apply(Some(source.clone()), false).unwrap();
3501
3502 assert_eq!(
3505 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3506 "fresh source"
3507 );
3508 }
3509
3510 #[test]
3513 fn merge_per_file_source_newer_force_overwrites_source() {
3514 let tmp = TempDir::new().unwrap();
3515 let source = utf8(tmp.path().join("dotfiles"));
3516 let target = utf8(tmp.path().join("target"));
3517 std::fs::create_dir_all(source.join("home/.config")).unwrap();
3518 std::fs::create_dir_all(target.join(".config")).unwrap();
3519 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3520
3521 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3522 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
3523 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
3524
3525 let cfg = format!(
3526 r#"
3527[absorb]
3528on_anomaly = "force"
3529
3530[[mount.entry]]
3531src = "home"
3532dst = "{}"
3533"#,
3534 toml_path(&target)
3535 );
3536 std::fs::write(source.join("config.toml"), cfg).unwrap();
3537 apply(Some(source.clone()), false).unwrap();
3538
3539 assert_eq!(
3541 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3542 "old target"
3543 );
3544 }
3545
3546 #[test]
3551 fn merge_per_file_identical_content_is_noop() {
3552 let tmp = TempDir::new().unwrap();
3553 let source = utf8(tmp.path().join("dotfiles"));
3554 let target = utf8(tmp.path().join("target"));
3555 std::fs::create_dir_all(source.join("home/.config")).unwrap();
3556 std::fs::create_dir_all(target.join(".config")).unwrap();
3557 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3558 std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
3559 std::fs::write(target.join(".config/app.toml"), "same").unwrap();
3560
3561 let cfg = format!(
3564 r#"
3565[[mount.entry]]
3566src = "home"
3567dst = "{}"
3568"#,
3569 toml_path(&target)
3570 );
3571 std::fs::write(source.join("config.toml"), cfg).unwrap();
3572 apply(Some(source.clone()), false).unwrap();
3573
3574 assert_eq!(
3575 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3576 "same"
3577 );
3578 }
3579
3580 #[test]
3581 fn manual_absorb_command_pulls_target_into_source() {
3582 let tmp = TempDir::new().unwrap();
3584 let source = utf8(tmp.path().join("dotfiles"));
3585 let target = utf8(tmp.path().join("target"));
3586 std::fs::create_dir_all(source.join("home")).unwrap();
3587 std::fs::create_dir_all(&target).unwrap();
3588 let cfg = format!(
3590 r#"
3591[absorb]
3592on_anomaly = "skip"
3593
3594[[mount.entry]]
3595src = "home"
3596dst = "{}"
3597"#,
3598 toml_path(&target)
3599 );
3600 std::fs::write(source.join("config.toml"), cfg).unwrap();
3601 std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
3602 std::fs::write(source.join("home/.bashrc"), "default").unwrap();
3603
3604 absorb(
3606 Some(source.clone()),
3607 target.join(".bashrc"),
3608 false,
3609 )
3610 .unwrap();
3611
3612 assert_eq!(
3614 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3615 "user picked this"
3616 );
3617 }
3618
3619 #[test]
3620 fn manual_absorb_errors_when_target_outside_known_mounts() {
3621 let tmp = TempDir::new().unwrap();
3622 let (source, _target) = setup_minimal_dotfiles(&tmp);
3623 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
3624 let stranger = utf8(tmp.path().join("not-managed/foo"));
3625 std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
3626 std::fs::write(&stranger, "not yui's").unwrap();
3627 let err = absorb(Some(source), stranger, false).unwrap_err();
3628 assert!(format!("{err}").contains("no mount entry"));
3629 }
3630
3631 #[test]
3632 fn yuiignore_excludes_file_from_linking() {
3633 let tmp = TempDir::new().unwrap();
3634 let (source, target) = setup_minimal_dotfiles(&tmp);
3635 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
3636 std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
3637 std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
3639 apply(Some(source.clone()), false).unwrap();
3640 assert!(target.join(".bashrc").exists());
3641 assert!(
3642 !target.join("lock.json").exists(),
3643 "yuiignore should keep lock.json out of target"
3644 );
3645 }
3646
3647 #[test]
3648 fn yuiignore_excludes_directory_subtree() {
3649 let tmp = TempDir::new().unwrap();
3650 let (source, target) = setup_minimal_dotfiles(&tmp);
3651 std::fs::create_dir_all(source.join("home/cache")).unwrap();
3652 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
3653 std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
3654 std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
3655 std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
3657 apply(Some(source.clone()), false).unwrap();
3658 assert!(target.join(".bashrc").exists());
3659 assert!(
3660 !target.join("cache").exists(),
3661 "yuiignore'd subtree should not appear in target"
3662 );
3663 }
3664
3665 #[test]
3666 fn yuiignore_negation_re_includes_file() {
3667 let tmp = TempDir::new().unwrap();
3668 let (source, target) = setup_minimal_dotfiles(&tmp);
3669 std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
3670 std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
3671 std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
3673 apply(Some(source.clone()), false).unwrap();
3674 assert!(target.join("keep.cache").exists());
3675 assert!(!target.join("drop.cache").exists());
3676 }
3677
3678 #[test]
3683 fn nested_yuiignore_only_affects_its_subtree() {
3684 let tmp = TempDir::new().unwrap();
3685 let (source, target) = setup_minimal_dotfiles(&tmp);
3686 std::fs::create_dir_all(source.join("home/inner")).unwrap();
3687 std::fs::write(source.join("home/secret.txt"), "outer keep").unwrap();
3688 std::fs::write(source.join("home/inner/secret.txt"), "inner drop").unwrap();
3689 std::fs::write(source.join("home/inner/keep.txt"), "inner keep").unwrap();
3690 std::fs::write(source.join("home/inner/.yuiignore"), "secret*\n").unwrap();
3692 apply(Some(source.clone()), false).unwrap();
3693 assert!(
3694 target.join("secret.txt").exists(),
3695 "outer secret.txt is outside the nested .yuiignore scope"
3696 );
3697 assert!(target.join("inner/keep.txt").exists());
3698 assert!(
3699 !target.join("inner/secret.txt").exists(),
3700 "inner secret.txt should be excluded by the nested .yuiignore"
3701 );
3702 }
3703
3704 #[test]
3708 fn nested_yuiignore_negation_overrides_root_rule() {
3709 let tmp = TempDir::new().unwrap();
3710 let (source, target) = setup_minimal_dotfiles(&tmp);
3711 std::fs::create_dir_all(source.join("home/keepers")).unwrap();
3712 std::fs::write(source.join("home/drop.lock"), "outer drop").unwrap();
3713 std::fs::write(source.join("home/keepers/wanted.lock"), "inner keep").unwrap();
3714 std::fs::write(source.join(".yuiignore"), "*.lock\n").unwrap();
3715 std::fs::write(source.join("home/keepers/.yuiignore"), "!*.lock\n").unwrap();
3717 apply(Some(source.clone()), false).unwrap();
3718 assert!(
3719 !target.join("drop.lock").exists(),
3720 "root rule still drops outer .lock file"
3721 );
3722 assert!(
3723 target.join("keepers/wanted.lock").exists(),
3724 "nested negation re-includes .lock under keepers/"
3725 );
3726 }
3727
3728 #[test]
3732 fn nested_yuiignore_status_walk_scoped() {
3733 let tmp = TempDir::new().unwrap();
3734 let (source, _target) = setup_minimal_dotfiles(&tmp);
3735 std::fs::create_dir_all(source.join("home/a")).unwrap();
3736 std::fs::create_dir_all(source.join("home/b")).unwrap();
3737 std::fs::write(source.join("home/a/foo.txt"), "a-foo").unwrap();
3738 std::fs::write(source.join("home/b/foo.txt"), "b-foo").unwrap();
3739 std::fs::write(source.join("home/a/.yuiignore"), "foo.txt\n").unwrap();
3741 apply(Some(source.clone()), false).unwrap();
3742 let res = status(Some(source), None, true);
3744 assert!(res.is_ok() || matches!(&res, Err(e) if format!("{e}").contains("diverged")));
3745 }
3746
3747 #[test]
3748 fn yuiignore_skips_template_in_render() {
3749 let tmp = TempDir::new().unwrap();
3750 let source = utf8(tmp.path().join("dotfiles"));
3751 let target = utf8(tmp.path().join("target"));
3752 std::fs::create_dir_all(source.join("home")).unwrap();
3753 std::fs::create_dir_all(&target).unwrap();
3754 std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
3755 std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
3756 let cfg = format!(
3757 r#"
3758[[mount.entry]]
3759src = "home"
3760dst = "{}"
3761"#,
3762 toml_path(&target)
3763 );
3764 std::fs::write(source.join("config.toml"), cfg).unwrap();
3765 apply(Some(source.clone()), false).unwrap();
3766 assert!(!source.join("home/note").exists());
3768 assert!(!target.join("note").exists());
3769 assert!(!target.join("note.tera").exists());
3770 }
3771
3772 #[test]
3776 fn nested_marker_accumulates_extra_dst() {
3777 let tmp = TempDir::new().unwrap();
3778 let source = utf8(tmp.path().join("dotfiles"));
3779 let parent_target = utf8(tmp.path().join("home"));
3780 let extra_target = utf8(tmp.path().join("extra"));
3781 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3782 std::fs::create_dir_all(&parent_target).unwrap();
3783 std::fs::create_dir_all(&extra_target).unwrap();
3784 std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
3785
3786 std::fs::write(
3788 source.join("home/.config/.yuilink"),
3789 format!(
3790 r#"
3791[[link]]
3792dst = "{}/.config"
3793"#,
3794 toml_path(&parent_target)
3795 ),
3796 )
3797 .unwrap();
3798 std::fs::write(
3801 source.join("home/.config/nvim/.yuilink"),
3802 format!(
3803 r#"
3804[[link]]
3805dst = "{}/nvim"
3806when = "{{{{ yui.os == '{}' }}}}"
3807"#,
3808 toml_path(&extra_target),
3809 std::env::consts::OS
3810 ),
3811 )
3812 .unwrap();
3813
3814 let cfg = format!(
3815 r#"
3816[[mount.entry]]
3817src = "home"
3818dst = "{}"
3819"#,
3820 toml_path(&parent_target)
3821 );
3822 std::fs::write(source.join("config.toml"), cfg).unwrap();
3823
3824 apply(Some(source.clone()), false).unwrap();
3825
3826 assert!(parent_target.join(".config/nvim/init.lua").exists());
3829 assert!(extra_target.join("nvim/init.lua").exists());
3830 }
3831
3832 #[test]
3837 fn marker_file_link_targets_specific_file() {
3838 let tmp = TempDir::new().unwrap();
3839 let source = utf8(tmp.path().join("dotfiles"));
3840 let parent_target = utf8(tmp.path().join("home"));
3841 let docs_target = utf8(tmp.path().join("docs"));
3842 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
3843 std::fs::create_dir_all(&parent_target).unwrap();
3844 std::fs::create_dir_all(&docs_target).unwrap();
3845 std::fs::write(
3846 source.join("home/.config/powershell/profile.ps1"),
3847 "# profile\n",
3848 )
3849 .unwrap();
3850 std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
3851
3852 std::fs::write(
3855 source.join("home/.config/powershell/.yuilink"),
3856 format!(
3857 r#"
3858[[link]]
3859src = "profile.ps1"
3860dst = "{}/Microsoft.PowerShell_profile.ps1"
3861"#,
3862 toml_path(&docs_target)
3863 ),
3864 )
3865 .unwrap();
3866
3867 let cfg = format!(
3868 r#"
3869[[mount.entry]]
3870src = "home"
3871dst = "{}"
3872"#,
3873 toml_path(&parent_target)
3874 );
3875 std::fs::write(source.join("config.toml"), cfg).unwrap();
3876
3877 apply(Some(source.clone()), false).unwrap();
3878
3879 assert!(
3881 docs_target
3882 .join("Microsoft.PowerShell_profile.ps1")
3883 .exists()
3884 );
3885 assert!(
3888 parent_target
3889 .join(".config/powershell/profile.ps1")
3890 .exists()
3891 );
3892 assert!(parent_target.join(".config/powershell/extra.txt").exists());
3893 }
3894
3895 #[test]
3898 fn marker_file_link_missing_src_errors() {
3899 let tmp = TempDir::new().unwrap();
3900 let source = utf8(tmp.path().join("dotfiles"));
3901 let parent_target = utf8(tmp.path().join("home"));
3902 let docs_target = utf8(tmp.path().join("docs"));
3903 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
3904 std::fs::create_dir_all(&parent_target).unwrap();
3905 std::fs::create_dir_all(&docs_target).unwrap();
3906
3907 std::fs::write(
3908 source.join("home/.config/powershell/.yuilink"),
3909 format!(
3910 r#"
3911[[link]]
3912src = "missing.ps1"
3913dst = "{}/profile.ps1"
3914"#,
3915 toml_path(&docs_target)
3916 ),
3917 )
3918 .unwrap();
3919
3920 let cfg = format!(
3921 r#"
3922[[mount.entry]]
3923src = "home"
3924dst = "{}"
3925"#,
3926 toml_path(&parent_target)
3927 );
3928 std::fs::write(source.join("config.toml"), cfg).unwrap();
3929
3930 let err = apply(Some(source.clone()), false).unwrap_err();
3931 assert!(format!("{err:#}").contains("missing.ps1"));
3932 }
3933
3934 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
3935 let mut out = Vec::new();
3936 let mut stack = vec![root.to_path_buf()];
3937 while let Some(dir) = stack.pop() {
3938 let Ok(entries) = std::fs::read_dir(&dir) else {
3939 continue;
3940 };
3941 for e in entries.flatten() {
3942 let p = utf8(e.path());
3943 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
3944 stack.push(p);
3945 } else {
3946 out.push(p);
3947 }
3948 }
3949 }
3950 out
3951 }
3952}