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(
1040 source: Option<Utf8PathBuf>,
1041 icons_override: Option<IconsMode>,
1042 no_color: bool,
1043) -> Result<()> {
1044 use owo_colors::OwoColorize as _;
1045
1046 let resolved_source = resolve_source(source);
1051
1052 let yui = match &resolved_source {
1057 Ok(s) => YuiVars::detect(s),
1058 Err(_) => YuiVars::detect(Utf8Path::new(".")),
1059 };
1060
1061 let cfg_res = match &resolved_source {
1066 Ok(s) => Some(config::load(s, &yui)),
1067 Err(_) => None,
1068 };
1069 let cfg = cfg_res.as_ref().and_then(|r| r.as_ref().ok());
1070 let icons_mode = icons_override
1071 .or_else(|| cfg.map(|c| c.ui.icons))
1072 .unwrap_or_default();
1073 let icons = Icons::for_mode(icons_mode);
1074 let color = !no_color && supports_color_stdout();
1075
1076 let mut probes: Vec<Probe> = Vec::new();
1077
1078 probes.push(Probe::group("identity"));
1080 probes.push(Probe::ok("os/arch", format!("{} / {}", yui.os, yui.arch)));
1081 probes.push(Probe::ok("user@host", format!("{}@{}", yui.user, yui.host)));
1082
1083 probes.push(Probe::group("repo"));
1085 let mut have_source = false;
1086 match &resolved_source {
1087 Ok(s) => {
1088 have_source = true;
1089 probes.push(Probe::ok("source", s.to_string()));
1090 match cfg_res.as_ref().expect("cfg_res set when source is Ok") {
1091 Ok(c) => {
1092 probes.push(Probe::ok(
1093 "config",
1094 format!(
1095 "{} mount{} · {} hook{} · {} render rule{}",
1096 c.mount.entry.len(),
1097 plural(c.mount.entry.len()),
1098 c.hook.len(),
1099 plural(c.hook.len()),
1100 c.render.rule.len(),
1101 plural(c.render.rule.len()),
1102 ),
1103 ));
1104 }
1105 Err(e) => probes.push(Probe::error("config", format!("{e}"))),
1106 }
1107 match crate::git::is_clean(s) {
1111 Ok(true) => probes.push(Probe::ok("git", "clean")),
1112 Ok(false) => probes.push(Probe::warn(
1113 "git",
1114 "uncommitted changes — `[absorb] require_clean_git` will defer auto-absorb",
1115 )),
1116 Err(_) => probes.push(Probe::warn(
1117 "git",
1118 "no git repo (auto-absorb still works; commit history won't track drift)",
1119 )),
1120 }
1121 }
1122 Err(e) => {
1123 probes.push(Probe::error("source", format!("not found — {e}")));
1124 }
1125 }
1126
1127 probes.push(Probe::group("links"));
1129 if cfg!(windows) {
1130 probes.push(Probe::ok(
1131 "default mode",
1132 "files=hardlink, dirs=junction (no admin needed)",
1133 ));
1134 } else {
1135 probes.push(Probe::ok("default mode", "files=symlink, dirs=symlink"));
1136 }
1137
1138 if have_source {
1140 if let (Ok(s), Some(c)) = (&resolved_source, cfg) {
1141 probes.push(Probe::group("hooks"));
1142 if c.hook.is_empty() {
1143 probes.push(Probe::ok("hooks", "(none configured)"));
1144 } else {
1145 let mut missing = 0usize;
1146 for h in &c.hook {
1147 if !s.join(&h.script).is_file() {
1148 missing += 1;
1149 probes.push(Probe::error(
1150 format!("hook[{}]", h.name),
1151 format!("script not found at {}", h.script),
1152 ));
1153 }
1154 }
1155 if missing == 0 {
1156 probes.push(Probe::ok(
1157 "scripts",
1158 format!(
1159 "{} hook{} configured, all scripts present",
1160 c.hook.len(),
1161 plural(c.hook.len())
1162 ),
1163 ));
1164 }
1165 }
1166 }
1167 }
1168
1169 if let Some(home) = paths::home_dir() {
1171 let chezmoi_src = home.join(".local/share/chezmoi");
1172 if chezmoi_src.is_dir() {
1173 probes.push(Probe::group("chezmoi"));
1174 probes.push(Probe::warn(
1175 "legacy source",
1176 format!(
1177 "{chezmoi_src} still exists — yui doesn't use it, safe to archive once your migration has settled"
1178 ),
1179 ));
1180 }
1181 }
1182
1183 println!();
1185 if color {
1186 println!(" {}", "yui doctor".bold().underline());
1187 } else {
1188 println!(" yui doctor");
1189 }
1190 println!();
1191 for probe in &probes {
1192 probe.print(&icons, color);
1193 }
1194
1195 let errors = probes.iter().filter(|p| p.is_error()).count();
1196 let warns = probes.iter().filter(|p| p.is_warn()).count();
1197 let oks = probes.iter().filter(|p| p.is_ok()).count();
1198 println!();
1199 let summary = format!("{oks} ok · {warns} warn · {errors} error");
1200 if color {
1201 if errors > 0 {
1202 println!(" {}", summary.red().bold());
1203 } else if warns > 0 {
1204 println!(" {}", summary.yellow());
1205 } else {
1206 println!(" {}", summary.green());
1207 }
1208 } else {
1209 println!(" {summary}");
1210 }
1211
1212 if errors > 0 {
1213 anyhow::bail!("doctor: {errors} probe(s) failed");
1214 }
1215 Ok(())
1216}
1217
1218#[derive(Debug)]
1219enum Probe {
1220 Group(&'static str),
1222 Ok {
1223 label: String,
1224 detail: String,
1225 },
1226 Warn {
1227 label: String,
1228 detail: String,
1229 },
1230 Error {
1231 label: String,
1232 detail: String,
1233 },
1234}
1235
1236impl Probe {
1237 fn group(label: &'static str) -> Self {
1238 Self::Group(label)
1239 }
1240 fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
1241 Self::Ok {
1242 label: label.into(),
1243 detail: detail.into(),
1244 }
1245 }
1246 fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
1247 Self::Warn {
1248 label: label.into(),
1249 detail: detail.into(),
1250 }
1251 }
1252 fn error(label: impl Into<String>, detail: impl Into<String>) -> Self {
1253 Self::Error {
1254 label: label.into(),
1255 detail: detail.into(),
1256 }
1257 }
1258 fn is_ok(&self) -> bool {
1259 matches!(self, Self::Ok { .. })
1260 }
1261 fn is_warn(&self) -> bool {
1262 matches!(self, Self::Warn { .. })
1263 }
1264 fn is_error(&self) -> bool {
1265 matches!(self, Self::Error { .. })
1266 }
1267 fn print(&self, icons: &Icons, color: bool) {
1268 use owo_colors::OwoColorize as _;
1269 match self {
1270 Self::Group(name) => {
1271 println!();
1272 if color {
1273 println!(" {}", name.cyan().bold());
1274 } else {
1275 println!(" {name}");
1276 }
1277 }
1278 Self::Ok { label, detail } => {
1279 let icon = icons.ok;
1280 let padded = format!("{label:<14}");
1284 if color {
1285 println!(
1286 " {} {} {}",
1287 icon.green(),
1288 padded.bold(),
1289 detail.dimmed()
1290 );
1291 } else {
1292 println!(" {icon} {padded} {detail}");
1293 }
1294 }
1295 Self::Warn { label, detail } => {
1296 let icon = icons.warn;
1297 let padded = format!("{label:<14}");
1298 if color {
1299 println!(
1300 " {} {} {}",
1301 icon.yellow(),
1302 padded.bold().yellow(),
1303 detail
1304 );
1305 } else {
1306 println!(" {icon} {padded} {detail}");
1307 }
1308 }
1309 Self::Error { label, detail } => {
1310 let icon = icons.error;
1311 let padded = format!("{label:<14}");
1312 if color {
1313 println!(
1314 " {} {} {}",
1315 icon.red().bold(),
1316 padded.bold().red(),
1317 detail.red()
1318 );
1319 } else {
1320 println!(" {icon} {padded} {detail}");
1321 }
1322 }
1323 }
1324 }
1325}
1326
1327fn plural(n: usize) -> &'static str {
1328 if n == 1 { "" } else { "s" }
1329}
1330
1331pub fn gc_backup(_source: Option<Utf8PathBuf>, _older_than: Option<String>) -> Result<()> {
1332 todo!("yui gc-backup — clean up old backups")
1333}
1334
1335pub fn hooks_list(
1337 source: Option<Utf8PathBuf>,
1338 icons_override: Option<IconsMode>,
1339 no_color: bool,
1340) -> Result<()> {
1341 let source = resolve_source(source)?;
1342 let yui = YuiVars::detect(&source);
1343 let config = config::load(&source, &yui)?;
1344 let state = hook::State::load(&source)?;
1345
1346 let icons_mode = icons_override.unwrap_or(config.ui.icons);
1347 let icons = Icons::for_mode(icons_mode);
1348 let color = !no_color && supports_color_stdout();
1349
1350 if config.hook.is_empty() {
1351 println!("(no [[hook]] entries in config)");
1352 return Ok(());
1353 }
1354
1355 let mut engine = template::Engine::new();
1359 let tera_ctx = template::template_context(&yui, &config.vars);
1360 let rows: Vec<HookRow> = config
1361 .hook
1362 .iter()
1363 .map(|h| -> Result<HookRow> {
1364 let active = match &h.when {
1368 None => true,
1369 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
1370 };
1371 let last_run_at = state.hooks.get(&h.name).and_then(|s| s.last_run_at.clone());
1372 Ok(HookRow {
1373 name: h.name.clone(),
1374 phase: match h.phase {
1375 HookPhase::Pre => "pre",
1376 HookPhase::Post => "post",
1377 },
1378 when_run: match h.when_run {
1379 config::WhenRun::Once => "once",
1380 config::WhenRun::Onchange => "onchange",
1381 config::WhenRun::Every => "every",
1382 },
1383 last_run_at,
1384 when: h.when.clone(),
1385 active,
1386 })
1387 })
1388 .collect::<Result<Vec<_>>>()?;
1389
1390 print_hooks_table(&rows, icons, color);
1391
1392 let total = rows.len();
1393 let active = rows.iter().filter(|r| r.active).count();
1394 let inactive = total - active;
1395 let ran = rows.iter().filter(|r| r.last_run_at.is_some()).count();
1396 let never = total - ran;
1397 println!();
1398 println!(
1399 " {total} hooks · {active} active · {inactive} inactive · {ran} ran · {never} never run"
1400 );
1401
1402 Ok(())
1403}
1404
1405#[derive(Debug)]
1406struct HookRow {
1407 name: String,
1408 phase: &'static str,
1409 when_run: &'static str,
1410 last_run_at: Option<String>,
1411 when: Option<String>,
1412 active: bool,
1413}
1414
1415fn print_hooks_table(rows: &[HookRow], icons: Icons, color: bool) {
1416 use owo_colors::OwoColorize as _;
1417 use std::fmt::Write as _;
1418
1419 let name_w = rows
1420 .iter()
1421 .map(|r| r.name.chars().count())
1422 .max()
1423 .unwrap_or(0)
1424 .max("NAME".len());
1425 let phase_w = rows
1426 .iter()
1427 .map(|r| r.phase.len())
1428 .max()
1429 .unwrap_or(0)
1430 .max("PHASE".len());
1431 let when_run_w = rows
1432 .iter()
1433 .map(|r| r.when_run.len())
1434 .max()
1435 .unwrap_or(0)
1436 .max("WHEN_RUN".len());
1437 let last_w = rows
1438 .iter()
1439 .map(|r| {
1440 r.last_run_at
1441 .as_deref()
1442 .map(|s| s.chars().count())
1443 .unwrap_or("(never)".len())
1444 })
1445 .max()
1446 .unwrap_or(0)
1447 .max("LAST_RUN".len());
1448 let status_w = "STATUS".len();
1449
1450 let mut header = String::new();
1452 let _ = write!(
1453 &mut header,
1454 " {:<status_w$} {:<name_w$} {:<phase_w$} {:<when_run_w$} {:<last_w$} WHEN",
1455 "STATUS", "NAME", "PHASE", "WHEN_RUN", "LAST_RUN"
1456 );
1457 if color {
1458 println!("{}", header.bold());
1459 } else {
1460 println!("{header}");
1461 }
1462
1463 let bar = |n: usize| icons.sep.to_string().repeat(n);
1465 let sep = format!(
1466 " {} {} {} {} {} {}",
1467 bar(status_w),
1468 bar(name_w),
1469 bar(phase_w),
1470 bar(when_run_w),
1471 bar(last_w),
1472 bar("WHEN".len())
1473 );
1474 if color {
1475 println!("{}", sep.dimmed());
1476 } else {
1477 println!("{sep}");
1478 }
1479
1480 for r in rows {
1482 let (icon, ran) = match (r.active, r.last_run_at.is_some()) {
1487 (false, _) => (icons.inactive, false),
1488 (true, true) => (icons.active, true),
1489 (true, false) => (icons.info, false),
1490 };
1491 let last = r.last_run_at.as_deref().unwrap_or("(never)");
1492 let when_str = r
1493 .when
1494 .as_deref()
1495 .map(strip_braces)
1496 .unwrap_or_else(|| "(always)".to_string());
1497
1498 let cell_status = format!("{icon:<status_w$}");
1499 let cell_name = format!("{:<name_w$}", r.name);
1500 let cell_phase = format!("{:<phase_w$}", r.phase);
1501 let cell_when_run = format!("{:<when_run_w$}", r.when_run);
1502 let cell_last = format!("{last:<last_w$}");
1503
1504 if !color {
1505 println!(
1506 " {cell_status} {cell_name} {cell_phase} {cell_when_run} {cell_last} {when_str}"
1507 );
1508 continue;
1509 }
1510
1511 if !r.active {
1515 println!(
1516 " {} {} {} {} {} {}",
1517 cell_status.dimmed(),
1518 cell_name.dimmed(),
1519 cell_phase.dimmed(),
1520 cell_when_run.dimmed(),
1521 cell_last.dimmed(),
1522 when_str.dimmed()
1523 );
1524 } else if ran {
1525 println!(
1526 " {} {} {} {} {} {}",
1527 cell_status.green(),
1528 cell_name.cyan().bold(),
1529 cell_phase.dimmed(),
1530 cell_when_run.dimmed(),
1531 cell_last.green(),
1532 when_str.dimmed()
1533 );
1534 } else {
1535 println!(
1536 " {} {} {} {} {} {}",
1537 cell_status.yellow(),
1538 cell_name.cyan().bold(),
1539 cell_phase.dimmed(),
1540 cell_when_run.dimmed(),
1541 cell_last.yellow(),
1542 when_str.dimmed()
1543 );
1544 }
1545 }
1546}
1547
1548pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
1552 let source = resolve_source(source)?;
1553 let yui = YuiVars::detect(&source);
1554 let config = config::load(&source, &yui)?;
1555 let mut engine = template::Engine::new();
1556 let tera_ctx = template::template_context(&yui, &config.vars);
1557
1558 let targets: Vec<&config::HookConfig> = match &name {
1559 Some(want) => {
1560 let m = config
1561 .hook
1562 .iter()
1563 .find(|h| &h.name == want)
1564 .ok_or_else(|| {
1565 anyhow::anyhow!(
1566 "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
1567 )
1568 })?;
1569 vec![m]
1570 }
1571 None => config.hook.iter().collect(),
1572 };
1573
1574 let mut state = hook::State::load(&source)?;
1575 for h in targets {
1576 let outcome = hook::run_hook(
1577 h,
1578 &source,
1579 &yui,
1580 &config.vars,
1581 &mut engine,
1582 &tera_ctx,
1583 &mut state,
1584 false,
1585 force,
1586 )?;
1587 let label = match outcome {
1588 HookOutcome::Ran => "ran",
1589 HookOutcome::SkippedOnce => "skipped (once: already ran)",
1590 HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
1591 HookOutcome::SkippedWhenFalse => "skipped (when=false)",
1592 HookOutcome::DryRun => "would run (dry-run)",
1593 };
1594 info!("hook[{}]: {label}", h.name);
1595 if outcome == HookOutcome::Ran {
1596 state.save(&source)?;
1597 }
1598 }
1599 Ok(())
1600}
1601
1602fn process_mount(
1607 source: &Utf8Path,
1608 m: &ResolvedMount,
1609 ctx: &ApplyCtx<'_>,
1610 engine: &mut template::Engine,
1611 tera_ctx: &TeraContext,
1612) -> Result<()> {
1613 let src_root = source.join(&m.src);
1614 if !src_root.is_dir() {
1615 warn!("mount src missing: {src_root}");
1616 return Ok(());
1617 }
1618 walk_and_link(&src_root, &m.dst, ctx, m.strategy, engine, tera_ctx, false)
1619}
1620
1621#[allow(clippy::too_many_arguments)]
1622fn walk_and_link(
1623 src_dir: &Utf8Path,
1624 dst_dir: &Utf8Path,
1625 ctx: &ApplyCtx<'_>,
1626 strategy: MountStrategy,
1627 engine: &mut template::Engine,
1628 tera_ctx: &TeraContext,
1629 parent_covered: bool,
1630) -> Result<()> {
1631 if paths::is_ignored(ctx.yuiignore, ctx.source, src_dir, true) {
1634 return Ok(());
1635 }
1636
1637 let marker_filename = &ctx.config.mount.marker_filename;
1638 let mut covered = parent_covered;
1639
1640 if strategy == MountStrategy::Marker {
1641 match marker::read_spec(src_dir, marker_filename)? {
1642 None => {} Some(MarkerSpec::PassThrough) => {
1644 link_dir_with_backup(src_dir, dst_dir, ctx)?;
1648 covered = true;
1649 }
1650 Some(MarkerSpec::Explicit { links }) => {
1651 let mut emitted_dir_link = false;
1652 let mut emitted_any = false;
1653 for link in &links {
1654 if let Some(when) = &link.when {
1657 if !template::eval_truthy(when, engine, tera_ctx)? {
1658 continue;
1659 }
1660 }
1661 let dst_str = engine.render(&link.dst, tera_ctx)?;
1662 let dst = paths::expand_tilde(dst_str.trim());
1663 if let Some(filename) = &link.src {
1664 let file_src = src_dir.join(filename);
1665 if !file_src.is_file() {
1666 anyhow::bail!(
1667 "marker at {src_dir}: [[link]] src={filename:?} \
1668 not found"
1669 );
1670 }
1671 link_file_with_backup(&file_src, &dst, ctx)?;
1672 } else {
1673 link_dir_with_backup(src_dir, &dst, ctx)?;
1674 emitted_dir_link = true;
1675 }
1676 emitted_any = true;
1677 }
1678 if !emitted_any {
1679 info!(
1684 "marker at {src_dir} had no active links \
1685 — falling back to defaults"
1686 );
1687 }
1688 if emitted_dir_link {
1689 covered = true;
1690 }
1691 }
1692 }
1693 }
1694
1695 for entry in std::fs::read_dir(src_dir)? {
1696 let entry = entry?;
1697 let name_os = entry.file_name();
1698 let Some(name) = name_os.to_str() else {
1699 continue;
1700 };
1701 if name == marker_filename {
1702 continue;
1703 }
1704 if name.ends_with(".tera") {
1705 continue;
1707 }
1708 let src_path = src_dir.join(name);
1709 let dst_path = dst_dir.join(name);
1710 let ft = entry.file_type()?;
1711
1712 if paths::is_ignored(ctx.yuiignore, ctx.source, &src_path, ft.is_dir()) {
1713 continue;
1714 }
1715
1716 if ft.is_dir() {
1717 walk_and_link(
1718 &src_path, &dst_path, ctx, strategy, engine, tera_ctx, covered,
1719 )?;
1720 } else if ft.is_file() {
1721 if !covered {
1727 link_file_with_backup(&src_path, &dst_path, ctx)?;
1728 }
1729 }
1730 }
1731 Ok(())
1732}
1733
1734fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1735 use absorb::AbsorbDecision::*;
1736
1737 let decision = absorb::classify(src, dst)?;
1738
1739 if ctx.dry_run {
1740 info!("[dry-run] {decision:?}: {src} → {dst}");
1741 return Ok(());
1742 }
1743
1744 match decision {
1745 InSync => {
1746 Ok(())
1748 }
1749 Restore => {
1750 info!("link: {src} → {dst}");
1751 link::link_file(src, dst, ctx.file_mode)?;
1752 Ok(())
1753 }
1754 RelinkOnly => {
1755 info!("relink: {src} → {dst}");
1758 link::unlink(dst)?;
1759 link::link_file(src, dst, ctx.file_mode)?;
1760 Ok(())
1761 }
1762 AutoAbsorb => {
1763 if !ctx.config.absorb.auto {
1766 return handle_anomaly(
1767 src,
1768 dst,
1769 ctx,
1770 "absorb.auto = false; treating divergence as anomaly",
1771 );
1772 }
1773 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
1774 return handle_anomaly(
1775 src,
1776 dst,
1777 ctx,
1778 "source repo is dirty; deferring auto-absorb",
1779 );
1780 }
1781 absorb_target_into_source(src, dst, ctx)
1782 }
1783 NeedsConfirm => handle_anomaly(
1784 src,
1785 dst,
1786 ctx,
1787 "anomaly: source equals/newer than target but content differs",
1788 ),
1789 }
1790}
1791
1792fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1796 info!("absorb: {dst} → {src}");
1797 backup_existing(src, ctx.backup_root, false)?;
1798 std::fs::copy(dst, src)?;
1799 link::unlink(dst)?;
1800 link::link_file(src, dst, ctx.file_mode)?;
1801 Ok(())
1802}
1803
1804fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
1810 use crate::config::AnomalyAction::*;
1811 match ctx.config.absorb.on_anomaly {
1812 Skip => {
1813 warn!("anomaly skip: {dst} ({reason})");
1814 Ok(())
1815 }
1816 Force => {
1817 warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
1818 absorb_target_into_source(src, dst, ctx)
1819 }
1820 Ask => {
1821 use std::io::IsTerminal;
1822 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
1823 if prompt_absorb_with_diff(src, dst, reason)? {
1824 absorb_target_into_source(src, dst, ctx)
1825 } else {
1826 warn!("anomaly skipped by user: {dst}");
1827 Ok(())
1828 }
1829 } else {
1830 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
1831 Ok(())
1832 }
1833 }
1834 }
1835}
1836
1837fn prompt_absorb_with_diff(src: &Utf8Path, dst: &Utf8Path, reason: &str) -> Result<bool> {
1838 use std::io::Write as _;
1839 let src_content = std::fs::read_to_string(src).unwrap_or_default();
1840 let dst_content = std::fs::read_to_string(dst).unwrap_or_default();
1841 eprintln!();
1842 eprintln!("anomaly: {reason}");
1843 eprintln!(" src: {src}");
1844 eprintln!(" dst: {dst}");
1845 eprintln!();
1846 eprintln!("--- diff (- source, + target) ---");
1847 let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
1848 for change in diff.iter_all_changes() {
1849 let sign = match change.tag() {
1850 similar::ChangeTag::Delete => "-",
1851 similar::ChangeTag::Insert => "+",
1852 similar::ChangeTag::Equal => " ",
1853 };
1854 eprint!("{sign}{change}");
1855 }
1856 eprintln!();
1857 eprint!("absorb target into source? [y/N]: ");
1858 std::io::stderr().flush().ok();
1863 let mut input = String::new();
1864 std::io::stdin().read_line(&mut input)?;
1865 let answer = input.trim();
1866 Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
1867}
1868
1869fn source_repo_is_clean(source: &Utf8Path) -> bool {
1874 match crate::git::is_clean(source) {
1875 Ok(b) => b,
1876 Err(e) => {
1877 warn!("git clean check failed at {source}: {e} — treating as clean");
1878 true
1879 }
1880 }
1881}
1882
1883fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1884 use absorb::AbsorbDecision::*;
1885 let decision = absorb::classify(src, dst)?;
1886
1887 if ctx.dry_run {
1888 info!("[dry-run] dir {decision:?}: {src} → {dst}");
1889 return Ok(());
1890 }
1891
1892 match decision {
1893 InSync => Ok(()),
1894 Restore => {
1895 info!("link dir: {src} → {dst}");
1896 link::link_dir(src, dst, ctx.dir_mode)?;
1897 Ok(())
1898 }
1899 RelinkOnly => {
1900 info!("relink dir: {src} → {dst}");
1905 remove_dir_link_or_real(dst)?;
1906 link::link_dir(src, dst, ctx.dir_mode)?;
1907 Ok(())
1908 }
1909 AutoAbsorb | NeedsConfirm => {
1910 if !ctx.config.absorb.auto {
1931 return handle_anomaly_dir(
1932 src,
1933 dst,
1934 ctx,
1935 "absorb.auto = false; treating divergence as anomaly",
1936 );
1937 }
1938 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
1939 return handle_anomaly_dir(
1940 src,
1941 dst,
1942 ctx,
1943 "source repo is dirty; deferring auto-absorb",
1944 );
1945 }
1946 absorb_target_dir_into_source(src, dst, ctx)
1947 }
1948 }
1949}
1950
1951fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
1961 if let Err(unlink_err) = link::unlink(dst) {
1962 let meta = std::fs::symlink_metadata(dst)
1963 .with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
1964 let ft = meta.file_type();
1965 if ft.is_dir() && !ft.is_symlink() {
1966 std::fs::remove_dir_all(dst).with_context(|| {
1967 format!(
1968 "remove_dir_all({dst}) after link::unlink failed: \
1969 {unlink_err}"
1970 )
1971 })?;
1972 } else {
1973 return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
1974 }
1975 }
1976 Ok(())
1977}
1978
1979fn merge_dir_target_into_source(
1989 target: &Utf8Path,
1990 source: &Utf8Path,
1991 ctx: &ApplyCtx<'_>,
1992) -> Result<()> {
1993 for entry in std::fs::read_dir(target)? {
1994 let entry = entry?;
1995 let name_os = entry.file_name();
1996 let Some(name) = name_os.to_str() else {
1997 continue;
1998 };
1999 let target_path = target.join(name);
2000 let source_path = source.join(name);
2001 let ft = entry.file_type()?;
2002
2003 if ft.is_dir() && !ft.is_symlink() {
2004 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
2010 let sft = src_meta.file_type();
2011 if !sft.is_dir() || sft.is_symlink() {
2012 link::unlink(&source_path).with_context(|| {
2013 format!("remove conflicting source entry before dir merge: {source_path}")
2014 })?;
2015 }
2016 }
2017 if !source_path.exists() {
2018 std::fs::create_dir_all(&source_path).with_context(|| {
2019 format!("create_dir_all({source_path}) during target→source merge")
2020 })?;
2021 }
2022 merge_dir_target_into_source(&target_path, &source_path, ctx)?;
2023 } else if ft.is_file() {
2024 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
2028 let sft = src_meta.file_type();
2029 if sft.is_dir() && !sft.is_symlink() {
2030 remove_dir_link_or_real(&source_path).with_context(|| {
2031 format!("remove conflicting source dir before file merge: {source_path}")
2032 })?;
2033 } else if sft.is_symlink() {
2034 link::unlink(&source_path).with_context(|| {
2035 format!(
2036 "remove conflicting source symlink before file merge: {source_path}"
2037 )
2038 })?;
2039 }
2040 }
2041 if let Some(parent) = source_path.parent() {
2042 if !parent.exists() {
2043 std::fs::create_dir_all(parent)?;
2044 }
2045 }
2046 if source_path.is_file() {
2060 merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
2061 } else {
2062 std::fs::copy(&target_path, &source_path)
2063 .with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
2064 }
2065 } else {
2066 warn!(
2067 "merge: skipping non-regular entry {target_path} \
2068 (symlink / junction / special — content not copied)"
2069 );
2070 }
2071 }
2072 Ok(())
2073}
2074
2075fn merge_resolve_file_conflict(
2089 target_path: &Utf8Path,
2090 source_path: &Utf8Path,
2091 ctx: &ApplyCtx<'_>,
2092) -> Result<()> {
2093 use absorb::AbsorbDecision::*;
2094 let decision = absorb::classify(source_path, target_path)?;
2095 match decision {
2096 InSync | RelinkOnly => Ok(()),
2097 AutoAbsorb => {
2098 std::fs::copy(target_path, source_path).with_context(|| {
2099 format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
2100 })?;
2101 Ok(())
2102 }
2103 Restore => {
2104 unreachable!(
2111 "merge_resolve_file_conflict reached with both files present, \
2112 but classify returned Restore (target {target_path} / source {source_path})"
2113 )
2114 }
2115 NeedsConfirm => {
2116 use crate::config::AnomalyAction::*;
2117 match ctx.config.absorb.on_anomaly {
2118 Skip => {
2119 warn!(
2120 "merge anomaly skip: {target_path} (source-newer / content drift) \
2121 — keeping source version, target version dropped"
2122 );
2123 Ok(())
2124 }
2125 Force => {
2126 warn!(
2127 "merge anomaly force: {target_path} \
2128 (source-newer / content drift) — overwriting source"
2129 );
2130 std::fs::copy(target_path, source_path)?;
2131 Ok(())
2132 }
2133 Ask => {
2134 use std::io::IsTerminal;
2135 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
2136 if prompt_absorb_with_diff(
2137 source_path,
2138 target_path,
2139 "merge: file content differs and source is newer",
2140 )? {
2141 std::fs::copy(target_path, source_path)?;
2142 } else {
2143 warn!("merge: kept source version by user choice: {source_path}");
2144 }
2145 Ok(())
2146 } else {
2147 warn!(
2148 "merge anomaly skip (non-TTY ask mode): {target_path} \
2149 — keeping source version"
2150 );
2151 Ok(())
2152 }
2153 }
2154 }
2155 }
2156 }
2157}
2158
2159fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2166 info!("absorb dir: {dst} → {src}");
2167 backup_existing(src, ctx.backup_root, true)?;
2168 merge_dir_target_into_source(dst, src, ctx)?;
2169 remove_dir_link_or_real(dst)?;
2172 link::link_dir(src, dst, ctx.dir_mode)?;
2173 Ok(())
2174}
2175
2176fn handle_anomaly_dir(
2180 src: &Utf8Path,
2181 dst: &Utf8Path,
2182 ctx: &ApplyCtx<'_>,
2183 reason: &str,
2184) -> Result<()> {
2185 use crate::config::AnomalyAction::*;
2186 match ctx.config.absorb.on_anomaly {
2187 Skip => {
2188 warn!("anomaly skip dir: {dst} ({reason})");
2189 Ok(())
2190 }
2191 Force => {
2192 warn!(
2193 "anomaly force dir: {dst} ({reason}) \
2194 — absorbing target into source"
2195 );
2196 absorb_target_dir_into_source(src, dst, ctx)
2197 }
2198 Ask => {
2199 use std::io::IsTerminal;
2200 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
2201 eprintln!();
2202 eprintln!("anomaly: {dst}");
2203 eprintln!(" {reason}");
2204 eprintln!(" source: {src}");
2205 eprint!(" absorb target dir into source? (y/N) ");
2206 use std::io::{BufRead as _, Write as _};
2207 std::io::stderr().flush().ok();
2208 let mut buf = String::new();
2209 std::io::stdin().lock().read_line(&mut buf)?;
2210 let answer = buf.trim();
2211 if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
2212 absorb_target_dir_into_source(src, dst, ctx)
2213 } else {
2214 warn!("anomaly skipped by user: {dst}");
2215 Ok(())
2216 }
2217 } else {
2218 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
2219 Ok(())
2220 }
2221 }
2222 }
2223}
2224
2225fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
2226 let abs_target = absolutize(target)?;
2227 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
2228 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
2229 info!("backup → {bp}");
2230 if is_dir {
2231 backup::backup_dir(target, &bp)?;
2232 } else {
2233 backup::backup_file(target, &bp)?;
2234 }
2235 Ok(())
2236}
2237
2238fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
2239 if let Some(s) = source {
2240 return absolutize(&s);
2241 }
2242 if let Ok(s) = std::env::var("YUI_SOURCE") {
2243 return absolutize(Utf8Path::new(&s));
2244 }
2245 let cwd = current_dir_utf8()?;
2246 for ancestor in cwd.ancestors() {
2247 if ancestor.join("config.toml").is_file() {
2248 return Ok(ancestor.to_path_buf());
2249 }
2250 }
2251 if let Some(home) = paths::home_dir() {
2252 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
2253 let p = home.join(c);
2254 if p.join("config.toml").is_file() {
2255 return Ok(p);
2256 }
2257 }
2258 }
2259 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
2260}
2261
2262fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
2263 let expanded = paths::expand_tilde(p.as_str());
2265 if expanded.is_absolute() {
2266 return Ok(expanded);
2267 }
2268 let cwd = current_dir_utf8()?;
2269 Ok(cwd.join(expanded))
2270}
2271
2272fn current_dir_utf8() -> Result<Utf8PathBuf> {
2273 let cwd = std::env::current_dir().context("getting cwd")?;
2274 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
2275}
2276
2277const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
2281
2282[vars]
2283# user-defined values; templates can reference these as {{ vars.foo }}
2284
2285# [link]
2286# file_mode = "auto" # auto | symlink | hardlink
2287# dir_mode = "auto" # auto | symlink | junction
2288
2289[mount]
2290default_strategy = "marker"
2291
2292[[mount.entry]]
2293src = "home"
2294# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
2295dst = "~"
2296
2297# [[mount.entry]]
2298# src = "appdata"
2299# dst = "{{ env(name='APPDATA') }}"
2300# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
2301# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
2302# when = "yui.os == 'windows'"
2303"#;
2304
2305const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
2306# .yui/bin/ is intentionally tracked — it holds your hook scripts.
2307/.yui/state.json
2308/.yui/state.json.tmp
2309/.yui/backup/
2310
2311# >>> yui rendered (auto-managed, do not edit) >>>
2312# <<< yui rendered (auto-managed) <<<
2313
2314# config.local.toml is per-machine; commit a config.local.example.toml instead.
2315config.local.toml
2316"#;
2317
2318#[cfg(test)]
2319mod tests {
2320 use super::*;
2321 use tempfile::TempDir;
2322
2323 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
2324 Utf8PathBuf::from_path_buf(p).unwrap()
2325 }
2326
2327 fn toml_path(p: &Utf8Path) -> String {
2329 p.as_str().replace('\\', "/")
2330 }
2331
2332 #[test]
2333 fn apply_links_a_raw_file() {
2334 let tmp = TempDir::new().unwrap();
2335 let source = utf8(tmp.path().join("dotfiles"));
2336 let target = utf8(tmp.path().join("target"));
2337 std::fs::create_dir_all(source.join("home")).unwrap();
2338 std::fs::create_dir_all(&target).unwrap();
2339 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2340
2341 let cfg = format!(
2342 r#"
2343[[mount.entry]]
2344src = "home"
2345dst = "{}"
2346"#,
2347 toml_path(&target)
2348 );
2349 std::fs::write(source.join("config.toml"), cfg).unwrap();
2350
2351 apply(Some(source), false).unwrap();
2352
2353 let linked = target.join(".bashrc");
2354 assert!(linked.exists(), "expected {linked} to exist");
2355 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
2356 }
2357
2358 #[test]
2359 fn apply_with_marker_links_whole_directory() {
2360 let tmp = TempDir::new().unwrap();
2361 let source = utf8(tmp.path().join("dotfiles"));
2362 let target = utf8(tmp.path().join("target"));
2363 let nvim_src = source.join("home/nvim");
2364 std::fs::create_dir_all(&nvim_src).unwrap();
2365 std::fs::create_dir_all(&target).unwrap();
2366 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
2367 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
2368 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
2369
2370 let cfg = format!(
2371 r#"
2372[[mount.entry]]
2373src = "home"
2374dst = "{}"
2375"#,
2376 toml_path(&target)
2377 );
2378 std::fs::write(source.join("config.toml"), cfg).unwrap();
2379
2380 apply(Some(source.clone()), false).unwrap();
2381
2382 let nvim_dst = target.join("nvim");
2383 assert!(nvim_dst.exists());
2384 assert_eq!(
2385 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
2386 "-- hi\n"
2387 );
2388 }
2392
2393 #[test]
2394 fn apply_dry_run_does_not_write() {
2395 let tmp = TempDir::new().unwrap();
2396 let source = utf8(tmp.path().join("dotfiles"));
2397 let target = utf8(tmp.path().join("target"));
2398 std::fs::create_dir_all(source.join("home")).unwrap();
2399 std::fs::create_dir_all(&target).unwrap();
2400 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
2401
2402 let cfg = format!(
2403 r#"
2404[[mount.entry]]
2405src = "home"
2406dst = "{}"
2407"#,
2408 toml_path(&target)
2409 );
2410 std::fs::write(source.join("config.toml"), cfg).unwrap();
2411
2412 apply(Some(source), true).unwrap();
2413
2414 assert!(!target.join(".bashrc").exists());
2415 }
2416
2417 #[test]
2418 fn apply_renders_templates_then_links_rendered_outputs() {
2419 let tmp = TempDir::new().unwrap();
2420 let source = utf8(tmp.path().join("dotfiles"));
2421 let target = utf8(tmp.path().join("target"));
2422 std::fs::create_dir_all(source.join("home")).unwrap();
2423 std::fs::create_dir_all(&target).unwrap();
2424 std::fs::write(
2425 source.join("home/.gitconfig.tera"),
2426 "[user]\n os = {{ yui.os }}\n",
2427 )
2428 .unwrap();
2429 std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
2430
2431 let cfg = format!(
2432 r#"
2433[[mount.entry]]
2434src = "home"
2435dst = "{}"
2436"#,
2437 toml_path(&target)
2438 );
2439 std::fs::write(source.join("config.toml"), cfg).unwrap();
2440
2441 apply(Some(source.clone()), false).unwrap();
2442
2443 assert!(target.join(".bashrc").exists());
2445 assert!(source.join("home/.gitconfig").exists());
2447 assert!(target.join(".gitconfig").exists());
2448 assert!(!target.join(".gitconfig.tera").exists());
2450 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
2452 assert!(linked.contains("os = "));
2453 }
2454
2455 #[test]
2456 fn apply_marker_override_links_to_custom_dst() {
2457 let tmp = TempDir::new().unwrap();
2458 let source = utf8(tmp.path().join("dotfiles"));
2459 let target_a = utf8(tmp.path().join("target_a"));
2460 let target_b = utf8(tmp.path().join("target_b"));
2461 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2462 std::fs::create_dir_all(&target_a).unwrap();
2463 std::fs::create_dir_all(&target_b).unwrap();
2464 std::fs::write(
2465 source.join("home/.config/nvim/init.lua"),
2466 "-- nvim config\n",
2467 )
2468 .unwrap();
2469
2470 std::fs::write(
2473 source.join("home/.config/nvim/.yuilink"),
2474 format!(
2475 r#"
2476[[link]]
2477dst = "{}/nvim"
2478
2479[[link]]
2480dst = "{}/nvim"
2481when = "{{{{ yui.os == '{}' }}}}"
2482"#,
2483 toml_path(&target_a),
2484 toml_path(&target_b),
2485 std::env::consts::OS
2486 ),
2487 )
2488 .unwrap();
2489
2490 let parent_target = utf8(tmp.path().join("parent_target"));
2491 std::fs::create_dir_all(&parent_target).unwrap();
2492 let cfg = format!(
2493 r#"
2494[[mount.entry]]
2495src = "home"
2496dst = "{}"
2497"#,
2498 toml_path(&parent_target)
2499 );
2500 std::fs::write(source.join("config.toml"), cfg).unwrap();
2501
2502 apply(Some(source.clone()), false).unwrap();
2503
2504 assert!(
2506 target_a.join("nvim/init.lua").exists(),
2507 "target_a/nvim/init.lua should be reachable through the link"
2508 );
2509 assert!(
2510 target_b.join("nvim/init.lua").exists(),
2511 "target_b/nvim/init.lua should be reachable through the link"
2512 );
2513 assert!(
2516 !parent_target.join(".config/nvim").exists(),
2517 "parent mount should have skipped the marker-claimed sub-dir"
2518 );
2519 }
2520
2521 #[test]
2522 fn apply_marker_inactive_link_falls_through_to_default() {
2523 let tmp = TempDir::new().unwrap();
2528 let source = utf8(tmp.path().join("dotfiles"));
2529 let target_inactive = utf8(tmp.path().join("inactive"));
2530 let parent_target = utf8(tmp.path().join("parent"));
2531 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2532 std::fs::create_dir_all(&parent_target).unwrap();
2533 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
2534
2535 std::fs::write(
2537 source.join("home/.config/nvim/.yuilink"),
2538 format!(
2539 r#"
2540[[link]]
2541dst = "{}/nvim"
2542when = "{{{{ yui.os == 'no-such-os' }}}}"
2543"#,
2544 toml_path(&target_inactive)
2545 ),
2546 )
2547 .unwrap();
2548
2549 let cfg = format!(
2550 r#"
2551[[mount.entry]]
2552src = "home"
2553dst = "{}"
2554"#,
2555 toml_path(&parent_target)
2556 );
2557 std::fs::write(source.join("config.toml"), cfg).unwrap();
2558
2559 apply(Some(source.clone()), false).unwrap();
2560
2561 assert!(!target_inactive.join("nvim").exists());
2563 assert!(parent_target.join(".config/nvim/init.lua").exists());
2566 }
2567
2568 #[test]
2569 fn list_shows_mount_entries_and_marker_overrides() {
2570 let tmp = TempDir::new().unwrap();
2571 let source = utf8(tmp.path().join("dotfiles"));
2572 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2573 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
2574 std::fs::write(
2575 source.join("home/.config/nvim/.yuilink"),
2576 r#"
2577[[link]]
2578dst = "/custom/nvim"
2579"#,
2580 )
2581 .unwrap();
2582 std::fs::write(
2583 source.join("config.toml"),
2584 r#"
2585[[mount.entry]]
2586src = "home"
2587dst = "/h"
2588"#,
2589 )
2590 .unwrap();
2591
2592 list(Some(source), false, None, true).unwrap();
2595 }
2596
2597 #[test]
2598 fn status_reports_in_sync_after_apply() {
2599 let tmp = TempDir::new().unwrap();
2600 let source = utf8(tmp.path().join("dotfiles"));
2601 let target = utf8(tmp.path().join("target"));
2602 std::fs::create_dir_all(source.join("home")).unwrap();
2603 std::fs::create_dir_all(&target).unwrap();
2604 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2605 let cfg = format!(
2606 r#"
2607[[mount.entry]]
2608src = "home"
2609dst = "{}"
2610"#,
2611 toml_path(&target)
2612 );
2613 std::fs::write(source.join("config.toml"), cfg).unwrap();
2614 apply(Some(source.clone()), false).unwrap();
2616 status(Some(source), None, true).unwrap();
2618 }
2619
2620 #[test]
2621 fn status_reports_template_drift() {
2622 let tmp = TempDir::new().unwrap();
2623 let source = utf8(tmp.path().join("dotfiles"));
2624 let target = utf8(tmp.path().join("target"));
2625 std::fs::create_dir_all(source.join("home")).unwrap();
2626 std::fs::create_dir_all(&target).unwrap();
2627 std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
2630 std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
2631
2632 let cfg = format!(
2633 r#"
2634[[mount.entry]]
2635src = "home"
2636dst = "{}"
2637"#,
2638 toml_path(&target)
2639 );
2640 std::fs::write(source.join("config.toml"), cfg).unwrap();
2641
2642 let err = status(Some(source), None, true).unwrap_err();
2643 assert!(format!("{err}").contains("diverged"));
2644 }
2645
2646 #[test]
2647 fn status_fails_when_target_missing() {
2648 let tmp = TempDir::new().unwrap();
2649 let source = utf8(tmp.path().join("dotfiles"));
2650 let target = utf8(tmp.path().join("target"));
2651 std::fs::create_dir_all(source.join("home")).unwrap();
2652 std::fs::create_dir_all(&target).unwrap();
2653 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2654 let cfg = format!(
2655 r#"
2656[[mount.entry]]
2657src = "home"
2658dst = "{}"
2659"#,
2660 toml_path(&target)
2661 );
2662 std::fs::write(source.join("config.toml"), cfg).unwrap();
2663 let err = status(Some(source), None, true).unwrap_err();
2665 assert!(format!("{err}").contains("diverged"));
2666 }
2667
2668 #[test]
2669 fn strip_braces_removes_outer_template_braces() {
2670 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
2671 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
2672 assert_eq!(strip_braces(" {{x}} "), "x");
2673 }
2674
2675 #[test]
2676 fn apply_aborts_on_render_drift() {
2677 let tmp = TempDir::new().unwrap();
2678 let source = utf8(tmp.path().join("dotfiles"));
2679 let target = utf8(tmp.path().join("target"));
2680 std::fs::create_dir_all(source.join("home")).unwrap();
2681 std::fs::create_dir_all(&target).unwrap();
2682 std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
2683 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
2684
2685 let cfg = format!(
2686 r#"
2687[[mount.entry]]
2688src = "home"
2689dst = "{}"
2690"#,
2691 toml_path(&target)
2692 );
2693 std::fs::write(source.join("config.toml"), cfg).unwrap();
2694
2695 let err = apply(Some(source.clone()), false).unwrap_err();
2696 assert!(format!("{err}").contains("drift"));
2697 assert_eq!(
2699 std::fs::read_to_string(source.join("home/foo")).unwrap(),
2700 "manually edited"
2701 );
2702 assert!(!target.join("foo").exists());
2704 }
2705
2706 #[test]
2707 fn init_creates_skeleton_when_dir_empty() {
2708 let tmp = TempDir::new().unwrap();
2709 let dir = utf8(tmp.path().join("new_dotfiles"));
2710 init(Some(dir.clone()), false).unwrap();
2711 assert!(dir.join("config.toml").is_file());
2712 assert!(dir.join(".gitignore").is_file());
2713 }
2714
2715 #[test]
2716 fn init_refuses_to_overwrite_existing_config() {
2717 let tmp = TempDir::new().unwrap();
2718 let dir = utf8(tmp.path().join("dotfiles"));
2719 std::fs::create_dir_all(&dir).unwrap();
2720 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
2721 let err = init(Some(dir), false).unwrap_err();
2722 assert!(format!("{err}").contains("already exists"));
2723 }
2724
2725 fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
2728 let source = utf8(tmp.path().join("dotfiles"));
2729 let target = utf8(tmp.path().join("target"));
2730 std::fs::create_dir_all(source.join("home")).unwrap();
2731 std::fs::create_dir_all(&target).unwrap();
2732 let cfg = format!(
2733 r#"
2734[[mount.entry]]
2735src = "home"
2736dst = "{}"
2737"#,
2738 toml_path(&target)
2739 );
2740 std::fs::write(source.join("config.toml"), cfg).unwrap();
2741 (source, target)
2742 }
2743
2744 fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
2745 std::fs::write(path, body).unwrap();
2746 let f = std::fs::OpenOptions::new()
2747 .write(true)
2748 .open(path)
2749 .expect("open writable");
2750 f.set_modified(when).expect("set_modified");
2751 }
2752
2753 #[test]
2754 fn apply_target_newer_absorbs_target_into_source() {
2755 let tmp = TempDir::new().unwrap();
2759 let (source, target) = setup_minimal_dotfiles(&tmp);
2760
2761 let now = std::time::SystemTime::now();
2762 let past = now - std::time::Duration::from_secs(120);
2763 write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
2764 write_with_mtime(&target.join(".bashrc"), "user's edit", now);
2766
2767 apply(Some(source.clone()), false).unwrap();
2768
2769 assert_eq!(
2771 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2772 "user's edit"
2773 );
2774 assert_eq!(
2776 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2777 "user's edit"
2778 );
2779 let backup_root = source.join(".yui/backup");
2781 let mut found_old = false;
2782 for entry in walkdir(&backup_root) {
2783 if let Ok(s) = std::fs::read_to_string(&entry) {
2784 if s == "default from repo" {
2785 found_old = true;
2786 break;
2787 }
2788 }
2789 }
2790 assert!(found_old, "expected backup containing 'default from repo'");
2791 }
2792
2793 #[test]
2794 fn apply_in_sync_target_is_a_no_op() {
2795 let tmp = TempDir::new().unwrap();
2798 let (source, target) = setup_minimal_dotfiles(&tmp);
2799 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2800 apply(Some(source.clone()), false).unwrap();
2801 let backup_root = source.join(".yui/backup");
2802 let backup_count_after_first = walkdir(&backup_root).len();
2803
2804 apply(Some(source.clone()), false).unwrap();
2806 assert_eq!(
2807 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2808 "echo hi\n"
2809 );
2810 let backup_count_after_second = walkdir(&backup_root).len();
2811 assert_eq!(
2812 backup_count_after_first, backup_count_after_second,
2813 "second apply on an in-sync tree should not produce backups"
2814 );
2815 }
2816
2817 #[test]
2818 fn apply_skip_policy_leaves_anomaly_alone() {
2819 let tmp = TempDir::new().unwrap();
2822 let source = utf8(tmp.path().join("dotfiles"));
2823 let target = utf8(tmp.path().join("target"));
2824 std::fs::create_dir_all(source.join("home")).unwrap();
2825 std::fs::create_dir_all(&target).unwrap();
2826 let cfg = format!(
2827 r#"
2828[absorb]
2829on_anomaly = "skip"
2830
2831[[mount.entry]]
2832src = "home"
2833dst = "{}"
2834"#,
2835 toml_path(&target)
2836 );
2837 std::fs::write(source.join("config.toml"), cfg).unwrap();
2838
2839 let now = std::time::SystemTime::now();
2840 let past = now - std::time::Duration::from_secs(120);
2841 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
2842 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
2843
2844 apply(Some(source.clone()), false).unwrap();
2845
2846 assert_eq!(
2848 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2849 "user's edit (older)"
2850 );
2851 assert_eq!(
2853 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2854 "fresh from upstream"
2855 );
2856 }
2857
2858 #[test]
2859 fn apply_force_policy_absorbs_anomaly_anyway() {
2860 let tmp = TempDir::new().unwrap();
2862 let source = utf8(tmp.path().join("dotfiles"));
2863 let target = utf8(tmp.path().join("target"));
2864 std::fs::create_dir_all(source.join("home")).unwrap();
2865 std::fs::create_dir_all(&target).unwrap();
2866 let cfg = format!(
2867 r#"
2868[absorb]
2869on_anomaly = "force"
2870
2871[[mount.entry]]
2872src = "home"
2873dst = "{}"
2874"#,
2875 toml_path(&target)
2876 );
2877 std::fs::write(source.join("config.toml"), cfg).unwrap();
2878
2879 let now = std::time::SystemTime::now();
2880 let past = now - std::time::Duration::from_secs(120);
2881 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
2882 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
2883
2884 apply(Some(source.clone()), false).unwrap();
2885
2886 assert_eq!(
2888 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2889 "user's edit (older)"
2890 );
2891 assert_eq!(
2892 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2893 "user's edit (older)"
2894 );
2895 }
2896
2897 #[test]
2909 fn apply_absorbs_non_empty_target_dir_target_wins() {
2910 let tmp = TempDir::new().unwrap();
2911 let source = utf8(tmp.path().join("dotfiles"));
2912 let target = utf8(tmp.path().join("target"));
2913 std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
2914 std::fs::create_dir_all(target.join(".config/app")).unwrap();
2915 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
2918 std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
2919 std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
2921 std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
2924 std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
2925
2926 let cfg = format!(
2927 r#"
2928[absorb]
2929on_anomaly = "force"
2930
2931[[mount.entry]]
2932src = "home"
2933dst = "{}"
2934"#,
2935 toml_path(&target)
2936 );
2937 std::fs::write(source.join("config.toml"), cfg).unwrap();
2938
2939 apply(Some(source.clone()), false).unwrap();
2941
2942 assert_eq!(
2944 std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
2945 "target side"
2946 );
2947 assert_eq!(
2949 std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
2950 "{}"
2951 );
2952 let backup_root = source.join(".yui/backup");
2955 let mut backup_files: Vec<String> = Vec::new();
2956 for entry in walkdir(&backup_root) {
2957 if let Some(n) = entry.file_name() {
2958 backup_files.push(n.to_string());
2959 }
2960 }
2961 assert!(
2962 backup_files.iter().any(|f| f == "config.toml"),
2963 "expected source's config.toml to land in the backup tree, got {backup_files:?}"
2964 );
2965 assert!(
2967 source.join("home/.config/app/source-only.toml").exists(),
2968 "source-only file should survive a target-wins merge"
2969 );
2970 assert!(
2972 source.join("home/.config/app/state.json").exists(),
2973 "target-only state.json should be merged into source"
2974 );
2975 }
2976
2977 #[test]
2983 fn marker_dir_absorbs_with_default_ask_policy() {
2984 let tmp = TempDir::new().unwrap();
2985 let source = utf8(tmp.path().join("dotfiles"));
2986 let target = utf8(tmp.path().join("target"));
2987 std::fs::create_dir_all(source.join("home/.config")).unwrap();
2988 std::fs::create_dir_all(target.join(".config/gh")).unwrap();
2989 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
2991 std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\n").unwrap();
2993
2994 let cfg = format!(
2998 r#"
2999[[mount.entry]]
3000src = "home"
3001dst = "{}"
3002"#,
3003 toml_path(&target)
3004 );
3005 std::fs::write(source.join("config.toml"), cfg).unwrap();
3006
3007 apply(Some(source.clone()), false).unwrap();
3011
3012 assert!(target.join(".config/gh/hosts.yml").exists());
3015 assert!(source.join("home/.config/gh/hosts.yml").exists());
3016 }
3017
3018 #[test]
3024 fn merge_handles_file_vs_dir_collisions_target_wins() {
3025 let tmp = TempDir::new().unwrap();
3026 let source = utf8(tmp.path().join("dotfiles"));
3027 let target = utf8(tmp.path().join("target"));
3028 std::fs::create_dir_all(source.join("home/.config/foo")).unwrap();
3029 std::fs::create_dir_all(target.join(".config")).unwrap();
3030 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3031
3032 std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
3034 std::fs::write(target.join(".config/foo"), "target file body").unwrap();
3035 std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
3037 std::fs::create_dir_all(target.join(".config/bar")).unwrap();
3038 std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
3039
3040 let cfg = format!(
3041 r#"
3042[absorb]
3043on_anomaly = "force"
3044
3045[[mount.entry]]
3046src = "home"
3047dst = "{}"
3048"#,
3049 toml_path(&target)
3050 );
3051 std::fs::write(source.join("config.toml"), cfg).unwrap();
3052 apply(Some(source.clone()), false).unwrap();
3053
3054 let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
3058 assert!(foo_meta.file_type().is_file(), "foo should be a file");
3059 assert_eq!(
3060 std::fs::read_to_string(target.join(".config/foo")).unwrap(),
3061 "target file body"
3062 );
3063 let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
3065 assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
3066 assert_eq!(
3067 std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
3068 "target nested"
3069 );
3070 }
3071
3072 #[test]
3076 fn merge_per_file_target_newer_auto_absorbs() {
3077 let tmp = TempDir::new().unwrap();
3078 let source = utf8(tmp.path().join("dotfiles"));
3079 let target = utf8(tmp.path().join("target"));
3080 std::fs::create_dir_all(source.join("home/.config")).unwrap();
3081 std::fs::create_dir_all(target.join(".config")).unwrap();
3082 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3083
3084 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3086 write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
3087 std::fs::write(target.join(".config/app.toml"), "user's live edit").unwrap();
3088
3089 let cfg = format!(
3093 r#"
3094[[mount.entry]]
3095src = "home"
3096dst = "{}"
3097"#,
3098 toml_path(&target)
3099 );
3100 std::fs::write(source.join("config.toml"), cfg).unwrap();
3101 apply(Some(source.clone()), false).unwrap();
3102
3103 assert_eq!(
3105 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3106 "user's live edit"
3107 );
3108 }
3109
3110 #[test]
3116 fn merge_per_file_source_newer_skip_keeps_source() {
3117 let tmp = TempDir::new().unwrap();
3118 let source = utf8(tmp.path().join("dotfiles"));
3119 let target = utf8(tmp.path().join("target"));
3120 std::fs::create_dir_all(source.join("home/.config")).unwrap();
3121 std::fs::create_dir_all(target.join(".config")).unwrap();
3122 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3123
3124 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3126 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
3127 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
3128
3129 let cfg = format!(
3130 r#"
3131[absorb]
3132on_anomaly = "skip"
3133
3134[[mount.entry]]
3135src = "home"
3136dst = "{}"
3137"#,
3138 toml_path(&target)
3139 );
3140 std::fs::write(source.join("config.toml"), cfg).unwrap();
3141 apply(Some(source.clone()), false).unwrap();
3142
3143 assert_eq!(
3146 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3147 "fresh source"
3148 );
3149 }
3150
3151 #[test]
3154 fn merge_per_file_source_newer_force_overwrites_source() {
3155 let tmp = TempDir::new().unwrap();
3156 let source = utf8(tmp.path().join("dotfiles"));
3157 let target = utf8(tmp.path().join("target"));
3158 std::fs::create_dir_all(source.join("home/.config")).unwrap();
3159 std::fs::create_dir_all(target.join(".config")).unwrap();
3160 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3161
3162 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3163 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
3164 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
3165
3166 let cfg = format!(
3167 r#"
3168[absorb]
3169on_anomaly = "force"
3170
3171[[mount.entry]]
3172src = "home"
3173dst = "{}"
3174"#,
3175 toml_path(&target)
3176 );
3177 std::fs::write(source.join("config.toml"), cfg).unwrap();
3178 apply(Some(source.clone()), false).unwrap();
3179
3180 assert_eq!(
3182 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3183 "old target"
3184 );
3185 }
3186
3187 #[test]
3192 fn merge_per_file_identical_content_is_noop() {
3193 let tmp = TempDir::new().unwrap();
3194 let source = utf8(tmp.path().join("dotfiles"));
3195 let target = utf8(tmp.path().join("target"));
3196 std::fs::create_dir_all(source.join("home/.config")).unwrap();
3197 std::fs::create_dir_all(target.join(".config")).unwrap();
3198 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3199 std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
3200 std::fs::write(target.join(".config/app.toml"), "same").unwrap();
3201
3202 let cfg = format!(
3205 r#"
3206[[mount.entry]]
3207src = "home"
3208dst = "{}"
3209"#,
3210 toml_path(&target)
3211 );
3212 std::fs::write(source.join("config.toml"), cfg).unwrap();
3213 apply(Some(source.clone()), false).unwrap();
3214
3215 assert_eq!(
3216 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3217 "same"
3218 );
3219 }
3220
3221 #[test]
3222 fn manual_absorb_command_pulls_target_into_source() {
3223 let tmp = TempDir::new().unwrap();
3225 let source = utf8(tmp.path().join("dotfiles"));
3226 let target = utf8(tmp.path().join("target"));
3227 std::fs::create_dir_all(source.join("home")).unwrap();
3228 std::fs::create_dir_all(&target).unwrap();
3229 let cfg = format!(
3231 r#"
3232[absorb]
3233on_anomaly = "skip"
3234
3235[[mount.entry]]
3236src = "home"
3237dst = "{}"
3238"#,
3239 toml_path(&target)
3240 );
3241 std::fs::write(source.join("config.toml"), cfg).unwrap();
3242 std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
3243 std::fs::write(source.join("home/.bashrc"), "default").unwrap();
3244
3245 absorb(
3247 Some(source.clone()),
3248 target.join(".bashrc"),
3249 false,
3250 )
3251 .unwrap();
3252
3253 assert_eq!(
3255 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3256 "user picked this"
3257 );
3258 }
3259
3260 #[test]
3261 fn manual_absorb_errors_when_target_outside_known_mounts() {
3262 let tmp = TempDir::new().unwrap();
3263 let (source, _target) = setup_minimal_dotfiles(&tmp);
3264 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
3265 let stranger = utf8(tmp.path().join("not-managed/foo"));
3266 std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
3267 std::fs::write(&stranger, "not yui's").unwrap();
3268 let err = absorb(Some(source), stranger, false).unwrap_err();
3269 assert!(format!("{err}").contains("no mount entry"));
3270 }
3271
3272 #[test]
3273 fn yuiignore_excludes_file_from_linking() {
3274 let tmp = TempDir::new().unwrap();
3275 let (source, target) = setup_minimal_dotfiles(&tmp);
3276 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
3277 std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
3278 std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
3280 apply(Some(source.clone()), false).unwrap();
3281 assert!(target.join(".bashrc").exists());
3282 assert!(
3283 !target.join("lock.json").exists(),
3284 "yuiignore should keep lock.json out of target"
3285 );
3286 }
3287
3288 #[test]
3289 fn yuiignore_excludes_directory_subtree() {
3290 let tmp = TempDir::new().unwrap();
3291 let (source, target) = setup_minimal_dotfiles(&tmp);
3292 std::fs::create_dir_all(source.join("home/cache")).unwrap();
3293 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
3294 std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
3295 std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
3296 std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
3298 apply(Some(source.clone()), false).unwrap();
3299 assert!(target.join(".bashrc").exists());
3300 assert!(
3301 !target.join("cache").exists(),
3302 "yuiignore'd subtree should not appear in target"
3303 );
3304 }
3305
3306 #[test]
3307 fn yuiignore_negation_re_includes_file() {
3308 let tmp = TempDir::new().unwrap();
3309 let (source, target) = setup_minimal_dotfiles(&tmp);
3310 std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
3311 std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
3312 std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
3314 apply(Some(source.clone()), false).unwrap();
3315 assert!(target.join("keep.cache").exists());
3316 assert!(!target.join("drop.cache").exists());
3317 }
3318
3319 #[test]
3320 fn yuiignore_skips_template_in_render() {
3321 let tmp = TempDir::new().unwrap();
3322 let source = utf8(tmp.path().join("dotfiles"));
3323 let target = utf8(tmp.path().join("target"));
3324 std::fs::create_dir_all(source.join("home")).unwrap();
3325 std::fs::create_dir_all(&target).unwrap();
3326 std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
3327 std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
3328 let cfg = format!(
3329 r#"
3330[[mount.entry]]
3331src = "home"
3332dst = "{}"
3333"#,
3334 toml_path(&target)
3335 );
3336 std::fs::write(source.join("config.toml"), cfg).unwrap();
3337 apply(Some(source.clone()), false).unwrap();
3338 assert!(!source.join("home/note").exists());
3340 assert!(!target.join("note").exists());
3341 assert!(!target.join("note.tera").exists());
3342 }
3343
3344 #[test]
3348 fn nested_marker_accumulates_extra_dst() {
3349 let tmp = TempDir::new().unwrap();
3350 let source = utf8(tmp.path().join("dotfiles"));
3351 let parent_target = utf8(tmp.path().join("home"));
3352 let extra_target = utf8(tmp.path().join("extra"));
3353 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3354 std::fs::create_dir_all(&parent_target).unwrap();
3355 std::fs::create_dir_all(&extra_target).unwrap();
3356 std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
3357
3358 std::fs::write(
3360 source.join("home/.config/.yuilink"),
3361 format!(
3362 r#"
3363[[link]]
3364dst = "{}/.config"
3365"#,
3366 toml_path(&parent_target)
3367 ),
3368 )
3369 .unwrap();
3370 std::fs::write(
3373 source.join("home/.config/nvim/.yuilink"),
3374 format!(
3375 r#"
3376[[link]]
3377dst = "{}/nvim"
3378when = "{{{{ yui.os == '{}' }}}}"
3379"#,
3380 toml_path(&extra_target),
3381 std::env::consts::OS
3382 ),
3383 )
3384 .unwrap();
3385
3386 let cfg = format!(
3387 r#"
3388[[mount.entry]]
3389src = "home"
3390dst = "{}"
3391"#,
3392 toml_path(&parent_target)
3393 );
3394 std::fs::write(source.join("config.toml"), cfg).unwrap();
3395
3396 apply(Some(source.clone()), false).unwrap();
3397
3398 assert!(parent_target.join(".config/nvim/init.lua").exists());
3401 assert!(extra_target.join("nvim/init.lua").exists());
3402 }
3403
3404 #[test]
3409 fn marker_file_link_targets_specific_file() {
3410 let tmp = TempDir::new().unwrap();
3411 let source = utf8(tmp.path().join("dotfiles"));
3412 let parent_target = utf8(tmp.path().join("home"));
3413 let docs_target = utf8(tmp.path().join("docs"));
3414 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
3415 std::fs::create_dir_all(&parent_target).unwrap();
3416 std::fs::create_dir_all(&docs_target).unwrap();
3417 std::fs::write(
3418 source.join("home/.config/powershell/profile.ps1"),
3419 "# profile\n",
3420 )
3421 .unwrap();
3422 std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
3423
3424 std::fs::write(
3427 source.join("home/.config/powershell/.yuilink"),
3428 format!(
3429 r#"
3430[[link]]
3431src = "profile.ps1"
3432dst = "{}/Microsoft.PowerShell_profile.ps1"
3433"#,
3434 toml_path(&docs_target)
3435 ),
3436 )
3437 .unwrap();
3438
3439 let cfg = format!(
3440 r#"
3441[[mount.entry]]
3442src = "home"
3443dst = "{}"
3444"#,
3445 toml_path(&parent_target)
3446 );
3447 std::fs::write(source.join("config.toml"), cfg).unwrap();
3448
3449 apply(Some(source.clone()), false).unwrap();
3450
3451 assert!(
3453 docs_target
3454 .join("Microsoft.PowerShell_profile.ps1")
3455 .exists()
3456 );
3457 assert!(
3460 parent_target
3461 .join(".config/powershell/profile.ps1")
3462 .exists()
3463 );
3464 assert!(parent_target.join(".config/powershell/extra.txt").exists());
3465 }
3466
3467 #[test]
3470 fn marker_file_link_missing_src_errors() {
3471 let tmp = TempDir::new().unwrap();
3472 let source = utf8(tmp.path().join("dotfiles"));
3473 let parent_target = utf8(tmp.path().join("home"));
3474 let docs_target = utf8(tmp.path().join("docs"));
3475 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
3476 std::fs::create_dir_all(&parent_target).unwrap();
3477 std::fs::create_dir_all(&docs_target).unwrap();
3478
3479 std::fs::write(
3480 source.join("home/.config/powershell/.yuilink"),
3481 format!(
3482 r#"
3483[[link]]
3484src = "missing.ps1"
3485dst = "{}/profile.ps1"
3486"#,
3487 toml_path(&docs_target)
3488 ),
3489 )
3490 .unwrap();
3491
3492 let cfg = format!(
3493 r#"
3494[[mount.entry]]
3495src = "home"
3496dst = "{}"
3497"#,
3498 toml_path(&parent_target)
3499 );
3500 std::fs::write(source.join("config.toml"), cfg).unwrap();
3501
3502 let err = apply(Some(source.clone()), false).unwrap_err();
3503 assert!(format!("{err:#}").contains("missing.ps1"));
3504 }
3505
3506 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
3507 let mut out = Vec::new();
3508 let mut stack = vec![root.to_path_buf()];
3509 while let Some(dir) = stack.pop() {
3510 let Ok(entries) = std::fs::read_dir(&dir) else {
3511 continue;
3512 };
3513 for e in entries.flatten() {
3514 let p = utf8(e.path());
3515 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
3516 stack.push(p);
3517 } else {
3518 out.push(p);
3519 }
3520 }
3521 }
3522 out
3523 }
3524}