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 _ => {
1474 backup_existing(dst, ctx.backup_root, true)?;
1480 if let Err(unlink_err) = link::unlink(dst) {
1495 let meta = std::fs::symlink_metadata(dst).with_context(|| {
1496 format!("stat {dst} after link::unlink failed: {unlink_err}")
1497 })?;
1498 let ft = meta.file_type();
1499 if ft.is_dir() && !ft.is_symlink() {
1500 std::fs::remove_dir_all(dst).with_context(|| {
1501 format!(
1502 "remove_dir_all({dst}) after link::unlink failed: \
1503 {unlink_err}"
1504 )
1505 })?;
1506 } else {
1507 return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
1508 }
1509 }
1510 info!("relink dir: {src} → {dst}");
1511 link::link_dir(src, dst, ctx.dir_mode)?;
1512 Ok(())
1513 }
1514 }
1515}
1516
1517fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
1518 let abs_target = absolutize(target)?;
1519 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
1520 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
1521 info!("backup → {bp}");
1522 if is_dir {
1523 backup::backup_dir(target, &bp)?;
1524 } else {
1525 backup::backup_file(target, &bp)?;
1526 }
1527 Ok(())
1528}
1529
1530fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
1531 if let Some(s) = source {
1532 return absolutize(&s);
1533 }
1534 if let Ok(s) = std::env::var("YUI_SOURCE") {
1535 return absolutize(Utf8Path::new(&s));
1536 }
1537 let cwd = current_dir_utf8()?;
1538 for ancestor in cwd.ancestors() {
1539 if ancestor.join("config.toml").is_file() {
1540 return Ok(ancestor.to_path_buf());
1541 }
1542 }
1543 if let Some(home) = paths::home_dir() {
1544 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
1545 let p = home.join(c);
1546 if p.join("config.toml").is_file() {
1547 return Ok(p);
1548 }
1549 }
1550 }
1551 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
1552}
1553
1554fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
1555 let expanded = paths::expand_tilde(p.as_str());
1557 if expanded.is_absolute() {
1558 return Ok(expanded);
1559 }
1560 let cwd = current_dir_utf8()?;
1561 Ok(cwd.join(expanded))
1562}
1563
1564fn current_dir_utf8() -> Result<Utf8PathBuf> {
1565 let cwd = std::env::current_dir().context("getting cwd")?;
1566 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
1567}
1568
1569const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
1573
1574[vars]
1575# user-defined values; templates can reference these as {{ vars.foo }}
1576
1577# [link]
1578# file_mode = "auto" # auto | symlink | hardlink
1579# dir_mode = "auto" # auto | symlink | junction
1580
1581[mount]
1582default_strategy = "marker"
1583
1584[[mount.entry]]
1585src = "home"
1586# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
1587dst = "~"
1588
1589# [[mount.entry]]
1590# src = "appdata"
1591# dst = "{{ env(name='APPDATA') }}"
1592# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
1593# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
1594# when = "yui.os == 'windows'"
1595"#;
1596
1597const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
1598# .yui/bin/ is intentionally tracked — it holds your hook scripts.
1599/.yui/state.json
1600/.yui/state.json.tmp
1601/.yui/backup/
1602
1603# >>> yui rendered (auto-managed, do not edit) >>>
1604# <<< yui rendered (auto-managed) <<<
1605
1606# config.local.toml is per-machine; commit a config.local.example.toml instead.
1607config.local.toml
1608"#;
1609
1610#[cfg(test)]
1611mod tests {
1612 use super::*;
1613 use tempfile::TempDir;
1614
1615 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
1616 Utf8PathBuf::from_path_buf(p).unwrap()
1617 }
1618
1619 fn toml_path(p: &Utf8Path) -> String {
1621 p.as_str().replace('\\', "/")
1622 }
1623
1624 #[test]
1625 fn apply_links_a_raw_file() {
1626 let tmp = TempDir::new().unwrap();
1627 let source = utf8(tmp.path().join("dotfiles"));
1628 let target = utf8(tmp.path().join("target"));
1629 std::fs::create_dir_all(source.join("home")).unwrap();
1630 std::fs::create_dir_all(&target).unwrap();
1631 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
1632
1633 let cfg = format!(
1634 r#"
1635[[mount.entry]]
1636src = "home"
1637dst = "{}"
1638"#,
1639 toml_path(&target)
1640 );
1641 std::fs::write(source.join("config.toml"), cfg).unwrap();
1642
1643 apply(Some(source), false).unwrap();
1644
1645 let linked = target.join(".bashrc");
1646 assert!(linked.exists(), "expected {linked} to exist");
1647 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
1648 }
1649
1650 #[test]
1651 fn apply_with_marker_links_whole_directory() {
1652 let tmp = TempDir::new().unwrap();
1653 let source = utf8(tmp.path().join("dotfiles"));
1654 let target = utf8(tmp.path().join("target"));
1655 let nvim_src = source.join("home/nvim");
1656 std::fs::create_dir_all(&nvim_src).unwrap();
1657 std::fs::create_dir_all(&target).unwrap();
1658 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
1659 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
1660 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
1661
1662 let cfg = format!(
1663 r#"
1664[[mount.entry]]
1665src = "home"
1666dst = "{}"
1667"#,
1668 toml_path(&target)
1669 );
1670 std::fs::write(source.join("config.toml"), cfg).unwrap();
1671
1672 apply(Some(source.clone()), false).unwrap();
1673
1674 let nvim_dst = target.join("nvim");
1675 assert!(nvim_dst.exists());
1676 assert_eq!(
1677 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
1678 "-- hi\n"
1679 );
1680 }
1684
1685 #[test]
1686 fn apply_dry_run_does_not_write() {
1687 let tmp = TempDir::new().unwrap();
1688 let source = utf8(tmp.path().join("dotfiles"));
1689 let target = utf8(tmp.path().join("target"));
1690 std::fs::create_dir_all(source.join("home")).unwrap();
1691 std::fs::create_dir_all(&target).unwrap();
1692 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
1693
1694 let cfg = format!(
1695 r#"
1696[[mount.entry]]
1697src = "home"
1698dst = "{}"
1699"#,
1700 toml_path(&target)
1701 );
1702 std::fs::write(source.join("config.toml"), cfg).unwrap();
1703
1704 apply(Some(source), true).unwrap();
1705
1706 assert!(!target.join(".bashrc").exists());
1707 }
1708
1709 #[test]
1710 fn apply_renders_templates_then_links_rendered_outputs() {
1711 let tmp = TempDir::new().unwrap();
1712 let source = utf8(tmp.path().join("dotfiles"));
1713 let target = utf8(tmp.path().join("target"));
1714 std::fs::create_dir_all(source.join("home")).unwrap();
1715 std::fs::create_dir_all(&target).unwrap();
1716 std::fs::write(
1717 source.join("home/.gitconfig.tera"),
1718 "[user]\n os = {{ yui.os }}\n",
1719 )
1720 .unwrap();
1721 std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
1722
1723 let cfg = format!(
1724 r#"
1725[[mount.entry]]
1726src = "home"
1727dst = "{}"
1728"#,
1729 toml_path(&target)
1730 );
1731 std::fs::write(source.join("config.toml"), cfg).unwrap();
1732
1733 apply(Some(source.clone()), false).unwrap();
1734
1735 assert!(target.join(".bashrc").exists());
1737 assert!(source.join("home/.gitconfig").exists());
1739 assert!(target.join(".gitconfig").exists());
1740 assert!(!target.join(".gitconfig.tera").exists());
1742 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
1744 assert!(linked.contains("os = "));
1745 }
1746
1747 #[test]
1748 fn apply_marker_override_links_to_custom_dst() {
1749 let tmp = TempDir::new().unwrap();
1750 let source = utf8(tmp.path().join("dotfiles"));
1751 let target_a = utf8(tmp.path().join("target_a"));
1752 let target_b = utf8(tmp.path().join("target_b"));
1753 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
1754 std::fs::create_dir_all(&target_a).unwrap();
1755 std::fs::create_dir_all(&target_b).unwrap();
1756 std::fs::write(
1757 source.join("home/.config/nvim/init.lua"),
1758 "-- nvim config\n",
1759 )
1760 .unwrap();
1761
1762 std::fs::write(
1765 source.join("home/.config/nvim/.yuilink"),
1766 format!(
1767 r#"
1768[[link]]
1769dst = "{}/nvim"
1770
1771[[link]]
1772dst = "{}/nvim"
1773when = "{{{{ yui.os == '{}' }}}}"
1774"#,
1775 toml_path(&target_a),
1776 toml_path(&target_b),
1777 std::env::consts::OS
1778 ),
1779 )
1780 .unwrap();
1781
1782 let parent_target = utf8(tmp.path().join("parent_target"));
1783 std::fs::create_dir_all(&parent_target).unwrap();
1784 let cfg = format!(
1785 r#"
1786[[mount.entry]]
1787src = "home"
1788dst = "{}"
1789"#,
1790 toml_path(&parent_target)
1791 );
1792 std::fs::write(source.join("config.toml"), cfg).unwrap();
1793
1794 apply(Some(source.clone()), false).unwrap();
1795
1796 assert!(
1798 target_a.join("nvim/init.lua").exists(),
1799 "target_a/nvim/init.lua should be reachable through the link"
1800 );
1801 assert!(
1802 target_b.join("nvim/init.lua").exists(),
1803 "target_b/nvim/init.lua should be reachable through the link"
1804 );
1805 assert!(
1808 !parent_target.join(".config/nvim").exists(),
1809 "parent mount should have skipped the marker-claimed sub-dir"
1810 );
1811 }
1812
1813 #[test]
1814 fn apply_marker_inactive_link_falls_through_to_default() {
1815 let tmp = TempDir::new().unwrap();
1820 let source = utf8(tmp.path().join("dotfiles"));
1821 let target_inactive = utf8(tmp.path().join("inactive"));
1822 let parent_target = utf8(tmp.path().join("parent"));
1823 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
1824 std::fs::create_dir_all(&parent_target).unwrap();
1825 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
1826
1827 std::fs::write(
1829 source.join("home/.config/nvim/.yuilink"),
1830 format!(
1831 r#"
1832[[link]]
1833dst = "{}/nvim"
1834when = "{{{{ yui.os == 'no-such-os' }}}}"
1835"#,
1836 toml_path(&target_inactive)
1837 ),
1838 )
1839 .unwrap();
1840
1841 let cfg = format!(
1842 r#"
1843[[mount.entry]]
1844src = "home"
1845dst = "{}"
1846"#,
1847 toml_path(&parent_target)
1848 );
1849 std::fs::write(source.join("config.toml"), cfg).unwrap();
1850
1851 apply(Some(source.clone()), false).unwrap();
1852
1853 assert!(!target_inactive.join("nvim").exists());
1855 assert!(parent_target.join(".config/nvim/init.lua").exists());
1858 }
1859
1860 #[test]
1861 fn list_shows_mount_entries_and_marker_overrides() {
1862 let tmp = TempDir::new().unwrap();
1863 let source = utf8(tmp.path().join("dotfiles"));
1864 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
1865 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
1866 std::fs::write(
1867 source.join("home/.config/nvim/.yuilink"),
1868 r#"
1869[[link]]
1870dst = "/custom/nvim"
1871"#,
1872 )
1873 .unwrap();
1874 std::fs::write(
1875 source.join("config.toml"),
1876 r#"
1877[[mount.entry]]
1878src = "home"
1879dst = "/h"
1880"#,
1881 )
1882 .unwrap();
1883
1884 list(Some(source), false, None, true).unwrap();
1887 }
1888
1889 #[test]
1890 fn status_reports_in_sync_after_apply() {
1891 let tmp = TempDir::new().unwrap();
1892 let source = utf8(tmp.path().join("dotfiles"));
1893 let target = utf8(tmp.path().join("target"));
1894 std::fs::create_dir_all(source.join("home")).unwrap();
1895 std::fs::create_dir_all(&target).unwrap();
1896 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
1897 let cfg = format!(
1898 r#"
1899[[mount.entry]]
1900src = "home"
1901dst = "{}"
1902"#,
1903 toml_path(&target)
1904 );
1905 std::fs::write(source.join("config.toml"), cfg).unwrap();
1906 apply(Some(source.clone()), false).unwrap();
1908 status(Some(source), None, true).unwrap();
1910 }
1911
1912 #[test]
1913 fn status_reports_template_drift() {
1914 let tmp = TempDir::new().unwrap();
1915 let source = utf8(tmp.path().join("dotfiles"));
1916 let target = utf8(tmp.path().join("target"));
1917 std::fs::create_dir_all(source.join("home")).unwrap();
1918 std::fs::create_dir_all(&target).unwrap();
1919 std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
1922 std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
1923
1924 let cfg = format!(
1925 r#"
1926[[mount.entry]]
1927src = "home"
1928dst = "{}"
1929"#,
1930 toml_path(&target)
1931 );
1932 std::fs::write(source.join("config.toml"), cfg).unwrap();
1933
1934 let err = status(Some(source), None, true).unwrap_err();
1935 assert!(format!("{err}").contains("diverged"));
1936 }
1937
1938 #[test]
1939 fn status_fails_when_target_missing() {
1940 let tmp = TempDir::new().unwrap();
1941 let source = utf8(tmp.path().join("dotfiles"));
1942 let target = utf8(tmp.path().join("target"));
1943 std::fs::create_dir_all(source.join("home")).unwrap();
1944 std::fs::create_dir_all(&target).unwrap();
1945 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
1946 let cfg = format!(
1947 r#"
1948[[mount.entry]]
1949src = "home"
1950dst = "{}"
1951"#,
1952 toml_path(&target)
1953 );
1954 std::fs::write(source.join("config.toml"), cfg).unwrap();
1955 let err = status(Some(source), None, true).unwrap_err();
1957 assert!(format!("{err}").contains("diverged"));
1958 }
1959
1960 #[test]
1961 fn strip_braces_removes_outer_template_braces() {
1962 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
1963 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
1964 assert_eq!(strip_braces(" {{x}} "), "x");
1965 }
1966
1967 #[test]
1968 fn apply_aborts_on_render_drift() {
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/foo.tera"), "fresh body").unwrap();
1975 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
1976
1977 let cfg = format!(
1978 r#"
1979[[mount.entry]]
1980src = "home"
1981dst = "{}"
1982"#,
1983 toml_path(&target)
1984 );
1985 std::fs::write(source.join("config.toml"), cfg).unwrap();
1986
1987 let err = apply(Some(source.clone()), false).unwrap_err();
1988 assert!(format!("{err}").contains("drift"));
1989 assert_eq!(
1991 std::fs::read_to_string(source.join("home/foo")).unwrap(),
1992 "manually edited"
1993 );
1994 assert!(!target.join("foo").exists());
1996 }
1997
1998 #[test]
1999 fn init_creates_skeleton_when_dir_empty() {
2000 let tmp = TempDir::new().unwrap();
2001 let dir = utf8(tmp.path().join("new_dotfiles"));
2002 init(Some(dir.clone()), false).unwrap();
2003 assert!(dir.join("config.toml").is_file());
2004 assert!(dir.join(".gitignore").is_file());
2005 }
2006
2007 #[test]
2008 fn init_refuses_to_overwrite_existing_config() {
2009 let tmp = TempDir::new().unwrap();
2010 let dir = utf8(tmp.path().join("dotfiles"));
2011 std::fs::create_dir_all(&dir).unwrap();
2012 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
2013 let err = init(Some(dir), false).unwrap_err();
2014 assert!(format!("{err}").contains("already exists"));
2015 }
2016
2017 fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
2020 let source = utf8(tmp.path().join("dotfiles"));
2021 let target = utf8(tmp.path().join("target"));
2022 std::fs::create_dir_all(source.join("home")).unwrap();
2023 std::fs::create_dir_all(&target).unwrap();
2024 let cfg = format!(
2025 r#"
2026[[mount.entry]]
2027src = "home"
2028dst = "{}"
2029"#,
2030 toml_path(&target)
2031 );
2032 std::fs::write(source.join("config.toml"), cfg).unwrap();
2033 (source, target)
2034 }
2035
2036 fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
2037 std::fs::write(path, body).unwrap();
2038 let f = std::fs::OpenOptions::new()
2039 .write(true)
2040 .open(path)
2041 .expect("open writable");
2042 f.set_modified(when).expect("set_modified");
2043 }
2044
2045 #[test]
2046 fn apply_target_newer_absorbs_target_into_source() {
2047 let tmp = TempDir::new().unwrap();
2051 let (source, target) = setup_minimal_dotfiles(&tmp);
2052
2053 let now = std::time::SystemTime::now();
2054 let past = now - std::time::Duration::from_secs(120);
2055 write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
2056 write_with_mtime(&target.join(".bashrc"), "user's edit", now);
2058
2059 apply(Some(source.clone()), false).unwrap();
2060
2061 assert_eq!(
2063 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2064 "user's edit"
2065 );
2066 assert_eq!(
2068 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2069 "user's edit"
2070 );
2071 let backup_root = source.join(".yui/backup");
2073 let mut found_old = false;
2074 for entry in walkdir(&backup_root) {
2075 if let Ok(s) = std::fs::read_to_string(&entry) {
2076 if s == "default from repo" {
2077 found_old = true;
2078 break;
2079 }
2080 }
2081 }
2082 assert!(found_old, "expected backup containing 'default from repo'");
2083 }
2084
2085 #[test]
2086 fn apply_in_sync_target_is_a_no_op() {
2087 let tmp = TempDir::new().unwrap();
2090 let (source, target) = setup_minimal_dotfiles(&tmp);
2091 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2092 apply(Some(source.clone()), false).unwrap();
2093 let backup_root = source.join(".yui/backup");
2094 let backup_count_after_first = walkdir(&backup_root).len();
2095
2096 apply(Some(source.clone()), false).unwrap();
2098 assert_eq!(
2099 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2100 "echo hi\n"
2101 );
2102 let backup_count_after_second = walkdir(&backup_root).len();
2103 assert_eq!(
2104 backup_count_after_first, backup_count_after_second,
2105 "second apply on an in-sync tree should not produce backups"
2106 );
2107 }
2108
2109 #[test]
2110 fn apply_skip_policy_leaves_anomaly_alone() {
2111 let tmp = TempDir::new().unwrap();
2114 let source = utf8(tmp.path().join("dotfiles"));
2115 let target = utf8(tmp.path().join("target"));
2116 std::fs::create_dir_all(source.join("home")).unwrap();
2117 std::fs::create_dir_all(&target).unwrap();
2118 let cfg = format!(
2119 r#"
2120[absorb]
2121on_anomaly = "skip"
2122
2123[[mount.entry]]
2124src = "home"
2125dst = "{}"
2126"#,
2127 toml_path(&target)
2128 );
2129 std::fs::write(source.join("config.toml"), cfg).unwrap();
2130
2131 let now = std::time::SystemTime::now();
2132 let past = now - std::time::Duration::from_secs(120);
2133 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
2134 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
2135
2136 apply(Some(source.clone()), false).unwrap();
2137
2138 assert_eq!(
2140 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2141 "user's edit (older)"
2142 );
2143 assert_eq!(
2145 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2146 "fresh from upstream"
2147 );
2148 }
2149
2150 #[test]
2151 fn apply_force_policy_absorbs_anomaly_anyway() {
2152 let tmp = TempDir::new().unwrap();
2154 let source = utf8(tmp.path().join("dotfiles"));
2155 let target = utf8(tmp.path().join("target"));
2156 std::fs::create_dir_all(source.join("home")).unwrap();
2157 std::fs::create_dir_all(&target).unwrap();
2158 let cfg = format!(
2159 r#"
2160[absorb]
2161on_anomaly = "force"
2162
2163[[mount.entry]]
2164src = "home"
2165dst = "{}"
2166"#,
2167 toml_path(&target)
2168 );
2169 std::fs::write(source.join("config.toml"), cfg).unwrap();
2170
2171 let now = std::time::SystemTime::now();
2172 let past = now - std::time::Duration::from_secs(120);
2173 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
2174 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
2175
2176 apply(Some(source.clone()), false).unwrap();
2177
2178 assert_eq!(
2180 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2181 "user's edit (older)"
2182 );
2183 assert_eq!(
2184 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2185 "user's edit (older)"
2186 );
2187 }
2188
2189 #[test]
2196 fn apply_replaces_non_empty_target_dir_after_backup() {
2197 let tmp = TempDir::new().unwrap();
2198 let source = utf8(tmp.path().join("dotfiles"));
2199 let target = utf8(tmp.path().join("target"));
2200 std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
2201 std::fs::create_dir_all(target.join(".config/app")).unwrap();
2202 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
2205 std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
2206 std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
2209 std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
2210
2211 let cfg = format!(
2212 r#"
2213[absorb]
2214on_anomaly = "force"
2215
2216[[mount.entry]]
2217src = "home"
2218dst = "{}"
2219"#,
2220 toml_path(&target)
2221 );
2222 std::fs::write(source.join("config.toml"), cfg).unwrap();
2223
2224 apply(Some(source.clone()), false).unwrap();
2226
2227 assert_eq!(
2229 std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
2230 "src side"
2231 );
2232 let backup_root = source.join(".yui/backup");
2234 let mut found_backup_state = false;
2235 for entry in walkdir(&backup_root) {
2236 if entry.file_name() == Some("state.json") {
2237 found_backup_state = true;
2238 break;
2239 }
2240 }
2241 assert!(
2242 found_backup_state,
2243 "expected target's state.json to land in the backup tree"
2244 );
2245 }
2246
2247 #[test]
2248 fn manual_absorb_command_pulls_target_into_source() {
2249 let tmp = TempDir::new().unwrap();
2251 let source = utf8(tmp.path().join("dotfiles"));
2252 let target = utf8(tmp.path().join("target"));
2253 std::fs::create_dir_all(source.join("home")).unwrap();
2254 std::fs::create_dir_all(&target).unwrap();
2255 let cfg = format!(
2257 r#"
2258[absorb]
2259on_anomaly = "skip"
2260
2261[[mount.entry]]
2262src = "home"
2263dst = "{}"
2264"#,
2265 toml_path(&target)
2266 );
2267 std::fs::write(source.join("config.toml"), cfg).unwrap();
2268 std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
2269 std::fs::write(source.join("home/.bashrc"), "default").unwrap();
2270
2271 absorb(
2273 Some(source.clone()),
2274 target.join(".bashrc"),
2275 false,
2276 )
2277 .unwrap();
2278
2279 assert_eq!(
2281 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2282 "user picked this"
2283 );
2284 }
2285
2286 #[test]
2287 fn manual_absorb_errors_when_target_outside_known_mounts() {
2288 let tmp = TempDir::new().unwrap();
2289 let (source, _target) = setup_minimal_dotfiles(&tmp);
2290 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
2291 let stranger = utf8(tmp.path().join("not-managed/foo"));
2292 std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
2293 std::fs::write(&stranger, "not yui's").unwrap();
2294 let err = absorb(Some(source), stranger, false).unwrap_err();
2295 assert!(format!("{err}").contains("no mount entry"));
2296 }
2297
2298 #[test]
2299 fn yuiignore_excludes_file_from_linking() {
2300 let tmp = TempDir::new().unwrap();
2301 let (source, target) = setup_minimal_dotfiles(&tmp);
2302 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
2303 std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
2304 std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
2306 apply(Some(source.clone()), false).unwrap();
2307 assert!(target.join(".bashrc").exists());
2308 assert!(
2309 !target.join("lock.json").exists(),
2310 "yuiignore should keep lock.json out of target"
2311 );
2312 }
2313
2314 #[test]
2315 fn yuiignore_excludes_directory_subtree() {
2316 let tmp = TempDir::new().unwrap();
2317 let (source, target) = setup_minimal_dotfiles(&tmp);
2318 std::fs::create_dir_all(source.join("home/cache")).unwrap();
2319 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
2320 std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
2321 std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
2322 std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
2324 apply(Some(source.clone()), false).unwrap();
2325 assert!(target.join(".bashrc").exists());
2326 assert!(
2327 !target.join("cache").exists(),
2328 "yuiignore'd subtree should not appear in target"
2329 );
2330 }
2331
2332 #[test]
2333 fn yuiignore_negation_re_includes_file() {
2334 let tmp = TempDir::new().unwrap();
2335 let (source, target) = setup_minimal_dotfiles(&tmp);
2336 std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
2337 std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
2338 std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
2340 apply(Some(source.clone()), false).unwrap();
2341 assert!(target.join("keep.cache").exists());
2342 assert!(!target.join("drop.cache").exists());
2343 }
2344
2345 #[test]
2346 fn yuiignore_skips_template_in_render() {
2347 let tmp = TempDir::new().unwrap();
2348 let source = utf8(tmp.path().join("dotfiles"));
2349 let target = utf8(tmp.path().join("target"));
2350 std::fs::create_dir_all(source.join("home")).unwrap();
2351 std::fs::create_dir_all(&target).unwrap();
2352 std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
2353 std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
2354 let cfg = format!(
2355 r#"
2356[[mount.entry]]
2357src = "home"
2358dst = "{}"
2359"#,
2360 toml_path(&target)
2361 );
2362 std::fs::write(source.join("config.toml"), cfg).unwrap();
2363 apply(Some(source.clone()), false).unwrap();
2364 assert!(!source.join("home/note").exists());
2366 assert!(!target.join("note").exists());
2367 assert!(!target.join("note.tera").exists());
2368 }
2369
2370 #[test]
2374 fn nested_marker_accumulates_extra_dst() {
2375 let tmp = TempDir::new().unwrap();
2376 let source = utf8(tmp.path().join("dotfiles"));
2377 let parent_target = utf8(tmp.path().join("home"));
2378 let extra_target = utf8(tmp.path().join("extra"));
2379 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2380 std::fs::create_dir_all(&parent_target).unwrap();
2381 std::fs::create_dir_all(&extra_target).unwrap();
2382 std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
2383
2384 std::fs::write(
2386 source.join("home/.config/.yuilink"),
2387 format!(
2388 r#"
2389[[link]]
2390dst = "{}/.config"
2391"#,
2392 toml_path(&parent_target)
2393 ),
2394 )
2395 .unwrap();
2396 std::fs::write(
2399 source.join("home/.config/nvim/.yuilink"),
2400 format!(
2401 r#"
2402[[link]]
2403dst = "{}/nvim"
2404when = "{{{{ yui.os == '{}' }}}}"
2405"#,
2406 toml_path(&extra_target),
2407 std::env::consts::OS
2408 ),
2409 )
2410 .unwrap();
2411
2412 let cfg = format!(
2413 r#"
2414[[mount.entry]]
2415src = "home"
2416dst = "{}"
2417"#,
2418 toml_path(&parent_target)
2419 );
2420 std::fs::write(source.join("config.toml"), cfg).unwrap();
2421
2422 apply(Some(source.clone()), false).unwrap();
2423
2424 assert!(parent_target.join(".config/nvim/init.lua").exists());
2427 assert!(extra_target.join("nvim/init.lua").exists());
2428 }
2429
2430 #[test]
2435 fn marker_file_link_targets_specific_file() {
2436 let tmp = TempDir::new().unwrap();
2437 let source = utf8(tmp.path().join("dotfiles"));
2438 let parent_target = utf8(tmp.path().join("home"));
2439 let docs_target = utf8(tmp.path().join("docs"));
2440 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
2441 std::fs::create_dir_all(&parent_target).unwrap();
2442 std::fs::create_dir_all(&docs_target).unwrap();
2443 std::fs::write(
2444 source.join("home/.config/powershell/profile.ps1"),
2445 "# profile\n",
2446 )
2447 .unwrap();
2448 std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
2449
2450 std::fs::write(
2453 source.join("home/.config/powershell/.yuilink"),
2454 format!(
2455 r#"
2456[[link]]
2457src = "profile.ps1"
2458dst = "{}/Microsoft.PowerShell_profile.ps1"
2459"#,
2460 toml_path(&docs_target)
2461 ),
2462 )
2463 .unwrap();
2464
2465 let cfg = format!(
2466 r#"
2467[[mount.entry]]
2468src = "home"
2469dst = "{}"
2470"#,
2471 toml_path(&parent_target)
2472 );
2473 std::fs::write(source.join("config.toml"), cfg).unwrap();
2474
2475 apply(Some(source.clone()), false).unwrap();
2476
2477 assert!(
2479 docs_target
2480 .join("Microsoft.PowerShell_profile.ps1")
2481 .exists()
2482 );
2483 assert!(
2486 parent_target
2487 .join(".config/powershell/profile.ps1")
2488 .exists()
2489 );
2490 assert!(parent_target.join(".config/powershell/extra.txt").exists());
2491 }
2492
2493 #[test]
2496 fn marker_file_link_missing_src_errors() {
2497 let tmp = TempDir::new().unwrap();
2498 let source = utf8(tmp.path().join("dotfiles"));
2499 let parent_target = utf8(tmp.path().join("home"));
2500 let docs_target = utf8(tmp.path().join("docs"));
2501 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
2502 std::fs::create_dir_all(&parent_target).unwrap();
2503 std::fs::create_dir_all(&docs_target).unwrap();
2504
2505 std::fs::write(
2506 source.join("home/.config/powershell/.yuilink"),
2507 format!(
2508 r#"
2509[[link]]
2510src = "missing.ps1"
2511dst = "{}/profile.ps1"
2512"#,
2513 toml_path(&docs_target)
2514 ),
2515 )
2516 .unwrap();
2517
2518 let cfg = format!(
2519 r#"
2520[[mount.entry]]
2521src = "home"
2522dst = "{}"
2523"#,
2524 toml_path(&parent_target)
2525 );
2526 std::fs::write(source.join("config.toml"), cfg).unwrap();
2527
2528 let err = apply(Some(source.clone()), false).unwrap_err();
2529 assert!(format!("{err:#}").contains("missing.ps1"));
2530 }
2531
2532 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
2533 let mut out = Vec::new();
2534 let mut stack = vec![root.to_path_buf()];
2535 while let Some(dir) = stack.pop() {
2536 let Ok(entries) = std::fs::read_dir(&dir) else {
2537 continue;
2538 };
2539 for e in entries.flatten() {
2540 let p = utf8(e.path());
2541 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
2542 stack.push(p);
2543 } else {
2544 out.push(p);
2545 }
2546 }
2547 }
2548 out
2549 }
2550}