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 if config_path.exists() {
38 anyhow::bail!("config.toml already exists at {config_path}");
39 }
40 std::fs::write(&config_path, SKELETON_CONFIG)?;
41 let gitignore_path = dir.join(".gitignore");
42 if !gitignore_path.exists() {
43 std::fs::write(&gitignore_path, SKELETON_GITIGNORE)?;
44 }
45 info!("initialized yui source repo at {dir}");
46 info!("created: {config_path}");
47 info!("next: edit config.toml, then run `yui apply`");
48 Ok(())
49}
50
51pub fn apply(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
52 let source = resolve_source(source)?;
53 let yui = YuiVars::detect(&source);
54 let config = config::load(&source, &yui)?;
55 let yuiignore = paths::load_yuiignore(&source)?;
58
59 let mut engine = template::Engine::new();
60 let tera_ctx = template::template_context(&yui, &config.vars);
61
62 hook::run_phase(
65 &config,
66 &source,
67 &yui,
68 &mut engine,
69 &tera_ctx,
70 HookPhase::Pre,
71 dry_run,
72 )?;
73
74 let render_report = render::render_all(&source, &config, &yui, &yuiignore, dry_run)?;
76 log_render_report(&render_report);
77 if render_report.has_drift() {
78 anyhow::bail!(
79 "render drift detected ({} file(s)); reflect target edits back into the .tera before re-running apply",
80 render_report.diverged.len()
81 );
82 }
83
84 let mounts = mount::resolve(
86 &config.mount.entry,
87 config.mount.default_strategy,
88 &mut engine,
89 &tera_ctx,
90 )?;
91
92 let backup_root = source.join(&config.backup.dir);
93 let ctx = ApplyCtx {
94 config: &config,
95 source: &source,
96 yuiignore: &yuiignore,
97 file_mode: resolve_file_mode(config.link.file_mode),
98 dir_mode: resolve_dir_mode(config.link.dir_mode),
99 backup_root: &backup_root,
100 dry_run,
101 };
102
103 info!("source: {source}");
104 info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
105 if dry_run {
106 info!("dry-run: nothing will be written");
107 }
108
109 for m in &mounts {
110 info!("mount: {} → {}", m.src, m.dst);
111 process_mount(&source, m, &ctx, &mut engine, &tera_ctx)?;
112 }
113
114 hook::run_phase(
116 &config,
117 &source,
118 &yui,
119 &mut engine,
120 &tera_ctx,
121 HookPhase::Post,
122 dry_run,
123 )?;
124 Ok(())
125}
126
127fn log_render_report(r: &RenderReport) {
128 if !r.written.is_empty() {
129 info!("rendered {} new file(s)", r.written.len());
130 }
131 if !r.unchanged.is_empty() {
132 info!("rendered {} file(s) unchanged", r.unchanged.len());
133 }
134 if !r.skipped_when_false.is_empty() {
135 info!(
136 "skipped {} template(s) (when=false)",
137 r.skipped_when_false.len()
138 );
139 }
140 for d in &r.diverged {
141 warn!("rendered file diverged from template: {d}");
142 }
143}
144
145struct ApplyCtx<'a> {
147 config: &'a Config,
148 source: &'a Utf8Path,
151 yuiignore: &'a ignore::gitignore::Gitignore,
154 file_mode: EffectiveFileMode,
155 dir_mode: EffectiveDirMode,
156 backup_root: &'a Utf8Path,
157 dry_run: bool,
158}
159
160pub fn list(
166 source: Option<Utf8PathBuf>,
167 all: bool,
168 icons_override: Option<IconsMode>,
169 no_color: bool,
170) -> Result<()> {
171 let source = resolve_source(source)?;
172 let yui = YuiVars::detect(&source);
173 let config = config::load(&source, &yui)?;
174
175 let icons_mode = icons_override.unwrap_or(config.ui.icons);
176 let icons = Icons::for_mode(icons_mode);
177 let color = !no_color && supports_color_stdout();
178
179 let items = collect_list_items(&source, &config, &yui)?;
180 let displayed: Vec<&ListItem> = if all {
181 items.iter().collect()
182 } else {
183 items.iter().filter(|i| i.active).collect()
184 };
185
186 print_list_table(&displayed, icons, color);
187
188 let total = items.len();
189 let active = items.iter().filter(|i| i.active).count();
190 let inactive = total - active;
191 println!();
192 if all {
193 println!(" {total} entries · {active} active · {inactive} inactive");
194 } else {
195 println!(
196 " {} of {} entries shown ({} inactive hidden — use --all)",
197 active, total, inactive
198 );
199 }
200 Ok(())
201}
202
203#[derive(Debug)]
204struct ListItem {
205 src: Utf8PathBuf,
206 dst: String,
207 when: Option<String>,
208 active: bool,
209}
210
211fn collect_list_items(source: &Utf8Path, config: &Config, yui: &YuiVars) -> Result<Vec<ListItem>> {
212 let mut engine = template::Engine::new();
213 let tera_ctx = template::template_context(yui, &config.vars);
214 let yuiignore = paths::load_yuiignore(source)?;
215 let mut items = Vec::new();
216
217 for entry in &config.mount.entry {
219 let active = match &entry.when {
220 None => true,
221 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
222 };
223 let dst = engine
224 .render(&entry.dst, &tera_ctx)
225 .map(|s| paths::expand_tilde(s.trim()).to_string())
226 .unwrap_or_else(|_| entry.dst.clone());
227 items.push(ListItem {
228 src: entry.src.clone(),
229 dst,
230 when: entry.when.clone(),
231 active,
232 });
233 }
234
235 let walker = paths::source_walker(source).build();
237 let marker_filename = &config.mount.marker_filename;
238 for entry in walker {
239 let entry = match entry {
240 Ok(e) => e,
241 Err(_) => continue,
242 };
243 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
244 continue;
245 }
246 if entry.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
247 continue;
248 }
249 let dir = match entry.path().parent() {
250 Some(d) => d,
251 None => continue,
252 };
253 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
254 Ok(p) => p,
255 Err(_) => continue,
256 };
257 if paths::is_ignored(&yuiignore, source, &dir_utf8, true) {
259 continue;
260 }
261 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
262 Some(s) => s,
263 None => continue,
264 };
265 let MarkerSpec::Explicit { links } = spec else {
266 continue; };
268 let rel = dir_utf8
269 .strip_prefix(source)
270 .map(Utf8PathBuf::from)
271 .unwrap_or(dir_utf8);
272 for link in &links {
273 let active = match &link.when {
274 None => true,
275 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
276 };
277 let dst = engine
278 .render(&link.dst, &tera_ctx)
279 .map(|s| paths::expand_tilde(s.trim()).to_string())
280 .unwrap_or_else(|_| link.dst.clone());
281 let src_display = match &link.src {
286 Some(filename) => rel.join(filename),
287 None => rel.clone(),
288 };
289 items.push(ListItem {
290 src: src_display,
291 dst,
292 when: link.when.clone(),
293 active,
294 });
295 }
296 }
297
298 items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
299 Ok(items)
300}
301
302fn supports_color_stdout() -> bool {
303 use std::io::IsTerminal;
304 std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
305}
306
307fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
308 let src_w = items
309 .iter()
310 .map(|i| i.src.as_str().chars().count())
311 .max()
312 .unwrap_or(0)
313 .max("SRC".len());
314 let dst_w = items
315 .iter()
316 .map(|i| i.dst.chars().count())
317 .max()
318 .unwrap_or(0)
319 .max("DST".len());
320
321 let status_w = "STATUS".len();
322 let arrow_w = icons.arrow.chars().count();
323
324 print_header(status_w, src_w, arrow_w, dst_w, color);
326
327 let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
329 if color {
330 use owo_colors::OwoColorize as _;
331 println!("{}", sep.dimmed());
332 } else {
333 println!("{sep}");
334 }
335
336 for item in items {
338 print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
339 }
340}
341
342fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
343 use owo_colors::OwoColorize as _;
344 let mut line = String::new();
345 let _ = write!(
346 &mut line,
347 " {:<status_w$} {:<src_w$} {:<arrow_w$} {:<dst_w$} WHEN",
348 "STATUS", "SRC", "", "DST"
349 );
350 if color {
351 println!("{}", line.bold());
352 } else {
353 println!("{line}");
354 }
355}
356
357fn render_separator(
358 sep_ch: char,
359 status_w: usize,
360 src_w: usize,
361 arrow_w: usize,
362 dst_w: usize,
363) -> String {
364 let bar = |n: usize| sep_ch.to_string().repeat(n);
365 format!(
366 " {} {} {} {} {}",
367 bar(status_w),
368 bar(src_w),
369 bar(arrow_w),
370 bar(dst_w),
371 bar("WHEN".len())
372 )
373}
374
375fn print_row(
376 item: &ListItem,
377 icons: Icons,
378 status_w: usize,
379 src_w: usize,
380 arrow_w: usize,
381 dst_w: usize,
382 color: bool,
383) {
384 use owo_colors::OwoColorize as _;
385 let status = if item.active {
386 icons.active
387 } else {
388 icons.inactive
389 };
390 let when_str = item
391 .when
392 .as_deref()
393 .map(strip_braces)
394 .unwrap_or_else(|| "(always)".to_string());
395
396 let src_display = item.src.as_str().replace('\\', "/");
398 let src = src_display.as_str();
399 let dst = &item.dst;
400 let arrow = icons.arrow;
401
402 let cell_status = format!("{:<status_w$}", status);
407 let cell_src = format!("{:<src_w$}", src);
408 let cell_arrow = format!("{:<arrow_w$}", arrow);
409 let cell_dst = format!("{:<dst_w$}", dst);
410
411 if !color {
412 println!(" {cell_status} {cell_src} {cell_arrow} {cell_dst} {when_str}");
413 return;
414 }
415
416 if item.active {
417 println!(
418 " {} {} {} {} {}",
419 cell_status.green(),
420 cell_src.cyan(),
421 cell_arrow.dimmed(),
422 cell_dst.green(),
423 when_str.dimmed()
424 );
425 } else {
426 println!(
427 " {} {} {} {} {}",
428 cell_status.red().dimmed(),
429 cell_src.dimmed(),
430 cell_arrow.dimmed(),
431 cell_dst.dimmed(),
432 when_str.dimmed()
433 );
434 }
435}
436
437fn strip_braces(expr: &str) -> String {
440 let trimmed = expr.trim();
441 if let Some(inner) = trimmed
442 .strip_prefix("{{")
443 .and_then(|s| s.strip_suffix("}}"))
444 {
445 inner.trim().to_string()
446 } else {
447 trimmed.to_string()
448 }
449}
450
451pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
452 let source = resolve_source(source)?;
453 let yui = YuiVars::detect(&source);
454 let config = config::load(&source, &yui)?;
455 let yuiignore = paths::load_yuiignore(&source)?;
456 let report = render::render_all(&source, &config, &yui, &yuiignore, dry_run || check)?;
458 log_render_report(&report);
459 if check && report.has_drift() {
460 anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
461 }
462 Ok(())
463}
464
465pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
466 apply(source, dry_run)
468}
469
470pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
471 let _source = resolve_source(source)?;
472 if paths_arg.is_empty() {
473 anyhow::bail!("yui unlink: provide at least one target path");
474 }
475 for p in paths_arg {
476 let abs = absolutize(&p)?;
477 info!("unlink: {abs}");
478 link::unlink(&abs)?;
479 }
480 Ok(())
481}
482
483pub fn status(
496 source: Option<Utf8PathBuf>,
497 icons_override: Option<IconsMode>,
498 no_color: bool,
499) -> Result<()> {
500 let source = resolve_source(source)?;
501 let yui = YuiVars::detect(&source);
502 let config = config::load(&source, &yui)?;
503
504 let mut engine = template::Engine::new();
505 let tera_ctx = template::template_context(&yui, &config.vars);
506 let mounts = mount::resolve(
507 &config.mount.entry,
508 config.mount.default_strategy,
509 &mut engine,
510 &tera_ctx,
511 )?;
512
513 let icons_mode = icons_override.unwrap_or(config.ui.icons);
514 let icons = Icons::for_mode(icons_mode);
515 let color = !no_color && supports_color_stdout();
516
517 let mut report: Vec<StatusItem> = Vec::new();
518 let yuiignore = paths::load_yuiignore(&source)?;
521
522 let render_report =
525 render::render_all(&source, &config, &yui, &yuiignore, true)?;
526 for rendered in &render_report.diverged {
527 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
531 report.push(StatusItem {
532 src: relative_for_display(&source, &tera_path),
533 dst: rendered.clone(),
534 state: StatusState::RenderDrift,
535 });
536 }
537
538 for m in &mounts {
540 let src_root = source.join(&m.src);
541 if !src_root.is_dir() {
542 warn!("mount src missing: {src_root}");
543 continue;
544 }
545 classify_walk(
546 &src_root,
547 &m.dst,
548 &config,
549 m.strategy,
550 &mut engine,
551 &tera_ctx,
552 &source,
553 &yuiignore,
554 &mut report,
555 )?;
556 }
557
558 report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
559
560 print_status_table(&report, icons, color);
561
562 let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
563
564 println!();
565 let total = report.len();
566 let in_sync = total - drift;
567 if drift == 0 {
568 println!(" {total} entries · all in sync");
569 Ok(())
570 } else {
571 println!(" {total} entries · {in_sync} in sync · {drift} diverged");
572 anyhow::bail!("status: {drift} entries diverged from source")
573 }
574}
575
576#[derive(Debug)]
577struct StatusItem {
578 src: Utf8PathBuf,
580 dst: Utf8PathBuf,
582 state: StatusState,
583}
584
585#[derive(Debug, Clone, Copy)]
586enum StatusState {
587 Link(absorb::AbsorbDecision),
588 RenderDrift,
591}
592
593impl StatusState {
594 fn is_in_sync(self) -> bool {
595 matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
596 }
597}
598
599#[allow(clippy::too_many_arguments)]
600fn classify_walk(
601 src_dir: &Utf8Path,
602 dst_dir: &Utf8Path,
603 config: &Config,
604 strategy: MountStrategy,
605 engine: &mut template::Engine,
606 tera_ctx: &TeraContext,
607 source_root: &Utf8Path,
608 yuiignore: &ignore::gitignore::Gitignore,
609 report: &mut Vec<StatusItem>,
610) -> Result<()> {
611 classify_walk_inner(
612 src_dir,
613 dst_dir,
614 config,
615 strategy,
616 engine,
617 tera_ctx,
618 source_root,
619 yuiignore,
620 report,
621 false,
622 )
623}
624
625#[allow(clippy::too_many_arguments)]
626fn classify_walk_inner(
627 src_dir: &Utf8Path,
628 dst_dir: &Utf8Path,
629 config: &Config,
630 strategy: MountStrategy,
631 engine: &mut template::Engine,
632 tera_ctx: &TeraContext,
633 source_root: &Utf8Path,
634 yuiignore: &ignore::gitignore::Gitignore,
635 report: &mut Vec<StatusItem>,
636 parent_covered: bool,
637) -> Result<()> {
638 if paths::is_ignored(yuiignore, source_root, src_dir, true) {
639 return Ok(());
640 }
641
642 let marker_filename = &config.mount.marker_filename;
643 let mut covered = parent_covered;
644
645 if strategy == MountStrategy::Marker {
646 match marker::read_spec(src_dir, marker_filename)? {
647 None => {}
648 Some(MarkerSpec::PassThrough) => {
649 let decision = absorb::classify(src_dir, dst_dir)?;
650 report.push(StatusItem {
651 src: relative_for_display(source_root, src_dir),
652 dst: dst_dir.to_path_buf(),
653 state: StatusState::Link(decision),
654 });
655 covered = true;
656 }
657 Some(MarkerSpec::Explicit { links }) => {
658 let mut emitted_dir_link = false;
659 for link in &links {
660 if let Some(when) = &link.when {
661 if !template::eval_truthy(when, engine, tera_ctx)? {
662 continue;
663 }
664 }
665 let dst_str = engine.render(&link.dst, tera_ctx)?;
666 let dst = paths::expand_tilde(dst_str.trim());
667 if let Some(filename) = &link.src {
668 let file_src = src_dir.join(filename);
669 if !file_src.is_file() {
670 anyhow::bail!(
671 "marker at {src_dir}: [[link]] src={filename:?} \
672 not found"
673 );
674 }
675 let decision = absorb::classify(&file_src, &dst)?;
676 report.push(StatusItem {
677 src: relative_for_display(source_root, &file_src),
678 dst,
679 state: StatusState::Link(decision),
680 });
681 } else {
682 let decision = absorb::classify(src_dir, &dst)?;
683 report.push(StatusItem {
684 src: relative_for_display(source_root, src_dir),
685 dst,
686 state: StatusState::Link(decision),
687 });
688 emitted_dir_link = true;
689 }
690 }
691 if emitted_dir_link {
692 covered = true;
693 }
694 }
695 }
696 }
697
698 for entry in std::fs::read_dir(src_dir)? {
699 let entry = entry?;
700 let name_os = entry.file_name();
701 let Some(name) = name_os.to_str() else {
702 continue;
703 };
704 if name == marker_filename || name.ends_with(".tera") {
705 continue;
706 }
707 let src_path = src_dir.join(name);
708 let dst_path = dst_dir.join(name);
709 let ft = entry.file_type()?;
710 if paths::is_ignored(yuiignore, source_root, &src_path, ft.is_dir()) {
711 continue;
712 }
713 if ft.is_dir() {
714 classify_walk_inner(
715 &src_path,
716 &dst_path,
717 config,
718 strategy,
719 engine,
720 tera_ctx,
721 source_root,
722 yuiignore,
723 report,
724 covered,
725 )?;
726 } else if ft.is_file() && !covered {
727 let decision = absorb::classify(&src_path, &dst_path)?;
728 report.push(StatusItem {
729 src: relative_for_display(source_root, &src_path),
730 dst: dst_path,
731 state: StatusState::Link(decision),
732 });
733 }
734 }
735 Ok(())
736}
737
738fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
739 p.strip_prefix(source_root)
740 .map(Utf8PathBuf::from)
741 .unwrap_or_else(|_| p.to_path_buf())
742}
743
744fn print_status_table(items: &[StatusItem], icons: Icons, color: bool) {
745 let src_w = items
746 .iter()
747 .map(|i| i.src.as_str().chars().count())
748 .max()
749 .unwrap_or(0)
750 .max("SRC".len());
751 let dst_w = items
752 .iter()
753 .map(|i| i.dst.as_str().chars().count())
754 .max()
755 .unwrap_or(0)
756 .max("DST".len());
757 let state_label_w = items
759 .iter()
760 .map(|i| state_label(i.state).len())
761 .max()
762 .unwrap_or(0)
763 .max("STATE".len() - 2); let state_w = state_label_w + 2; print_status_header(state_w, src_w, dst_w, color);
767 let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
768 if color {
769 use owo_colors::OwoColorize as _;
770 println!("{}", sep.dimmed());
771 } else {
772 println!("{sep}");
773 }
774 for item in items {
775 print_status_row(item, icons, state_w, src_w, dst_w, color);
776 }
777}
778
779fn state_label(s: StatusState) -> &'static str {
780 use absorb::AbsorbDecision::*;
781 match s {
782 StatusState::Link(InSync) => "in-sync",
783 StatusState::Link(RelinkOnly) => "relink",
784 StatusState::Link(AutoAbsorb) => "drift (auto)",
785 StatusState::Link(NeedsConfirm) => "drift (anomaly)",
786 StatusState::Link(Restore) => "missing",
787 StatusState::RenderDrift => "render drift",
788 }
789}
790
791fn state_icon(s: StatusState, icons: Icons) -> &'static str {
792 use absorb::AbsorbDecision::*;
793 match s {
794 StatusState::Link(InSync) => icons.ok,
795 StatusState::Link(RelinkOnly) => icons.warn,
796 StatusState::Link(AutoAbsorb) => icons.warn,
797 StatusState::Link(NeedsConfirm) => icons.error,
798 StatusState::Link(Restore) => icons.info,
799 StatusState::RenderDrift => icons.error,
800 }
801}
802
803fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
804 use owo_colors::OwoColorize as _;
805 let line = format!(
808 " {:<state_w$} {:<src_w$} {:<dst_w$}",
809 "STATE", "SRC", "DST"
810 );
811 if color {
812 println!("{}", line.bold());
813 } else {
814 println!("{line}");
815 }
816}
817
818fn render_status_separator(
819 sep_ch: char,
820 state_w: usize,
821 src_w: usize,
822 dst_w: usize,
823 arrow: &str,
824) -> String {
825 let bar = |n: usize| sep_ch.to_string().repeat(n);
826 format!(
827 " {} {} {} {}",
828 bar(state_w),
829 bar(src_w),
830 bar(arrow.chars().count()),
831 bar(dst_w)
832 )
833}
834
835fn print_status_row(
836 item: &StatusItem,
837 icons: Icons,
838 state_w: usize,
839 src_w: usize,
840 dst_w: usize,
841 color: bool,
842) {
843 use owo_colors::OwoColorize as _;
844 let icon = state_icon(item.state, icons);
845 let label = state_label(item.state);
846 let state_text = format!("{icon} {label}");
847 let src_display = item.src.as_str().replace('\\', "/");
848 let dst_display = item.dst.as_str().replace('\\', "/");
849 let arrow = icons.arrow;
850
851 let cell_state = format!("{:<state_w$}", state_text);
852 let cell_src = format!("{:<src_w$}", src_display);
853 let cell_dst = format!("{:<dst_w$}", dst_display);
854
855 if !color {
856 println!(" {cell_state} {cell_src} {arrow} {cell_dst}");
857 return;
858 }
859
860 use absorb::AbsorbDecision::*;
861 let state_colored = match item.state {
862 StatusState::Link(InSync) => cell_state.green().to_string(),
863 StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
864 cell_state.yellow().to_string()
865 }
866 StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
867 StatusState::Link(Restore) => cell_state.cyan().to_string(),
868 StatusState::RenderDrift => cell_state.red().to_string(),
869 };
870 let src_colored = cell_src.cyan().to_string();
871 let arrow_colored = arrow.dimmed().to_string();
872 let dst_colored = cell_dst.dimmed().to_string();
873 println!(" {state_colored} {src_colored} {arrow_colored} {dst_colored}");
874}
875
876pub fn absorb(source: Option<Utf8PathBuf>, target: Utf8PathBuf, dry_run: bool) -> Result<()> {
885 let source = resolve_source(source)?;
886 let target = absolutize(&target)?;
887 let yui = YuiVars::detect(&source);
888 let config = config::load(&source, &yui)?;
889
890 let mut engine = template::Engine::new();
891 let tera_ctx = template::template_context(&yui, &config.vars);
892 let yuiignore = paths::load_yuiignore(&source)?;
895
896 let src_path = match find_source_for_target(
897 &source,
898 &config,
899 &target,
900 &mut engine,
901 &tera_ctx,
902 &yuiignore,
903 )? {
904 Some(s) => s,
905 None => anyhow::bail!(
906 "no mount entry / .yuilink override claims target {target}; \
907 pass a path inside a known dst"
908 ),
909 };
910
911 info!("source for {target}: {src_path}");
912
913 if dry_run {
914 info!("[dry-run] would absorb {target} → {src_path}");
915 return Ok(());
916 }
917
918 let backup_root = source.join(&config.backup.dir);
919 let ctx = ApplyCtx {
920 config: &config,
921 source: &source,
922 yuiignore: &yuiignore,
923 file_mode: resolve_file_mode(config.link.file_mode),
924 dir_mode: resolve_dir_mode(config.link.dir_mode),
925 backup_root: &backup_root,
926 dry_run: false,
927 };
928
929 absorb_target_into_source(&src_path, &target, &ctx)
932}
933
934fn find_source_for_target(
938 source: &Utf8Path,
939 config: &Config,
940 target: &Utf8Path,
941 engine: &mut template::Engine,
942 tera_ctx: &TeraContext,
943 yuiignore: &ignore::gitignore::Gitignore,
944) -> Result<Option<Utf8PathBuf>> {
945 for entry in &config.mount.entry {
947 if let Some(when) = &entry.when {
948 if !template::eval_truthy(when, engine, tera_ctx)? {
949 continue;
950 }
951 }
952 let dst_str = engine.render(&entry.dst, tera_ctx)?;
953 let dst_root = paths::expand_tilde(dst_str.trim());
954 if let Ok(rel) = target.strip_prefix(&dst_root) {
955 let candidate = source.join(&entry.src).join(rel);
956 if paths::is_ignored(yuiignore, source, &candidate, candidate.is_dir()) {
960 continue;
961 }
962 return Ok(Some(candidate));
963 }
964 }
965
966 let walker = paths::source_walker(source).build();
970 let marker_filename = &config.mount.marker_filename;
971 for ent in walker {
972 let ent = match ent {
973 Ok(e) => e,
974 Err(_) => continue,
975 };
976 if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
977 continue;
978 }
979 if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
980 continue;
981 }
982 let dir = match ent.path().parent() {
983 Some(d) => d,
984 None => continue,
985 };
986 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
987 Ok(p) => p,
988 Err(_) => continue,
989 };
990 if paths::is_ignored(yuiignore, source, &dir_utf8, true) {
991 continue;
992 }
993 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
994 Some(s) => s,
995 None => continue,
996 };
997 let MarkerSpec::Explicit { links } = spec else {
998 continue;
999 };
1000 for link in &links {
1001 if let Some(when) = &link.when {
1002 if !template::eval_truthy(when, engine, tera_ctx)? {
1003 continue;
1004 }
1005 }
1006 let dst_str = engine.render(&link.dst, tera_ctx)?;
1007 let dst = paths::expand_tilde(dst_str.trim());
1008 if let Some(filename) = &link.src {
1015 let file_src = dir_utf8.join(filename);
1016 if !file_src.is_file() {
1017 anyhow::bail!(
1018 "marker at {dir_utf8}: [[link]] src={filename:?} \
1019 not found"
1020 );
1021 }
1022 if target == dst {
1023 return Ok(Some(file_src));
1024 }
1025 continue;
1026 }
1027 if target == dst {
1028 return Ok(Some(dir_utf8));
1029 }
1030 if let Ok(rel) = target.strip_prefix(&dst) {
1031 return Ok(Some(dir_utf8.join(rel)));
1032 }
1033 }
1034 }
1035
1036 Ok(None)
1037}
1038
1039pub fn doctor(source: Option<Utf8PathBuf>) -> Result<()> {
1040 let yui = YuiVars::detect(Utf8Path::new("."));
1041 println!("yui doctor");
1042 println!("==========");
1043 println!("os: {}", yui.os);
1044 println!("arch: {}", yui.arch);
1045 println!("user: {}", yui.user);
1046 println!("host: {}", yui.host);
1047 match resolve_source(source) {
1048 Ok(s) => {
1049 println!("source: {s}");
1050 match config::load(&s, &yui) {
1052 Ok(cfg) => println!(
1053 "config: ok ({} mount entries, {} render rules)",
1054 cfg.mount.entry.len(),
1055 cfg.render.rule.len()
1056 ),
1057 Err(e) => println!("config: ERROR — {e}"),
1058 }
1059 }
1060 Err(e) => println!("source: NOT FOUND — {e}"),
1061 }
1062 println!();
1063 println!("link mode (auto resolves to):");
1064 if cfg!(windows) {
1065 println!(" files: hardlink");
1066 println!(" dirs: junction");
1067 } else {
1068 println!(" files: symlink");
1069 println!(" dirs: symlink");
1070 }
1071 Ok(())
1072}
1073
1074pub fn gc_backup(_source: Option<Utf8PathBuf>, _older_than: Option<String>) -> Result<()> {
1075 todo!("yui gc-backup — clean up old backups")
1076}
1077
1078pub fn hooks_list(source: Option<Utf8PathBuf>) -> Result<()> {
1080 let source = resolve_source(source)?;
1081 let yui = YuiVars::detect(&source);
1082 let config = config::load(&source, &yui)?;
1083 let state = hook::State::load(&source)?;
1084
1085 if config.hook.is_empty() {
1086 println!("(no [[hook]] entries in config)");
1087 return Ok(());
1088 }
1089
1090 for h in &config.hook {
1091 let phase = match h.phase {
1092 HookPhase::Pre => "pre",
1093 HookPhase::Post => "post",
1094 };
1095 let when_run = match h.when_run {
1096 config::WhenRun::Once => "once",
1097 config::WhenRun::Onchange => "onchange",
1098 config::WhenRun::Every => "every",
1099 };
1100 let last = state
1101 .hooks
1102 .get(&h.name)
1103 .and_then(|s| s.last_run_at.as_deref())
1104 .unwrap_or("(never)");
1105 println!(
1106 "{name:<20} phase={phase:<4} when_run={when_run:<8} last_run_at={last}",
1107 name = h.name,
1108 );
1109 if let Some(when) = &h.when {
1110 println!(" when = {when}");
1111 }
1112 println!(" script = {}", h.script);
1113 println!(
1114 " command = {} {}",
1115 h.command,
1116 h.args.join(" ")
1117 );
1118 }
1119 Ok(())
1120}
1121
1122pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
1126 let source = resolve_source(source)?;
1127 let yui = YuiVars::detect(&source);
1128 let config = config::load(&source, &yui)?;
1129 let mut engine = template::Engine::new();
1130 let tera_ctx = template::template_context(&yui, &config.vars);
1131
1132 let targets: Vec<&config::HookConfig> = match &name {
1133 Some(want) => {
1134 let m = config
1135 .hook
1136 .iter()
1137 .find(|h| &h.name == want)
1138 .ok_or_else(|| {
1139 anyhow::anyhow!(
1140 "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
1141 )
1142 })?;
1143 vec![m]
1144 }
1145 None => config.hook.iter().collect(),
1146 };
1147
1148 let mut state = hook::State::load(&source)?;
1149 for h in targets {
1150 let outcome = hook::run_hook(
1151 h,
1152 &source,
1153 &yui,
1154 &config.vars,
1155 &mut engine,
1156 &tera_ctx,
1157 &mut state,
1158 false,
1159 force,
1160 )?;
1161 let label = match outcome {
1162 HookOutcome::Ran => "ran",
1163 HookOutcome::SkippedOnce => "skipped (once: already ran)",
1164 HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
1165 HookOutcome::SkippedWhenFalse => "skipped (when=false)",
1166 HookOutcome::DryRun => "would run (dry-run)",
1167 };
1168 info!("hook[{}]: {label}", h.name);
1169 if outcome == HookOutcome::Ran {
1170 state.save(&source)?;
1171 }
1172 }
1173 Ok(())
1174}
1175
1176fn process_mount(
1181 source: &Utf8Path,
1182 m: &ResolvedMount,
1183 ctx: &ApplyCtx<'_>,
1184 engine: &mut template::Engine,
1185 tera_ctx: &TeraContext,
1186) -> Result<()> {
1187 let src_root = source.join(&m.src);
1188 if !src_root.is_dir() {
1189 warn!("mount src missing: {src_root}");
1190 return Ok(());
1191 }
1192 walk_and_link(&src_root, &m.dst, ctx, m.strategy, engine, tera_ctx, false)
1193}
1194
1195#[allow(clippy::too_many_arguments)]
1196fn walk_and_link(
1197 src_dir: &Utf8Path,
1198 dst_dir: &Utf8Path,
1199 ctx: &ApplyCtx<'_>,
1200 strategy: MountStrategy,
1201 engine: &mut template::Engine,
1202 tera_ctx: &TeraContext,
1203 parent_covered: bool,
1204) -> Result<()> {
1205 if paths::is_ignored(ctx.yuiignore, ctx.source, src_dir, true) {
1208 return Ok(());
1209 }
1210
1211 let marker_filename = &ctx.config.mount.marker_filename;
1212 let mut covered = parent_covered;
1213
1214 if strategy == MountStrategy::Marker {
1215 match marker::read_spec(src_dir, marker_filename)? {
1216 None => {} Some(MarkerSpec::PassThrough) => {
1218 link_dir_with_backup(src_dir, dst_dir, ctx)?;
1222 covered = true;
1223 }
1224 Some(MarkerSpec::Explicit { links }) => {
1225 let mut emitted_dir_link = false;
1226 let mut emitted_any = false;
1227 for link in &links {
1228 if let Some(when) = &link.when {
1231 if !template::eval_truthy(when, engine, tera_ctx)? {
1232 continue;
1233 }
1234 }
1235 let dst_str = engine.render(&link.dst, tera_ctx)?;
1236 let dst = paths::expand_tilde(dst_str.trim());
1237 if let Some(filename) = &link.src {
1238 let file_src = src_dir.join(filename);
1239 if !file_src.is_file() {
1240 anyhow::bail!(
1241 "marker at {src_dir}: [[link]] src={filename:?} \
1242 not found"
1243 );
1244 }
1245 link_file_with_backup(&file_src, &dst, ctx)?;
1246 } else {
1247 link_dir_with_backup(src_dir, &dst, ctx)?;
1248 emitted_dir_link = true;
1249 }
1250 emitted_any = true;
1251 }
1252 if !emitted_any {
1253 info!(
1258 "marker at {src_dir} had no active links \
1259 — falling back to defaults"
1260 );
1261 }
1262 if emitted_dir_link {
1263 covered = true;
1264 }
1265 }
1266 }
1267 }
1268
1269 for entry in std::fs::read_dir(src_dir)? {
1270 let entry = entry?;
1271 let name_os = entry.file_name();
1272 let Some(name) = name_os.to_str() else {
1273 continue;
1274 };
1275 if name == marker_filename {
1276 continue;
1277 }
1278 if name.ends_with(".tera") {
1279 continue;
1281 }
1282 let src_path = src_dir.join(name);
1283 let dst_path = dst_dir.join(name);
1284 let ft = entry.file_type()?;
1285
1286 if paths::is_ignored(ctx.yuiignore, ctx.source, &src_path, ft.is_dir()) {
1287 continue;
1288 }
1289
1290 if ft.is_dir() {
1291 walk_and_link(
1292 &src_path, &dst_path, ctx, strategy, engine, tera_ctx, covered,
1293 )?;
1294 } else if ft.is_file() {
1295 if !covered {
1301 link_file_with_backup(&src_path, &dst_path, ctx)?;
1302 }
1303 }
1304 }
1305 Ok(())
1306}
1307
1308fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1309 use absorb::AbsorbDecision::*;
1310
1311 let decision = absorb::classify(src, dst)?;
1312
1313 if ctx.dry_run {
1314 info!("[dry-run] {decision:?}: {src} → {dst}");
1315 return Ok(());
1316 }
1317
1318 match decision {
1319 InSync => {
1320 Ok(())
1322 }
1323 Restore => {
1324 info!("link: {src} → {dst}");
1325 link::link_file(src, dst, ctx.file_mode)?;
1326 Ok(())
1327 }
1328 RelinkOnly => {
1329 info!("relink: {src} → {dst}");
1332 link::unlink(dst)?;
1333 link::link_file(src, dst, ctx.file_mode)?;
1334 Ok(())
1335 }
1336 AutoAbsorb => {
1337 if !ctx.config.absorb.auto {
1340 return handle_anomaly(
1341 src,
1342 dst,
1343 ctx,
1344 "absorb.auto = false; treating divergence as anomaly",
1345 );
1346 }
1347 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
1348 return handle_anomaly(
1349 src,
1350 dst,
1351 ctx,
1352 "source repo is dirty; deferring auto-absorb",
1353 );
1354 }
1355 absorb_target_into_source(src, dst, ctx)
1356 }
1357 NeedsConfirm => handle_anomaly(
1358 src,
1359 dst,
1360 ctx,
1361 "anomaly: source equals/newer than target but content differs",
1362 ),
1363 }
1364}
1365
1366fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1370 info!("absorb: {dst} → {src}");
1371 backup_existing(src, ctx.backup_root, false)?;
1372 std::fs::copy(dst, src)?;
1373 link::unlink(dst)?;
1374 link::link_file(src, dst, ctx.file_mode)?;
1375 Ok(())
1376}
1377
1378fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
1384 use crate::config::AnomalyAction::*;
1385 match ctx.config.absorb.on_anomaly {
1386 Skip => {
1387 warn!("anomaly skip: {dst} ({reason})");
1388 Ok(())
1389 }
1390 Force => {
1391 warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
1392 absorb_target_into_source(src, dst, ctx)
1393 }
1394 Ask => {
1395 use std::io::IsTerminal;
1396 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
1397 if prompt_absorb_with_diff(src, dst, reason)? {
1398 absorb_target_into_source(src, dst, ctx)
1399 } else {
1400 warn!("anomaly skipped by user: {dst}");
1401 Ok(())
1402 }
1403 } else {
1404 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
1405 Ok(())
1406 }
1407 }
1408 }
1409}
1410
1411fn prompt_absorb_with_diff(src: &Utf8Path, dst: &Utf8Path, reason: &str) -> Result<bool> {
1412 use std::io::Write as _;
1413 let src_content = std::fs::read_to_string(src).unwrap_or_default();
1414 let dst_content = std::fs::read_to_string(dst).unwrap_or_default();
1415 eprintln!();
1416 eprintln!("anomaly: {reason}");
1417 eprintln!(" src: {src}");
1418 eprintln!(" dst: {dst}");
1419 eprintln!();
1420 eprintln!("--- diff (- source, + target) ---");
1421 let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
1422 for change in diff.iter_all_changes() {
1423 let sign = match change.tag() {
1424 similar::ChangeTag::Delete => "-",
1425 similar::ChangeTag::Insert => "+",
1426 similar::ChangeTag::Equal => " ",
1427 };
1428 eprint!("{sign}{change}");
1429 }
1430 eprintln!();
1431 eprint!("absorb target into source? [y/N]: ");
1432 std::io::stderr().flush().ok();
1437 let mut input = String::new();
1438 std::io::stdin().read_line(&mut input)?;
1439 let answer = input.trim();
1440 Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
1441}
1442
1443fn source_repo_is_clean(source: &Utf8Path) -> bool {
1448 match crate::git::is_clean(source) {
1449 Ok(b) => b,
1450 Err(e) => {
1451 warn!("git clean check failed at {source}: {e} — treating as clean");
1452 true
1453 }
1454 }
1455}
1456
1457fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1458 use absorb::AbsorbDecision::*;
1459 let decision = absorb::classify(src, dst)?;
1460
1461 if ctx.dry_run {
1462 info!("[dry-run] dir {decision:?}: {src} → {dst}");
1463 return Ok(());
1464 }
1465
1466 match decision {
1467 InSync => Ok(()),
1468 Restore => {
1469 info!("link dir: {src} → {dst}");
1470 link::link_dir(src, dst, ctx.dir_mode)?;
1471 Ok(())
1472 }
1473 RelinkOnly => {
1474 info!("relink dir: {src} → {dst}");
1479 remove_dir_link_or_real(dst)?;
1480 link::link_dir(src, dst, ctx.dir_mode)?;
1481 Ok(())
1482 }
1483 AutoAbsorb | NeedsConfirm => {
1484 if !ctx.config.absorb.auto {
1505 return handle_anomaly_dir(
1506 src,
1507 dst,
1508 ctx,
1509 "absorb.auto = false; treating divergence as anomaly",
1510 );
1511 }
1512 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
1513 return handle_anomaly_dir(
1514 src,
1515 dst,
1516 ctx,
1517 "source repo is dirty; deferring auto-absorb",
1518 );
1519 }
1520 absorb_target_dir_into_source(src, dst, ctx)
1521 }
1522 }
1523}
1524
1525fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
1535 if let Err(unlink_err) = link::unlink(dst) {
1536 let meta = std::fs::symlink_metadata(dst)
1537 .with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
1538 let ft = meta.file_type();
1539 if ft.is_dir() && !ft.is_symlink() {
1540 std::fs::remove_dir_all(dst).with_context(|| {
1541 format!(
1542 "remove_dir_all({dst}) after link::unlink failed: \
1543 {unlink_err}"
1544 )
1545 })?;
1546 } else {
1547 return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
1548 }
1549 }
1550 Ok(())
1551}
1552
1553fn merge_dir_target_into_source(
1563 target: &Utf8Path,
1564 source: &Utf8Path,
1565 ctx: &ApplyCtx<'_>,
1566) -> Result<()> {
1567 for entry in std::fs::read_dir(target)? {
1568 let entry = entry?;
1569 let name_os = entry.file_name();
1570 let Some(name) = name_os.to_str() else {
1571 continue;
1572 };
1573 let target_path = target.join(name);
1574 let source_path = source.join(name);
1575 let ft = entry.file_type()?;
1576
1577 if ft.is_dir() && !ft.is_symlink() {
1578 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
1584 let sft = src_meta.file_type();
1585 if !sft.is_dir() || sft.is_symlink() {
1586 link::unlink(&source_path).with_context(|| {
1587 format!("remove conflicting source entry before dir merge: {source_path}")
1588 })?;
1589 }
1590 }
1591 if !source_path.exists() {
1592 std::fs::create_dir_all(&source_path).with_context(|| {
1593 format!("create_dir_all({source_path}) during target→source merge")
1594 })?;
1595 }
1596 merge_dir_target_into_source(&target_path, &source_path, ctx)?;
1597 } else if ft.is_file() {
1598 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
1602 let sft = src_meta.file_type();
1603 if sft.is_dir() && !sft.is_symlink() {
1604 remove_dir_link_or_real(&source_path).with_context(|| {
1605 format!("remove conflicting source dir before file merge: {source_path}")
1606 })?;
1607 } else if sft.is_symlink() {
1608 link::unlink(&source_path).with_context(|| {
1609 format!(
1610 "remove conflicting source symlink before file merge: {source_path}"
1611 )
1612 })?;
1613 }
1614 }
1615 if let Some(parent) = source_path.parent() {
1616 if !parent.exists() {
1617 std::fs::create_dir_all(parent)?;
1618 }
1619 }
1620 if source_path.is_file() {
1634 merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
1635 } else {
1636 std::fs::copy(&target_path, &source_path)
1637 .with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
1638 }
1639 } else {
1640 warn!(
1641 "merge: skipping non-regular entry {target_path} \
1642 (symlink / junction / special — content not copied)"
1643 );
1644 }
1645 }
1646 Ok(())
1647}
1648
1649fn merge_resolve_file_conflict(
1663 target_path: &Utf8Path,
1664 source_path: &Utf8Path,
1665 ctx: &ApplyCtx<'_>,
1666) -> Result<()> {
1667 use absorb::AbsorbDecision::*;
1668 let decision = absorb::classify(source_path, target_path)?;
1669 match decision {
1670 InSync | RelinkOnly => Ok(()),
1671 AutoAbsorb => {
1672 std::fs::copy(target_path, source_path).with_context(|| {
1673 format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
1674 })?;
1675 Ok(())
1676 }
1677 Restore => {
1678 unreachable!(
1685 "merge_resolve_file_conflict reached with both files present, \
1686 but classify returned Restore (target {target_path} / source {source_path})"
1687 )
1688 }
1689 NeedsConfirm => {
1690 use crate::config::AnomalyAction::*;
1691 match ctx.config.absorb.on_anomaly {
1692 Skip => {
1693 warn!(
1694 "merge anomaly skip: {target_path} (source-newer / content drift) \
1695 — keeping source version, target version dropped"
1696 );
1697 Ok(())
1698 }
1699 Force => {
1700 warn!(
1701 "merge anomaly force: {target_path} \
1702 (source-newer / content drift) — overwriting source"
1703 );
1704 std::fs::copy(target_path, source_path)?;
1705 Ok(())
1706 }
1707 Ask => {
1708 use std::io::IsTerminal;
1709 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
1710 if prompt_absorb_with_diff(
1711 source_path,
1712 target_path,
1713 "merge: file content differs and source is newer",
1714 )? {
1715 std::fs::copy(target_path, source_path)?;
1716 } else {
1717 warn!("merge: kept source version by user choice: {source_path}");
1718 }
1719 Ok(())
1720 } else {
1721 warn!(
1722 "merge anomaly skip (non-TTY ask mode): {target_path} \
1723 — keeping source version"
1724 );
1725 Ok(())
1726 }
1727 }
1728 }
1729 }
1730 }
1731}
1732
1733fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1740 info!("absorb dir: {dst} → {src}");
1741 backup_existing(src, ctx.backup_root, true)?;
1742 merge_dir_target_into_source(dst, src, ctx)?;
1743 remove_dir_link_or_real(dst)?;
1746 link::link_dir(src, dst, ctx.dir_mode)?;
1747 Ok(())
1748}
1749
1750fn handle_anomaly_dir(
1754 src: &Utf8Path,
1755 dst: &Utf8Path,
1756 ctx: &ApplyCtx<'_>,
1757 reason: &str,
1758) -> Result<()> {
1759 use crate::config::AnomalyAction::*;
1760 match ctx.config.absorb.on_anomaly {
1761 Skip => {
1762 warn!("anomaly skip dir: {dst} ({reason})");
1763 Ok(())
1764 }
1765 Force => {
1766 warn!(
1767 "anomaly force dir: {dst} ({reason}) \
1768 — absorbing target into source"
1769 );
1770 absorb_target_dir_into_source(src, dst, ctx)
1771 }
1772 Ask => {
1773 use std::io::IsTerminal;
1774 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
1775 eprintln!();
1776 eprintln!("anomaly: {dst}");
1777 eprintln!(" {reason}");
1778 eprintln!(" source: {src}");
1779 eprint!(" absorb target dir into source? (y/N) ");
1780 use std::io::{BufRead as _, Write as _};
1781 std::io::stderr().flush().ok();
1782 let mut buf = String::new();
1783 std::io::stdin().lock().read_line(&mut buf)?;
1784 let answer = buf.trim();
1785 if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
1786 absorb_target_dir_into_source(src, dst, ctx)
1787 } else {
1788 warn!("anomaly skipped by user: {dst}");
1789 Ok(())
1790 }
1791 } else {
1792 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
1793 Ok(())
1794 }
1795 }
1796 }
1797}
1798
1799fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
1800 let abs_target = absolutize(target)?;
1801 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
1802 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
1803 info!("backup → {bp}");
1804 if is_dir {
1805 backup::backup_dir(target, &bp)?;
1806 } else {
1807 backup::backup_file(target, &bp)?;
1808 }
1809 Ok(())
1810}
1811
1812fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
1813 if let Some(s) = source {
1814 return absolutize(&s);
1815 }
1816 if let Ok(s) = std::env::var("YUI_SOURCE") {
1817 return absolutize(Utf8Path::new(&s));
1818 }
1819 let cwd = current_dir_utf8()?;
1820 for ancestor in cwd.ancestors() {
1821 if ancestor.join("config.toml").is_file() {
1822 return Ok(ancestor.to_path_buf());
1823 }
1824 }
1825 if let Some(home) = paths::home_dir() {
1826 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
1827 let p = home.join(c);
1828 if p.join("config.toml").is_file() {
1829 return Ok(p);
1830 }
1831 }
1832 }
1833 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
1834}
1835
1836fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
1837 let expanded = paths::expand_tilde(p.as_str());
1839 if expanded.is_absolute() {
1840 return Ok(expanded);
1841 }
1842 let cwd = current_dir_utf8()?;
1843 Ok(cwd.join(expanded))
1844}
1845
1846fn current_dir_utf8() -> Result<Utf8PathBuf> {
1847 let cwd = std::env::current_dir().context("getting cwd")?;
1848 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
1849}
1850
1851const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
1855
1856[vars]
1857# user-defined values; templates can reference these as {{ vars.foo }}
1858
1859# [link]
1860# file_mode = "auto" # auto | symlink | hardlink
1861# dir_mode = "auto" # auto | symlink | junction
1862
1863[mount]
1864default_strategy = "marker"
1865
1866[[mount.entry]]
1867src = "home"
1868# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
1869dst = "~"
1870
1871# [[mount.entry]]
1872# src = "appdata"
1873# dst = "{{ env(name='APPDATA') }}"
1874# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
1875# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
1876# when = "yui.os == 'windows'"
1877"#;
1878
1879const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
1880# .yui/bin/ is intentionally tracked — it holds your hook scripts.
1881/.yui/state.json
1882/.yui/state.json.tmp
1883/.yui/backup/
1884
1885# >>> yui rendered (auto-managed, do not edit) >>>
1886# <<< yui rendered (auto-managed) <<<
1887
1888# config.local.toml is per-machine; commit a config.local.example.toml instead.
1889config.local.toml
1890"#;
1891
1892#[cfg(test)]
1893mod tests {
1894 use super::*;
1895 use tempfile::TempDir;
1896
1897 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
1898 Utf8PathBuf::from_path_buf(p).unwrap()
1899 }
1900
1901 fn toml_path(p: &Utf8Path) -> String {
1903 p.as_str().replace('\\', "/")
1904 }
1905
1906 #[test]
1907 fn apply_links_a_raw_file() {
1908 let tmp = TempDir::new().unwrap();
1909 let source = utf8(tmp.path().join("dotfiles"));
1910 let target = utf8(tmp.path().join("target"));
1911 std::fs::create_dir_all(source.join("home")).unwrap();
1912 std::fs::create_dir_all(&target).unwrap();
1913 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
1914
1915 let cfg = format!(
1916 r#"
1917[[mount.entry]]
1918src = "home"
1919dst = "{}"
1920"#,
1921 toml_path(&target)
1922 );
1923 std::fs::write(source.join("config.toml"), cfg).unwrap();
1924
1925 apply(Some(source), false).unwrap();
1926
1927 let linked = target.join(".bashrc");
1928 assert!(linked.exists(), "expected {linked} to exist");
1929 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
1930 }
1931
1932 #[test]
1933 fn apply_with_marker_links_whole_directory() {
1934 let tmp = TempDir::new().unwrap();
1935 let source = utf8(tmp.path().join("dotfiles"));
1936 let target = utf8(tmp.path().join("target"));
1937 let nvim_src = source.join("home/nvim");
1938 std::fs::create_dir_all(&nvim_src).unwrap();
1939 std::fs::create_dir_all(&target).unwrap();
1940 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
1941 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
1942 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
1943
1944 let cfg = format!(
1945 r#"
1946[[mount.entry]]
1947src = "home"
1948dst = "{}"
1949"#,
1950 toml_path(&target)
1951 );
1952 std::fs::write(source.join("config.toml"), cfg).unwrap();
1953
1954 apply(Some(source.clone()), false).unwrap();
1955
1956 let nvim_dst = target.join("nvim");
1957 assert!(nvim_dst.exists());
1958 assert_eq!(
1959 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
1960 "-- hi\n"
1961 );
1962 }
1966
1967 #[test]
1968 fn apply_dry_run_does_not_write() {
1969 let tmp = TempDir::new().unwrap();
1970 let source = utf8(tmp.path().join("dotfiles"));
1971 let target = utf8(tmp.path().join("target"));
1972 std::fs::create_dir_all(source.join("home")).unwrap();
1973 std::fs::create_dir_all(&target).unwrap();
1974 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
1975
1976 let cfg = format!(
1977 r#"
1978[[mount.entry]]
1979src = "home"
1980dst = "{}"
1981"#,
1982 toml_path(&target)
1983 );
1984 std::fs::write(source.join("config.toml"), cfg).unwrap();
1985
1986 apply(Some(source), true).unwrap();
1987
1988 assert!(!target.join(".bashrc").exists());
1989 }
1990
1991 #[test]
1992 fn apply_renders_templates_then_links_rendered_outputs() {
1993 let tmp = TempDir::new().unwrap();
1994 let source = utf8(tmp.path().join("dotfiles"));
1995 let target = utf8(tmp.path().join("target"));
1996 std::fs::create_dir_all(source.join("home")).unwrap();
1997 std::fs::create_dir_all(&target).unwrap();
1998 std::fs::write(
1999 source.join("home/.gitconfig.tera"),
2000 "[user]\n os = {{ yui.os }}\n",
2001 )
2002 .unwrap();
2003 std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
2004
2005 let cfg = format!(
2006 r#"
2007[[mount.entry]]
2008src = "home"
2009dst = "{}"
2010"#,
2011 toml_path(&target)
2012 );
2013 std::fs::write(source.join("config.toml"), cfg).unwrap();
2014
2015 apply(Some(source.clone()), false).unwrap();
2016
2017 assert!(target.join(".bashrc").exists());
2019 assert!(source.join("home/.gitconfig").exists());
2021 assert!(target.join(".gitconfig").exists());
2022 assert!(!target.join(".gitconfig.tera").exists());
2024 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
2026 assert!(linked.contains("os = "));
2027 }
2028
2029 #[test]
2030 fn apply_marker_override_links_to_custom_dst() {
2031 let tmp = TempDir::new().unwrap();
2032 let source = utf8(tmp.path().join("dotfiles"));
2033 let target_a = utf8(tmp.path().join("target_a"));
2034 let target_b = utf8(tmp.path().join("target_b"));
2035 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2036 std::fs::create_dir_all(&target_a).unwrap();
2037 std::fs::create_dir_all(&target_b).unwrap();
2038 std::fs::write(
2039 source.join("home/.config/nvim/init.lua"),
2040 "-- nvim config\n",
2041 )
2042 .unwrap();
2043
2044 std::fs::write(
2047 source.join("home/.config/nvim/.yuilink"),
2048 format!(
2049 r#"
2050[[link]]
2051dst = "{}/nvim"
2052
2053[[link]]
2054dst = "{}/nvim"
2055when = "{{{{ yui.os == '{}' }}}}"
2056"#,
2057 toml_path(&target_a),
2058 toml_path(&target_b),
2059 std::env::consts::OS
2060 ),
2061 )
2062 .unwrap();
2063
2064 let parent_target = utf8(tmp.path().join("parent_target"));
2065 std::fs::create_dir_all(&parent_target).unwrap();
2066 let cfg = format!(
2067 r#"
2068[[mount.entry]]
2069src = "home"
2070dst = "{}"
2071"#,
2072 toml_path(&parent_target)
2073 );
2074 std::fs::write(source.join("config.toml"), cfg).unwrap();
2075
2076 apply(Some(source.clone()), false).unwrap();
2077
2078 assert!(
2080 target_a.join("nvim/init.lua").exists(),
2081 "target_a/nvim/init.lua should be reachable through the link"
2082 );
2083 assert!(
2084 target_b.join("nvim/init.lua").exists(),
2085 "target_b/nvim/init.lua should be reachable through the link"
2086 );
2087 assert!(
2090 !parent_target.join(".config/nvim").exists(),
2091 "parent mount should have skipped the marker-claimed sub-dir"
2092 );
2093 }
2094
2095 #[test]
2096 fn apply_marker_inactive_link_falls_through_to_default() {
2097 let tmp = TempDir::new().unwrap();
2102 let source = utf8(tmp.path().join("dotfiles"));
2103 let target_inactive = utf8(tmp.path().join("inactive"));
2104 let parent_target = utf8(tmp.path().join("parent"));
2105 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2106 std::fs::create_dir_all(&parent_target).unwrap();
2107 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
2108
2109 std::fs::write(
2111 source.join("home/.config/nvim/.yuilink"),
2112 format!(
2113 r#"
2114[[link]]
2115dst = "{}/nvim"
2116when = "{{{{ yui.os == 'no-such-os' }}}}"
2117"#,
2118 toml_path(&target_inactive)
2119 ),
2120 )
2121 .unwrap();
2122
2123 let cfg = format!(
2124 r#"
2125[[mount.entry]]
2126src = "home"
2127dst = "{}"
2128"#,
2129 toml_path(&parent_target)
2130 );
2131 std::fs::write(source.join("config.toml"), cfg).unwrap();
2132
2133 apply(Some(source.clone()), false).unwrap();
2134
2135 assert!(!target_inactive.join("nvim").exists());
2137 assert!(parent_target.join(".config/nvim/init.lua").exists());
2140 }
2141
2142 #[test]
2143 fn list_shows_mount_entries_and_marker_overrides() {
2144 let tmp = TempDir::new().unwrap();
2145 let source = utf8(tmp.path().join("dotfiles"));
2146 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2147 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
2148 std::fs::write(
2149 source.join("home/.config/nvim/.yuilink"),
2150 r#"
2151[[link]]
2152dst = "/custom/nvim"
2153"#,
2154 )
2155 .unwrap();
2156 std::fs::write(
2157 source.join("config.toml"),
2158 r#"
2159[[mount.entry]]
2160src = "home"
2161dst = "/h"
2162"#,
2163 )
2164 .unwrap();
2165
2166 list(Some(source), false, None, true).unwrap();
2169 }
2170
2171 #[test]
2172 fn status_reports_in_sync_after_apply() {
2173 let tmp = TempDir::new().unwrap();
2174 let source = utf8(tmp.path().join("dotfiles"));
2175 let target = utf8(tmp.path().join("target"));
2176 std::fs::create_dir_all(source.join("home")).unwrap();
2177 std::fs::create_dir_all(&target).unwrap();
2178 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2179 let cfg = format!(
2180 r#"
2181[[mount.entry]]
2182src = "home"
2183dst = "{}"
2184"#,
2185 toml_path(&target)
2186 );
2187 std::fs::write(source.join("config.toml"), cfg).unwrap();
2188 apply(Some(source.clone()), false).unwrap();
2190 status(Some(source), None, true).unwrap();
2192 }
2193
2194 #[test]
2195 fn status_reports_template_drift() {
2196 let tmp = TempDir::new().unwrap();
2197 let source = utf8(tmp.path().join("dotfiles"));
2198 let target = utf8(tmp.path().join("target"));
2199 std::fs::create_dir_all(source.join("home")).unwrap();
2200 std::fs::create_dir_all(&target).unwrap();
2201 std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
2204 std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
2205
2206 let cfg = format!(
2207 r#"
2208[[mount.entry]]
2209src = "home"
2210dst = "{}"
2211"#,
2212 toml_path(&target)
2213 );
2214 std::fs::write(source.join("config.toml"), cfg).unwrap();
2215
2216 let err = status(Some(source), None, true).unwrap_err();
2217 assert!(format!("{err}").contains("diverged"));
2218 }
2219
2220 #[test]
2221 fn status_fails_when_target_missing() {
2222 let tmp = TempDir::new().unwrap();
2223 let source = utf8(tmp.path().join("dotfiles"));
2224 let target = utf8(tmp.path().join("target"));
2225 std::fs::create_dir_all(source.join("home")).unwrap();
2226 std::fs::create_dir_all(&target).unwrap();
2227 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2228 let cfg = format!(
2229 r#"
2230[[mount.entry]]
2231src = "home"
2232dst = "{}"
2233"#,
2234 toml_path(&target)
2235 );
2236 std::fs::write(source.join("config.toml"), cfg).unwrap();
2237 let err = status(Some(source), None, true).unwrap_err();
2239 assert!(format!("{err}").contains("diverged"));
2240 }
2241
2242 #[test]
2243 fn strip_braces_removes_outer_template_braces() {
2244 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
2245 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
2246 assert_eq!(strip_braces(" {{x}} "), "x");
2247 }
2248
2249 #[test]
2250 fn apply_aborts_on_render_drift() {
2251 let tmp = TempDir::new().unwrap();
2252 let source = utf8(tmp.path().join("dotfiles"));
2253 let target = utf8(tmp.path().join("target"));
2254 std::fs::create_dir_all(source.join("home")).unwrap();
2255 std::fs::create_dir_all(&target).unwrap();
2256 std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
2257 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
2258
2259 let cfg = format!(
2260 r#"
2261[[mount.entry]]
2262src = "home"
2263dst = "{}"
2264"#,
2265 toml_path(&target)
2266 );
2267 std::fs::write(source.join("config.toml"), cfg).unwrap();
2268
2269 let err = apply(Some(source.clone()), false).unwrap_err();
2270 assert!(format!("{err}").contains("drift"));
2271 assert_eq!(
2273 std::fs::read_to_string(source.join("home/foo")).unwrap(),
2274 "manually edited"
2275 );
2276 assert!(!target.join("foo").exists());
2278 }
2279
2280 #[test]
2281 fn init_creates_skeleton_when_dir_empty() {
2282 let tmp = TempDir::new().unwrap();
2283 let dir = utf8(tmp.path().join("new_dotfiles"));
2284 init(Some(dir.clone()), false).unwrap();
2285 assert!(dir.join("config.toml").is_file());
2286 assert!(dir.join(".gitignore").is_file());
2287 }
2288
2289 #[test]
2290 fn init_refuses_to_overwrite_existing_config() {
2291 let tmp = TempDir::new().unwrap();
2292 let dir = utf8(tmp.path().join("dotfiles"));
2293 std::fs::create_dir_all(&dir).unwrap();
2294 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
2295 let err = init(Some(dir), false).unwrap_err();
2296 assert!(format!("{err}").contains("already exists"));
2297 }
2298
2299 fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
2302 let source = utf8(tmp.path().join("dotfiles"));
2303 let target = utf8(tmp.path().join("target"));
2304 std::fs::create_dir_all(source.join("home")).unwrap();
2305 std::fs::create_dir_all(&target).unwrap();
2306 let cfg = format!(
2307 r#"
2308[[mount.entry]]
2309src = "home"
2310dst = "{}"
2311"#,
2312 toml_path(&target)
2313 );
2314 std::fs::write(source.join("config.toml"), cfg).unwrap();
2315 (source, target)
2316 }
2317
2318 fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
2319 std::fs::write(path, body).unwrap();
2320 let f = std::fs::OpenOptions::new()
2321 .write(true)
2322 .open(path)
2323 .expect("open writable");
2324 f.set_modified(when).expect("set_modified");
2325 }
2326
2327 #[test]
2328 fn apply_target_newer_absorbs_target_into_source() {
2329 let tmp = TempDir::new().unwrap();
2333 let (source, target) = setup_minimal_dotfiles(&tmp);
2334
2335 let now = std::time::SystemTime::now();
2336 let past = now - std::time::Duration::from_secs(120);
2337 write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
2338 write_with_mtime(&target.join(".bashrc"), "user's edit", now);
2340
2341 apply(Some(source.clone()), false).unwrap();
2342
2343 assert_eq!(
2345 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2346 "user's edit"
2347 );
2348 assert_eq!(
2350 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2351 "user's edit"
2352 );
2353 let backup_root = source.join(".yui/backup");
2355 let mut found_old = false;
2356 for entry in walkdir(&backup_root) {
2357 if let Ok(s) = std::fs::read_to_string(&entry) {
2358 if s == "default from repo" {
2359 found_old = true;
2360 break;
2361 }
2362 }
2363 }
2364 assert!(found_old, "expected backup containing 'default from repo'");
2365 }
2366
2367 #[test]
2368 fn apply_in_sync_target_is_a_no_op() {
2369 let tmp = TempDir::new().unwrap();
2372 let (source, target) = setup_minimal_dotfiles(&tmp);
2373 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2374 apply(Some(source.clone()), false).unwrap();
2375 let backup_root = source.join(".yui/backup");
2376 let backup_count_after_first = walkdir(&backup_root).len();
2377
2378 apply(Some(source.clone()), false).unwrap();
2380 assert_eq!(
2381 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2382 "echo hi\n"
2383 );
2384 let backup_count_after_second = walkdir(&backup_root).len();
2385 assert_eq!(
2386 backup_count_after_first, backup_count_after_second,
2387 "second apply on an in-sync tree should not produce backups"
2388 );
2389 }
2390
2391 #[test]
2392 fn apply_skip_policy_leaves_anomaly_alone() {
2393 let tmp = TempDir::new().unwrap();
2396 let source = utf8(tmp.path().join("dotfiles"));
2397 let target = utf8(tmp.path().join("target"));
2398 std::fs::create_dir_all(source.join("home")).unwrap();
2399 std::fs::create_dir_all(&target).unwrap();
2400 let cfg = format!(
2401 r#"
2402[absorb]
2403on_anomaly = "skip"
2404
2405[[mount.entry]]
2406src = "home"
2407dst = "{}"
2408"#,
2409 toml_path(&target)
2410 );
2411 std::fs::write(source.join("config.toml"), cfg).unwrap();
2412
2413 let now = std::time::SystemTime::now();
2414 let past = now - std::time::Duration::from_secs(120);
2415 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
2416 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
2417
2418 apply(Some(source.clone()), false).unwrap();
2419
2420 assert_eq!(
2422 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2423 "user's edit (older)"
2424 );
2425 assert_eq!(
2427 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2428 "fresh from upstream"
2429 );
2430 }
2431
2432 #[test]
2433 fn apply_force_policy_absorbs_anomaly_anyway() {
2434 let tmp = TempDir::new().unwrap();
2436 let source = utf8(tmp.path().join("dotfiles"));
2437 let target = utf8(tmp.path().join("target"));
2438 std::fs::create_dir_all(source.join("home")).unwrap();
2439 std::fs::create_dir_all(&target).unwrap();
2440 let cfg = format!(
2441 r#"
2442[absorb]
2443on_anomaly = "force"
2444
2445[[mount.entry]]
2446src = "home"
2447dst = "{}"
2448"#,
2449 toml_path(&target)
2450 );
2451 std::fs::write(source.join("config.toml"), cfg).unwrap();
2452
2453 let now = std::time::SystemTime::now();
2454 let past = now - std::time::Duration::from_secs(120);
2455 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
2456 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
2457
2458 apply(Some(source.clone()), false).unwrap();
2459
2460 assert_eq!(
2462 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2463 "user's edit (older)"
2464 );
2465 assert_eq!(
2466 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2467 "user's edit (older)"
2468 );
2469 }
2470
2471 #[test]
2483 fn apply_absorbs_non_empty_target_dir_target_wins() {
2484 let tmp = TempDir::new().unwrap();
2485 let source = utf8(tmp.path().join("dotfiles"));
2486 let target = utf8(tmp.path().join("target"));
2487 std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
2488 std::fs::create_dir_all(target.join(".config/app")).unwrap();
2489 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
2492 std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
2493 std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
2495 std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
2498 std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
2499
2500 let cfg = format!(
2501 r#"
2502[absorb]
2503on_anomaly = "force"
2504
2505[[mount.entry]]
2506src = "home"
2507dst = "{}"
2508"#,
2509 toml_path(&target)
2510 );
2511 std::fs::write(source.join("config.toml"), cfg).unwrap();
2512
2513 apply(Some(source.clone()), false).unwrap();
2515
2516 assert_eq!(
2518 std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
2519 "target side"
2520 );
2521 assert_eq!(
2523 std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
2524 "{}"
2525 );
2526 let backup_root = source.join(".yui/backup");
2529 let mut backup_files: Vec<String> = Vec::new();
2530 for entry in walkdir(&backup_root) {
2531 if let Some(n) = entry.file_name() {
2532 backup_files.push(n.to_string());
2533 }
2534 }
2535 assert!(
2536 backup_files.iter().any(|f| f == "config.toml"),
2537 "expected source's config.toml to land in the backup tree, got {backup_files:?}"
2538 );
2539 assert!(
2541 source.join("home/.config/app/source-only.toml").exists(),
2542 "source-only file should survive a target-wins merge"
2543 );
2544 assert!(
2546 source.join("home/.config/app/state.json").exists(),
2547 "target-only state.json should be merged into source"
2548 );
2549 }
2550
2551 #[test]
2557 fn marker_dir_absorbs_with_default_ask_policy() {
2558 let tmp = TempDir::new().unwrap();
2559 let source = utf8(tmp.path().join("dotfiles"));
2560 let target = utf8(tmp.path().join("target"));
2561 std::fs::create_dir_all(source.join("home/.config")).unwrap();
2562 std::fs::create_dir_all(target.join(".config/gh")).unwrap();
2563 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
2565 std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\n").unwrap();
2567
2568 let cfg = format!(
2572 r#"
2573[[mount.entry]]
2574src = "home"
2575dst = "{}"
2576"#,
2577 toml_path(&target)
2578 );
2579 std::fs::write(source.join("config.toml"), cfg).unwrap();
2580
2581 apply(Some(source.clone()), false).unwrap();
2585
2586 assert!(target.join(".config/gh/hosts.yml").exists());
2589 assert!(source.join("home/.config/gh/hosts.yml").exists());
2590 }
2591
2592 #[test]
2598 fn merge_handles_file_vs_dir_collisions_target_wins() {
2599 let tmp = TempDir::new().unwrap();
2600 let source = utf8(tmp.path().join("dotfiles"));
2601 let target = utf8(tmp.path().join("target"));
2602 std::fs::create_dir_all(source.join("home/.config/foo")).unwrap();
2603 std::fs::create_dir_all(target.join(".config")).unwrap();
2604 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
2605
2606 std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
2608 std::fs::write(target.join(".config/foo"), "target file body").unwrap();
2609 std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
2611 std::fs::create_dir_all(target.join(".config/bar")).unwrap();
2612 std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
2613
2614 let cfg = format!(
2615 r#"
2616[absorb]
2617on_anomaly = "force"
2618
2619[[mount.entry]]
2620src = "home"
2621dst = "{}"
2622"#,
2623 toml_path(&target)
2624 );
2625 std::fs::write(source.join("config.toml"), cfg).unwrap();
2626 apply(Some(source.clone()), false).unwrap();
2627
2628 let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
2632 assert!(foo_meta.file_type().is_file(), "foo should be a file");
2633 assert_eq!(
2634 std::fs::read_to_string(target.join(".config/foo")).unwrap(),
2635 "target file body"
2636 );
2637 let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
2639 assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
2640 assert_eq!(
2641 std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
2642 "target nested"
2643 );
2644 }
2645
2646 #[test]
2650 fn merge_per_file_target_newer_auto_absorbs() {
2651 let tmp = TempDir::new().unwrap();
2652 let source = utf8(tmp.path().join("dotfiles"));
2653 let target = utf8(tmp.path().join("target"));
2654 std::fs::create_dir_all(source.join("home/.config")).unwrap();
2655 std::fs::create_dir_all(target.join(".config")).unwrap();
2656 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
2657
2658 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
2660 write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
2661 std::fs::write(target.join(".config/app.toml"), "user's live edit").unwrap();
2662
2663 let cfg = format!(
2667 r#"
2668[[mount.entry]]
2669src = "home"
2670dst = "{}"
2671"#,
2672 toml_path(&target)
2673 );
2674 std::fs::write(source.join("config.toml"), cfg).unwrap();
2675 apply(Some(source.clone()), false).unwrap();
2676
2677 assert_eq!(
2679 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
2680 "user's live edit"
2681 );
2682 }
2683
2684 #[test]
2690 fn merge_per_file_source_newer_skip_keeps_source() {
2691 let tmp = TempDir::new().unwrap();
2692 let source = utf8(tmp.path().join("dotfiles"));
2693 let target = utf8(tmp.path().join("target"));
2694 std::fs::create_dir_all(source.join("home/.config")).unwrap();
2695 std::fs::create_dir_all(target.join(".config")).unwrap();
2696 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
2697
2698 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
2700 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
2701 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
2702
2703 let cfg = format!(
2704 r#"
2705[absorb]
2706on_anomaly = "skip"
2707
2708[[mount.entry]]
2709src = "home"
2710dst = "{}"
2711"#,
2712 toml_path(&target)
2713 );
2714 std::fs::write(source.join("config.toml"), cfg).unwrap();
2715 apply(Some(source.clone()), false).unwrap();
2716
2717 assert_eq!(
2720 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
2721 "fresh source"
2722 );
2723 }
2724
2725 #[test]
2728 fn merge_per_file_source_newer_force_overwrites_source() {
2729 let tmp = TempDir::new().unwrap();
2730 let source = utf8(tmp.path().join("dotfiles"));
2731 let target = utf8(tmp.path().join("target"));
2732 std::fs::create_dir_all(source.join("home/.config")).unwrap();
2733 std::fs::create_dir_all(target.join(".config")).unwrap();
2734 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
2735
2736 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
2737 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
2738 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
2739
2740 let cfg = format!(
2741 r#"
2742[absorb]
2743on_anomaly = "force"
2744
2745[[mount.entry]]
2746src = "home"
2747dst = "{}"
2748"#,
2749 toml_path(&target)
2750 );
2751 std::fs::write(source.join("config.toml"), cfg).unwrap();
2752 apply(Some(source.clone()), false).unwrap();
2753
2754 assert_eq!(
2756 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
2757 "old target"
2758 );
2759 }
2760
2761 #[test]
2766 fn merge_per_file_identical_content_is_noop() {
2767 let tmp = TempDir::new().unwrap();
2768 let source = utf8(tmp.path().join("dotfiles"));
2769 let target = utf8(tmp.path().join("target"));
2770 std::fs::create_dir_all(source.join("home/.config")).unwrap();
2771 std::fs::create_dir_all(target.join(".config")).unwrap();
2772 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
2773 std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
2774 std::fs::write(target.join(".config/app.toml"), "same").unwrap();
2775
2776 let cfg = format!(
2779 r#"
2780[[mount.entry]]
2781src = "home"
2782dst = "{}"
2783"#,
2784 toml_path(&target)
2785 );
2786 std::fs::write(source.join("config.toml"), cfg).unwrap();
2787 apply(Some(source.clone()), false).unwrap();
2788
2789 assert_eq!(
2790 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
2791 "same"
2792 );
2793 }
2794
2795 #[test]
2796 fn manual_absorb_command_pulls_target_into_source() {
2797 let tmp = TempDir::new().unwrap();
2799 let source = utf8(tmp.path().join("dotfiles"));
2800 let target = utf8(tmp.path().join("target"));
2801 std::fs::create_dir_all(source.join("home")).unwrap();
2802 std::fs::create_dir_all(&target).unwrap();
2803 let cfg = format!(
2805 r#"
2806[absorb]
2807on_anomaly = "skip"
2808
2809[[mount.entry]]
2810src = "home"
2811dst = "{}"
2812"#,
2813 toml_path(&target)
2814 );
2815 std::fs::write(source.join("config.toml"), cfg).unwrap();
2816 std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
2817 std::fs::write(source.join("home/.bashrc"), "default").unwrap();
2818
2819 absorb(
2821 Some(source.clone()),
2822 target.join(".bashrc"),
2823 false,
2824 )
2825 .unwrap();
2826
2827 assert_eq!(
2829 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2830 "user picked this"
2831 );
2832 }
2833
2834 #[test]
2835 fn manual_absorb_errors_when_target_outside_known_mounts() {
2836 let tmp = TempDir::new().unwrap();
2837 let (source, _target) = setup_minimal_dotfiles(&tmp);
2838 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
2839 let stranger = utf8(tmp.path().join("not-managed/foo"));
2840 std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
2841 std::fs::write(&stranger, "not yui's").unwrap();
2842 let err = absorb(Some(source), stranger, false).unwrap_err();
2843 assert!(format!("{err}").contains("no mount entry"));
2844 }
2845
2846 #[test]
2847 fn yuiignore_excludes_file_from_linking() {
2848 let tmp = TempDir::new().unwrap();
2849 let (source, target) = setup_minimal_dotfiles(&tmp);
2850 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
2851 std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
2852 std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
2854 apply(Some(source.clone()), false).unwrap();
2855 assert!(target.join(".bashrc").exists());
2856 assert!(
2857 !target.join("lock.json").exists(),
2858 "yuiignore should keep lock.json out of target"
2859 );
2860 }
2861
2862 #[test]
2863 fn yuiignore_excludes_directory_subtree() {
2864 let tmp = TempDir::new().unwrap();
2865 let (source, target) = setup_minimal_dotfiles(&tmp);
2866 std::fs::create_dir_all(source.join("home/cache")).unwrap();
2867 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
2868 std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
2869 std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
2870 std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
2872 apply(Some(source.clone()), false).unwrap();
2873 assert!(target.join(".bashrc").exists());
2874 assert!(
2875 !target.join("cache").exists(),
2876 "yuiignore'd subtree should not appear in target"
2877 );
2878 }
2879
2880 #[test]
2881 fn yuiignore_negation_re_includes_file() {
2882 let tmp = TempDir::new().unwrap();
2883 let (source, target) = setup_minimal_dotfiles(&tmp);
2884 std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
2885 std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
2886 std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
2888 apply(Some(source.clone()), false).unwrap();
2889 assert!(target.join("keep.cache").exists());
2890 assert!(!target.join("drop.cache").exists());
2891 }
2892
2893 #[test]
2894 fn yuiignore_skips_template_in_render() {
2895 let tmp = TempDir::new().unwrap();
2896 let source = utf8(tmp.path().join("dotfiles"));
2897 let target = utf8(tmp.path().join("target"));
2898 std::fs::create_dir_all(source.join("home")).unwrap();
2899 std::fs::create_dir_all(&target).unwrap();
2900 std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
2901 std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
2902 let cfg = format!(
2903 r#"
2904[[mount.entry]]
2905src = "home"
2906dst = "{}"
2907"#,
2908 toml_path(&target)
2909 );
2910 std::fs::write(source.join("config.toml"), cfg).unwrap();
2911 apply(Some(source.clone()), false).unwrap();
2912 assert!(!source.join("home/note").exists());
2914 assert!(!target.join("note").exists());
2915 assert!(!target.join("note.tera").exists());
2916 }
2917
2918 #[test]
2922 fn nested_marker_accumulates_extra_dst() {
2923 let tmp = TempDir::new().unwrap();
2924 let source = utf8(tmp.path().join("dotfiles"));
2925 let parent_target = utf8(tmp.path().join("home"));
2926 let extra_target = utf8(tmp.path().join("extra"));
2927 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2928 std::fs::create_dir_all(&parent_target).unwrap();
2929 std::fs::create_dir_all(&extra_target).unwrap();
2930 std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
2931
2932 std::fs::write(
2934 source.join("home/.config/.yuilink"),
2935 format!(
2936 r#"
2937[[link]]
2938dst = "{}/.config"
2939"#,
2940 toml_path(&parent_target)
2941 ),
2942 )
2943 .unwrap();
2944 std::fs::write(
2947 source.join("home/.config/nvim/.yuilink"),
2948 format!(
2949 r#"
2950[[link]]
2951dst = "{}/nvim"
2952when = "{{{{ yui.os == '{}' }}}}"
2953"#,
2954 toml_path(&extra_target),
2955 std::env::consts::OS
2956 ),
2957 )
2958 .unwrap();
2959
2960 let cfg = format!(
2961 r#"
2962[[mount.entry]]
2963src = "home"
2964dst = "{}"
2965"#,
2966 toml_path(&parent_target)
2967 );
2968 std::fs::write(source.join("config.toml"), cfg).unwrap();
2969
2970 apply(Some(source.clone()), false).unwrap();
2971
2972 assert!(parent_target.join(".config/nvim/init.lua").exists());
2975 assert!(extra_target.join("nvim/init.lua").exists());
2976 }
2977
2978 #[test]
2983 fn marker_file_link_targets_specific_file() {
2984 let tmp = TempDir::new().unwrap();
2985 let source = utf8(tmp.path().join("dotfiles"));
2986 let parent_target = utf8(tmp.path().join("home"));
2987 let docs_target = utf8(tmp.path().join("docs"));
2988 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
2989 std::fs::create_dir_all(&parent_target).unwrap();
2990 std::fs::create_dir_all(&docs_target).unwrap();
2991 std::fs::write(
2992 source.join("home/.config/powershell/profile.ps1"),
2993 "# profile\n",
2994 )
2995 .unwrap();
2996 std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
2997
2998 std::fs::write(
3001 source.join("home/.config/powershell/.yuilink"),
3002 format!(
3003 r#"
3004[[link]]
3005src = "profile.ps1"
3006dst = "{}/Microsoft.PowerShell_profile.ps1"
3007"#,
3008 toml_path(&docs_target)
3009 ),
3010 )
3011 .unwrap();
3012
3013 let cfg = format!(
3014 r#"
3015[[mount.entry]]
3016src = "home"
3017dst = "{}"
3018"#,
3019 toml_path(&parent_target)
3020 );
3021 std::fs::write(source.join("config.toml"), cfg).unwrap();
3022
3023 apply(Some(source.clone()), false).unwrap();
3024
3025 assert!(
3027 docs_target
3028 .join("Microsoft.PowerShell_profile.ps1")
3029 .exists()
3030 );
3031 assert!(
3034 parent_target
3035 .join(".config/powershell/profile.ps1")
3036 .exists()
3037 );
3038 assert!(parent_target.join(".config/powershell/extra.txt").exists());
3039 }
3040
3041 #[test]
3044 fn marker_file_link_missing_src_errors() {
3045 let tmp = TempDir::new().unwrap();
3046 let source = utf8(tmp.path().join("dotfiles"));
3047 let parent_target = utf8(tmp.path().join("home"));
3048 let docs_target = utf8(tmp.path().join("docs"));
3049 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
3050 std::fs::create_dir_all(&parent_target).unwrap();
3051 std::fs::create_dir_all(&docs_target).unwrap();
3052
3053 std::fs::write(
3054 source.join("home/.config/powershell/.yuilink"),
3055 format!(
3056 r#"
3057[[link]]
3058src = "missing.ps1"
3059dst = "{}/profile.ps1"
3060"#,
3061 toml_path(&docs_target)
3062 ),
3063 )
3064 .unwrap();
3065
3066 let cfg = format!(
3067 r#"
3068[[mount.entry]]
3069src = "home"
3070dst = "{}"
3071"#,
3072 toml_path(&parent_target)
3073 );
3074 std::fs::write(source.join("config.toml"), cfg).unwrap();
3075
3076 let err = apply(Some(source.clone()), false).unwrap_err();
3077 assert!(format!("{err:#}").contains("missing.ps1"));
3078 }
3079
3080 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
3081 let mut out = Vec::new();
3082 let mut stack = vec![root.to_path_buf()];
3083 while let Some(dir) = stack.pop() {
3084 let Ok(entries) = std::fs::read_dir(&dir) else {
3085 continue;
3086 };
3087 for e in entries.flatten() {
3088 let p = utf8(e.path());
3089 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
3090 stack.push(p);
3091 } else {
3092 out.push(p);
3093 }
3094 }
3095 }
3096 out
3097 }
3098}