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