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 link::unlink(dst)?;
1481 info!("relink dir: {src} → {dst}");
1482 link::link_dir(src, dst, ctx.dir_mode)?;
1483 Ok(())
1484 }
1485 }
1486}
1487
1488fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
1489 let abs_target = absolutize(target)?;
1490 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
1491 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
1492 info!("backup → {bp}");
1493 if is_dir {
1494 backup::backup_dir(target, &bp)?;
1495 } else {
1496 backup::backup_file(target, &bp)?;
1497 }
1498 Ok(())
1499}
1500
1501fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
1502 if let Some(s) = source {
1503 return absolutize(&s);
1504 }
1505 if let Ok(s) = std::env::var("YUI_SOURCE") {
1506 return absolutize(Utf8Path::new(&s));
1507 }
1508 let cwd = current_dir_utf8()?;
1509 for ancestor in cwd.ancestors() {
1510 if ancestor.join("config.toml").is_file() {
1511 return Ok(ancestor.to_path_buf());
1512 }
1513 }
1514 if let Some(home) = paths::home_dir() {
1515 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
1516 let p = home.join(c);
1517 if p.join("config.toml").is_file() {
1518 return Ok(p);
1519 }
1520 }
1521 }
1522 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
1523}
1524
1525fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
1526 let expanded = paths::expand_tilde(p.as_str());
1528 if expanded.is_absolute() {
1529 return Ok(expanded);
1530 }
1531 let cwd = current_dir_utf8()?;
1532 Ok(cwd.join(expanded))
1533}
1534
1535fn current_dir_utf8() -> Result<Utf8PathBuf> {
1536 let cwd = std::env::current_dir().context("getting cwd")?;
1537 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
1538}
1539
1540const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
1544
1545[vars]
1546# user-defined values; templates can reference these as {{ vars.foo }}
1547
1548# [link]
1549# file_mode = "auto" # auto | symlink | hardlink
1550# dir_mode = "auto" # auto | symlink | junction
1551
1552[mount]
1553default_strategy = "marker"
1554
1555[[mount.entry]]
1556src = "home"
1557# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
1558dst = "~"
1559
1560# [[mount.entry]]
1561# src = "appdata"
1562# dst = "{{ env(name='APPDATA') }}"
1563# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
1564# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
1565# when = "yui.os == 'windows'"
1566"#;
1567
1568const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
1569# .yui/bin/ is intentionally tracked — it holds your hook scripts.
1570/.yui/state.json
1571/.yui/state.json.tmp
1572/.yui/backup/
1573
1574# >>> yui rendered (auto-managed, do not edit) >>>
1575# <<< yui rendered (auto-managed) <<<
1576
1577# config.local.toml is per-machine; commit a config.local.example.toml instead.
1578config.local.toml
1579"#;
1580
1581#[cfg(test)]
1582mod tests {
1583 use super::*;
1584 use tempfile::TempDir;
1585
1586 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
1587 Utf8PathBuf::from_path_buf(p).unwrap()
1588 }
1589
1590 fn toml_path(p: &Utf8Path) -> String {
1592 p.as_str().replace('\\', "/")
1593 }
1594
1595 #[test]
1596 fn apply_links_a_raw_file() {
1597 let tmp = TempDir::new().unwrap();
1598 let source = utf8(tmp.path().join("dotfiles"));
1599 let target = utf8(tmp.path().join("target"));
1600 std::fs::create_dir_all(source.join("home")).unwrap();
1601 std::fs::create_dir_all(&target).unwrap();
1602 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
1603
1604 let cfg = format!(
1605 r#"
1606[[mount.entry]]
1607src = "home"
1608dst = "{}"
1609"#,
1610 toml_path(&target)
1611 );
1612 std::fs::write(source.join("config.toml"), cfg).unwrap();
1613
1614 apply(Some(source), false).unwrap();
1615
1616 let linked = target.join(".bashrc");
1617 assert!(linked.exists(), "expected {linked} to exist");
1618 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
1619 }
1620
1621 #[test]
1622 fn apply_with_marker_links_whole_directory() {
1623 let tmp = TempDir::new().unwrap();
1624 let source = utf8(tmp.path().join("dotfiles"));
1625 let target = utf8(tmp.path().join("target"));
1626 let nvim_src = source.join("home/nvim");
1627 std::fs::create_dir_all(&nvim_src).unwrap();
1628 std::fs::create_dir_all(&target).unwrap();
1629 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
1630 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
1631 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\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.clone()), false).unwrap();
1644
1645 let nvim_dst = target.join("nvim");
1646 assert!(nvim_dst.exists());
1647 assert_eq!(
1648 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
1649 "-- hi\n"
1650 );
1651 }
1655
1656 #[test]
1657 fn apply_dry_run_does_not_write() {
1658 let tmp = TempDir::new().unwrap();
1659 let source = utf8(tmp.path().join("dotfiles"));
1660 let target = utf8(tmp.path().join("target"));
1661 std::fs::create_dir_all(source.join("home")).unwrap();
1662 std::fs::create_dir_all(&target).unwrap();
1663 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
1664
1665 let cfg = format!(
1666 r#"
1667[[mount.entry]]
1668src = "home"
1669dst = "{}"
1670"#,
1671 toml_path(&target)
1672 );
1673 std::fs::write(source.join("config.toml"), cfg).unwrap();
1674
1675 apply(Some(source), true).unwrap();
1676
1677 assert!(!target.join(".bashrc").exists());
1678 }
1679
1680 #[test]
1681 fn apply_renders_templates_then_links_rendered_outputs() {
1682 let tmp = TempDir::new().unwrap();
1683 let source = utf8(tmp.path().join("dotfiles"));
1684 let target = utf8(tmp.path().join("target"));
1685 std::fs::create_dir_all(source.join("home")).unwrap();
1686 std::fs::create_dir_all(&target).unwrap();
1687 std::fs::write(
1688 source.join("home/.gitconfig.tera"),
1689 "[user]\n os = {{ yui.os }}\n",
1690 )
1691 .unwrap();
1692 std::fs::write(source.join("home/.bashrc"), "raw").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.clone()), false).unwrap();
1705
1706 assert!(target.join(".bashrc").exists());
1708 assert!(source.join("home/.gitconfig").exists());
1710 assert!(target.join(".gitconfig").exists());
1711 assert!(!target.join(".gitconfig.tera").exists());
1713 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
1715 assert!(linked.contains("os = "));
1716 }
1717
1718 #[test]
1719 fn apply_marker_override_links_to_custom_dst() {
1720 let tmp = TempDir::new().unwrap();
1721 let source = utf8(tmp.path().join("dotfiles"));
1722 let target_a = utf8(tmp.path().join("target_a"));
1723 let target_b = utf8(tmp.path().join("target_b"));
1724 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
1725 std::fs::create_dir_all(&target_a).unwrap();
1726 std::fs::create_dir_all(&target_b).unwrap();
1727 std::fs::write(
1728 source.join("home/.config/nvim/init.lua"),
1729 "-- nvim config\n",
1730 )
1731 .unwrap();
1732
1733 std::fs::write(
1736 source.join("home/.config/nvim/.yuilink"),
1737 format!(
1738 r#"
1739[[link]]
1740dst = "{}/nvim"
1741
1742[[link]]
1743dst = "{}/nvim"
1744when = "{{{{ yui.os == '{}' }}}}"
1745"#,
1746 toml_path(&target_a),
1747 toml_path(&target_b),
1748 std::env::consts::OS
1749 ),
1750 )
1751 .unwrap();
1752
1753 let parent_target = utf8(tmp.path().join("parent_target"));
1754 std::fs::create_dir_all(&parent_target).unwrap();
1755 let cfg = format!(
1756 r#"
1757[[mount.entry]]
1758src = "home"
1759dst = "{}"
1760"#,
1761 toml_path(&parent_target)
1762 );
1763 std::fs::write(source.join("config.toml"), cfg).unwrap();
1764
1765 apply(Some(source.clone()), false).unwrap();
1766
1767 assert!(
1769 target_a.join("nvim/init.lua").exists(),
1770 "target_a/nvim/init.lua should be reachable through the link"
1771 );
1772 assert!(
1773 target_b.join("nvim/init.lua").exists(),
1774 "target_b/nvim/init.lua should be reachable through the link"
1775 );
1776 assert!(
1779 !parent_target.join(".config/nvim").exists(),
1780 "parent mount should have skipped the marker-claimed sub-dir"
1781 );
1782 }
1783
1784 #[test]
1785 fn apply_marker_inactive_link_falls_through_to_default() {
1786 let tmp = TempDir::new().unwrap();
1791 let source = utf8(tmp.path().join("dotfiles"));
1792 let target_inactive = utf8(tmp.path().join("inactive"));
1793 let parent_target = utf8(tmp.path().join("parent"));
1794 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
1795 std::fs::create_dir_all(&parent_target).unwrap();
1796 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
1797
1798 std::fs::write(
1800 source.join("home/.config/nvim/.yuilink"),
1801 format!(
1802 r#"
1803[[link]]
1804dst = "{}/nvim"
1805when = "{{{{ yui.os == 'no-such-os' }}}}"
1806"#,
1807 toml_path(&target_inactive)
1808 ),
1809 )
1810 .unwrap();
1811
1812 let cfg = format!(
1813 r#"
1814[[mount.entry]]
1815src = "home"
1816dst = "{}"
1817"#,
1818 toml_path(&parent_target)
1819 );
1820 std::fs::write(source.join("config.toml"), cfg).unwrap();
1821
1822 apply(Some(source.clone()), false).unwrap();
1823
1824 assert!(!target_inactive.join("nvim").exists());
1826 assert!(parent_target.join(".config/nvim/init.lua").exists());
1829 }
1830
1831 #[test]
1832 fn list_shows_mount_entries_and_marker_overrides() {
1833 let tmp = TempDir::new().unwrap();
1834 let source = utf8(tmp.path().join("dotfiles"));
1835 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
1836 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
1837 std::fs::write(
1838 source.join("home/.config/nvim/.yuilink"),
1839 r#"
1840[[link]]
1841dst = "/custom/nvim"
1842"#,
1843 )
1844 .unwrap();
1845 std::fs::write(
1846 source.join("config.toml"),
1847 r#"
1848[[mount.entry]]
1849src = "home"
1850dst = "/h"
1851"#,
1852 )
1853 .unwrap();
1854
1855 list(Some(source), false, None, true).unwrap();
1858 }
1859
1860 #[test]
1861 fn status_reports_in_sync_after_apply() {
1862 let tmp = TempDir::new().unwrap();
1863 let source = utf8(tmp.path().join("dotfiles"));
1864 let target = utf8(tmp.path().join("target"));
1865 std::fs::create_dir_all(source.join("home")).unwrap();
1866 std::fs::create_dir_all(&target).unwrap();
1867 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
1868 let cfg = format!(
1869 r#"
1870[[mount.entry]]
1871src = "home"
1872dst = "{}"
1873"#,
1874 toml_path(&target)
1875 );
1876 std::fs::write(source.join("config.toml"), cfg).unwrap();
1877 apply(Some(source.clone()), false).unwrap();
1879 status(Some(source), None, true).unwrap();
1881 }
1882
1883 #[test]
1884 fn status_reports_template_drift() {
1885 let tmp = TempDir::new().unwrap();
1886 let source = utf8(tmp.path().join("dotfiles"));
1887 let target = utf8(tmp.path().join("target"));
1888 std::fs::create_dir_all(source.join("home")).unwrap();
1889 std::fs::create_dir_all(&target).unwrap();
1890 std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
1893 std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
1894
1895 let cfg = format!(
1896 r#"
1897[[mount.entry]]
1898src = "home"
1899dst = "{}"
1900"#,
1901 toml_path(&target)
1902 );
1903 std::fs::write(source.join("config.toml"), cfg).unwrap();
1904
1905 let err = status(Some(source), None, true).unwrap_err();
1906 assert!(format!("{err}").contains("diverged"));
1907 }
1908
1909 #[test]
1910 fn status_fails_when_target_missing() {
1911 let tmp = TempDir::new().unwrap();
1912 let source = utf8(tmp.path().join("dotfiles"));
1913 let target = utf8(tmp.path().join("target"));
1914 std::fs::create_dir_all(source.join("home")).unwrap();
1915 std::fs::create_dir_all(&target).unwrap();
1916 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
1917 let cfg = format!(
1918 r#"
1919[[mount.entry]]
1920src = "home"
1921dst = "{}"
1922"#,
1923 toml_path(&target)
1924 );
1925 std::fs::write(source.join("config.toml"), cfg).unwrap();
1926 let err = status(Some(source), None, true).unwrap_err();
1928 assert!(format!("{err}").contains("diverged"));
1929 }
1930
1931 #[test]
1932 fn strip_braces_removes_outer_template_braces() {
1933 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
1934 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
1935 assert_eq!(strip_braces(" {{x}} "), "x");
1936 }
1937
1938 #[test]
1939 fn apply_aborts_on_render_drift() {
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/foo.tera"), "fresh body").unwrap();
1946 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
1947
1948 let cfg = format!(
1949 r#"
1950[[mount.entry]]
1951src = "home"
1952dst = "{}"
1953"#,
1954 toml_path(&target)
1955 );
1956 std::fs::write(source.join("config.toml"), cfg).unwrap();
1957
1958 let err = apply(Some(source.clone()), false).unwrap_err();
1959 assert!(format!("{err}").contains("drift"));
1960 assert_eq!(
1962 std::fs::read_to_string(source.join("home/foo")).unwrap(),
1963 "manually edited"
1964 );
1965 assert!(!target.join("foo").exists());
1967 }
1968
1969 #[test]
1970 fn init_creates_skeleton_when_dir_empty() {
1971 let tmp = TempDir::new().unwrap();
1972 let dir = utf8(tmp.path().join("new_dotfiles"));
1973 init(Some(dir.clone()), false).unwrap();
1974 assert!(dir.join("config.toml").is_file());
1975 assert!(dir.join(".gitignore").is_file());
1976 }
1977
1978 #[test]
1979 fn init_refuses_to_overwrite_existing_config() {
1980 let tmp = TempDir::new().unwrap();
1981 let dir = utf8(tmp.path().join("dotfiles"));
1982 std::fs::create_dir_all(&dir).unwrap();
1983 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
1984 let err = init(Some(dir), false).unwrap_err();
1985 assert!(format!("{err}").contains("already exists"));
1986 }
1987
1988 fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
1991 let source = utf8(tmp.path().join("dotfiles"));
1992 let target = utf8(tmp.path().join("target"));
1993 std::fs::create_dir_all(source.join("home")).unwrap();
1994 std::fs::create_dir_all(&target).unwrap();
1995 let cfg = format!(
1996 r#"
1997[[mount.entry]]
1998src = "home"
1999dst = "{}"
2000"#,
2001 toml_path(&target)
2002 );
2003 std::fs::write(source.join("config.toml"), cfg).unwrap();
2004 (source, target)
2005 }
2006
2007 fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
2008 std::fs::write(path, body).unwrap();
2009 let f = std::fs::OpenOptions::new()
2010 .write(true)
2011 .open(path)
2012 .expect("open writable");
2013 f.set_modified(when).expect("set_modified");
2014 }
2015
2016 #[test]
2017 fn apply_target_newer_absorbs_target_into_source() {
2018 let tmp = TempDir::new().unwrap();
2022 let (source, target) = setup_minimal_dotfiles(&tmp);
2023
2024 let now = std::time::SystemTime::now();
2025 let past = now - std::time::Duration::from_secs(120);
2026 write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
2027 write_with_mtime(&target.join(".bashrc"), "user's edit", now);
2029
2030 apply(Some(source.clone()), false).unwrap();
2031
2032 assert_eq!(
2034 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2035 "user's edit"
2036 );
2037 assert_eq!(
2039 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2040 "user's edit"
2041 );
2042 let backup_root = source.join(".yui/backup");
2044 let mut found_old = false;
2045 for entry in walkdir(&backup_root) {
2046 if let Ok(s) = std::fs::read_to_string(&entry) {
2047 if s == "default from repo" {
2048 found_old = true;
2049 break;
2050 }
2051 }
2052 }
2053 assert!(found_old, "expected backup containing 'default from repo'");
2054 }
2055
2056 #[test]
2057 fn apply_in_sync_target_is_a_no_op() {
2058 let tmp = TempDir::new().unwrap();
2061 let (source, target) = setup_minimal_dotfiles(&tmp);
2062 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2063 apply(Some(source.clone()), false).unwrap();
2064 let backup_root = source.join(".yui/backup");
2065 let backup_count_after_first = walkdir(&backup_root).len();
2066
2067 apply(Some(source.clone()), false).unwrap();
2069 assert_eq!(
2070 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2071 "echo hi\n"
2072 );
2073 let backup_count_after_second = walkdir(&backup_root).len();
2074 assert_eq!(
2075 backup_count_after_first, backup_count_after_second,
2076 "second apply on an in-sync tree should not produce backups"
2077 );
2078 }
2079
2080 #[test]
2081 fn apply_skip_policy_leaves_anomaly_alone() {
2082 let tmp = TempDir::new().unwrap();
2085 let source = utf8(tmp.path().join("dotfiles"));
2086 let target = utf8(tmp.path().join("target"));
2087 std::fs::create_dir_all(source.join("home")).unwrap();
2088 std::fs::create_dir_all(&target).unwrap();
2089 let cfg = format!(
2090 r#"
2091[absorb]
2092on_anomaly = "skip"
2093
2094[[mount.entry]]
2095src = "home"
2096dst = "{}"
2097"#,
2098 toml_path(&target)
2099 );
2100 std::fs::write(source.join("config.toml"), cfg).unwrap();
2101
2102 let now = std::time::SystemTime::now();
2103 let past = now - std::time::Duration::from_secs(120);
2104 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
2105 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
2106
2107 apply(Some(source.clone()), false).unwrap();
2108
2109 assert_eq!(
2111 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2112 "user's edit (older)"
2113 );
2114 assert_eq!(
2116 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2117 "fresh from upstream"
2118 );
2119 }
2120
2121 #[test]
2122 fn apply_force_policy_absorbs_anomaly_anyway() {
2123 let tmp = TempDir::new().unwrap();
2125 let source = utf8(tmp.path().join("dotfiles"));
2126 let target = utf8(tmp.path().join("target"));
2127 std::fs::create_dir_all(source.join("home")).unwrap();
2128 std::fs::create_dir_all(&target).unwrap();
2129 let cfg = format!(
2130 r#"
2131[absorb]
2132on_anomaly = "force"
2133
2134[[mount.entry]]
2135src = "home"
2136dst = "{}"
2137"#,
2138 toml_path(&target)
2139 );
2140 std::fs::write(source.join("config.toml"), cfg).unwrap();
2141
2142 let now = std::time::SystemTime::now();
2143 let past = now - std::time::Duration::from_secs(120);
2144 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
2145 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
2146
2147 apply(Some(source.clone()), false).unwrap();
2148
2149 assert_eq!(
2151 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2152 "user's edit (older)"
2153 );
2154 assert_eq!(
2155 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2156 "user's edit (older)"
2157 );
2158 }
2159
2160 #[test]
2161 fn manual_absorb_command_pulls_target_into_source() {
2162 let tmp = TempDir::new().unwrap();
2164 let source = utf8(tmp.path().join("dotfiles"));
2165 let target = utf8(tmp.path().join("target"));
2166 std::fs::create_dir_all(source.join("home")).unwrap();
2167 std::fs::create_dir_all(&target).unwrap();
2168 let cfg = format!(
2170 r#"
2171[absorb]
2172on_anomaly = "skip"
2173
2174[[mount.entry]]
2175src = "home"
2176dst = "{}"
2177"#,
2178 toml_path(&target)
2179 );
2180 std::fs::write(source.join("config.toml"), cfg).unwrap();
2181 std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
2182 std::fs::write(source.join("home/.bashrc"), "default").unwrap();
2183
2184 absorb(
2186 Some(source.clone()),
2187 target.join(".bashrc"),
2188 false,
2189 )
2190 .unwrap();
2191
2192 assert_eq!(
2194 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2195 "user picked this"
2196 );
2197 }
2198
2199 #[test]
2200 fn manual_absorb_errors_when_target_outside_known_mounts() {
2201 let tmp = TempDir::new().unwrap();
2202 let (source, _target) = setup_minimal_dotfiles(&tmp);
2203 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
2204 let stranger = utf8(tmp.path().join("not-managed/foo"));
2205 std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
2206 std::fs::write(&stranger, "not yui's").unwrap();
2207 let err = absorb(Some(source), stranger, false).unwrap_err();
2208 assert!(format!("{err}").contains("no mount entry"));
2209 }
2210
2211 #[test]
2212 fn yuiignore_excludes_file_from_linking() {
2213 let tmp = TempDir::new().unwrap();
2214 let (source, target) = setup_minimal_dotfiles(&tmp);
2215 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
2216 std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
2217 std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
2219 apply(Some(source.clone()), false).unwrap();
2220 assert!(target.join(".bashrc").exists());
2221 assert!(
2222 !target.join("lock.json").exists(),
2223 "yuiignore should keep lock.json out of target"
2224 );
2225 }
2226
2227 #[test]
2228 fn yuiignore_excludes_directory_subtree() {
2229 let tmp = TempDir::new().unwrap();
2230 let (source, target) = setup_minimal_dotfiles(&tmp);
2231 std::fs::create_dir_all(source.join("home/cache")).unwrap();
2232 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
2233 std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
2234 std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
2235 std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
2237 apply(Some(source.clone()), false).unwrap();
2238 assert!(target.join(".bashrc").exists());
2239 assert!(
2240 !target.join("cache").exists(),
2241 "yuiignore'd subtree should not appear in target"
2242 );
2243 }
2244
2245 #[test]
2246 fn yuiignore_negation_re_includes_file() {
2247 let tmp = TempDir::new().unwrap();
2248 let (source, target) = setup_minimal_dotfiles(&tmp);
2249 std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
2250 std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
2251 std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
2253 apply(Some(source.clone()), false).unwrap();
2254 assert!(target.join("keep.cache").exists());
2255 assert!(!target.join("drop.cache").exists());
2256 }
2257
2258 #[test]
2259 fn yuiignore_skips_template_in_render() {
2260 let tmp = TempDir::new().unwrap();
2261 let source = utf8(tmp.path().join("dotfiles"));
2262 let target = utf8(tmp.path().join("target"));
2263 std::fs::create_dir_all(source.join("home")).unwrap();
2264 std::fs::create_dir_all(&target).unwrap();
2265 std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
2266 std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
2267 let cfg = format!(
2268 r#"
2269[[mount.entry]]
2270src = "home"
2271dst = "{}"
2272"#,
2273 toml_path(&target)
2274 );
2275 std::fs::write(source.join("config.toml"), cfg).unwrap();
2276 apply(Some(source.clone()), false).unwrap();
2277 assert!(!source.join("home/note").exists());
2279 assert!(!target.join("note").exists());
2280 assert!(!target.join("note.tera").exists());
2281 }
2282
2283 #[test]
2287 fn nested_marker_accumulates_extra_dst() {
2288 let tmp = TempDir::new().unwrap();
2289 let source = utf8(tmp.path().join("dotfiles"));
2290 let parent_target = utf8(tmp.path().join("home"));
2291 let extra_target = utf8(tmp.path().join("extra"));
2292 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2293 std::fs::create_dir_all(&parent_target).unwrap();
2294 std::fs::create_dir_all(&extra_target).unwrap();
2295 std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
2296
2297 std::fs::write(
2299 source.join("home/.config/.yuilink"),
2300 format!(
2301 r#"
2302[[link]]
2303dst = "{}/.config"
2304"#,
2305 toml_path(&parent_target)
2306 ),
2307 )
2308 .unwrap();
2309 std::fs::write(
2312 source.join("home/.config/nvim/.yuilink"),
2313 format!(
2314 r#"
2315[[link]]
2316dst = "{}/nvim"
2317when = "{{{{ yui.os == '{}' }}}}"
2318"#,
2319 toml_path(&extra_target),
2320 std::env::consts::OS
2321 ),
2322 )
2323 .unwrap();
2324
2325 let cfg = format!(
2326 r#"
2327[[mount.entry]]
2328src = "home"
2329dst = "{}"
2330"#,
2331 toml_path(&parent_target)
2332 );
2333 std::fs::write(source.join("config.toml"), cfg).unwrap();
2334
2335 apply(Some(source.clone()), false).unwrap();
2336
2337 assert!(parent_target.join(".config/nvim/init.lua").exists());
2340 assert!(extra_target.join("nvim/init.lua").exists());
2341 }
2342
2343 #[test]
2348 fn marker_file_link_targets_specific_file() {
2349 let tmp = TempDir::new().unwrap();
2350 let source = utf8(tmp.path().join("dotfiles"));
2351 let parent_target = utf8(tmp.path().join("home"));
2352 let docs_target = utf8(tmp.path().join("docs"));
2353 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
2354 std::fs::create_dir_all(&parent_target).unwrap();
2355 std::fs::create_dir_all(&docs_target).unwrap();
2356 std::fs::write(
2357 source.join("home/.config/powershell/profile.ps1"),
2358 "# profile\n",
2359 )
2360 .unwrap();
2361 std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
2362
2363 std::fs::write(
2366 source.join("home/.config/powershell/.yuilink"),
2367 format!(
2368 r#"
2369[[link]]
2370src = "profile.ps1"
2371dst = "{}/Microsoft.PowerShell_profile.ps1"
2372"#,
2373 toml_path(&docs_target)
2374 ),
2375 )
2376 .unwrap();
2377
2378 let cfg = format!(
2379 r#"
2380[[mount.entry]]
2381src = "home"
2382dst = "{}"
2383"#,
2384 toml_path(&parent_target)
2385 );
2386 std::fs::write(source.join("config.toml"), cfg).unwrap();
2387
2388 apply(Some(source.clone()), false).unwrap();
2389
2390 assert!(
2392 docs_target
2393 .join("Microsoft.PowerShell_profile.ps1")
2394 .exists()
2395 );
2396 assert!(
2399 parent_target
2400 .join(".config/powershell/profile.ps1")
2401 .exists()
2402 );
2403 assert!(parent_target.join(".config/powershell/extra.txt").exists());
2404 }
2405
2406 #[test]
2409 fn marker_file_link_missing_src_errors() {
2410 let tmp = TempDir::new().unwrap();
2411 let source = utf8(tmp.path().join("dotfiles"));
2412 let parent_target = utf8(tmp.path().join("home"));
2413 let docs_target = utf8(tmp.path().join("docs"));
2414 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
2415 std::fs::create_dir_all(&parent_target).unwrap();
2416 std::fs::create_dir_all(&docs_target).unwrap();
2417
2418 std::fs::write(
2419 source.join("home/.config/powershell/.yuilink"),
2420 format!(
2421 r#"
2422[[link]]
2423src = "missing.ps1"
2424dst = "{}/profile.ps1"
2425"#,
2426 toml_path(&docs_target)
2427 ),
2428 )
2429 .unwrap();
2430
2431 let cfg = format!(
2432 r#"
2433[[mount.entry]]
2434src = "home"
2435dst = "{}"
2436"#,
2437 toml_path(&parent_target)
2438 );
2439 std::fs::write(source.join("config.toml"), cfg).unwrap();
2440
2441 let err = apply(Some(source.clone()), false).unwrap_err();
2442 assert!(format!("{err:#}").contains("missing.ps1"));
2443 }
2444
2445 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
2446 let mut out = Vec::new();
2447 let mut stack = vec![root.to_path_buf()];
2448 while let Some(dir) = stack.pop() {
2449 let Ok(entries) = std::fs::read_dir(&dir) else {
2450 continue;
2451 };
2452 for e in entries.flatten() {
2453 let p = utf8(e.path());
2454 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
2455 stack.push(p);
2456 } else {
2457 out.push(p);
2458 }
2459 }
2460 }
2461 out
2462 }
2463}