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 let yuiignore = paths::load_yuiignore(&source)?;
202
203 let mut engine = template::Engine::new();
204 let tera_ctx = template::template_context(&yui, &config.vars);
205
206 hook::run_phase(
209 &config,
210 &source,
211 &yui,
212 &mut engine,
213 &tera_ctx,
214 HookPhase::Pre,
215 dry_run,
216 )?;
217
218 let render_report = render::render_all(&source, &config, &yui, &yuiignore, dry_run)?;
220 log_render_report(&render_report);
221 if render_report.has_drift() {
222 anyhow::bail!(
223 "render drift detected ({} file(s)); reflect target edits back into the .tera before re-running apply",
224 render_report.diverged.len()
225 );
226 }
227
228 let mounts = mount::resolve(
230 &config.mount.entry,
231 config.mount.default_strategy,
232 &mut engine,
233 &tera_ctx,
234 )?;
235
236 let backup_root = source.join(&config.backup.dir);
237 let ctx = ApplyCtx {
238 config: &config,
239 source: &source,
240 yuiignore: &yuiignore,
241 file_mode: resolve_file_mode(config.link.file_mode),
242 dir_mode: resolve_dir_mode(config.link.dir_mode),
243 backup_root: &backup_root,
244 dry_run,
245 };
246
247 info!("source: {source}");
248 info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
249 if dry_run {
250 info!("dry-run: nothing will be written");
251 }
252
253 for m in &mounts {
254 info!("mount: {} → {}", m.src, m.dst);
255 process_mount(&source, m, &ctx, &mut engine, &tera_ctx)?;
256 }
257
258 hook::run_phase(
260 &config,
261 &source,
262 &yui,
263 &mut engine,
264 &tera_ctx,
265 HookPhase::Post,
266 dry_run,
267 )?;
268 Ok(())
269}
270
271fn log_render_report(r: &RenderReport) {
272 if !r.written.is_empty() {
273 info!("rendered {} new file(s)", r.written.len());
274 }
275 if !r.unchanged.is_empty() {
276 info!("rendered {} file(s) unchanged", r.unchanged.len());
277 }
278 if !r.skipped_when_false.is_empty() {
279 info!(
280 "skipped {} template(s) (when=false)",
281 r.skipped_when_false.len()
282 );
283 }
284 for d in &r.diverged {
285 warn!("rendered file diverged from template: {d}");
286 }
287}
288
289struct ApplyCtx<'a> {
291 config: &'a Config,
292 source: &'a Utf8Path,
295 yuiignore: &'a ignore::gitignore::Gitignore,
298 file_mode: EffectiveFileMode,
299 dir_mode: EffectiveDirMode,
300 backup_root: &'a Utf8Path,
301 dry_run: bool,
302}
303
304pub fn list(
310 source: Option<Utf8PathBuf>,
311 all: bool,
312 icons_override: Option<IconsMode>,
313 no_color: bool,
314) -> Result<()> {
315 let source = resolve_source(source)?;
316 let yui = YuiVars::detect(&source);
317 let config = config::load(&source, &yui)?;
318
319 let icons_mode = icons_override.unwrap_or(config.ui.icons);
320 let icons = Icons::for_mode(icons_mode);
321 let color = !no_color && supports_color_stdout();
322
323 let items = collect_list_items(&source, &config, &yui)?;
324 let displayed: Vec<&ListItem> = if all {
325 items.iter().collect()
326 } else {
327 items.iter().filter(|i| i.active).collect()
328 };
329
330 print_list_table(&displayed, icons, color);
331
332 let total = items.len();
333 let active = items.iter().filter(|i| i.active).count();
334 let inactive = total - active;
335 println!();
336 if all {
337 println!(" {total} entries · {active} active · {inactive} inactive");
338 } else {
339 println!(
340 " {} of {} entries shown ({} inactive hidden — use --all)",
341 active, total, inactive
342 );
343 }
344 Ok(())
345}
346
347#[derive(Debug)]
348struct ListItem {
349 src: Utf8PathBuf,
350 dst: String,
351 when: Option<String>,
352 active: bool,
353}
354
355fn collect_list_items(source: &Utf8Path, config: &Config, yui: &YuiVars) -> Result<Vec<ListItem>> {
356 let mut engine = template::Engine::new();
357 let tera_ctx = template::template_context(yui, &config.vars);
358 let yuiignore = paths::load_yuiignore(source)?;
359 let mut items = Vec::new();
360
361 for entry in &config.mount.entry {
363 let active = match &entry.when {
364 None => true,
365 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
366 };
367 let dst = engine
368 .render(&entry.dst, &tera_ctx)
369 .map(|s| paths::expand_tilde(s.trim()).to_string())
370 .unwrap_or_else(|_| entry.dst.clone());
371 items.push(ListItem {
372 src: entry.src.clone(),
373 dst,
374 when: entry.when.clone(),
375 active,
376 });
377 }
378
379 let walker = paths::source_walker(source).build();
381 let marker_filename = &config.mount.marker_filename;
382 for entry in walker {
383 let entry = match entry {
384 Ok(e) => e,
385 Err(_) => continue,
386 };
387 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
388 continue;
389 }
390 if entry.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
391 continue;
392 }
393 let dir = match entry.path().parent() {
394 Some(d) => d,
395 None => continue,
396 };
397 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
398 Ok(p) => p,
399 Err(_) => continue,
400 };
401 if paths::is_ignored(&yuiignore, source, &dir_utf8, true) {
403 continue;
404 }
405 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
406 Some(s) => s,
407 None => continue,
408 };
409 let MarkerSpec::Explicit { links } = spec else {
410 continue; };
412 let rel = dir_utf8
413 .strip_prefix(source)
414 .map(Utf8PathBuf::from)
415 .unwrap_or(dir_utf8);
416 for link in &links {
417 let active = match &link.when {
418 None => true,
419 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
420 };
421 let dst = engine
422 .render(&link.dst, &tera_ctx)
423 .map(|s| paths::expand_tilde(s.trim()).to_string())
424 .unwrap_or_else(|_| link.dst.clone());
425 let src_display = match &link.src {
430 Some(filename) => rel.join(filename),
431 None => rel.clone(),
432 };
433 items.push(ListItem {
434 src: src_display,
435 dst,
436 when: link.when.clone(),
437 active,
438 });
439 }
440 }
441
442 items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
443 Ok(items)
444}
445
446fn supports_color_stdout() -> bool {
447 use std::io::IsTerminal;
448 std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
449}
450
451fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
452 let src_w = items
453 .iter()
454 .map(|i| i.src.as_str().chars().count())
455 .max()
456 .unwrap_or(0)
457 .max("SRC".len());
458 let dst_w = items
459 .iter()
460 .map(|i| i.dst.chars().count())
461 .max()
462 .unwrap_or(0)
463 .max("DST".len());
464
465 let status_w = "STATUS".len();
466 let arrow_w = icons.arrow.chars().count();
467
468 print_header(status_w, src_w, arrow_w, dst_w, color);
470
471 let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
473 if color {
474 use owo_colors::OwoColorize as _;
475 println!("{}", sep.dimmed());
476 } else {
477 println!("{sep}");
478 }
479
480 for item in items {
482 print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
483 }
484}
485
486fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
487 use owo_colors::OwoColorize as _;
488 let mut line = String::new();
489 let _ = write!(
490 &mut line,
491 " {:<status_w$} {:<src_w$} {:<arrow_w$} {:<dst_w$} WHEN",
492 "STATUS", "SRC", "", "DST"
493 );
494 if color {
495 println!("{}", line.bold());
496 } else {
497 println!("{line}");
498 }
499}
500
501fn render_separator(
502 sep_ch: char,
503 status_w: usize,
504 src_w: usize,
505 arrow_w: usize,
506 dst_w: usize,
507) -> String {
508 let bar = |n: usize| sep_ch.to_string().repeat(n);
509 format!(
510 " {} {} {} {} {}",
511 bar(status_w),
512 bar(src_w),
513 bar(arrow_w),
514 bar(dst_w),
515 bar("WHEN".len())
516 )
517}
518
519fn print_row(
520 item: &ListItem,
521 icons: Icons,
522 status_w: usize,
523 src_w: usize,
524 arrow_w: usize,
525 dst_w: usize,
526 color: bool,
527) {
528 use owo_colors::OwoColorize as _;
529 let status = if item.active {
530 icons.active
531 } else {
532 icons.inactive
533 };
534 let when_str = item
535 .when
536 .as_deref()
537 .map(strip_braces)
538 .unwrap_or_else(|| "(always)".to_string());
539
540 let src_display = item.src.as_str().replace('\\', "/");
542 let src = src_display.as_str();
543 let dst = &item.dst;
544 let arrow = icons.arrow;
545
546 let cell_status = format!("{:<status_w$}", status);
551 let cell_src = format!("{:<src_w$}", src);
552 let cell_arrow = format!("{:<arrow_w$}", arrow);
553 let cell_dst = format!("{:<dst_w$}", dst);
554
555 if !color {
556 println!(" {cell_status} {cell_src} {cell_arrow} {cell_dst} {when_str}");
557 return;
558 }
559
560 if item.active {
561 println!(
562 " {} {} {} {} {}",
563 cell_status.green(),
564 cell_src.cyan(),
565 cell_arrow.dimmed(),
566 cell_dst.green(),
567 when_str.dimmed()
568 );
569 } else {
570 println!(
571 " {} {} {} {} {}",
572 cell_status.red().dimmed(),
573 cell_src.dimmed(),
574 cell_arrow.dimmed(),
575 cell_dst.dimmed(),
576 when_str.dimmed()
577 );
578 }
579}
580
581fn strip_braces(expr: &str) -> String {
584 let trimmed = expr.trim();
585 if let Some(inner) = trimmed
586 .strip_prefix("{{")
587 .and_then(|s| s.strip_suffix("}}"))
588 {
589 inner.trim().to_string()
590 } else {
591 trimmed.to_string()
592 }
593}
594
595pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
596 let source = resolve_source(source)?;
597 let yui = YuiVars::detect(&source);
598 let config = config::load(&source, &yui)?;
599 let yuiignore = paths::load_yuiignore(&source)?;
600 let report = render::render_all(&source, &config, &yui, &yuiignore, dry_run || check)?;
602 log_render_report(&report);
603 if check && report.has_drift() {
604 anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
605 }
606 Ok(())
607}
608
609pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
610 apply(source, dry_run)
612}
613
614pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
615 let _source = resolve_source(source)?;
616 if paths_arg.is_empty() {
617 anyhow::bail!("yui unlink: provide at least one target path");
618 }
619 for p in paths_arg {
620 let abs = absolutize(&p)?;
621 info!("unlink: {abs}");
622 link::unlink(&abs)?;
623 }
624 Ok(())
625}
626
627pub fn status(
640 source: Option<Utf8PathBuf>,
641 icons_override: Option<IconsMode>,
642 no_color: bool,
643) -> Result<()> {
644 let source = resolve_source(source)?;
645 let yui = YuiVars::detect(&source);
646 let config = config::load(&source, &yui)?;
647
648 let mut engine = template::Engine::new();
649 let tera_ctx = template::template_context(&yui, &config.vars);
650 let mounts = mount::resolve(
651 &config.mount.entry,
652 config.mount.default_strategy,
653 &mut engine,
654 &tera_ctx,
655 )?;
656
657 let icons_mode = icons_override.unwrap_or(config.ui.icons);
658 let icons = Icons::for_mode(icons_mode);
659 let color = !no_color && supports_color_stdout();
660
661 let mut report: Vec<StatusItem> = Vec::new();
662 let yuiignore = paths::load_yuiignore(&source)?;
665
666 let render_report =
669 render::render_all(&source, &config, &yui, &yuiignore, 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 for m in &mounts {
684 let src_root = source.join(&m.src);
685 if !src_root.is_dir() {
686 warn!("mount src missing: {src_root}");
687 continue;
688 }
689 classify_walk(
690 &src_root,
691 &m.dst,
692 &config,
693 m.strategy,
694 &mut engine,
695 &tera_ctx,
696 &source,
697 &yuiignore,
698 &mut report,
699 )?;
700 }
701
702 report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
703
704 print_status_table(&report, icons, color);
705
706 let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
707
708 println!();
709 let total = report.len();
710 let in_sync = total - drift;
711 if drift == 0 {
712 println!(" {total} entries · all in sync");
713 Ok(())
714 } else {
715 println!(" {total} entries · {in_sync} in sync · {drift} diverged");
716 anyhow::bail!("status: {drift} entries diverged from source")
717 }
718}
719
720#[derive(Debug)]
721struct StatusItem {
722 src: Utf8PathBuf,
724 dst: Utf8PathBuf,
726 state: StatusState,
727}
728
729#[derive(Debug, Clone, Copy)]
730enum StatusState {
731 Link(absorb::AbsorbDecision),
732 RenderDrift,
735}
736
737impl StatusState {
738 fn is_in_sync(self) -> bool {
739 matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
740 }
741}
742
743#[allow(clippy::too_many_arguments)]
744fn classify_walk(
745 src_dir: &Utf8Path,
746 dst_dir: &Utf8Path,
747 config: &Config,
748 strategy: MountStrategy,
749 engine: &mut template::Engine,
750 tera_ctx: &TeraContext,
751 source_root: &Utf8Path,
752 yuiignore: &ignore::gitignore::Gitignore,
753 report: &mut Vec<StatusItem>,
754) -> Result<()> {
755 classify_walk_inner(
756 src_dir,
757 dst_dir,
758 config,
759 strategy,
760 engine,
761 tera_ctx,
762 source_root,
763 yuiignore,
764 report,
765 false,
766 )
767}
768
769#[allow(clippy::too_many_arguments)]
770fn classify_walk_inner(
771 src_dir: &Utf8Path,
772 dst_dir: &Utf8Path,
773 config: &Config,
774 strategy: MountStrategy,
775 engine: &mut template::Engine,
776 tera_ctx: &TeraContext,
777 source_root: &Utf8Path,
778 yuiignore: &ignore::gitignore::Gitignore,
779 report: &mut Vec<StatusItem>,
780 parent_covered: bool,
781) -> Result<()> {
782 if paths::is_ignored(yuiignore, source_root, src_dir, true) {
783 return Ok(());
784 }
785
786 let marker_filename = &config.mount.marker_filename;
787 let mut covered = parent_covered;
788
789 if strategy == MountStrategy::Marker {
790 match marker::read_spec(src_dir, marker_filename)? {
791 None => {}
792 Some(MarkerSpec::PassThrough) => {
793 let decision = absorb::classify(src_dir, dst_dir)?;
794 report.push(StatusItem {
795 src: relative_for_display(source_root, src_dir),
796 dst: dst_dir.to_path_buf(),
797 state: StatusState::Link(decision),
798 });
799 covered = true;
800 }
801 Some(MarkerSpec::Explicit { links }) => {
802 let mut emitted_dir_link = false;
803 for link in &links {
804 if let Some(when) = &link.when {
805 if !template::eval_truthy(when, engine, tera_ctx)? {
806 continue;
807 }
808 }
809 let dst_str = engine.render(&link.dst, tera_ctx)?;
810 let dst = paths::expand_tilde(dst_str.trim());
811 if let Some(filename) = &link.src {
812 let file_src = src_dir.join(filename);
813 if !file_src.is_file() {
814 anyhow::bail!(
815 "marker at {src_dir}: [[link]] src={filename:?} \
816 not found"
817 );
818 }
819 let decision = absorb::classify(&file_src, &dst)?;
820 report.push(StatusItem {
821 src: relative_for_display(source_root, &file_src),
822 dst,
823 state: StatusState::Link(decision),
824 });
825 } else {
826 let decision = absorb::classify(src_dir, &dst)?;
827 report.push(StatusItem {
828 src: relative_for_display(source_root, src_dir),
829 dst,
830 state: StatusState::Link(decision),
831 });
832 emitted_dir_link = true;
833 }
834 }
835 if emitted_dir_link {
836 covered = true;
837 }
838 }
839 }
840 }
841
842 for entry in std::fs::read_dir(src_dir)? {
843 let entry = entry?;
844 let name_os = entry.file_name();
845 let Some(name) = name_os.to_str() else {
846 continue;
847 };
848 if name == marker_filename || name.ends_with(".tera") {
849 continue;
850 }
851 let src_path = src_dir.join(name);
852 let dst_path = dst_dir.join(name);
853 let ft = entry.file_type()?;
854 if paths::is_ignored(yuiignore, source_root, &src_path, ft.is_dir()) {
855 continue;
856 }
857 if ft.is_dir() {
858 classify_walk_inner(
859 &src_path,
860 &dst_path,
861 config,
862 strategy,
863 engine,
864 tera_ctx,
865 source_root,
866 yuiignore,
867 report,
868 covered,
869 )?;
870 } else if ft.is_file() && !covered {
871 let decision = absorb::classify(&src_path, &dst_path)?;
872 report.push(StatusItem {
873 src: relative_for_display(source_root, &src_path),
874 dst: dst_path,
875 state: StatusState::Link(decision),
876 });
877 }
878 }
879 Ok(())
880}
881
882fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
883 p.strip_prefix(source_root)
884 .map(Utf8PathBuf::from)
885 .unwrap_or_else(|_| p.to_path_buf())
886}
887
888fn print_status_table(items: &[StatusItem], icons: Icons, color: bool) {
889 let src_w = items
890 .iter()
891 .map(|i| i.src.as_str().chars().count())
892 .max()
893 .unwrap_or(0)
894 .max("SRC".len());
895 let dst_w = items
896 .iter()
897 .map(|i| i.dst.as_str().chars().count())
898 .max()
899 .unwrap_or(0)
900 .max("DST".len());
901 let state_label_w = items
903 .iter()
904 .map(|i| state_label(i.state).len())
905 .max()
906 .unwrap_or(0)
907 .max("STATE".len() - 2); let state_w = state_label_w + 2; print_status_header(state_w, src_w, dst_w, color);
911 let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
912 if color {
913 use owo_colors::OwoColorize as _;
914 println!("{}", sep.dimmed());
915 } else {
916 println!("{sep}");
917 }
918 for item in items {
919 print_status_row(item, icons, state_w, src_w, dst_w, color);
920 }
921}
922
923fn state_label(s: StatusState) -> &'static str {
924 use absorb::AbsorbDecision::*;
925 match s {
926 StatusState::Link(InSync) => "in-sync",
927 StatusState::Link(RelinkOnly) => "relink",
928 StatusState::Link(AutoAbsorb) => "drift (auto)",
929 StatusState::Link(NeedsConfirm) => "drift (anomaly)",
930 StatusState::Link(Restore) => "missing",
931 StatusState::RenderDrift => "render drift",
932 }
933}
934
935fn state_icon(s: StatusState, icons: Icons) -> &'static str {
936 use absorb::AbsorbDecision::*;
937 match s {
938 StatusState::Link(InSync) => icons.ok,
939 StatusState::Link(RelinkOnly) => icons.warn,
940 StatusState::Link(AutoAbsorb) => icons.warn,
941 StatusState::Link(NeedsConfirm) => icons.error,
942 StatusState::Link(Restore) => icons.info,
943 StatusState::RenderDrift => icons.error,
944 }
945}
946
947fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
948 use owo_colors::OwoColorize as _;
949 let line = format!(
952 " {:<state_w$} {:<src_w$} {:<dst_w$}",
953 "STATE", "SRC", "DST"
954 );
955 if color {
956 println!("{}", line.bold());
957 } else {
958 println!("{line}");
959 }
960}
961
962fn render_status_separator(
963 sep_ch: char,
964 state_w: usize,
965 src_w: usize,
966 dst_w: usize,
967 arrow: &str,
968) -> String {
969 let bar = |n: usize| sep_ch.to_string().repeat(n);
970 format!(
971 " {} {} {} {}",
972 bar(state_w),
973 bar(src_w),
974 bar(arrow.chars().count()),
975 bar(dst_w)
976 )
977}
978
979fn print_status_row(
980 item: &StatusItem,
981 icons: Icons,
982 state_w: usize,
983 src_w: usize,
984 dst_w: usize,
985 color: bool,
986) {
987 use owo_colors::OwoColorize as _;
988 let icon = state_icon(item.state, icons);
989 let label = state_label(item.state);
990 let state_text = format!("{icon} {label}");
991 let src_display = item.src.as_str().replace('\\', "/");
992 let dst_display = item.dst.as_str().replace('\\', "/");
993 let arrow = icons.arrow;
994
995 let cell_state = format!("{:<state_w$}", state_text);
996 let cell_src = format!("{:<src_w$}", src_display);
997 let cell_dst = format!("{:<dst_w$}", dst_display);
998
999 if !color {
1000 println!(" {cell_state} {cell_src} {arrow} {cell_dst}");
1001 return;
1002 }
1003
1004 use absorb::AbsorbDecision::*;
1005 let state_colored = match item.state {
1006 StatusState::Link(InSync) => cell_state.green().to_string(),
1007 StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
1008 cell_state.yellow().to_string()
1009 }
1010 StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
1011 StatusState::Link(Restore) => cell_state.cyan().to_string(),
1012 StatusState::RenderDrift => cell_state.red().to_string(),
1013 };
1014 let src_colored = cell_src.cyan().to_string();
1015 let arrow_colored = arrow.dimmed().to_string();
1016 let dst_colored = cell_dst.dimmed().to_string();
1017 println!(" {state_colored} {src_colored} {arrow_colored} {dst_colored}");
1018}
1019
1020pub fn absorb(source: Option<Utf8PathBuf>, target: Utf8PathBuf, dry_run: bool) -> Result<()> {
1029 let source = resolve_source(source)?;
1030 let target = absolutize(&target)?;
1031 let yui = YuiVars::detect(&source);
1032 let config = config::load(&source, &yui)?;
1033
1034 let mut engine = template::Engine::new();
1035 let tera_ctx = template::template_context(&yui, &config.vars);
1036 let yuiignore = paths::load_yuiignore(&source)?;
1039
1040 let src_path = match find_source_for_target(
1041 &source,
1042 &config,
1043 &target,
1044 &mut engine,
1045 &tera_ctx,
1046 &yuiignore,
1047 )? {
1048 Some(s) => s,
1049 None => anyhow::bail!(
1050 "no mount entry / .yuilink override claims target {target}; \
1051 pass a path inside a known dst"
1052 ),
1053 };
1054
1055 info!("source for {target}: {src_path}");
1056
1057 if dry_run {
1058 info!("[dry-run] would absorb {target} → {src_path}");
1059 return Ok(());
1060 }
1061
1062 let backup_root = source.join(&config.backup.dir);
1063 let ctx = ApplyCtx {
1064 config: &config,
1065 source: &source,
1066 yuiignore: &yuiignore,
1067 file_mode: resolve_file_mode(config.link.file_mode),
1068 dir_mode: resolve_dir_mode(config.link.dir_mode),
1069 backup_root: &backup_root,
1070 dry_run: false,
1071 };
1072
1073 absorb_target_into_source(&src_path, &target, &ctx)
1076}
1077
1078fn find_source_for_target(
1082 source: &Utf8Path,
1083 config: &Config,
1084 target: &Utf8Path,
1085 engine: &mut template::Engine,
1086 tera_ctx: &TeraContext,
1087 yuiignore: &ignore::gitignore::Gitignore,
1088) -> Result<Option<Utf8PathBuf>> {
1089 for entry in &config.mount.entry {
1091 if let Some(when) = &entry.when {
1092 if !template::eval_truthy(when, engine, tera_ctx)? {
1093 continue;
1094 }
1095 }
1096 let dst_str = engine.render(&entry.dst, tera_ctx)?;
1097 let dst_root = paths::expand_tilde(dst_str.trim());
1098 if let Ok(rel) = target.strip_prefix(&dst_root) {
1099 let candidate = source.join(&entry.src).join(rel);
1100 if paths::is_ignored(yuiignore, source, &candidate, candidate.is_dir()) {
1104 continue;
1105 }
1106 return Ok(Some(candidate));
1107 }
1108 }
1109
1110 let walker = paths::source_walker(source).build();
1114 let marker_filename = &config.mount.marker_filename;
1115 for ent in walker {
1116 let ent = match ent {
1117 Ok(e) => e,
1118 Err(_) => continue,
1119 };
1120 if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
1121 continue;
1122 }
1123 if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
1124 continue;
1125 }
1126 let dir = match ent.path().parent() {
1127 Some(d) => d,
1128 None => continue,
1129 };
1130 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
1131 Ok(p) => p,
1132 Err(_) => continue,
1133 };
1134 if paths::is_ignored(yuiignore, source, &dir_utf8, true) {
1135 continue;
1136 }
1137 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
1138 Some(s) => s,
1139 None => continue,
1140 };
1141 let MarkerSpec::Explicit { links } = spec else {
1142 continue;
1143 };
1144 for link in &links {
1145 if let Some(when) = &link.when {
1146 if !template::eval_truthy(when, engine, tera_ctx)? {
1147 continue;
1148 }
1149 }
1150 let dst_str = engine.render(&link.dst, tera_ctx)?;
1151 let dst = paths::expand_tilde(dst_str.trim());
1152 if let Some(filename) = &link.src {
1159 let file_src = dir_utf8.join(filename);
1160 if !file_src.is_file() {
1161 anyhow::bail!(
1162 "marker at {dir_utf8}: [[link]] src={filename:?} \
1163 not found"
1164 );
1165 }
1166 if target == dst {
1167 return Ok(Some(file_src));
1168 }
1169 continue;
1170 }
1171 if target == dst {
1172 return Ok(Some(dir_utf8));
1173 }
1174 if let Ok(rel) = target.strip_prefix(&dst) {
1175 return Ok(Some(dir_utf8.join(rel)));
1176 }
1177 }
1178 }
1179
1180 Ok(None)
1181}
1182
1183pub fn doctor(
1184 source: Option<Utf8PathBuf>,
1185 icons_override: Option<IconsMode>,
1186 no_color: bool,
1187) -> Result<()> {
1188 use owo_colors::OwoColorize as _;
1189
1190 let resolved_source = resolve_source(source);
1195
1196 let yui = match &resolved_source {
1201 Ok(s) => YuiVars::detect(s),
1202 Err(_) => YuiVars::detect(Utf8Path::new(".")),
1203 };
1204
1205 let cfg_res = match &resolved_source {
1210 Ok(s) => Some(config::load(s, &yui)),
1211 Err(_) => None,
1212 };
1213 let cfg = cfg_res.as_ref().and_then(|r| r.as_ref().ok());
1214 let icons_mode = icons_override
1215 .or_else(|| cfg.map(|c| c.ui.icons))
1216 .unwrap_or_default();
1217 let icons = Icons::for_mode(icons_mode);
1218 let color = !no_color && supports_color_stdout();
1219
1220 let mut probes: Vec<Probe> = Vec::new();
1221
1222 probes.push(Probe::group("identity"));
1224 probes.push(Probe::ok("os/arch", format!("{} / {}", yui.os, yui.arch)));
1225 probes.push(Probe::ok("user@host", format!("{}@{}", yui.user, yui.host)));
1226
1227 probes.push(Probe::group("repo"));
1229 let mut have_source = false;
1230 match &resolved_source {
1231 Ok(s) => {
1232 have_source = true;
1233 probes.push(Probe::ok("source", s.to_string()));
1234 match cfg_res.as_ref().expect("cfg_res set when source is Ok") {
1235 Ok(c) => {
1236 probes.push(Probe::ok(
1237 "config",
1238 format!(
1239 "{} mount{} · {} hook{} · {} render rule{}",
1240 c.mount.entry.len(),
1241 plural(c.mount.entry.len()),
1242 c.hook.len(),
1243 plural(c.hook.len()),
1244 c.render.rule.len(),
1245 plural(c.render.rule.len()),
1246 ),
1247 ));
1248 }
1249 Err(e) => probes.push(Probe::error("config", format!("{e}"))),
1250 }
1251 match crate::git::is_clean(s) {
1255 Ok(true) => probes.push(Probe::ok("git", "clean")),
1256 Ok(false) => probes.push(Probe::warn(
1257 "git",
1258 "uncommitted changes — `[absorb] require_clean_git` will defer auto-absorb",
1259 )),
1260 Err(_) => probes.push(Probe::warn(
1261 "git",
1262 "no git repo (auto-absorb still works; commit history won't track drift)",
1263 )),
1264 }
1265 }
1266 Err(e) => {
1267 probes.push(Probe::error("source", format!("not found — {e}")));
1268 }
1269 }
1270
1271 probes.push(Probe::group("links"));
1273 if cfg!(windows) {
1274 probes.push(Probe::ok(
1275 "default mode",
1276 "files=hardlink, dirs=junction (no admin needed)",
1277 ));
1278 } else {
1279 probes.push(Probe::ok("default mode", "files=symlink, dirs=symlink"));
1280 }
1281
1282 if have_source {
1284 if let (Ok(s), Some(c)) = (&resolved_source, cfg) {
1285 probes.push(Probe::group("hooks"));
1286 if c.hook.is_empty() {
1287 probes.push(Probe::ok("hooks", "(none configured)"));
1288 } else {
1289 let mut missing = 0usize;
1290 for h in &c.hook {
1291 if !s.join(&h.script).is_file() {
1292 missing += 1;
1293 probes.push(Probe::error(
1294 format!("hook[{}]", h.name),
1295 format!("script not found at {}", h.script),
1296 ));
1297 }
1298 }
1299 if missing == 0 {
1300 probes.push(Probe::ok(
1301 "scripts",
1302 format!(
1303 "{} hook{} configured, all scripts present",
1304 c.hook.len(),
1305 plural(c.hook.len())
1306 ),
1307 ));
1308 }
1309 }
1310 }
1311 }
1312
1313 if let Some(home) = paths::home_dir() {
1315 let chezmoi_src = home.join(".local/share/chezmoi");
1316 if chezmoi_src.is_dir() {
1317 probes.push(Probe::group("chezmoi"));
1318 probes.push(Probe::warn(
1319 "legacy source",
1320 format!(
1321 "{chezmoi_src} still exists — yui doesn't use it, safe to archive once your migration has settled"
1322 ),
1323 ));
1324 }
1325 }
1326
1327 println!();
1329 if color {
1330 println!(" {}", "yui doctor".bold().underline());
1331 } else {
1332 println!(" yui doctor");
1333 }
1334 println!();
1335 for probe in &probes {
1336 probe.print(&icons, color);
1337 }
1338
1339 let errors = probes.iter().filter(|p| p.is_error()).count();
1340 let warns = probes.iter().filter(|p| p.is_warn()).count();
1341 let oks = probes.iter().filter(|p| p.is_ok()).count();
1342 println!();
1343 let summary = format!("{oks} ok · {warns} warn · {errors} error");
1344 if color {
1345 if errors > 0 {
1346 println!(" {}", summary.red().bold());
1347 } else if warns > 0 {
1348 println!(" {}", summary.yellow());
1349 } else {
1350 println!(" {}", summary.green());
1351 }
1352 } else {
1353 println!(" {summary}");
1354 }
1355
1356 if errors > 0 {
1357 anyhow::bail!("doctor: {errors} probe(s) failed");
1358 }
1359 Ok(())
1360}
1361
1362#[derive(Debug)]
1363enum Probe {
1364 Group(&'static str),
1366 Ok {
1367 label: String,
1368 detail: String,
1369 },
1370 Warn {
1371 label: String,
1372 detail: String,
1373 },
1374 Error {
1375 label: String,
1376 detail: String,
1377 },
1378}
1379
1380impl Probe {
1381 fn group(label: &'static str) -> Self {
1382 Self::Group(label)
1383 }
1384 fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
1385 Self::Ok {
1386 label: label.into(),
1387 detail: detail.into(),
1388 }
1389 }
1390 fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
1391 Self::Warn {
1392 label: label.into(),
1393 detail: detail.into(),
1394 }
1395 }
1396 fn error(label: impl Into<String>, detail: impl Into<String>) -> Self {
1397 Self::Error {
1398 label: label.into(),
1399 detail: detail.into(),
1400 }
1401 }
1402 fn is_ok(&self) -> bool {
1403 matches!(self, Self::Ok { .. })
1404 }
1405 fn is_warn(&self) -> bool {
1406 matches!(self, Self::Warn { .. })
1407 }
1408 fn is_error(&self) -> bool {
1409 matches!(self, Self::Error { .. })
1410 }
1411 fn print(&self, icons: &Icons, color: bool) {
1412 use owo_colors::OwoColorize as _;
1413 match self {
1414 Self::Group(name) => {
1415 println!();
1416 if color {
1417 println!(" {}", name.cyan().bold());
1418 } else {
1419 println!(" {name}");
1420 }
1421 }
1422 Self::Ok { label, detail } => {
1423 let icon = icons.ok;
1424 let padded = format!("{label:<14}");
1428 if color {
1429 println!(
1430 " {} {} {}",
1431 icon.green(),
1432 padded.bold(),
1433 detail.dimmed()
1434 );
1435 } else {
1436 println!(" {icon} {padded} {detail}");
1437 }
1438 }
1439 Self::Warn { label, detail } => {
1440 let icon = icons.warn;
1441 let padded = format!("{label:<14}");
1442 if color {
1443 println!(
1444 " {} {} {}",
1445 icon.yellow(),
1446 padded.bold().yellow(),
1447 detail
1448 );
1449 } else {
1450 println!(" {icon} {padded} {detail}");
1451 }
1452 }
1453 Self::Error { label, detail } => {
1454 let icon = icons.error;
1455 let padded = format!("{label:<14}");
1456 if color {
1457 println!(
1458 " {} {} {}",
1459 icon.red().bold(),
1460 padded.bold().red(),
1461 detail.red()
1462 );
1463 } else {
1464 println!(" {icon} {padded} {detail}");
1465 }
1466 }
1467 }
1468 }
1469}
1470
1471fn plural(n: usize) -> &'static str {
1472 if n == 1 { "" } else { "s" }
1473}
1474
1475pub fn gc_backup(_source: Option<Utf8PathBuf>, _older_than: Option<String>) -> Result<()> {
1476 todo!("yui gc-backup — clean up old backups")
1477}
1478
1479pub fn hooks_list(
1481 source: Option<Utf8PathBuf>,
1482 icons_override: Option<IconsMode>,
1483 no_color: bool,
1484) -> Result<()> {
1485 let source = resolve_source(source)?;
1486 let yui = YuiVars::detect(&source);
1487 let config = config::load(&source, &yui)?;
1488 let state = hook::State::load(&source)?;
1489
1490 let icons_mode = icons_override.unwrap_or(config.ui.icons);
1491 let icons = Icons::for_mode(icons_mode);
1492 let color = !no_color && supports_color_stdout();
1493
1494 if config.hook.is_empty() {
1495 println!("(no [[hook]] entries in config)");
1496 return Ok(());
1497 }
1498
1499 let mut engine = template::Engine::new();
1503 let tera_ctx = template::template_context(&yui, &config.vars);
1504 let rows: Vec<HookRow> = config
1505 .hook
1506 .iter()
1507 .map(|h| -> Result<HookRow> {
1508 let active = match &h.when {
1512 None => true,
1513 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
1514 };
1515 let last_run_at = state.hooks.get(&h.name).and_then(|s| s.last_run_at.clone());
1516 Ok(HookRow {
1517 name: h.name.clone(),
1518 phase: match h.phase {
1519 HookPhase::Pre => "pre",
1520 HookPhase::Post => "post",
1521 },
1522 when_run: match h.when_run {
1523 config::WhenRun::Once => "once",
1524 config::WhenRun::Onchange => "onchange",
1525 config::WhenRun::Every => "every",
1526 },
1527 last_run_at,
1528 when: h.when.clone(),
1529 active,
1530 })
1531 })
1532 .collect::<Result<Vec<_>>>()?;
1533
1534 print_hooks_table(&rows, icons, color);
1535
1536 let total = rows.len();
1537 let active = rows.iter().filter(|r| r.active).count();
1538 let inactive = total - active;
1539 let ran = rows.iter().filter(|r| r.last_run_at.is_some()).count();
1540 let never = total - ran;
1541 println!();
1542 println!(
1543 " {total} hooks · {active} active · {inactive} inactive · {ran} ran · {never} never run"
1544 );
1545
1546 Ok(())
1547}
1548
1549#[derive(Debug)]
1550struct HookRow {
1551 name: String,
1552 phase: &'static str,
1553 when_run: &'static str,
1554 last_run_at: Option<String>,
1555 when: Option<String>,
1556 active: bool,
1557}
1558
1559fn print_hooks_table(rows: &[HookRow], icons: Icons, color: bool) {
1560 use owo_colors::OwoColorize as _;
1561 use std::fmt::Write as _;
1562
1563 let name_w = rows
1564 .iter()
1565 .map(|r| r.name.chars().count())
1566 .max()
1567 .unwrap_or(0)
1568 .max("NAME".len());
1569 let phase_w = rows
1570 .iter()
1571 .map(|r| r.phase.len())
1572 .max()
1573 .unwrap_or(0)
1574 .max("PHASE".len());
1575 let when_run_w = rows
1576 .iter()
1577 .map(|r| r.when_run.len())
1578 .max()
1579 .unwrap_or(0)
1580 .max("WHEN_RUN".len());
1581 let last_w = rows
1582 .iter()
1583 .map(|r| {
1584 r.last_run_at
1585 .as_deref()
1586 .map(|s| s.chars().count())
1587 .unwrap_or("(never)".len())
1588 })
1589 .max()
1590 .unwrap_or(0)
1591 .max("LAST_RUN".len());
1592 let status_w = "STATUS".len();
1593
1594 let mut header = String::new();
1596 let _ = write!(
1597 &mut header,
1598 " {:<status_w$} {:<name_w$} {:<phase_w$} {:<when_run_w$} {:<last_w$} WHEN",
1599 "STATUS", "NAME", "PHASE", "WHEN_RUN", "LAST_RUN"
1600 );
1601 if color {
1602 println!("{}", header.bold());
1603 } else {
1604 println!("{header}");
1605 }
1606
1607 let bar = |n: usize| icons.sep.to_string().repeat(n);
1609 let sep = format!(
1610 " {} {} {} {} {} {}",
1611 bar(status_w),
1612 bar(name_w),
1613 bar(phase_w),
1614 bar(when_run_w),
1615 bar(last_w),
1616 bar("WHEN".len())
1617 );
1618 if color {
1619 println!("{}", sep.dimmed());
1620 } else {
1621 println!("{sep}");
1622 }
1623
1624 for r in rows {
1626 let (icon, ran) = match (r.active, r.last_run_at.is_some()) {
1631 (false, _) => (icons.inactive, false),
1632 (true, true) => (icons.active, true),
1633 (true, false) => (icons.info, false),
1634 };
1635 let last = r.last_run_at.as_deref().unwrap_or("(never)");
1636 let when_str = r
1637 .when
1638 .as_deref()
1639 .map(strip_braces)
1640 .unwrap_or_else(|| "(always)".to_string());
1641
1642 let cell_status = format!("{icon:<status_w$}");
1643 let cell_name = format!("{:<name_w$}", r.name);
1644 let cell_phase = format!("{:<phase_w$}", r.phase);
1645 let cell_when_run = format!("{:<when_run_w$}", r.when_run);
1646 let cell_last = format!("{last:<last_w$}");
1647
1648 if !color {
1649 println!(
1650 " {cell_status} {cell_name} {cell_phase} {cell_when_run} {cell_last} {when_str}"
1651 );
1652 continue;
1653 }
1654
1655 if !r.active {
1659 println!(
1660 " {} {} {} {} {} {}",
1661 cell_status.dimmed(),
1662 cell_name.dimmed(),
1663 cell_phase.dimmed(),
1664 cell_when_run.dimmed(),
1665 cell_last.dimmed(),
1666 when_str.dimmed()
1667 );
1668 } else if ran {
1669 println!(
1670 " {} {} {} {} {} {}",
1671 cell_status.green(),
1672 cell_name.cyan().bold(),
1673 cell_phase.dimmed(),
1674 cell_when_run.dimmed(),
1675 cell_last.green(),
1676 when_str.dimmed()
1677 );
1678 } else {
1679 println!(
1680 " {} {} {} {} {} {}",
1681 cell_status.yellow(),
1682 cell_name.cyan().bold(),
1683 cell_phase.dimmed(),
1684 cell_when_run.dimmed(),
1685 cell_last.yellow(),
1686 when_str.dimmed()
1687 );
1688 }
1689 }
1690}
1691
1692pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
1696 let source = resolve_source(source)?;
1697 let yui = YuiVars::detect(&source);
1698 let config = config::load(&source, &yui)?;
1699 let mut engine = template::Engine::new();
1700 let tera_ctx = template::template_context(&yui, &config.vars);
1701
1702 let targets: Vec<&config::HookConfig> = match &name {
1703 Some(want) => {
1704 let m = config
1705 .hook
1706 .iter()
1707 .find(|h| &h.name == want)
1708 .ok_or_else(|| {
1709 anyhow::anyhow!(
1710 "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
1711 )
1712 })?;
1713 vec![m]
1714 }
1715 None => config.hook.iter().collect(),
1716 };
1717
1718 let mut state = hook::State::load(&source)?;
1719 for h in targets {
1720 let outcome = hook::run_hook(
1721 h,
1722 &source,
1723 &yui,
1724 &config.vars,
1725 &mut engine,
1726 &tera_ctx,
1727 &mut state,
1728 false,
1729 force,
1730 )?;
1731 let label = match outcome {
1732 HookOutcome::Ran => "ran",
1733 HookOutcome::SkippedOnce => "skipped (once: already ran)",
1734 HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
1735 HookOutcome::SkippedWhenFalse => "skipped (when=false)",
1736 HookOutcome::DryRun => "would run (dry-run)",
1737 };
1738 info!("hook[{}]: {label}", h.name);
1739 if outcome == HookOutcome::Ran {
1740 state.save(&source)?;
1741 }
1742 }
1743 Ok(())
1744}
1745
1746fn process_mount(
1751 source: &Utf8Path,
1752 m: &ResolvedMount,
1753 ctx: &ApplyCtx<'_>,
1754 engine: &mut template::Engine,
1755 tera_ctx: &TeraContext,
1756) -> Result<()> {
1757 let src_root = source.join(&m.src);
1758 if !src_root.is_dir() {
1759 warn!("mount src missing: {src_root}");
1760 return Ok(());
1761 }
1762 walk_and_link(&src_root, &m.dst, ctx, m.strategy, engine, tera_ctx, false)
1763}
1764
1765#[allow(clippy::too_many_arguments)]
1766fn walk_and_link(
1767 src_dir: &Utf8Path,
1768 dst_dir: &Utf8Path,
1769 ctx: &ApplyCtx<'_>,
1770 strategy: MountStrategy,
1771 engine: &mut template::Engine,
1772 tera_ctx: &TeraContext,
1773 parent_covered: bool,
1774) -> Result<()> {
1775 if paths::is_ignored(ctx.yuiignore, ctx.source, src_dir, true) {
1778 return Ok(());
1779 }
1780
1781 let marker_filename = &ctx.config.mount.marker_filename;
1782 let mut covered = parent_covered;
1783
1784 if strategy == MountStrategy::Marker {
1785 match marker::read_spec(src_dir, marker_filename)? {
1786 None => {} Some(MarkerSpec::PassThrough) => {
1788 link_dir_with_backup(src_dir, dst_dir, ctx)?;
1792 covered = true;
1793 }
1794 Some(MarkerSpec::Explicit { links }) => {
1795 let mut emitted_dir_link = false;
1796 let mut emitted_any = false;
1797 for link in &links {
1798 if let Some(when) = &link.when {
1801 if !template::eval_truthy(when, engine, tera_ctx)? {
1802 continue;
1803 }
1804 }
1805 let dst_str = engine.render(&link.dst, tera_ctx)?;
1806 let dst = paths::expand_tilde(dst_str.trim());
1807 if let Some(filename) = &link.src {
1808 let file_src = src_dir.join(filename);
1809 if !file_src.is_file() {
1810 anyhow::bail!(
1811 "marker at {src_dir}: [[link]] src={filename:?} \
1812 not found"
1813 );
1814 }
1815 link_file_with_backup(&file_src, &dst, ctx)?;
1816 } else {
1817 link_dir_with_backup(src_dir, &dst, ctx)?;
1818 emitted_dir_link = true;
1819 }
1820 emitted_any = true;
1821 }
1822 if !emitted_any {
1823 info!(
1828 "marker at {src_dir} had no active links \
1829 — falling back to defaults"
1830 );
1831 }
1832 if emitted_dir_link {
1833 covered = true;
1834 }
1835 }
1836 }
1837 }
1838
1839 for entry in std::fs::read_dir(src_dir)? {
1840 let entry = entry?;
1841 let name_os = entry.file_name();
1842 let Some(name) = name_os.to_str() else {
1843 continue;
1844 };
1845 if name == marker_filename {
1846 continue;
1847 }
1848 if name.ends_with(".tera") {
1849 continue;
1851 }
1852 let src_path = src_dir.join(name);
1853 let dst_path = dst_dir.join(name);
1854 let ft = entry.file_type()?;
1855
1856 if paths::is_ignored(ctx.yuiignore, ctx.source, &src_path, ft.is_dir()) {
1857 continue;
1858 }
1859
1860 if ft.is_dir() {
1861 walk_and_link(
1862 &src_path, &dst_path, ctx, strategy, engine, tera_ctx, covered,
1863 )?;
1864 } else if ft.is_file() {
1865 if !covered {
1871 link_file_with_backup(&src_path, &dst_path, ctx)?;
1872 }
1873 }
1874 }
1875 Ok(())
1876}
1877
1878fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1879 use absorb::AbsorbDecision::*;
1880
1881 let decision = absorb::classify(src, dst)?;
1882
1883 if ctx.dry_run {
1884 info!("[dry-run] {decision:?}: {src} → {dst}");
1885 return Ok(());
1886 }
1887
1888 match decision {
1889 InSync => {
1890 Ok(())
1892 }
1893 Restore => {
1894 info!("link: {src} → {dst}");
1895 link::link_file(src, dst, ctx.file_mode)?;
1896 Ok(())
1897 }
1898 RelinkOnly => {
1899 info!("relink: {src} → {dst}");
1902 link::unlink(dst)?;
1903 link::link_file(src, dst, ctx.file_mode)?;
1904 Ok(())
1905 }
1906 AutoAbsorb => {
1907 if !ctx.config.absorb.auto {
1910 return handle_anomaly(
1911 src,
1912 dst,
1913 ctx,
1914 "absorb.auto = false; treating divergence as anomaly",
1915 );
1916 }
1917 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
1918 return handle_anomaly(
1919 src,
1920 dst,
1921 ctx,
1922 "source repo is dirty; deferring auto-absorb",
1923 );
1924 }
1925 absorb_target_into_source(src, dst, ctx)
1926 }
1927 NeedsConfirm => handle_anomaly(
1928 src,
1929 dst,
1930 ctx,
1931 "anomaly: source equals/newer than target but content differs",
1932 ),
1933 }
1934}
1935
1936fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1940 info!("absorb: {dst} → {src}");
1941 backup_existing(src, ctx.backup_root, false)?;
1942 std::fs::copy(dst, src)?;
1943 link::unlink(dst)?;
1944 link::link_file(src, dst, ctx.file_mode)?;
1945 Ok(())
1946}
1947
1948fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
1954 use crate::config::AnomalyAction::*;
1955 match ctx.config.absorb.on_anomaly {
1956 Skip => {
1957 warn!("anomaly skip: {dst} ({reason})");
1958 Ok(())
1959 }
1960 Force => {
1961 warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
1962 absorb_target_into_source(src, dst, ctx)
1963 }
1964 Ask => {
1965 use std::io::IsTerminal;
1966 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
1967 if prompt_absorb_with_diff(src, dst, reason)? {
1968 absorb_target_into_source(src, dst, ctx)
1969 } else {
1970 warn!("anomaly skipped by user: {dst}");
1971 Ok(())
1972 }
1973 } else {
1974 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
1975 Ok(())
1976 }
1977 }
1978 }
1979}
1980
1981fn prompt_absorb_with_diff(src: &Utf8Path, dst: &Utf8Path, reason: &str) -> Result<bool> {
1982 use std::io::Write as _;
1983 let src_content = std::fs::read_to_string(src).unwrap_or_default();
1984 let dst_content = std::fs::read_to_string(dst).unwrap_or_default();
1985 eprintln!();
1986 eprintln!("anomaly: {reason}");
1987 eprintln!(" src: {src}");
1988 eprintln!(" dst: {dst}");
1989 eprintln!();
1990 eprintln!("--- diff (- source, + target) ---");
1991 let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
1992 for change in diff.iter_all_changes() {
1993 let sign = match change.tag() {
1994 similar::ChangeTag::Delete => "-",
1995 similar::ChangeTag::Insert => "+",
1996 similar::ChangeTag::Equal => " ",
1997 };
1998 eprint!("{sign}{change}");
1999 }
2000 eprintln!();
2001 eprint!("absorb target into source? [y/N]: ");
2002 std::io::stderr().flush().ok();
2007 let mut input = String::new();
2008 std::io::stdin().read_line(&mut input)?;
2009 let answer = input.trim();
2010 Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
2011}
2012
2013fn source_repo_is_clean(source: &Utf8Path) -> bool {
2018 match crate::git::is_clean(source) {
2019 Ok(b) => b,
2020 Err(e) => {
2021 warn!("git clean check failed at {source}: {e} — treating as clean");
2022 true
2023 }
2024 }
2025}
2026
2027fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2028 use absorb::AbsorbDecision::*;
2029 let decision = absorb::classify(src, dst)?;
2030
2031 if ctx.dry_run {
2032 info!("[dry-run] dir {decision:?}: {src} → {dst}");
2033 return Ok(());
2034 }
2035
2036 match decision {
2037 InSync => Ok(()),
2038 Restore => {
2039 info!("link dir: {src} → {dst}");
2040 link::link_dir(src, dst, ctx.dir_mode)?;
2041 Ok(())
2042 }
2043 RelinkOnly => {
2044 info!("relink dir: {src} → {dst}");
2049 remove_dir_link_or_real(dst)?;
2050 link::link_dir(src, dst, ctx.dir_mode)?;
2051 Ok(())
2052 }
2053 AutoAbsorb | NeedsConfirm => {
2054 if !ctx.config.absorb.auto {
2075 return handle_anomaly_dir(
2076 src,
2077 dst,
2078 ctx,
2079 "absorb.auto = false; treating divergence as anomaly",
2080 );
2081 }
2082 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
2083 return handle_anomaly_dir(
2084 src,
2085 dst,
2086 ctx,
2087 "source repo is dirty; deferring auto-absorb",
2088 );
2089 }
2090 absorb_target_dir_into_source(src, dst, ctx)
2091 }
2092 }
2093}
2094
2095fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
2105 if let Err(unlink_err) = link::unlink(dst) {
2106 let meta = std::fs::symlink_metadata(dst)
2107 .with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
2108 let ft = meta.file_type();
2109 if ft.is_dir() && !ft.is_symlink() {
2110 std::fs::remove_dir_all(dst).with_context(|| {
2111 format!(
2112 "remove_dir_all({dst}) after link::unlink failed: \
2113 {unlink_err}"
2114 )
2115 })?;
2116 } else {
2117 return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
2118 }
2119 }
2120 Ok(())
2121}
2122
2123fn merge_dir_target_into_source(
2133 target: &Utf8Path,
2134 source: &Utf8Path,
2135 ctx: &ApplyCtx<'_>,
2136) -> Result<()> {
2137 for entry in std::fs::read_dir(target)? {
2138 let entry = entry?;
2139 let name_os = entry.file_name();
2140 let Some(name) = name_os.to_str() else {
2141 continue;
2142 };
2143 let target_path = target.join(name);
2144 let source_path = source.join(name);
2145 let ft = entry.file_type()?;
2146
2147 if ft.is_dir() && !ft.is_symlink() {
2148 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
2154 let sft = src_meta.file_type();
2155 if !sft.is_dir() || sft.is_symlink() {
2156 link::unlink(&source_path).with_context(|| {
2157 format!("remove conflicting source entry before dir merge: {source_path}")
2158 })?;
2159 }
2160 }
2161 if !source_path.exists() {
2162 std::fs::create_dir_all(&source_path).with_context(|| {
2163 format!("create_dir_all({source_path}) during target→source merge")
2164 })?;
2165 }
2166 merge_dir_target_into_source(&target_path, &source_path, ctx)?;
2167 } else if ft.is_file() {
2168 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
2172 let sft = src_meta.file_type();
2173 if sft.is_dir() && !sft.is_symlink() {
2174 remove_dir_link_or_real(&source_path).with_context(|| {
2175 format!("remove conflicting source dir before file merge: {source_path}")
2176 })?;
2177 } else if sft.is_symlink() {
2178 link::unlink(&source_path).with_context(|| {
2179 format!(
2180 "remove conflicting source symlink before file merge: {source_path}"
2181 )
2182 })?;
2183 }
2184 }
2185 if let Some(parent) = source_path.parent() {
2186 if !parent.exists() {
2187 std::fs::create_dir_all(parent)?;
2188 }
2189 }
2190 if source_path.is_file() {
2204 merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
2205 } else {
2206 std::fs::copy(&target_path, &source_path)
2207 .with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
2208 }
2209 } else {
2210 warn!(
2211 "merge: skipping non-regular entry {target_path} \
2212 (symlink / junction / special — content not copied)"
2213 );
2214 }
2215 }
2216 Ok(())
2217}
2218
2219fn merge_resolve_file_conflict(
2233 target_path: &Utf8Path,
2234 source_path: &Utf8Path,
2235 ctx: &ApplyCtx<'_>,
2236) -> Result<()> {
2237 use absorb::AbsorbDecision::*;
2238 let decision = absorb::classify(source_path, target_path)?;
2239 match decision {
2240 InSync | RelinkOnly => Ok(()),
2241 AutoAbsorb => {
2242 std::fs::copy(target_path, source_path).with_context(|| {
2243 format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
2244 })?;
2245 Ok(())
2246 }
2247 Restore => {
2248 unreachable!(
2255 "merge_resolve_file_conflict reached with both files present, \
2256 but classify returned Restore (target {target_path} / source {source_path})"
2257 )
2258 }
2259 NeedsConfirm => {
2260 use crate::config::AnomalyAction::*;
2261 match ctx.config.absorb.on_anomaly {
2262 Skip => {
2263 warn!(
2264 "merge anomaly skip: {target_path} (source-newer / content drift) \
2265 — keeping source version, target version dropped"
2266 );
2267 Ok(())
2268 }
2269 Force => {
2270 warn!(
2271 "merge anomaly force: {target_path} \
2272 (source-newer / content drift) — overwriting source"
2273 );
2274 std::fs::copy(target_path, source_path)?;
2275 Ok(())
2276 }
2277 Ask => {
2278 use std::io::IsTerminal;
2279 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
2280 if prompt_absorb_with_diff(
2281 source_path,
2282 target_path,
2283 "merge: file content differs and source is newer",
2284 )? {
2285 std::fs::copy(target_path, source_path)?;
2286 } else {
2287 warn!("merge: kept source version by user choice: {source_path}");
2288 }
2289 Ok(())
2290 } else {
2291 warn!(
2292 "merge anomaly skip (non-TTY ask mode): {target_path} \
2293 — keeping source version"
2294 );
2295 Ok(())
2296 }
2297 }
2298 }
2299 }
2300 }
2301}
2302
2303fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2310 info!("absorb dir: {dst} → {src}");
2311 backup_existing(src, ctx.backup_root, true)?;
2312 merge_dir_target_into_source(dst, src, ctx)?;
2313 remove_dir_link_or_real(dst)?;
2316 link::link_dir(src, dst, ctx.dir_mode)?;
2317 Ok(())
2318}
2319
2320fn handle_anomaly_dir(
2324 src: &Utf8Path,
2325 dst: &Utf8Path,
2326 ctx: &ApplyCtx<'_>,
2327 reason: &str,
2328) -> Result<()> {
2329 use crate::config::AnomalyAction::*;
2330 match ctx.config.absorb.on_anomaly {
2331 Skip => {
2332 warn!("anomaly skip dir: {dst} ({reason})");
2333 Ok(())
2334 }
2335 Force => {
2336 warn!(
2337 "anomaly force dir: {dst} ({reason}) \
2338 — absorbing target into source"
2339 );
2340 absorb_target_dir_into_source(src, dst, ctx)
2341 }
2342 Ask => {
2343 use std::io::IsTerminal;
2344 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
2345 eprintln!();
2346 eprintln!("anomaly: {dst}");
2347 eprintln!(" {reason}");
2348 eprintln!(" source: {src}");
2349 eprint!(" absorb target dir into source? (y/N) ");
2350 use std::io::{BufRead as _, Write as _};
2351 std::io::stderr().flush().ok();
2352 let mut buf = String::new();
2353 std::io::stdin().lock().read_line(&mut buf)?;
2354 let answer = buf.trim();
2355 if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
2356 absorb_target_dir_into_source(src, dst, ctx)
2357 } else {
2358 warn!("anomaly skipped by user: {dst}");
2359 Ok(())
2360 }
2361 } else {
2362 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
2363 Ok(())
2364 }
2365 }
2366 }
2367}
2368
2369fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
2370 let abs_target = absolutize(target)?;
2371 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
2372 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
2373 info!("backup → {bp}");
2374 if is_dir {
2375 backup::backup_dir(target, &bp)?;
2376 } else {
2377 backup::backup_file(target, &bp)?;
2378 }
2379 Ok(())
2380}
2381
2382fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
2383 if let Some(s) = source {
2384 return absolutize(&s);
2385 }
2386 if let Ok(s) = std::env::var("YUI_SOURCE") {
2387 return absolutize(Utf8Path::new(&s));
2388 }
2389 let cwd = current_dir_utf8()?;
2390 for ancestor in cwd.ancestors() {
2391 if ancestor.join("config.toml").is_file() {
2392 return Ok(ancestor.to_path_buf());
2393 }
2394 }
2395 if let Some(home) = paths::home_dir() {
2396 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
2397 let p = home.join(c);
2398 if p.join("config.toml").is_file() {
2399 return Ok(p);
2400 }
2401 }
2402 }
2403 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
2404}
2405
2406fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
2407 let expanded = paths::expand_tilde(p.as_str());
2409 if expanded.is_absolute() {
2410 return Ok(expanded);
2411 }
2412 let cwd = current_dir_utf8()?;
2413 Ok(cwd.join(expanded))
2414}
2415
2416fn current_dir_utf8() -> Result<Utf8PathBuf> {
2417 let cwd = std::env::current_dir().context("getting cwd")?;
2418 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
2419}
2420
2421const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
2425
2426[vars]
2427# user-defined values; templates can reference these as {{ vars.foo }}
2428
2429# [link]
2430# file_mode = "auto" # auto | symlink | hardlink
2431# dir_mode = "auto" # auto | symlink | junction
2432
2433[mount]
2434default_strategy = "marker"
2435
2436[[mount.entry]]
2437src = "home"
2438# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
2439dst = "~"
2440
2441# [[mount.entry]]
2442# src = "appdata"
2443# dst = "{{ env(name='APPDATA') }}"
2444# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
2445# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
2446# when = "yui.os == 'windows'"
2447"#;
2448
2449const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
2450# .yui/bin/ is intentionally tracked — it holds your hook scripts.
2451/.yui/state.json
2452/.yui/state.json.tmp
2453/.yui/backup/
2454
2455# >>> yui rendered (auto-managed, do not edit) >>>
2456# <<< yui rendered (auto-managed) <<<
2457
2458# config.local.toml is per-machine; commit a config.local.example.toml instead.
2459config.local.toml
2460"#;
2461
2462#[cfg(test)]
2463mod tests {
2464 use super::*;
2465 use tempfile::TempDir;
2466
2467 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
2468 Utf8PathBuf::from_path_buf(p).unwrap()
2469 }
2470
2471 fn toml_path(p: &Utf8Path) -> String {
2473 p.as_str().replace('\\', "/")
2474 }
2475
2476 #[test]
2477 fn apply_links_a_raw_file() {
2478 let tmp = TempDir::new().unwrap();
2479 let source = utf8(tmp.path().join("dotfiles"));
2480 let target = utf8(tmp.path().join("target"));
2481 std::fs::create_dir_all(source.join("home")).unwrap();
2482 std::fs::create_dir_all(&target).unwrap();
2483 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2484
2485 let cfg = format!(
2486 r#"
2487[[mount.entry]]
2488src = "home"
2489dst = "{}"
2490"#,
2491 toml_path(&target)
2492 );
2493 std::fs::write(source.join("config.toml"), cfg).unwrap();
2494
2495 apply(Some(source), false).unwrap();
2496
2497 let linked = target.join(".bashrc");
2498 assert!(linked.exists(), "expected {linked} to exist");
2499 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
2500 }
2501
2502 #[test]
2503 fn apply_with_marker_links_whole_directory() {
2504 let tmp = TempDir::new().unwrap();
2505 let source = utf8(tmp.path().join("dotfiles"));
2506 let target = utf8(tmp.path().join("target"));
2507 let nvim_src = source.join("home/nvim");
2508 std::fs::create_dir_all(&nvim_src).unwrap();
2509 std::fs::create_dir_all(&target).unwrap();
2510 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
2511 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
2512 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
2513
2514 let cfg = format!(
2515 r#"
2516[[mount.entry]]
2517src = "home"
2518dst = "{}"
2519"#,
2520 toml_path(&target)
2521 );
2522 std::fs::write(source.join("config.toml"), cfg).unwrap();
2523
2524 apply(Some(source.clone()), false).unwrap();
2525
2526 let nvim_dst = target.join("nvim");
2527 assert!(nvim_dst.exists());
2528 assert_eq!(
2529 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
2530 "-- hi\n"
2531 );
2532 }
2536
2537 #[test]
2538 fn apply_dry_run_does_not_write() {
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").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), true).unwrap();
2557
2558 assert!(!target.join(".bashrc").exists());
2559 }
2560
2561 #[test]
2562 fn apply_renders_templates_then_links_rendered_outputs() {
2563 let tmp = TempDir::new().unwrap();
2564 let source = utf8(tmp.path().join("dotfiles"));
2565 let target = utf8(tmp.path().join("target"));
2566 std::fs::create_dir_all(source.join("home")).unwrap();
2567 std::fs::create_dir_all(&target).unwrap();
2568 std::fs::write(
2569 source.join("home/.gitconfig.tera"),
2570 "[user]\n os = {{ yui.os }}\n",
2571 )
2572 .unwrap();
2573 std::fs::write(source.join("home/.bashrc"), "raw").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 assert!(target.join(".bashrc").exists());
2589 assert!(source.join("home/.gitconfig").exists());
2591 assert!(target.join(".gitconfig").exists());
2592 assert!(!target.join(".gitconfig.tera").exists());
2594 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
2596 assert!(linked.contains("os = "));
2597 }
2598
2599 #[test]
2600 fn apply_marker_override_links_to_custom_dst() {
2601 let tmp = TempDir::new().unwrap();
2602 let source = utf8(tmp.path().join("dotfiles"));
2603 let target_a = utf8(tmp.path().join("target_a"));
2604 let target_b = utf8(tmp.path().join("target_b"));
2605 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2606 std::fs::create_dir_all(&target_a).unwrap();
2607 std::fs::create_dir_all(&target_b).unwrap();
2608 std::fs::write(
2609 source.join("home/.config/nvim/init.lua"),
2610 "-- nvim config\n",
2611 )
2612 .unwrap();
2613
2614 std::fs::write(
2617 source.join("home/.config/nvim/.yuilink"),
2618 format!(
2619 r#"
2620[[link]]
2621dst = "{}/nvim"
2622
2623[[link]]
2624dst = "{}/nvim"
2625when = "{{{{ yui.os == '{}' }}}}"
2626"#,
2627 toml_path(&target_a),
2628 toml_path(&target_b),
2629 std::env::consts::OS
2630 ),
2631 )
2632 .unwrap();
2633
2634 let parent_target = utf8(tmp.path().join("parent_target"));
2635 std::fs::create_dir_all(&parent_target).unwrap();
2636 let cfg = format!(
2637 r#"
2638[[mount.entry]]
2639src = "home"
2640dst = "{}"
2641"#,
2642 toml_path(&parent_target)
2643 );
2644 std::fs::write(source.join("config.toml"), cfg).unwrap();
2645
2646 apply(Some(source.clone()), false).unwrap();
2647
2648 assert!(
2650 target_a.join("nvim/init.lua").exists(),
2651 "target_a/nvim/init.lua should be reachable through the link"
2652 );
2653 assert!(
2654 target_b.join("nvim/init.lua").exists(),
2655 "target_b/nvim/init.lua should be reachable through the link"
2656 );
2657 assert!(
2660 !parent_target.join(".config/nvim").exists(),
2661 "parent mount should have skipped the marker-claimed sub-dir"
2662 );
2663 }
2664
2665 #[test]
2666 fn apply_marker_inactive_link_falls_through_to_default() {
2667 let tmp = TempDir::new().unwrap();
2672 let source = utf8(tmp.path().join("dotfiles"));
2673 let target_inactive = utf8(tmp.path().join("inactive"));
2674 let parent_target = utf8(tmp.path().join("parent"));
2675 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2676 std::fs::create_dir_all(&parent_target).unwrap();
2677 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
2678
2679 std::fs::write(
2681 source.join("home/.config/nvim/.yuilink"),
2682 format!(
2683 r#"
2684[[link]]
2685dst = "{}/nvim"
2686when = "{{{{ yui.os == 'no-such-os' }}}}"
2687"#,
2688 toml_path(&target_inactive)
2689 ),
2690 )
2691 .unwrap();
2692
2693 let cfg = format!(
2694 r#"
2695[[mount.entry]]
2696src = "home"
2697dst = "{}"
2698"#,
2699 toml_path(&parent_target)
2700 );
2701 std::fs::write(source.join("config.toml"), cfg).unwrap();
2702
2703 apply(Some(source.clone()), false).unwrap();
2704
2705 assert!(!target_inactive.join("nvim").exists());
2707 assert!(parent_target.join(".config/nvim/init.lua").exists());
2710 }
2711
2712 #[test]
2713 fn list_shows_mount_entries_and_marker_overrides() {
2714 let tmp = TempDir::new().unwrap();
2715 let source = utf8(tmp.path().join("dotfiles"));
2716 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2717 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
2718 std::fs::write(
2719 source.join("home/.config/nvim/.yuilink"),
2720 r#"
2721[[link]]
2722dst = "/custom/nvim"
2723"#,
2724 )
2725 .unwrap();
2726 std::fs::write(
2727 source.join("config.toml"),
2728 r#"
2729[[mount.entry]]
2730src = "home"
2731dst = "/h"
2732"#,
2733 )
2734 .unwrap();
2735
2736 list(Some(source), false, None, true).unwrap();
2739 }
2740
2741 #[test]
2742 fn status_reports_in_sync_after_apply() {
2743 let tmp = TempDir::new().unwrap();
2744 let source = utf8(tmp.path().join("dotfiles"));
2745 let target = utf8(tmp.path().join("target"));
2746 std::fs::create_dir_all(source.join("home")).unwrap();
2747 std::fs::create_dir_all(&target).unwrap();
2748 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2749 let cfg = format!(
2750 r#"
2751[[mount.entry]]
2752src = "home"
2753dst = "{}"
2754"#,
2755 toml_path(&target)
2756 );
2757 std::fs::write(source.join("config.toml"), cfg).unwrap();
2758 apply(Some(source.clone()), false).unwrap();
2760 status(Some(source), None, true).unwrap();
2762 }
2763
2764 #[test]
2765 fn status_reports_template_drift() {
2766 let tmp = TempDir::new().unwrap();
2767 let source = utf8(tmp.path().join("dotfiles"));
2768 let target = utf8(tmp.path().join("target"));
2769 std::fs::create_dir_all(source.join("home")).unwrap();
2770 std::fs::create_dir_all(&target).unwrap();
2771 std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
2774 std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
2775
2776 let cfg = format!(
2777 r#"
2778[[mount.entry]]
2779src = "home"
2780dst = "{}"
2781"#,
2782 toml_path(&target)
2783 );
2784 std::fs::write(source.join("config.toml"), cfg).unwrap();
2785
2786 let err = status(Some(source), None, true).unwrap_err();
2787 assert!(format!("{err}").contains("diverged"));
2788 }
2789
2790 #[test]
2791 fn status_fails_when_target_missing() {
2792 let tmp = TempDir::new().unwrap();
2793 let source = utf8(tmp.path().join("dotfiles"));
2794 let target = utf8(tmp.path().join("target"));
2795 std::fs::create_dir_all(source.join("home")).unwrap();
2796 std::fs::create_dir_all(&target).unwrap();
2797 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2798 let cfg = format!(
2799 r#"
2800[[mount.entry]]
2801src = "home"
2802dst = "{}"
2803"#,
2804 toml_path(&target)
2805 );
2806 std::fs::write(source.join("config.toml"), cfg).unwrap();
2807 let err = status(Some(source), None, true).unwrap_err();
2809 assert!(format!("{err}").contains("diverged"));
2810 }
2811
2812 #[test]
2813 fn strip_braces_removes_outer_template_braces() {
2814 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
2815 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
2816 assert_eq!(strip_braces(" {{x}} "), "x");
2817 }
2818
2819 #[test]
2820 fn apply_aborts_on_render_drift() {
2821 let tmp = TempDir::new().unwrap();
2822 let source = utf8(tmp.path().join("dotfiles"));
2823 let target = utf8(tmp.path().join("target"));
2824 std::fs::create_dir_all(source.join("home")).unwrap();
2825 std::fs::create_dir_all(&target).unwrap();
2826 std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
2827 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
2828
2829 let cfg = format!(
2830 r#"
2831[[mount.entry]]
2832src = "home"
2833dst = "{}"
2834"#,
2835 toml_path(&target)
2836 );
2837 std::fs::write(source.join("config.toml"), cfg).unwrap();
2838
2839 let err = apply(Some(source.clone()), false).unwrap_err();
2840 assert!(format!("{err}").contains("drift"));
2841 assert_eq!(
2843 std::fs::read_to_string(source.join("home/foo")).unwrap(),
2844 "manually edited"
2845 );
2846 assert!(!target.join("foo").exists());
2848 }
2849
2850 #[test]
2851 fn init_creates_skeleton_when_dir_empty() {
2852 let tmp = TempDir::new().unwrap();
2853 let dir = utf8(tmp.path().join("new_dotfiles"));
2854 init(Some(dir.clone()), false).unwrap();
2855 assert!(dir.join("config.toml").is_file());
2856 assert!(dir.join(".gitignore").is_file());
2857 }
2858
2859 #[test]
2860 fn init_refuses_to_overwrite_existing_config() {
2861 let tmp = TempDir::new().unwrap();
2862 let dir = utf8(tmp.path().join("dotfiles"));
2863 std::fs::create_dir_all(&dir).unwrap();
2864 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
2865 let err = init(Some(dir), false).unwrap_err();
2866 assert!(format!("{err}").contains("already exists"));
2867 }
2868
2869 #[test]
2875 fn init_appends_missing_gitignore_entries_into_existing_file() {
2876 let tmp = TempDir::new().unwrap();
2877 let dir = utf8(tmp.path().join("dotfiles"));
2878 std::fs::create_dir_all(&dir).unwrap();
2879 let user_gitignore = "# user entries\n*.swp\nnode_modules/\n";
2881 std::fs::write(dir.join(".gitignore"), user_gitignore).unwrap();
2882
2883 init(Some(dir.clone()), false).unwrap();
2884
2885 let body = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
2886 assert!(body.contains("*.swp"));
2888 assert!(body.contains("node_modules/"));
2889 assert!(body.contains("/.yui/state.json"));
2891 assert!(body.contains("/.yui/backup/"));
2892 assert!(body.contains("config.local.toml"));
2893 let before_rerun = body.clone();
2895 std::fs::remove_file(dir.join("config.toml")).unwrap();
2898 init(Some(dir.clone()), false).unwrap();
2899 let after_rerun = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
2900 assert_eq!(
2901 before_rerun, after_rerun,
2902 "init must be idempotent when the gitignore already has every yui entry"
2903 );
2904 }
2905
2906 #[test]
2912 fn init_with_git_hooks_installs_into_existing_repo() {
2913 let tmp = TempDir::new().unwrap();
2914 let dir = utf8(tmp.path().join("dotfiles"));
2915 std::fs::create_dir_all(&dir).unwrap();
2916 let st = std::process::Command::new("git")
2917 .args(["init", "-q"])
2918 .current_dir(dir.as_std_path())
2919 .status()
2920 .expect("git init");
2921 if !st.success() {
2922 return;
2923 }
2924 let user_config = "# user already wrote this\n";
2926 std::fs::write(dir.join("config.toml"), user_config).unwrap();
2927
2928 init(Some(dir.clone()), true).unwrap();
2930
2931 assert_eq!(
2932 std::fs::read_to_string(dir.join("config.toml")).unwrap(),
2933 user_config
2934 );
2935 assert!(dir.join(".git/hooks/pre-commit").is_file());
2936 assert!(dir.join(".git/hooks/pre-push").is_file());
2937 }
2938
2939 #[test]
2944 fn init_with_git_hooks_writes_pre_commit_and_pre_push() {
2945 let tmp = TempDir::new().unwrap();
2946 let dir = utf8(tmp.path().join("dotfiles"));
2947 std::fs::create_dir_all(&dir).unwrap();
2948 let st = std::process::Command::new("git")
2950 .args(["init", "-q"])
2951 .current_dir(dir.as_std_path())
2952 .status()
2953 .expect("git init");
2954 if !st.success() {
2955 eprintln!("skipping: git not available");
2957 return;
2958 }
2959 init(Some(dir.clone()), true).unwrap();
2960
2961 let pre_commit = dir.join(".git/hooks/pre-commit");
2962 let pre_push = dir.join(".git/hooks/pre-push");
2963 assert!(pre_commit.is_file(), "pre-commit hook should be written");
2964 assert!(pre_push.is_file(), "pre-push hook should be written");
2965
2966 let body = std::fs::read_to_string(&pre_commit).unwrap();
2967 assert!(
2968 body.contains("yui render --check"),
2969 "pre-commit hook should call `yui render --check`, got: {body}"
2970 );
2971 }
2972
2973 #[test]
2977 fn init_with_git_hooks_errors_outside_a_git_repo() {
2978 let tmp = TempDir::new().unwrap();
2979 let dir = utf8(tmp.path().join("not-a-repo"));
2980 std::fs::create_dir_all(&dir).unwrap();
2981 let err = init(Some(dir), true).unwrap_err();
2982 let msg = format!("{err:#}");
2983 assert!(
2984 msg.contains("git repo") || msg.contains("git rev-parse"),
2985 "expected error to mention the git issue, got: {msg}"
2986 );
2987 }
2988
2989 #[test]
2992 fn init_with_git_hooks_does_not_clobber_existing_hooks() {
2993 let tmp = TempDir::new().unwrap();
2994 let dir = utf8(tmp.path().join("dotfiles"));
2995 std::fs::create_dir_all(&dir).unwrap();
2996 let st = std::process::Command::new("git")
2997 .args(["init", "-q"])
2998 .current_dir(dir.as_std_path())
2999 .status()
3000 .expect("git init");
3001 if !st.success() {
3002 return;
3003 }
3004 let hooks = dir.join(".git/hooks");
3005 std::fs::create_dir_all(&hooks).unwrap();
3006 std::fs::write(hooks.join("pre-commit"), "#! /bin/sh\nexit 0\n").unwrap();
3007
3008 init(Some(dir.clone()), true).unwrap();
3009
3010 let pc = std::fs::read_to_string(hooks.join("pre-commit")).unwrap();
3012 assert!(
3013 !pc.contains("yui render --check"),
3014 "existing pre-commit must not be overwritten"
3015 );
3016 let pp = std::fs::read_to_string(hooks.join("pre-push")).unwrap();
3017 assert!(
3018 pp.contains("yui render --check"),
3019 "missing pre-push should be written: {pp}"
3020 );
3021 }
3022
3023 fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
3026 let source = utf8(tmp.path().join("dotfiles"));
3027 let target = utf8(tmp.path().join("target"));
3028 std::fs::create_dir_all(source.join("home")).unwrap();
3029 std::fs::create_dir_all(&target).unwrap();
3030 let cfg = format!(
3031 r#"
3032[[mount.entry]]
3033src = "home"
3034dst = "{}"
3035"#,
3036 toml_path(&target)
3037 );
3038 std::fs::write(source.join("config.toml"), cfg).unwrap();
3039 (source, target)
3040 }
3041
3042 fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
3043 std::fs::write(path, body).unwrap();
3044 let f = std::fs::OpenOptions::new()
3045 .write(true)
3046 .open(path)
3047 .expect("open writable");
3048 f.set_modified(when).expect("set_modified");
3049 }
3050
3051 #[test]
3052 fn apply_target_newer_absorbs_target_into_source() {
3053 let tmp = TempDir::new().unwrap();
3057 let (source, target) = setup_minimal_dotfiles(&tmp);
3058
3059 let now = std::time::SystemTime::now();
3060 let past = now - std::time::Duration::from_secs(120);
3061 write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
3062 write_with_mtime(&target.join(".bashrc"), "user's edit", now);
3064
3065 apply(Some(source.clone()), false).unwrap();
3066
3067 assert_eq!(
3069 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3070 "user's edit"
3071 );
3072 assert_eq!(
3074 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3075 "user's edit"
3076 );
3077 let backup_root = source.join(".yui/backup");
3079 let mut found_old = false;
3080 for entry in walkdir(&backup_root) {
3081 if let Ok(s) = std::fs::read_to_string(&entry) {
3082 if s == "default from repo" {
3083 found_old = true;
3084 break;
3085 }
3086 }
3087 }
3088 assert!(found_old, "expected backup containing 'default from repo'");
3089 }
3090
3091 #[test]
3092 fn apply_in_sync_target_is_a_no_op() {
3093 let tmp = TempDir::new().unwrap();
3096 let (source, target) = setup_minimal_dotfiles(&tmp);
3097 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3098 apply(Some(source.clone()), false).unwrap();
3099 let backup_root = source.join(".yui/backup");
3100 let backup_count_after_first = walkdir(&backup_root).len();
3101
3102 apply(Some(source.clone()), false).unwrap();
3104 assert_eq!(
3105 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3106 "echo hi\n"
3107 );
3108 let backup_count_after_second = walkdir(&backup_root).len();
3109 assert_eq!(
3110 backup_count_after_first, backup_count_after_second,
3111 "second apply on an in-sync tree should not produce backups"
3112 );
3113 }
3114
3115 #[test]
3116 fn apply_skip_policy_leaves_anomaly_alone() {
3117 let tmp = TempDir::new().unwrap();
3120 let source = utf8(tmp.path().join("dotfiles"));
3121 let target = utf8(tmp.path().join("target"));
3122 std::fs::create_dir_all(source.join("home")).unwrap();
3123 std::fs::create_dir_all(&target).unwrap();
3124 let cfg = format!(
3125 r#"
3126[absorb]
3127on_anomaly = "skip"
3128
3129[[mount.entry]]
3130src = "home"
3131dst = "{}"
3132"#,
3133 toml_path(&target)
3134 );
3135 std::fs::write(source.join("config.toml"), cfg).unwrap();
3136
3137 let now = std::time::SystemTime::now();
3138 let past = now - std::time::Duration::from_secs(120);
3139 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
3140 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
3141
3142 apply(Some(source.clone()), false).unwrap();
3143
3144 assert_eq!(
3146 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3147 "user's edit (older)"
3148 );
3149 assert_eq!(
3151 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3152 "fresh from upstream"
3153 );
3154 }
3155
3156 #[test]
3157 fn apply_force_policy_absorbs_anomaly_anyway() {
3158 let tmp = TempDir::new().unwrap();
3160 let source = utf8(tmp.path().join("dotfiles"));
3161 let target = utf8(tmp.path().join("target"));
3162 std::fs::create_dir_all(source.join("home")).unwrap();
3163 std::fs::create_dir_all(&target).unwrap();
3164 let cfg = format!(
3165 r#"
3166[absorb]
3167on_anomaly = "force"
3168
3169[[mount.entry]]
3170src = "home"
3171dst = "{}"
3172"#,
3173 toml_path(&target)
3174 );
3175 std::fs::write(source.join("config.toml"), cfg).unwrap();
3176
3177 let now = std::time::SystemTime::now();
3178 let past = now - std::time::Duration::from_secs(120);
3179 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
3180 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
3181
3182 apply(Some(source.clone()), false).unwrap();
3183
3184 assert_eq!(
3186 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3187 "user's edit (older)"
3188 );
3189 assert_eq!(
3190 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3191 "user's edit (older)"
3192 );
3193 }
3194
3195 #[test]
3207 fn apply_absorbs_non_empty_target_dir_target_wins() {
3208 let tmp = TempDir::new().unwrap();
3209 let source = utf8(tmp.path().join("dotfiles"));
3210 let target = utf8(tmp.path().join("target"));
3211 std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
3212 std::fs::create_dir_all(target.join(".config/app")).unwrap();
3213 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3216 std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
3217 std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
3219 std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
3222 std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
3223
3224 let cfg = format!(
3225 r#"
3226[absorb]
3227on_anomaly = "force"
3228
3229[[mount.entry]]
3230src = "home"
3231dst = "{}"
3232"#,
3233 toml_path(&target)
3234 );
3235 std::fs::write(source.join("config.toml"), cfg).unwrap();
3236
3237 apply(Some(source.clone()), false).unwrap();
3239
3240 assert_eq!(
3242 std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
3243 "target side"
3244 );
3245 assert_eq!(
3247 std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
3248 "{}"
3249 );
3250 let backup_root = source.join(".yui/backup");
3253 let mut backup_files: Vec<String> = Vec::new();
3254 for entry in walkdir(&backup_root) {
3255 if let Some(n) = entry.file_name() {
3256 backup_files.push(n.to_string());
3257 }
3258 }
3259 assert!(
3260 backup_files.iter().any(|f| f == "config.toml"),
3261 "expected source's config.toml to land in the backup tree, got {backup_files:?}"
3262 );
3263 assert!(
3265 source.join("home/.config/app/source-only.toml").exists(),
3266 "source-only file should survive a target-wins merge"
3267 );
3268 assert!(
3270 source.join("home/.config/app/state.json").exists(),
3271 "target-only state.json should be merged into source"
3272 );
3273 }
3274
3275 #[test]
3281 fn marker_dir_absorbs_with_default_ask_policy() {
3282 let tmp = TempDir::new().unwrap();
3283 let source = utf8(tmp.path().join("dotfiles"));
3284 let target = utf8(tmp.path().join("target"));
3285 std::fs::create_dir_all(source.join("home/.config")).unwrap();
3286 std::fs::create_dir_all(target.join(".config/gh")).unwrap();
3287 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3289 std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\n").unwrap();
3291
3292 let cfg = format!(
3296 r#"
3297[[mount.entry]]
3298src = "home"
3299dst = "{}"
3300"#,
3301 toml_path(&target)
3302 );
3303 std::fs::write(source.join("config.toml"), cfg).unwrap();
3304
3305 apply(Some(source.clone()), false).unwrap();
3309
3310 assert!(target.join(".config/gh/hosts.yml").exists());
3313 assert!(source.join("home/.config/gh/hosts.yml").exists());
3314 }
3315
3316 #[test]
3322 fn merge_handles_file_vs_dir_collisions_target_wins() {
3323 let tmp = TempDir::new().unwrap();
3324 let source = utf8(tmp.path().join("dotfiles"));
3325 let target = utf8(tmp.path().join("target"));
3326 std::fs::create_dir_all(source.join("home/.config/foo")).unwrap();
3327 std::fs::create_dir_all(target.join(".config")).unwrap();
3328 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3329
3330 std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
3332 std::fs::write(target.join(".config/foo"), "target file body").unwrap();
3333 std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
3335 std::fs::create_dir_all(target.join(".config/bar")).unwrap();
3336 std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
3337
3338 let cfg = format!(
3339 r#"
3340[absorb]
3341on_anomaly = "force"
3342
3343[[mount.entry]]
3344src = "home"
3345dst = "{}"
3346"#,
3347 toml_path(&target)
3348 );
3349 std::fs::write(source.join("config.toml"), cfg).unwrap();
3350 apply(Some(source.clone()), false).unwrap();
3351
3352 let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
3356 assert!(foo_meta.file_type().is_file(), "foo should be a file");
3357 assert_eq!(
3358 std::fs::read_to_string(target.join(".config/foo")).unwrap(),
3359 "target file body"
3360 );
3361 let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
3363 assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
3364 assert_eq!(
3365 std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
3366 "target nested"
3367 );
3368 }
3369
3370 #[test]
3374 fn merge_per_file_target_newer_auto_absorbs() {
3375 let tmp = TempDir::new().unwrap();
3376 let source = utf8(tmp.path().join("dotfiles"));
3377 let target = utf8(tmp.path().join("target"));
3378 std::fs::create_dir_all(source.join("home/.config")).unwrap();
3379 std::fs::create_dir_all(target.join(".config")).unwrap();
3380 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3381
3382 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3384 write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
3385 std::fs::write(target.join(".config/app.toml"), "user's live edit").unwrap();
3386
3387 let cfg = format!(
3391 r#"
3392[[mount.entry]]
3393src = "home"
3394dst = "{}"
3395"#,
3396 toml_path(&target)
3397 );
3398 std::fs::write(source.join("config.toml"), cfg).unwrap();
3399 apply(Some(source.clone()), false).unwrap();
3400
3401 assert_eq!(
3403 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3404 "user's live edit"
3405 );
3406 }
3407
3408 #[test]
3414 fn merge_per_file_source_newer_skip_keeps_source() {
3415 let tmp = TempDir::new().unwrap();
3416 let source = utf8(tmp.path().join("dotfiles"));
3417 let target = utf8(tmp.path().join("target"));
3418 std::fs::create_dir_all(source.join("home/.config")).unwrap();
3419 std::fs::create_dir_all(target.join(".config")).unwrap();
3420 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3421
3422 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3424 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
3425 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
3426
3427 let cfg = format!(
3428 r#"
3429[absorb]
3430on_anomaly = "skip"
3431
3432[[mount.entry]]
3433src = "home"
3434dst = "{}"
3435"#,
3436 toml_path(&target)
3437 );
3438 std::fs::write(source.join("config.toml"), cfg).unwrap();
3439 apply(Some(source.clone()), false).unwrap();
3440
3441 assert_eq!(
3444 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3445 "fresh source"
3446 );
3447 }
3448
3449 #[test]
3452 fn merge_per_file_source_newer_force_overwrites_source() {
3453 let tmp = TempDir::new().unwrap();
3454 let source = utf8(tmp.path().join("dotfiles"));
3455 let target = utf8(tmp.path().join("target"));
3456 std::fs::create_dir_all(source.join("home/.config")).unwrap();
3457 std::fs::create_dir_all(target.join(".config")).unwrap();
3458 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3459
3460 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3461 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
3462 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
3463
3464 let cfg = format!(
3465 r#"
3466[absorb]
3467on_anomaly = "force"
3468
3469[[mount.entry]]
3470src = "home"
3471dst = "{}"
3472"#,
3473 toml_path(&target)
3474 );
3475 std::fs::write(source.join("config.toml"), cfg).unwrap();
3476 apply(Some(source.clone()), false).unwrap();
3477
3478 assert_eq!(
3480 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3481 "old target"
3482 );
3483 }
3484
3485 #[test]
3490 fn merge_per_file_identical_content_is_noop() {
3491 let tmp = TempDir::new().unwrap();
3492 let source = utf8(tmp.path().join("dotfiles"));
3493 let target = utf8(tmp.path().join("target"));
3494 std::fs::create_dir_all(source.join("home/.config")).unwrap();
3495 std::fs::create_dir_all(target.join(".config")).unwrap();
3496 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3497 std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
3498 std::fs::write(target.join(".config/app.toml"), "same").unwrap();
3499
3500 let cfg = format!(
3503 r#"
3504[[mount.entry]]
3505src = "home"
3506dst = "{}"
3507"#,
3508 toml_path(&target)
3509 );
3510 std::fs::write(source.join("config.toml"), cfg).unwrap();
3511 apply(Some(source.clone()), false).unwrap();
3512
3513 assert_eq!(
3514 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3515 "same"
3516 );
3517 }
3518
3519 #[test]
3520 fn manual_absorb_command_pulls_target_into_source() {
3521 let tmp = TempDir::new().unwrap();
3523 let source = utf8(tmp.path().join("dotfiles"));
3524 let target = utf8(tmp.path().join("target"));
3525 std::fs::create_dir_all(source.join("home")).unwrap();
3526 std::fs::create_dir_all(&target).unwrap();
3527 let cfg = format!(
3529 r#"
3530[absorb]
3531on_anomaly = "skip"
3532
3533[[mount.entry]]
3534src = "home"
3535dst = "{}"
3536"#,
3537 toml_path(&target)
3538 );
3539 std::fs::write(source.join("config.toml"), cfg).unwrap();
3540 std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
3541 std::fs::write(source.join("home/.bashrc"), "default").unwrap();
3542
3543 absorb(
3545 Some(source.clone()),
3546 target.join(".bashrc"),
3547 false,
3548 )
3549 .unwrap();
3550
3551 assert_eq!(
3553 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3554 "user picked this"
3555 );
3556 }
3557
3558 #[test]
3559 fn manual_absorb_errors_when_target_outside_known_mounts() {
3560 let tmp = TempDir::new().unwrap();
3561 let (source, _target) = setup_minimal_dotfiles(&tmp);
3562 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
3563 let stranger = utf8(tmp.path().join("not-managed/foo"));
3564 std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
3565 std::fs::write(&stranger, "not yui's").unwrap();
3566 let err = absorb(Some(source), stranger, false).unwrap_err();
3567 assert!(format!("{err}").contains("no mount entry"));
3568 }
3569
3570 #[test]
3571 fn yuiignore_excludes_file_from_linking() {
3572 let tmp = TempDir::new().unwrap();
3573 let (source, target) = setup_minimal_dotfiles(&tmp);
3574 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
3575 std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
3576 std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
3578 apply(Some(source.clone()), false).unwrap();
3579 assert!(target.join(".bashrc").exists());
3580 assert!(
3581 !target.join("lock.json").exists(),
3582 "yuiignore should keep lock.json out of target"
3583 );
3584 }
3585
3586 #[test]
3587 fn yuiignore_excludes_directory_subtree() {
3588 let tmp = TempDir::new().unwrap();
3589 let (source, target) = setup_minimal_dotfiles(&tmp);
3590 std::fs::create_dir_all(source.join("home/cache")).unwrap();
3591 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
3592 std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
3593 std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
3594 std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
3596 apply(Some(source.clone()), false).unwrap();
3597 assert!(target.join(".bashrc").exists());
3598 assert!(
3599 !target.join("cache").exists(),
3600 "yuiignore'd subtree should not appear in target"
3601 );
3602 }
3603
3604 #[test]
3605 fn yuiignore_negation_re_includes_file() {
3606 let tmp = TempDir::new().unwrap();
3607 let (source, target) = setup_minimal_dotfiles(&tmp);
3608 std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
3609 std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
3610 std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
3612 apply(Some(source.clone()), false).unwrap();
3613 assert!(target.join("keep.cache").exists());
3614 assert!(!target.join("drop.cache").exists());
3615 }
3616
3617 #[test]
3618 fn yuiignore_skips_template_in_render() {
3619 let tmp = TempDir::new().unwrap();
3620 let source = utf8(tmp.path().join("dotfiles"));
3621 let target = utf8(tmp.path().join("target"));
3622 std::fs::create_dir_all(source.join("home")).unwrap();
3623 std::fs::create_dir_all(&target).unwrap();
3624 std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
3625 std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
3626 let cfg = format!(
3627 r#"
3628[[mount.entry]]
3629src = "home"
3630dst = "{}"
3631"#,
3632 toml_path(&target)
3633 );
3634 std::fs::write(source.join("config.toml"), cfg).unwrap();
3635 apply(Some(source.clone()), false).unwrap();
3636 assert!(!source.join("home/note").exists());
3638 assert!(!target.join("note").exists());
3639 assert!(!target.join("note.tera").exists());
3640 }
3641
3642 #[test]
3646 fn nested_marker_accumulates_extra_dst() {
3647 let tmp = TempDir::new().unwrap();
3648 let source = utf8(tmp.path().join("dotfiles"));
3649 let parent_target = utf8(tmp.path().join("home"));
3650 let extra_target = utf8(tmp.path().join("extra"));
3651 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3652 std::fs::create_dir_all(&parent_target).unwrap();
3653 std::fs::create_dir_all(&extra_target).unwrap();
3654 std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
3655
3656 std::fs::write(
3658 source.join("home/.config/.yuilink"),
3659 format!(
3660 r#"
3661[[link]]
3662dst = "{}/.config"
3663"#,
3664 toml_path(&parent_target)
3665 ),
3666 )
3667 .unwrap();
3668 std::fs::write(
3671 source.join("home/.config/nvim/.yuilink"),
3672 format!(
3673 r#"
3674[[link]]
3675dst = "{}/nvim"
3676when = "{{{{ yui.os == '{}' }}}}"
3677"#,
3678 toml_path(&extra_target),
3679 std::env::consts::OS
3680 ),
3681 )
3682 .unwrap();
3683
3684 let cfg = format!(
3685 r#"
3686[[mount.entry]]
3687src = "home"
3688dst = "{}"
3689"#,
3690 toml_path(&parent_target)
3691 );
3692 std::fs::write(source.join("config.toml"), cfg).unwrap();
3693
3694 apply(Some(source.clone()), false).unwrap();
3695
3696 assert!(parent_target.join(".config/nvim/init.lua").exists());
3699 assert!(extra_target.join("nvim/init.lua").exists());
3700 }
3701
3702 #[test]
3707 fn marker_file_link_targets_specific_file() {
3708 let tmp = TempDir::new().unwrap();
3709 let source = utf8(tmp.path().join("dotfiles"));
3710 let parent_target = utf8(tmp.path().join("home"));
3711 let docs_target = utf8(tmp.path().join("docs"));
3712 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
3713 std::fs::create_dir_all(&parent_target).unwrap();
3714 std::fs::create_dir_all(&docs_target).unwrap();
3715 std::fs::write(
3716 source.join("home/.config/powershell/profile.ps1"),
3717 "# profile\n",
3718 )
3719 .unwrap();
3720 std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
3721
3722 std::fs::write(
3725 source.join("home/.config/powershell/.yuilink"),
3726 format!(
3727 r#"
3728[[link]]
3729src = "profile.ps1"
3730dst = "{}/Microsoft.PowerShell_profile.ps1"
3731"#,
3732 toml_path(&docs_target)
3733 ),
3734 )
3735 .unwrap();
3736
3737 let cfg = format!(
3738 r#"
3739[[mount.entry]]
3740src = "home"
3741dst = "{}"
3742"#,
3743 toml_path(&parent_target)
3744 );
3745 std::fs::write(source.join("config.toml"), cfg).unwrap();
3746
3747 apply(Some(source.clone()), false).unwrap();
3748
3749 assert!(
3751 docs_target
3752 .join("Microsoft.PowerShell_profile.ps1")
3753 .exists()
3754 );
3755 assert!(
3758 parent_target
3759 .join(".config/powershell/profile.ps1")
3760 .exists()
3761 );
3762 assert!(parent_target.join(".config/powershell/extra.txt").exists());
3763 }
3764
3765 #[test]
3768 fn marker_file_link_missing_src_errors() {
3769 let tmp = TempDir::new().unwrap();
3770 let source = utf8(tmp.path().join("dotfiles"));
3771 let parent_target = utf8(tmp.path().join("home"));
3772 let docs_target = utf8(tmp.path().join("docs"));
3773 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
3774 std::fs::create_dir_all(&parent_target).unwrap();
3775 std::fs::create_dir_all(&docs_target).unwrap();
3776
3777 std::fs::write(
3778 source.join("home/.config/powershell/.yuilink"),
3779 format!(
3780 r#"
3781[[link]]
3782src = "missing.ps1"
3783dst = "{}/profile.ps1"
3784"#,
3785 toml_path(&docs_target)
3786 ),
3787 )
3788 .unwrap();
3789
3790 let cfg = format!(
3791 r#"
3792[[mount.entry]]
3793src = "home"
3794dst = "{}"
3795"#,
3796 toml_path(&parent_target)
3797 );
3798 std::fs::write(source.join("config.toml"), cfg).unwrap();
3799
3800 let err = apply(Some(source.clone()), false).unwrap_err();
3801 assert!(format!("{err:#}").contains("missing.ps1"));
3802 }
3803
3804 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
3805 let mut out = Vec::new();
3806 let mut stack = vec![root.to_path_buf()];
3807 while let Some(dir) = stack.pop() {
3808 let Ok(entries) = std::fs::read_dir(&dir) else {
3809 continue;
3810 };
3811 for e in entries.flatten() {
3812 let p = utf8(e.path());
3813 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
3814 stack.push(p);
3815 } else {
3816 out.push(p);
3817 }
3818 }
3819 }
3820 out
3821 }
3822}