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(
1080 source: Option<Utf8PathBuf>,
1081 icons_override: Option<IconsMode>,
1082 no_color: bool,
1083) -> Result<()> {
1084 let source = resolve_source(source)?;
1085 let yui = YuiVars::detect(&source);
1086 let config = config::load(&source, &yui)?;
1087 let state = hook::State::load(&source)?;
1088
1089 let icons_mode = icons_override.unwrap_or(config.ui.icons);
1090 let icons = Icons::for_mode(icons_mode);
1091 let color = !no_color && supports_color_stdout();
1092
1093 if config.hook.is_empty() {
1094 println!("(no [[hook]] entries in config)");
1095 return Ok(());
1096 }
1097
1098 let mut engine = template::Engine::new();
1102 let tera_ctx = template::template_context(&yui, &config.vars);
1103 let rows: Vec<HookRow> = config
1104 .hook
1105 .iter()
1106 .map(|h| -> Result<HookRow> {
1107 let active = match &h.when {
1111 None => true,
1112 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
1113 };
1114 let last_run_at = state.hooks.get(&h.name).and_then(|s| s.last_run_at.clone());
1115 Ok(HookRow {
1116 name: h.name.clone(),
1117 phase: match h.phase {
1118 HookPhase::Pre => "pre",
1119 HookPhase::Post => "post",
1120 },
1121 when_run: match h.when_run {
1122 config::WhenRun::Once => "once",
1123 config::WhenRun::Onchange => "onchange",
1124 config::WhenRun::Every => "every",
1125 },
1126 last_run_at,
1127 when: h.when.clone(),
1128 active,
1129 })
1130 })
1131 .collect::<Result<Vec<_>>>()?;
1132
1133 print_hooks_table(&rows, icons, color);
1134
1135 let total = rows.len();
1136 let active = rows.iter().filter(|r| r.active).count();
1137 let inactive = total - active;
1138 let ran = rows.iter().filter(|r| r.last_run_at.is_some()).count();
1139 let never = total - ran;
1140 println!();
1141 println!(
1142 " {total} hooks · {active} active · {inactive} inactive · {ran} ran · {never} never run"
1143 );
1144
1145 Ok(())
1146}
1147
1148#[derive(Debug)]
1149struct HookRow {
1150 name: String,
1151 phase: &'static str,
1152 when_run: &'static str,
1153 last_run_at: Option<String>,
1154 when: Option<String>,
1155 active: bool,
1156}
1157
1158fn print_hooks_table(rows: &[HookRow], icons: Icons, color: bool) {
1159 use owo_colors::OwoColorize as _;
1160 use std::fmt::Write as _;
1161
1162 let name_w = rows
1163 .iter()
1164 .map(|r| r.name.chars().count())
1165 .max()
1166 .unwrap_or(0)
1167 .max("NAME".len());
1168 let phase_w = rows
1169 .iter()
1170 .map(|r| r.phase.len())
1171 .max()
1172 .unwrap_or(0)
1173 .max("PHASE".len());
1174 let when_run_w = rows
1175 .iter()
1176 .map(|r| r.when_run.len())
1177 .max()
1178 .unwrap_or(0)
1179 .max("WHEN_RUN".len());
1180 let last_w = rows
1181 .iter()
1182 .map(|r| {
1183 r.last_run_at
1184 .as_deref()
1185 .map(|s| s.chars().count())
1186 .unwrap_or("(never)".len())
1187 })
1188 .max()
1189 .unwrap_or(0)
1190 .max("LAST_RUN".len());
1191 let status_w = "STATUS".len();
1192
1193 let mut header = String::new();
1195 let _ = write!(
1196 &mut header,
1197 " {:<status_w$} {:<name_w$} {:<phase_w$} {:<when_run_w$} {:<last_w$} WHEN",
1198 "STATUS", "NAME", "PHASE", "WHEN_RUN", "LAST_RUN"
1199 );
1200 if color {
1201 println!("{}", header.bold());
1202 } else {
1203 println!("{header}");
1204 }
1205
1206 let bar = |n: usize| icons.sep.to_string().repeat(n);
1208 let sep = format!(
1209 " {} {} {} {} {} {}",
1210 bar(status_w),
1211 bar(name_w),
1212 bar(phase_w),
1213 bar(when_run_w),
1214 bar(last_w),
1215 bar("WHEN".len())
1216 );
1217 if color {
1218 println!("{}", sep.dimmed());
1219 } else {
1220 println!("{sep}");
1221 }
1222
1223 for r in rows {
1225 let (icon, ran) = match (r.active, r.last_run_at.is_some()) {
1230 (false, _) => (icons.inactive, false),
1231 (true, true) => (icons.active, true),
1232 (true, false) => (icons.info, false),
1233 };
1234 let last = r.last_run_at.as_deref().unwrap_or("(never)");
1235 let when_str = r
1236 .when
1237 .as_deref()
1238 .map(strip_braces)
1239 .unwrap_or_else(|| "(always)".to_string());
1240
1241 let cell_status = format!("{icon:<status_w$}");
1242 let cell_name = format!("{:<name_w$}", r.name);
1243 let cell_phase = format!("{:<phase_w$}", r.phase);
1244 let cell_when_run = format!("{:<when_run_w$}", r.when_run);
1245 let cell_last = format!("{last:<last_w$}");
1246
1247 if !color {
1248 println!(
1249 " {cell_status} {cell_name} {cell_phase} {cell_when_run} {cell_last} {when_str}"
1250 );
1251 continue;
1252 }
1253
1254 if !r.active {
1258 println!(
1259 " {} {} {} {} {} {}",
1260 cell_status.dimmed(),
1261 cell_name.dimmed(),
1262 cell_phase.dimmed(),
1263 cell_when_run.dimmed(),
1264 cell_last.dimmed(),
1265 when_str.dimmed()
1266 );
1267 } else if ran {
1268 println!(
1269 " {} {} {} {} {} {}",
1270 cell_status.green(),
1271 cell_name.cyan().bold(),
1272 cell_phase.dimmed(),
1273 cell_when_run.dimmed(),
1274 cell_last.green(),
1275 when_str.dimmed()
1276 );
1277 } else {
1278 println!(
1279 " {} {} {} {} {} {}",
1280 cell_status.yellow(),
1281 cell_name.cyan().bold(),
1282 cell_phase.dimmed(),
1283 cell_when_run.dimmed(),
1284 cell_last.yellow(),
1285 when_str.dimmed()
1286 );
1287 }
1288 }
1289}
1290
1291pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
1295 let source = resolve_source(source)?;
1296 let yui = YuiVars::detect(&source);
1297 let config = config::load(&source, &yui)?;
1298 let mut engine = template::Engine::new();
1299 let tera_ctx = template::template_context(&yui, &config.vars);
1300
1301 let targets: Vec<&config::HookConfig> = match &name {
1302 Some(want) => {
1303 let m = config
1304 .hook
1305 .iter()
1306 .find(|h| &h.name == want)
1307 .ok_or_else(|| {
1308 anyhow::anyhow!(
1309 "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
1310 )
1311 })?;
1312 vec![m]
1313 }
1314 None => config.hook.iter().collect(),
1315 };
1316
1317 let mut state = hook::State::load(&source)?;
1318 for h in targets {
1319 let outcome = hook::run_hook(
1320 h,
1321 &source,
1322 &yui,
1323 &config.vars,
1324 &mut engine,
1325 &tera_ctx,
1326 &mut state,
1327 false,
1328 force,
1329 )?;
1330 let label = match outcome {
1331 HookOutcome::Ran => "ran",
1332 HookOutcome::SkippedOnce => "skipped (once: already ran)",
1333 HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
1334 HookOutcome::SkippedWhenFalse => "skipped (when=false)",
1335 HookOutcome::DryRun => "would run (dry-run)",
1336 };
1337 info!("hook[{}]: {label}", h.name);
1338 if outcome == HookOutcome::Ran {
1339 state.save(&source)?;
1340 }
1341 }
1342 Ok(())
1343}
1344
1345fn process_mount(
1350 source: &Utf8Path,
1351 m: &ResolvedMount,
1352 ctx: &ApplyCtx<'_>,
1353 engine: &mut template::Engine,
1354 tera_ctx: &TeraContext,
1355) -> Result<()> {
1356 let src_root = source.join(&m.src);
1357 if !src_root.is_dir() {
1358 warn!("mount src missing: {src_root}");
1359 return Ok(());
1360 }
1361 walk_and_link(&src_root, &m.dst, ctx, m.strategy, engine, tera_ctx, false)
1362}
1363
1364#[allow(clippy::too_many_arguments)]
1365fn walk_and_link(
1366 src_dir: &Utf8Path,
1367 dst_dir: &Utf8Path,
1368 ctx: &ApplyCtx<'_>,
1369 strategy: MountStrategy,
1370 engine: &mut template::Engine,
1371 tera_ctx: &TeraContext,
1372 parent_covered: bool,
1373) -> Result<()> {
1374 if paths::is_ignored(ctx.yuiignore, ctx.source, src_dir, true) {
1377 return Ok(());
1378 }
1379
1380 let marker_filename = &ctx.config.mount.marker_filename;
1381 let mut covered = parent_covered;
1382
1383 if strategy == MountStrategy::Marker {
1384 match marker::read_spec(src_dir, marker_filename)? {
1385 None => {} Some(MarkerSpec::PassThrough) => {
1387 link_dir_with_backup(src_dir, dst_dir, ctx)?;
1391 covered = true;
1392 }
1393 Some(MarkerSpec::Explicit { links }) => {
1394 let mut emitted_dir_link = false;
1395 let mut emitted_any = false;
1396 for link in &links {
1397 if let Some(when) = &link.when {
1400 if !template::eval_truthy(when, engine, tera_ctx)? {
1401 continue;
1402 }
1403 }
1404 let dst_str = engine.render(&link.dst, tera_ctx)?;
1405 let dst = paths::expand_tilde(dst_str.trim());
1406 if let Some(filename) = &link.src {
1407 let file_src = src_dir.join(filename);
1408 if !file_src.is_file() {
1409 anyhow::bail!(
1410 "marker at {src_dir}: [[link]] src={filename:?} \
1411 not found"
1412 );
1413 }
1414 link_file_with_backup(&file_src, &dst, ctx)?;
1415 } else {
1416 link_dir_with_backup(src_dir, &dst, ctx)?;
1417 emitted_dir_link = true;
1418 }
1419 emitted_any = true;
1420 }
1421 if !emitted_any {
1422 info!(
1427 "marker at {src_dir} had no active links \
1428 — falling back to defaults"
1429 );
1430 }
1431 if emitted_dir_link {
1432 covered = true;
1433 }
1434 }
1435 }
1436 }
1437
1438 for entry in std::fs::read_dir(src_dir)? {
1439 let entry = entry?;
1440 let name_os = entry.file_name();
1441 let Some(name) = name_os.to_str() else {
1442 continue;
1443 };
1444 if name == marker_filename {
1445 continue;
1446 }
1447 if name.ends_with(".tera") {
1448 continue;
1450 }
1451 let src_path = src_dir.join(name);
1452 let dst_path = dst_dir.join(name);
1453 let ft = entry.file_type()?;
1454
1455 if paths::is_ignored(ctx.yuiignore, ctx.source, &src_path, ft.is_dir()) {
1456 continue;
1457 }
1458
1459 if ft.is_dir() {
1460 walk_and_link(
1461 &src_path, &dst_path, ctx, strategy, engine, tera_ctx, covered,
1462 )?;
1463 } else if ft.is_file() {
1464 if !covered {
1470 link_file_with_backup(&src_path, &dst_path, ctx)?;
1471 }
1472 }
1473 }
1474 Ok(())
1475}
1476
1477fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1478 use absorb::AbsorbDecision::*;
1479
1480 let decision = absorb::classify(src, dst)?;
1481
1482 if ctx.dry_run {
1483 info!("[dry-run] {decision:?}: {src} → {dst}");
1484 return Ok(());
1485 }
1486
1487 match decision {
1488 InSync => {
1489 Ok(())
1491 }
1492 Restore => {
1493 info!("link: {src} → {dst}");
1494 link::link_file(src, dst, ctx.file_mode)?;
1495 Ok(())
1496 }
1497 RelinkOnly => {
1498 info!("relink: {src} → {dst}");
1501 link::unlink(dst)?;
1502 link::link_file(src, dst, ctx.file_mode)?;
1503 Ok(())
1504 }
1505 AutoAbsorb => {
1506 if !ctx.config.absorb.auto {
1509 return handle_anomaly(
1510 src,
1511 dst,
1512 ctx,
1513 "absorb.auto = false; treating divergence as anomaly",
1514 );
1515 }
1516 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
1517 return handle_anomaly(
1518 src,
1519 dst,
1520 ctx,
1521 "source repo is dirty; deferring auto-absorb",
1522 );
1523 }
1524 absorb_target_into_source(src, dst, ctx)
1525 }
1526 NeedsConfirm => handle_anomaly(
1527 src,
1528 dst,
1529 ctx,
1530 "anomaly: source equals/newer than target but content differs",
1531 ),
1532 }
1533}
1534
1535fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1539 info!("absorb: {dst} → {src}");
1540 backup_existing(src, ctx.backup_root, false)?;
1541 std::fs::copy(dst, src)?;
1542 link::unlink(dst)?;
1543 link::link_file(src, dst, ctx.file_mode)?;
1544 Ok(())
1545}
1546
1547fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
1553 use crate::config::AnomalyAction::*;
1554 match ctx.config.absorb.on_anomaly {
1555 Skip => {
1556 warn!("anomaly skip: {dst} ({reason})");
1557 Ok(())
1558 }
1559 Force => {
1560 warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
1561 absorb_target_into_source(src, dst, ctx)
1562 }
1563 Ask => {
1564 use std::io::IsTerminal;
1565 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
1566 if prompt_absorb_with_diff(src, dst, reason)? {
1567 absorb_target_into_source(src, dst, ctx)
1568 } else {
1569 warn!("anomaly skipped by user: {dst}");
1570 Ok(())
1571 }
1572 } else {
1573 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
1574 Ok(())
1575 }
1576 }
1577 }
1578}
1579
1580fn prompt_absorb_with_diff(src: &Utf8Path, dst: &Utf8Path, reason: &str) -> Result<bool> {
1581 use std::io::Write as _;
1582 let src_content = std::fs::read_to_string(src).unwrap_or_default();
1583 let dst_content = std::fs::read_to_string(dst).unwrap_or_default();
1584 eprintln!();
1585 eprintln!("anomaly: {reason}");
1586 eprintln!(" src: {src}");
1587 eprintln!(" dst: {dst}");
1588 eprintln!();
1589 eprintln!("--- diff (- source, + target) ---");
1590 let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
1591 for change in diff.iter_all_changes() {
1592 let sign = match change.tag() {
1593 similar::ChangeTag::Delete => "-",
1594 similar::ChangeTag::Insert => "+",
1595 similar::ChangeTag::Equal => " ",
1596 };
1597 eprint!("{sign}{change}");
1598 }
1599 eprintln!();
1600 eprint!("absorb target into source? [y/N]: ");
1601 std::io::stderr().flush().ok();
1606 let mut input = String::new();
1607 std::io::stdin().read_line(&mut input)?;
1608 let answer = input.trim();
1609 Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
1610}
1611
1612fn source_repo_is_clean(source: &Utf8Path) -> bool {
1617 match crate::git::is_clean(source) {
1618 Ok(b) => b,
1619 Err(e) => {
1620 warn!("git clean check failed at {source}: {e} — treating as clean");
1621 true
1622 }
1623 }
1624}
1625
1626fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1627 use absorb::AbsorbDecision::*;
1628 let decision = absorb::classify(src, dst)?;
1629
1630 if ctx.dry_run {
1631 info!("[dry-run] dir {decision:?}: {src} → {dst}");
1632 return Ok(());
1633 }
1634
1635 match decision {
1636 InSync => Ok(()),
1637 Restore => {
1638 info!("link dir: {src} → {dst}");
1639 link::link_dir(src, dst, ctx.dir_mode)?;
1640 Ok(())
1641 }
1642 RelinkOnly => {
1643 info!("relink dir: {src} → {dst}");
1648 remove_dir_link_or_real(dst)?;
1649 link::link_dir(src, dst, ctx.dir_mode)?;
1650 Ok(())
1651 }
1652 AutoAbsorb | NeedsConfirm => {
1653 if !ctx.config.absorb.auto {
1674 return handle_anomaly_dir(
1675 src,
1676 dst,
1677 ctx,
1678 "absorb.auto = false; treating divergence as anomaly",
1679 );
1680 }
1681 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
1682 return handle_anomaly_dir(
1683 src,
1684 dst,
1685 ctx,
1686 "source repo is dirty; deferring auto-absorb",
1687 );
1688 }
1689 absorb_target_dir_into_source(src, dst, ctx)
1690 }
1691 }
1692}
1693
1694fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
1704 if let Err(unlink_err) = link::unlink(dst) {
1705 let meta = std::fs::symlink_metadata(dst)
1706 .with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
1707 let ft = meta.file_type();
1708 if ft.is_dir() && !ft.is_symlink() {
1709 std::fs::remove_dir_all(dst).with_context(|| {
1710 format!(
1711 "remove_dir_all({dst}) after link::unlink failed: \
1712 {unlink_err}"
1713 )
1714 })?;
1715 } else {
1716 return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
1717 }
1718 }
1719 Ok(())
1720}
1721
1722fn merge_dir_target_into_source(
1732 target: &Utf8Path,
1733 source: &Utf8Path,
1734 ctx: &ApplyCtx<'_>,
1735) -> Result<()> {
1736 for entry in std::fs::read_dir(target)? {
1737 let entry = entry?;
1738 let name_os = entry.file_name();
1739 let Some(name) = name_os.to_str() else {
1740 continue;
1741 };
1742 let target_path = target.join(name);
1743 let source_path = source.join(name);
1744 let ft = entry.file_type()?;
1745
1746 if ft.is_dir() && !ft.is_symlink() {
1747 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
1753 let sft = src_meta.file_type();
1754 if !sft.is_dir() || sft.is_symlink() {
1755 link::unlink(&source_path).with_context(|| {
1756 format!("remove conflicting source entry before dir merge: {source_path}")
1757 })?;
1758 }
1759 }
1760 if !source_path.exists() {
1761 std::fs::create_dir_all(&source_path).with_context(|| {
1762 format!("create_dir_all({source_path}) during target→source merge")
1763 })?;
1764 }
1765 merge_dir_target_into_source(&target_path, &source_path, ctx)?;
1766 } else if ft.is_file() {
1767 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
1771 let sft = src_meta.file_type();
1772 if sft.is_dir() && !sft.is_symlink() {
1773 remove_dir_link_or_real(&source_path).with_context(|| {
1774 format!("remove conflicting source dir before file merge: {source_path}")
1775 })?;
1776 } else if sft.is_symlink() {
1777 link::unlink(&source_path).with_context(|| {
1778 format!(
1779 "remove conflicting source symlink before file merge: {source_path}"
1780 )
1781 })?;
1782 }
1783 }
1784 if let Some(parent) = source_path.parent() {
1785 if !parent.exists() {
1786 std::fs::create_dir_all(parent)?;
1787 }
1788 }
1789 if source_path.is_file() {
1803 merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
1804 } else {
1805 std::fs::copy(&target_path, &source_path)
1806 .with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
1807 }
1808 } else {
1809 warn!(
1810 "merge: skipping non-regular entry {target_path} \
1811 (symlink / junction / special — content not copied)"
1812 );
1813 }
1814 }
1815 Ok(())
1816}
1817
1818fn merge_resolve_file_conflict(
1832 target_path: &Utf8Path,
1833 source_path: &Utf8Path,
1834 ctx: &ApplyCtx<'_>,
1835) -> Result<()> {
1836 use absorb::AbsorbDecision::*;
1837 let decision = absorb::classify(source_path, target_path)?;
1838 match decision {
1839 InSync | RelinkOnly => Ok(()),
1840 AutoAbsorb => {
1841 std::fs::copy(target_path, source_path).with_context(|| {
1842 format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
1843 })?;
1844 Ok(())
1845 }
1846 Restore => {
1847 unreachable!(
1854 "merge_resolve_file_conflict reached with both files present, \
1855 but classify returned Restore (target {target_path} / source {source_path})"
1856 )
1857 }
1858 NeedsConfirm => {
1859 use crate::config::AnomalyAction::*;
1860 match ctx.config.absorb.on_anomaly {
1861 Skip => {
1862 warn!(
1863 "merge anomaly skip: {target_path} (source-newer / content drift) \
1864 — keeping source version, target version dropped"
1865 );
1866 Ok(())
1867 }
1868 Force => {
1869 warn!(
1870 "merge anomaly force: {target_path} \
1871 (source-newer / content drift) — overwriting source"
1872 );
1873 std::fs::copy(target_path, source_path)?;
1874 Ok(())
1875 }
1876 Ask => {
1877 use std::io::IsTerminal;
1878 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
1879 if prompt_absorb_with_diff(
1880 source_path,
1881 target_path,
1882 "merge: file content differs and source is newer",
1883 )? {
1884 std::fs::copy(target_path, source_path)?;
1885 } else {
1886 warn!("merge: kept source version by user choice: {source_path}");
1887 }
1888 Ok(())
1889 } else {
1890 warn!(
1891 "merge anomaly skip (non-TTY ask mode): {target_path} \
1892 — keeping source version"
1893 );
1894 Ok(())
1895 }
1896 }
1897 }
1898 }
1899 }
1900}
1901
1902fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1909 info!("absorb dir: {dst} → {src}");
1910 backup_existing(src, ctx.backup_root, true)?;
1911 merge_dir_target_into_source(dst, src, ctx)?;
1912 remove_dir_link_or_real(dst)?;
1915 link::link_dir(src, dst, ctx.dir_mode)?;
1916 Ok(())
1917}
1918
1919fn handle_anomaly_dir(
1923 src: &Utf8Path,
1924 dst: &Utf8Path,
1925 ctx: &ApplyCtx<'_>,
1926 reason: &str,
1927) -> Result<()> {
1928 use crate::config::AnomalyAction::*;
1929 match ctx.config.absorb.on_anomaly {
1930 Skip => {
1931 warn!("anomaly skip dir: {dst} ({reason})");
1932 Ok(())
1933 }
1934 Force => {
1935 warn!(
1936 "anomaly force dir: {dst} ({reason}) \
1937 — absorbing target into source"
1938 );
1939 absorb_target_dir_into_source(src, dst, ctx)
1940 }
1941 Ask => {
1942 use std::io::IsTerminal;
1943 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
1944 eprintln!();
1945 eprintln!("anomaly: {dst}");
1946 eprintln!(" {reason}");
1947 eprintln!(" source: {src}");
1948 eprint!(" absorb target dir into source? (y/N) ");
1949 use std::io::{BufRead as _, Write as _};
1950 std::io::stderr().flush().ok();
1951 let mut buf = String::new();
1952 std::io::stdin().lock().read_line(&mut buf)?;
1953 let answer = buf.trim();
1954 if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
1955 absorb_target_dir_into_source(src, dst, ctx)
1956 } else {
1957 warn!("anomaly skipped by user: {dst}");
1958 Ok(())
1959 }
1960 } else {
1961 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
1962 Ok(())
1963 }
1964 }
1965 }
1966}
1967
1968fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
1969 let abs_target = absolutize(target)?;
1970 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
1971 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
1972 info!("backup → {bp}");
1973 if is_dir {
1974 backup::backup_dir(target, &bp)?;
1975 } else {
1976 backup::backup_file(target, &bp)?;
1977 }
1978 Ok(())
1979}
1980
1981fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
1982 if let Some(s) = source {
1983 return absolutize(&s);
1984 }
1985 if let Ok(s) = std::env::var("YUI_SOURCE") {
1986 return absolutize(Utf8Path::new(&s));
1987 }
1988 let cwd = current_dir_utf8()?;
1989 for ancestor in cwd.ancestors() {
1990 if ancestor.join("config.toml").is_file() {
1991 return Ok(ancestor.to_path_buf());
1992 }
1993 }
1994 if let Some(home) = paths::home_dir() {
1995 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
1996 let p = home.join(c);
1997 if p.join("config.toml").is_file() {
1998 return Ok(p);
1999 }
2000 }
2001 }
2002 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
2003}
2004
2005fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
2006 let expanded = paths::expand_tilde(p.as_str());
2008 if expanded.is_absolute() {
2009 return Ok(expanded);
2010 }
2011 let cwd = current_dir_utf8()?;
2012 Ok(cwd.join(expanded))
2013}
2014
2015fn current_dir_utf8() -> Result<Utf8PathBuf> {
2016 let cwd = std::env::current_dir().context("getting cwd")?;
2017 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
2018}
2019
2020const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
2024
2025[vars]
2026# user-defined values; templates can reference these as {{ vars.foo }}
2027
2028# [link]
2029# file_mode = "auto" # auto | symlink | hardlink
2030# dir_mode = "auto" # auto | symlink | junction
2031
2032[mount]
2033default_strategy = "marker"
2034
2035[[mount.entry]]
2036src = "home"
2037# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
2038dst = "~"
2039
2040# [[mount.entry]]
2041# src = "appdata"
2042# dst = "{{ env(name='APPDATA') }}"
2043# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
2044# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
2045# when = "yui.os == 'windows'"
2046"#;
2047
2048const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
2049# .yui/bin/ is intentionally tracked — it holds your hook scripts.
2050/.yui/state.json
2051/.yui/state.json.tmp
2052/.yui/backup/
2053
2054# >>> yui rendered (auto-managed, do not edit) >>>
2055# <<< yui rendered (auto-managed) <<<
2056
2057# config.local.toml is per-machine; commit a config.local.example.toml instead.
2058config.local.toml
2059"#;
2060
2061#[cfg(test)]
2062mod tests {
2063 use super::*;
2064 use tempfile::TempDir;
2065
2066 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
2067 Utf8PathBuf::from_path_buf(p).unwrap()
2068 }
2069
2070 fn toml_path(p: &Utf8Path) -> String {
2072 p.as_str().replace('\\', "/")
2073 }
2074
2075 #[test]
2076 fn apply_links_a_raw_file() {
2077 let tmp = TempDir::new().unwrap();
2078 let source = utf8(tmp.path().join("dotfiles"));
2079 let target = utf8(tmp.path().join("target"));
2080 std::fs::create_dir_all(source.join("home")).unwrap();
2081 std::fs::create_dir_all(&target).unwrap();
2082 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2083
2084 let cfg = format!(
2085 r#"
2086[[mount.entry]]
2087src = "home"
2088dst = "{}"
2089"#,
2090 toml_path(&target)
2091 );
2092 std::fs::write(source.join("config.toml"), cfg).unwrap();
2093
2094 apply(Some(source), false).unwrap();
2095
2096 let linked = target.join(".bashrc");
2097 assert!(linked.exists(), "expected {linked} to exist");
2098 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
2099 }
2100
2101 #[test]
2102 fn apply_with_marker_links_whole_directory() {
2103 let tmp = TempDir::new().unwrap();
2104 let source = utf8(tmp.path().join("dotfiles"));
2105 let target = utf8(tmp.path().join("target"));
2106 let nvim_src = source.join("home/nvim");
2107 std::fs::create_dir_all(&nvim_src).unwrap();
2108 std::fs::create_dir_all(&target).unwrap();
2109 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
2110 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
2111 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
2112
2113 let cfg = format!(
2114 r#"
2115[[mount.entry]]
2116src = "home"
2117dst = "{}"
2118"#,
2119 toml_path(&target)
2120 );
2121 std::fs::write(source.join("config.toml"), cfg).unwrap();
2122
2123 apply(Some(source.clone()), false).unwrap();
2124
2125 let nvim_dst = target.join("nvim");
2126 assert!(nvim_dst.exists());
2127 assert_eq!(
2128 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
2129 "-- hi\n"
2130 );
2131 }
2135
2136 #[test]
2137 fn apply_dry_run_does_not_write() {
2138 let tmp = TempDir::new().unwrap();
2139 let source = utf8(tmp.path().join("dotfiles"));
2140 let target = utf8(tmp.path().join("target"));
2141 std::fs::create_dir_all(source.join("home")).unwrap();
2142 std::fs::create_dir_all(&target).unwrap();
2143 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
2144
2145 let cfg = format!(
2146 r#"
2147[[mount.entry]]
2148src = "home"
2149dst = "{}"
2150"#,
2151 toml_path(&target)
2152 );
2153 std::fs::write(source.join("config.toml"), cfg).unwrap();
2154
2155 apply(Some(source), true).unwrap();
2156
2157 assert!(!target.join(".bashrc").exists());
2158 }
2159
2160 #[test]
2161 fn apply_renders_templates_then_links_rendered_outputs() {
2162 let tmp = TempDir::new().unwrap();
2163 let source = utf8(tmp.path().join("dotfiles"));
2164 let target = utf8(tmp.path().join("target"));
2165 std::fs::create_dir_all(source.join("home")).unwrap();
2166 std::fs::create_dir_all(&target).unwrap();
2167 std::fs::write(
2168 source.join("home/.gitconfig.tera"),
2169 "[user]\n os = {{ yui.os }}\n",
2170 )
2171 .unwrap();
2172 std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
2173
2174 let cfg = format!(
2175 r#"
2176[[mount.entry]]
2177src = "home"
2178dst = "{}"
2179"#,
2180 toml_path(&target)
2181 );
2182 std::fs::write(source.join("config.toml"), cfg).unwrap();
2183
2184 apply(Some(source.clone()), false).unwrap();
2185
2186 assert!(target.join(".bashrc").exists());
2188 assert!(source.join("home/.gitconfig").exists());
2190 assert!(target.join(".gitconfig").exists());
2191 assert!(!target.join(".gitconfig.tera").exists());
2193 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
2195 assert!(linked.contains("os = "));
2196 }
2197
2198 #[test]
2199 fn apply_marker_override_links_to_custom_dst() {
2200 let tmp = TempDir::new().unwrap();
2201 let source = utf8(tmp.path().join("dotfiles"));
2202 let target_a = utf8(tmp.path().join("target_a"));
2203 let target_b = utf8(tmp.path().join("target_b"));
2204 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2205 std::fs::create_dir_all(&target_a).unwrap();
2206 std::fs::create_dir_all(&target_b).unwrap();
2207 std::fs::write(
2208 source.join("home/.config/nvim/init.lua"),
2209 "-- nvim config\n",
2210 )
2211 .unwrap();
2212
2213 std::fs::write(
2216 source.join("home/.config/nvim/.yuilink"),
2217 format!(
2218 r#"
2219[[link]]
2220dst = "{}/nvim"
2221
2222[[link]]
2223dst = "{}/nvim"
2224when = "{{{{ yui.os == '{}' }}}}"
2225"#,
2226 toml_path(&target_a),
2227 toml_path(&target_b),
2228 std::env::consts::OS
2229 ),
2230 )
2231 .unwrap();
2232
2233 let parent_target = utf8(tmp.path().join("parent_target"));
2234 std::fs::create_dir_all(&parent_target).unwrap();
2235 let cfg = format!(
2236 r#"
2237[[mount.entry]]
2238src = "home"
2239dst = "{}"
2240"#,
2241 toml_path(&parent_target)
2242 );
2243 std::fs::write(source.join("config.toml"), cfg).unwrap();
2244
2245 apply(Some(source.clone()), false).unwrap();
2246
2247 assert!(
2249 target_a.join("nvim/init.lua").exists(),
2250 "target_a/nvim/init.lua should be reachable through the link"
2251 );
2252 assert!(
2253 target_b.join("nvim/init.lua").exists(),
2254 "target_b/nvim/init.lua should be reachable through the link"
2255 );
2256 assert!(
2259 !parent_target.join(".config/nvim").exists(),
2260 "parent mount should have skipped the marker-claimed sub-dir"
2261 );
2262 }
2263
2264 #[test]
2265 fn apply_marker_inactive_link_falls_through_to_default() {
2266 let tmp = TempDir::new().unwrap();
2271 let source = utf8(tmp.path().join("dotfiles"));
2272 let target_inactive = utf8(tmp.path().join("inactive"));
2273 let parent_target = utf8(tmp.path().join("parent"));
2274 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2275 std::fs::create_dir_all(&parent_target).unwrap();
2276 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
2277
2278 std::fs::write(
2280 source.join("home/.config/nvim/.yuilink"),
2281 format!(
2282 r#"
2283[[link]]
2284dst = "{}/nvim"
2285when = "{{{{ yui.os == 'no-such-os' }}}}"
2286"#,
2287 toml_path(&target_inactive)
2288 ),
2289 )
2290 .unwrap();
2291
2292 let cfg = format!(
2293 r#"
2294[[mount.entry]]
2295src = "home"
2296dst = "{}"
2297"#,
2298 toml_path(&parent_target)
2299 );
2300 std::fs::write(source.join("config.toml"), cfg).unwrap();
2301
2302 apply(Some(source.clone()), false).unwrap();
2303
2304 assert!(!target_inactive.join("nvim").exists());
2306 assert!(parent_target.join(".config/nvim/init.lua").exists());
2309 }
2310
2311 #[test]
2312 fn list_shows_mount_entries_and_marker_overrides() {
2313 let tmp = TempDir::new().unwrap();
2314 let source = utf8(tmp.path().join("dotfiles"));
2315 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2316 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
2317 std::fs::write(
2318 source.join("home/.config/nvim/.yuilink"),
2319 r#"
2320[[link]]
2321dst = "/custom/nvim"
2322"#,
2323 )
2324 .unwrap();
2325 std::fs::write(
2326 source.join("config.toml"),
2327 r#"
2328[[mount.entry]]
2329src = "home"
2330dst = "/h"
2331"#,
2332 )
2333 .unwrap();
2334
2335 list(Some(source), false, None, true).unwrap();
2338 }
2339
2340 #[test]
2341 fn status_reports_in_sync_after_apply() {
2342 let tmp = TempDir::new().unwrap();
2343 let source = utf8(tmp.path().join("dotfiles"));
2344 let target = utf8(tmp.path().join("target"));
2345 std::fs::create_dir_all(source.join("home")).unwrap();
2346 std::fs::create_dir_all(&target).unwrap();
2347 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2348 let cfg = format!(
2349 r#"
2350[[mount.entry]]
2351src = "home"
2352dst = "{}"
2353"#,
2354 toml_path(&target)
2355 );
2356 std::fs::write(source.join("config.toml"), cfg).unwrap();
2357 apply(Some(source.clone()), false).unwrap();
2359 status(Some(source), None, true).unwrap();
2361 }
2362
2363 #[test]
2364 fn status_reports_template_drift() {
2365 let tmp = TempDir::new().unwrap();
2366 let source = utf8(tmp.path().join("dotfiles"));
2367 let target = utf8(tmp.path().join("target"));
2368 std::fs::create_dir_all(source.join("home")).unwrap();
2369 std::fs::create_dir_all(&target).unwrap();
2370 std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
2373 std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
2374
2375 let cfg = format!(
2376 r#"
2377[[mount.entry]]
2378src = "home"
2379dst = "{}"
2380"#,
2381 toml_path(&target)
2382 );
2383 std::fs::write(source.join("config.toml"), cfg).unwrap();
2384
2385 let err = status(Some(source), None, true).unwrap_err();
2386 assert!(format!("{err}").contains("diverged"));
2387 }
2388
2389 #[test]
2390 fn status_fails_when_target_missing() {
2391 let tmp = TempDir::new().unwrap();
2392 let source = utf8(tmp.path().join("dotfiles"));
2393 let target = utf8(tmp.path().join("target"));
2394 std::fs::create_dir_all(source.join("home")).unwrap();
2395 std::fs::create_dir_all(&target).unwrap();
2396 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2397 let cfg = format!(
2398 r#"
2399[[mount.entry]]
2400src = "home"
2401dst = "{}"
2402"#,
2403 toml_path(&target)
2404 );
2405 std::fs::write(source.join("config.toml"), cfg).unwrap();
2406 let err = status(Some(source), None, true).unwrap_err();
2408 assert!(format!("{err}").contains("diverged"));
2409 }
2410
2411 #[test]
2412 fn strip_braces_removes_outer_template_braces() {
2413 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
2414 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
2415 assert_eq!(strip_braces(" {{x}} "), "x");
2416 }
2417
2418 #[test]
2419 fn apply_aborts_on_render_drift() {
2420 let tmp = TempDir::new().unwrap();
2421 let source = utf8(tmp.path().join("dotfiles"));
2422 let target = utf8(tmp.path().join("target"));
2423 std::fs::create_dir_all(source.join("home")).unwrap();
2424 std::fs::create_dir_all(&target).unwrap();
2425 std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
2426 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
2427
2428 let cfg = format!(
2429 r#"
2430[[mount.entry]]
2431src = "home"
2432dst = "{}"
2433"#,
2434 toml_path(&target)
2435 );
2436 std::fs::write(source.join("config.toml"), cfg).unwrap();
2437
2438 let err = apply(Some(source.clone()), false).unwrap_err();
2439 assert!(format!("{err}").contains("drift"));
2440 assert_eq!(
2442 std::fs::read_to_string(source.join("home/foo")).unwrap(),
2443 "manually edited"
2444 );
2445 assert!(!target.join("foo").exists());
2447 }
2448
2449 #[test]
2450 fn init_creates_skeleton_when_dir_empty() {
2451 let tmp = TempDir::new().unwrap();
2452 let dir = utf8(tmp.path().join("new_dotfiles"));
2453 init(Some(dir.clone()), false).unwrap();
2454 assert!(dir.join("config.toml").is_file());
2455 assert!(dir.join(".gitignore").is_file());
2456 }
2457
2458 #[test]
2459 fn init_refuses_to_overwrite_existing_config() {
2460 let tmp = TempDir::new().unwrap();
2461 let dir = utf8(tmp.path().join("dotfiles"));
2462 std::fs::create_dir_all(&dir).unwrap();
2463 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
2464 let err = init(Some(dir), false).unwrap_err();
2465 assert!(format!("{err}").contains("already exists"));
2466 }
2467
2468 fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
2471 let source = utf8(tmp.path().join("dotfiles"));
2472 let target = utf8(tmp.path().join("target"));
2473 std::fs::create_dir_all(source.join("home")).unwrap();
2474 std::fs::create_dir_all(&target).unwrap();
2475 let cfg = format!(
2476 r#"
2477[[mount.entry]]
2478src = "home"
2479dst = "{}"
2480"#,
2481 toml_path(&target)
2482 );
2483 std::fs::write(source.join("config.toml"), cfg).unwrap();
2484 (source, target)
2485 }
2486
2487 fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
2488 std::fs::write(path, body).unwrap();
2489 let f = std::fs::OpenOptions::new()
2490 .write(true)
2491 .open(path)
2492 .expect("open writable");
2493 f.set_modified(when).expect("set_modified");
2494 }
2495
2496 #[test]
2497 fn apply_target_newer_absorbs_target_into_source() {
2498 let tmp = TempDir::new().unwrap();
2502 let (source, target) = setup_minimal_dotfiles(&tmp);
2503
2504 let now = std::time::SystemTime::now();
2505 let past = now - std::time::Duration::from_secs(120);
2506 write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
2507 write_with_mtime(&target.join(".bashrc"), "user's edit", now);
2509
2510 apply(Some(source.clone()), false).unwrap();
2511
2512 assert_eq!(
2514 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2515 "user's edit"
2516 );
2517 assert_eq!(
2519 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2520 "user's edit"
2521 );
2522 let backup_root = source.join(".yui/backup");
2524 let mut found_old = false;
2525 for entry in walkdir(&backup_root) {
2526 if let Ok(s) = std::fs::read_to_string(&entry) {
2527 if s == "default from repo" {
2528 found_old = true;
2529 break;
2530 }
2531 }
2532 }
2533 assert!(found_old, "expected backup containing 'default from repo'");
2534 }
2535
2536 #[test]
2537 fn apply_in_sync_target_is_a_no_op() {
2538 let tmp = TempDir::new().unwrap();
2541 let (source, target) = setup_minimal_dotfiles(&tmp);
2542 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2543 apply(Some(source.clone()), false).unwrap();
2544 let backup_root = source.join(".yui/backup");
2545 let backup_count_after_first = walkdir(&backup_root).len();
2546
2547 apply(Some(source.clone()), false).unwrap();
2549 assert_eq!(
2550 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2551 "echo hi\n"
2552 );
2553 let backup_count_after_second = walkdir(&backup_root).len();
2554 assert_eq!(
2555 backup_count_after_first, backup_count_after_second,
2556 "second apply on an in-sync tree should not produce backups"
2557 );
2558 }
2559
2560 #[test]
2561 fn apply_skip_policy_leaves_anomaly_alone() {
2562 let tmp = TempDir::new().unwrap();
2565 let source = utf8(tmp.path().join("dotfiles"));
2566 let target = utf8(tmp.path().join("target"));
2567 std::fs::create_dir_all(source.join("home")).unwrap();
2568 std::fs::create_dir_all(&target).unwrap();
2569 let cfg = format!(
2570 r#"
2571[absorb]
2572on_anomaly = "skip"
2573
2574[[mount.entry]]
2575src = "home"
2576dst = "{}"
2577"#,
2578 toml_path(&target)
2579 );
2580 std::fs::write(source.join("config.toml"), cfg).unwrap();
2581
2582 let now = std::time::SystemTime::now();
2583 let past = now - std::time::Duration::from_secs(120);
2584 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
2585 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
2586
2587 apply(Some(source.clone()), false).unwrap();
2588
2589 assert_eq!(
2591 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2592 "user's edit (older)"
2593 );
2594 assert_eq!(
2596 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2597 "fresh from upstream"
2598 );
2599 }
2600
2601 #[test]
2602 fn apply_force_policy_absorbs_anomaly_anyway() {
2603 let tmp = TempDir::new().unwrap();
2605 let source = utf8(tmp.path().join("dotfiles"));
2606 let target = utf8(tmp.path().join("target"));
2607 std::fs::create_dir_all(source.join("home")).unwrap();
2608 std::fs::create_dir_all(&target).unwrap();
2609 let cfg = format!(
2610 r#"
2611[absorb]
2612on_anomaly = "force"
2613
2614[[mount.entry]]
2615src = "home"
2616dst = "{}"
2617"#,
2618 toml_path(&target)
2619 );
2620 std::fs::write(source.join("config.toml"), cfg).unwrap();
2621
2622 let now = std::time::SystemTime::now();
2623 let past = now - std::time::Duration::from_secs(120);
2624 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
2625 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
2626
2627 apply(Some(source.clone()), false).unwrap();
2628
2629 assert_eq!(
2631 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2632 "user's edit (older)"
2633 );
2634 assert_eq!(
2635 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2636 "user's edit (older)"
2637 );
2638 }
2639
2640 #[test]
2652 fn apply_absorbs_non_empty_target_dir_target_wins() {
2653 let tmp = TempDir::new().unwrap();
2654 let source = utf8(tmp.path().join("dotfiles"));
2655 let target = utf8(tmp.path().join("target"));
2656 std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
2657 std::fs::create_dir_all(target.join(".config/app")).unwrap();
2658 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
2661 std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
2662 std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
2664 std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
2667 std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
2668
2669 let cfg = format!(
2670 r#"
2671[absorb]
2672on_anomaly = "force"
2673
2674[[mount.entry]]
2675src = "home"
2676dst = "{}"
2677"#,
2678 toml_path(&target)
2679 );
2680 std::fs::write(source.join("config.toml"), cfg).unwrap();
2681
2682 apply(Some(source.clone()), false).unwrap();
2684
2685 assert_eq!(
2687 std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
2688 "target side"
2689 );
2690 assert_eq!(
2692 std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
2693 "{}"
2694 );
2695 let backup_root = source.join(".yui/backup");
2698 let mut backup_files: Vec<String> = Vec::new();
2699 for entry in walkdir(&backup_root) {
2700 if let Some(n) = entry.file_name() {
2701 backup_files.push(n.to_string());
2702 }
2703 }
2704 assert!(
2705 backup_files.iter().any(|f| f == "config.toml"),
2706 "expected source's config.toml to land in the backup tree, got {backup_files:?}"
2707 );
2708 assert!(
2710 source.join("home/.config/app/source-only.toml").exists(),
2711 "source-only file should survive a target-wins merge"
2712 );
2713 assert!(
2715 source.join("home/.config/app/state.json").exists(),
2716 "target-only state.json should be merged into source"
2717 );
2718 }
2719
2720 #[test]
2726 fn marker_dir_absorbs_with_default_ask_policy() {
2727 let tmp = TempDir::new().unwrap();
2728 let source = utf8(tmp.path().join("dotfiles"));
2729 let target = utf8(tmp.path().join("target"));
2730 std::fs::create_dir_all(source.join("home/.config")).unwrap();
2731 std::fs::create_dir_all(target.join(".config/gh")).unwrap();
2732 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
2734 std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\n").unwrap();
2736
2737 let cfg = format!(
2741 r#"
2742[[mount.entry]]
2743src = "home"
2744dst = "{}"
2745"#,
2746 toml_path(&target)
2747 );
2748 std::fs::write(source.join("config.toml"), cfg).unwrap();
2749
2750 apply(Some(source.clone()), false).unwrap();
2754
2755 assert!(target.join(".config/gh/hosts.yml").exists());
2758 assert!(source.join("home/.config/gh/hosts.yml").exists());
2759 }
2760
2761 #[test]
2767 fn merge_handles_file_vs_dir_collisions_target_wins() {
2768 let tmp = TempDir::new().unwrap();
2769 let source = utf8(tmp.path().join("dotfiles"));
2770 let target = utf8(tmp.path().join("target"));
2771 std::fs::create_dir_all(source.join("home/.config/foo")).unwrap();
2772 std::fs::create_dir_all(target.join(".config")).unwrap();
2773 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
2774
2775 std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
2777 std::fs::write(target.join(".config/foo"), "target file body").unwrap();
2778 std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
2780 std::fs::create_dir_all(target.join(".config/bar")).unwrap();
2781 std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
2782
2783 let cfg = format!(
2784 r#"
2785[absorb]
2786on_anomaly = "force"
2787
2788[[mount.entry]]
2789src = "home"
2790dst = "{}"
2791"#,
2792 toml_path(&target)
2793 );
2794 std::fs::write(source.join("config.toml"), cfg).unwrap();
2795 apply(Some(source.clone()), false).unwrap();
2796
2797 let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
2801 assert!(foo_meta.file_type().is_file(), "foo should be a file");
2802 assert_eq!(
2803 std::fs::read_to_string(target.join(".config/foo")).unwrap(),
2804 "target file body"
2805 );
2806 let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
2808 assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
2809 assert_eq!(
2810 std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
2811 "target nested"
2812 );
2813 }
2814
2815 #[test]
2819 fn merge_per_file_target_newer_auto_absorbs() {
2820 let tmp = TempDir::new().unwrap();
2821 let source = utf8(tmp.path().join("dotfiles"));
2822 let target = utf8(tmp.path().join("target"));
2823 std::fs::create_dir_all(source.join("home/.config")).unwrap();
2824 std::fs::create_dir_all(target.join(".config")).unwrap();
2825 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
2826
2827 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
2829 write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
2830 std::fs::write(target.join(".config/app.toml"), "user's live edit").unwrap();
2831
2832 let cfg = format!(
2836 r#"
2837[[mount.entry]]
2838src = "home"
2839dst = "{}"
2840"#,
2841 toml_path(&target)
2842 );
2843 std::fs::write(source.join("config.toml"), cfg).unwrap();
2844 apply(Some(source.clone()), false).unwrap();
2845
2846 assert_eq!(
2848 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
2849 "user's live edit"
2850 );
2851 }
2852
2853 #[test]
2859 fn merge_per_file_source_newer_skip_keeps_source() {
2860 let tmp = TempDir::new().unwrap();
2861 let source = utf8(tmp.path().join("dotfiles"));
2862 let target = utf8(tmp.path().join("target"));
2863 std::fs::create_dir_all(source.join("home/.config")).unwrap();
2864 std::fs::create_dir_all(target.join(".config")).unwrap();
2865 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
2866
2867 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
2869 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
2870 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
2871
2872 let cfg = format!(
2873 r#"
2874[absorb]
2875on_anomaly = "skip"
2876
2877[[mount.entry]]
2878src = "home"
2879dst = "{}"
2880"#,
2881 toml_path(&target)
2882 );
2883 std::fs::write(source.join("config.toml"), cfg).unwrap();
2884 apply(Some(source.clone()), false).unwrap();
2885
2886 assert_eq!(
2889 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
2890 "fresh source"
2891 );
2892 }
2893
2894 #[test]
2897 fn merge_per_file_source_newer_force_overwrites_source() {
2898 let tmp = TempDir::new().unwrap();
2899 let source = utf8(tmp.path().join("dotfiles"));
2900 let target = utf8(tmp.path().join("target"));
2901 std::fs::create_dir_all(source.join("home/.config")).unwrap();
2902 std::fs::create_dir_all(target.join(".config")).unwrap();
2903 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
2904
2905 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
2906 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
2907 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
2908
2909 let cfg = format!(
2910 r#"
2911[absorb]
2912on_anomaly = "force"
2913
2914[[mount.entry]]
2915src = "home"
2916dst = "{}"
2917"#,
2918 toml_path(&target)
2919 );
2920 std::fs::write(source.join("config.toml"), cfg).unwrap();
2921 apply(Some(source.clone()), false).unwrap();
2922
2923 assert_eq!(
2925 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
2926 "old target"
2927 );
2928 }
2929
2930 #[test]
2935 fn merge_per_file_identical_content_is_noop() {
2936 let tmp = TempDir::new().unwrap();
2937 let source = utf8(tmp.path().join("dotfiles"));
2938 let target = utf8(tmp.path().join("target"));
2939 std::fs::create_dir_all(source.join("home/.config")).unwrap();
2940 std::fs::create_dir_all(target.join(".config")).unwrap();
2941 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
2942 std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
2943 std::fs::write(target.join(".config/app.toml"), "same").unwrap();
2944
2945 let cfg = format!(
2948 r#"
2949[[mount.entry]]
2950src = "home"
2951dst = "{}"
2952"#,
2953 toml_path(&target)
2954 );
2955 std::fs::write(source.join("config.toml"), cfg).unwrap();
2956 apply(Some(source.clone()), false).unwrap();
2957
2958 assert_eq!(
2959 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
2960 "same"
2961 );
2962 }
2963
2964 #[test]
2965 fn manual_absorb_command_pulls_target_into_source() {
2966 let tmp = TempDir::new().unwrap();
2968 let source = utf8(tmp.path().join("dotfiles"));
2969 let target = utf8(tmp.path().join("target"));
2970 std::fs::create_dir_all(source.join("home")).unwrap();
2971 std::fs::create_dir_all(&target).unwrap();
2972 let cfg = format!(
2974 r#"
2975[absorb]
2976on_anomaly = "skip"
2977
2978[[mount.entry]]
2979src = "home"
2980dst = "{}"
2981"#,
2982 toml_path(&target)
2983 );
2984 std::fs::write(source.join("config.toml"), cfg).unwrap();
2985 std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
2986 std::fs::write(source.join("home/.bashrc"), "default").unwrap();
2987
2988 absorb(
2990 Some(source.clone()),
2991 target.join(".bashrc"),
2992 false,
2993 )
2994 .unwrap();
2995
2996 assert_eq!(
2998 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2999 "user picked this"
3000 );
3001 }
3002
3003 #[test]
3004 fn manual_absorb_errors_when_target_outside_known_mounts() {
3005 let tmp = TempDir::new().unwrap();
3006 let (source, _target) = setup_minimal_dotfiles(&tmp);
3007 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
3008 let stranger = utf8(tmp.path().join("not-managed/foo"));
3009 std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
3010 std::fs::write(&stranger, "not yui's").unwrap();
3011 let err = absorb(Some(source), stranger, false).unwrap_err();
3012 assert!(format!("{err}").contains("no mount entry"));
3013 }
3014
3015 #[test]
3016 fn yuiignore_excludes_file_from_linking() {
3017 let tmp = TempDir::new().unwrap();
3018 let (source, target) = setup_minimal_dotfiles(&tmp);
3019 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
3020 std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
3021 std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
3023 apply(Some(source.clone()), false).unwrap();
3024 assert!(target.join(".bashrc").exists());
3025 assert!(
3026 !target.join("lock.json").exists(),
3027 "yuiignore should keep lock.json out of target"
3028 );
3029 }
3030
3031 #[test]
3032 fn yuiignore_excludes_directory_subtree() {
3033 let tmp = TempDir::new().unwrap();
3034 let (source, target) = setup_minimal_dotfiles(&tmp);
3035 std::fs::create_dir_all(source.join("home/cache")).unwrap();
3036 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
3037 std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
3038 std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
3039 std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
3041 apply(Some(source.clone()), false).unwrap();
3042 assert!(target.join(".bashrc").exists());
3043 assert!(
3044 !target.join("cache").exists(),
3045 "yuiignore'd subtree should not appear in target"
3046 );
3047 }
3048
3049 #[test]
3050 fn yuiignore_negation_re_includes_file() {
3051 let tmp = TempDir::new().unwrap();
3052 let (source, target) = setup_minimal_dotfiles(&tmp);
3053 std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
3054 std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
3055 std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
3057 apply(Some(source.clone()), false).unwrap();
3058 assert!(target.join("keep.cache").exists());
3059 assert!(!target.join("drop.cache").exists());
3060 }
3061
3062 #[test]
3063 fn yuiignore_skips_template_in_render() {
3064 let tmp = TempDir::new().unwrap();
3065 let source = utf8(tmp.path().join("dotfiles"));
3066 let target = utf8(tmp.path().join("target"));
3067 std::fs::create_dir_all(source.join("home")).unwrap();
3068 std::fs::create_dir_all(&target).unwrap();
3069 std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
3070 std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
3071 let cfg = format!(
3072 r#"
3073[[mount.entry]]
3074src = "home"
3075dst = "{}"
3076"#,
3077 toml_path(&target)
3078 );
3079 std::fs::write(source.join("config.toml"), cfg).unwrap();
3080 apply(Some(source.clone()), false).unwrap();
3081 assert!(!source.join("home/note").exists());
3083 assert!(!target.join("note").exists());
3084 assert!(!target.join("note.tera").exists());
3085 }
3086
3087 #[test]
3091 fn nested_marker_accumulates_extra_dst() {
3092 let tmp = TempDir::new().unwrap();
3093 let source = utf8(tmp.path().join("dotfiles"));
3094 let parent_target = utf8(tmp.path().join("home"));
3095 let extra_target = utf8(tmp.path().join("extra"));
3096 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3097 std::fs::create_dir_all(&parent_target).unwrap();
3098 std::fs::create_dir_all(&extra_target).unwrap();
3099 std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
3100
3101 std::fs::write(
3103 source.join("home/.config/.yuilink"),
3104 format!(
3105 r#"
3106[[link]]
3107dst = "{}/.config"
3108"#,
3109 toml_path(&parent_target)
3110 ),
3111 )
3112 .unwrap();
3113 std::fs::write(
3116 source.join("home/.config/nvim/.yuilink"),
3117 format!(
3118 r#"
3119[[link]]
3120dst = "{}/nvim"
3121when = "{{{{ yui.os == '{}' }}}}"
3122"#,
3123 toml_path(&extra_target),
3124 std::env::consts::OS
3125 ),
3126 )
3127 .unwrap();
3128
3129 let cfg = format!(
3130 r#"
3131[[mount.entry]]
3132src = "home"
3133dst = "{}"
3134"#,
3135 toml_path(&parent_target)
3136 );
3137 std::fs::write(source.join("config.toml"), cfg).unwrap();
3138
3139 apply(Some(source.clone()), false).unwrap();
3140
3141 assert!(parent_target.join(".config/nvim/init.lua").exists());
3144 assert!(extra_target.join("nvim/init.lua").exists());
3145 }
3146
3147 #[test]
3152 fn marker_file_link_targets_specific_file() {
3153 let tmp = TempDir::new().unwrap();
3154 let source = utf8(tmp.path().join("dotfiles"));
3155 let parent_target = utf8(tmp.path().join("home"));
3156 let docs_target = utf8(tmp.path().join("docs"));
3157 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
3158 std::fs::create_dir_all(&parent_target).unwrap();
3159 std::fs::create_dir_all(&docs_target).unwrap();
3160 std::fs::write(
3161 source.join("home/.config/powershell/profile.ps1"),
3162 "# profile\n",
3163 )
3164 .unwrap();
3165 std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
3166
3167 std::fs::write(
3170 source.join("home/.config/powershell/.yuilink"),
3171 format!(
3172 r#"
3173[[link]]
3174src = "profile.ps1"
3175dst = "{}/Microsoft.PowerShell_profile.ps1"
3176"#,
3177 toml_path(&docs_target)
3178 ),
3179 )
3180 .unwrap();
3181
3182 let cfg = format!(
3183 r#"
3184[[mount.entry]]
3185src = "home"
3186dst = "{}"
3187"#,
3188 toml_path(&parent_target)
3189 );
3190 std::fs::write(source.join("config.toml"), cfg).unwrap();
3191
3192 apply(Some(source.clone()), false).unwrap();
3193
3194 assert!(
3196 docs_target
3197 .join("Microsoft.PowerShell_profile.ps1")
3198 .exists()
3199 );
3200 assert!(
3203 parent_target
3204 .join(".config/powershell/profile.ps1")
3205 .exists()
3206 );
3207 assert!(parent_target.join(".config/powershell/extra.txt").exists());
3208 }
3209
3210 #[test]
3213 fn marker_file_link_missing_src_errors() {
3214 let tmp = TempDir::new().unwrap();
3215 let source = utf8(tmp.path().join("dotfiles"));
3216 let parent_target = utf8(tmp.path().join("home"));
3217 let docs_target = utf8(tmp.path().join("docs"));
3218 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
3219 std::fs::create_dir_all(&parent_target).unwrap();
3220 std::fs::create_dir_all(&docs_target).unwrap();
3221
3222 std::fs::write(
3223 source.join("home/.config/powershell/.yuilink"),
3224 format!(
3225 r#"
3226[[link]]
3227src = "missing.ps1"
3228dst = "{}/profile.ps1"
3229"#,
3230 toml_path(&docs_target)
3231 ),
3232 )
3233 .unwrap();
3234
3235 let cfg = format!(
3236 r#"
3237[[mount.entry]]
3238src = "home"
3239dst = "{}"
3240"#,
3241 toml_path(&parent_target)
3242 );
3243 std::fs::write(source.join("config.toml"), cfg).unwrap();
3244
3245 let err = apply(Some(source.clone()), false).unwrap_err();
3246 assert!(format!("{err:#}").contains("missing.ps1"));
3247 }
3248
3249 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
3250 let mut out = Vec::new();
3251 let mut stack = vec![root.to_path_buf()];
3252 while let Some(dir) = stack.pop() {
3253 let Ok(entries) = std::fs::read_dir(&dir) else {
3254 continue;
3255 };
3256 for e in entries.flatten() {
3257 let p = utf8(e.path());
3258 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
3259 stack.push(p);
3260 } else {
3261 out.push(p);
3262 }
3263 }
3264 }
3265 out
3266 }
3267}