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::Override { 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 items.push(ListItem {
282 src: rel.clone(),
283 dst,
284 when: link.when.clone(),
285 active,
286 });
287 }
288 }
289
290 items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
291 Ok(items)
292}
293
294fn supports_color_stdout() -> bool {
295 use std::io::IsTerminal;
296 std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
297}
298
299fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
300 let src_w = items
301 .iter()
302 .map(|i| i.src.as_str().chars().count())
303 .max()
304 .unwrap_or(0)
305 .max("SRC".len());
306 let dst_w = items
307 .iter()
308 .map(|i| i.dst.chars().count())
309 .max()
310 .unwrap_or(0)
311 .max("DST".len());
312
313 let status_w = "STATUS".len();
314 let arrow_w = icons.arrow.chars().count();
315
316 print_header(status_w, src_w, arrow_w, dst_w, color);
318
319 let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
321 if color {
322 use owo_colors::OwoColorize as _;
323 println!("{}", sep.dimmed());
324 } else {
325 println!("{sep}");
326 }
327
328 for item in items {
330 print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
331 }
332}
333
334fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
335 use owo_colors::OwoColorize as _;
336 let mut line = String::new();
337 let _ = write!(
338 &mut line,
339 " {:<status_w$} {:<src_w$} {:<arrow_w$} {:<dst_w$} WHEN",
340 "STATUS", "SRC", "", "DST"
341 );
342 if color {
343 println!("{}", line.bold());
344 } else {
345 println!("{line}");
346 }
347}
348
349fn render_separator(
350 sep_ch: char,
351 status_w: usize,
352 src_w: usize,
353 arrow_w: usize,
354 dst_w: usize,
355) -> String {
356 let bar = |n: usize| sep_ch.to_string().repeat(n);
357 format!(
358 " {} {} {} {} {}",
359 bar(status_w),
360 bar(src_w),
361 bar(arrow_w),
362 bar(dst_w),
363 bar("WHEN".len())
364 )
365}
366
367fn print_row(
368 item: &ListItem,
369 icons: Icons,
370 status_w: usize,
371 src_w: usize,
372 arrow_w: usize,
373 dst_w: usize,
374 color: bool,
375) {
376 use owo_colors::OwoColorize as _;
377 let status = if item.active {
378 icons.active
379 } else {
380 icons.inactive
381 };
382 let when_str = item
383 .when
384 .as_deref()
385 .map(strip_braces)
386 .unwrap_or_else(|| "(always)".to_string());
387
388 let src_display = item.src.as_str().replace('\\', "/");
390 let src = src_display.as_str();
391 let dst = &item.dst;
392 let arrow = icons.arrow;
393
394 let cell_status = format!("{:<status_w$}", status);
399 let cell_src = format!("{:<src_w$}", src);
400 let cell_arrow = format!("{:<arrow_w$}", arrow);
401 let cell_dst = format!("{:<dst_w$}", dst);
402
403 if !color {
404 println!(" {cell_status} {cell_src} {cell_arrow} {cell_dst} {when_str}");
405 return;
406 }
407
408 if item.active {
409 println!(
410 " {} {} {} {} {}",
411 cell_status.green(),
412 cell_src.cyan(),
413 cell_arrow.dimmed(),
414 cell_dst.green(),
415 when_str.dimmed()
416 );
417 } else {
418 println!(
419 " {} {} {} {} {}",
420 cell_status.red().dimmed(),
421 cell_src.dimmed(),
422 cell_arrow.dimmed(),
423 cell_dst.dimmed(),
424 when_str.dimmed()
425 );
426 }
427}
428
429fn strip_braces(expr: &str) -> String {
432 let trimmed = expr.trim();
433 if let Some(inner) = trimmed
434 .strip_prefix("{{")
435 .and_then(|s| s.strip_suffix("}}"))
436 {
437 inner.trim().to_string()
438 } else {
439 trimmed.to_string()
440 }
441}
442
443pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
444 let source = resolve_source(source)?;
445 let yui = YuiVars::detect(&source);
446 let config = config::load(&source, &yui)?;
447 let yuiignore = paths::load_yuiignore(&source)?;
448 let report = render::render_all(&source, &config, &yui, &yuiignore, dry_run || check)?;
450 log_render_report(&report);
451 if check && report.has_drift() {
452 anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
453 }
454 Ok(())
455}
456
457pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
458 apply(source, dry_run)
460}
461
462pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
463 let _source = resolve_source(source)?;
464 if paths_arg.is_empty() {
465 anyhow::bail!("yui unlink: provide at least one target path");
466 }
467 for p in paths_arg {
468 let abs = absolutize(&p)?;
469 info!("unlink: {abs}");
470 link::unlink(&abs)?;
471 }
472 Ok(())
473}
474
475pub fn status(
488 source: Option<Utf8PathBuf>,
489 icons_override: Option<IconsMode>,
490 no_color: bool,
491) -> Result<()> {
492 let source = resolve_source(source)?;
493 let yui = YuiVars::detect(&source);
494 let config = config::load(&source, &yui)?;
495
496 let mut engine = template::Engine::new();
497 let tera_ctx = template::template_context(&yui, &config.vars);
498 let mounts = mount::resolve(
499 &config.mount.entry,
500 config.mount.default_strategy,
501 &mut engine,
502 &tera_ctx,
503 )?;
504
505 let icons_mode = icons_override.unwrap_or(config.ui.icons);
506 let icons = Icons::for_mode(icons_mode);
507 let color = !no_color && supports_color_stdout();
508
509 let mut report: Vec<StatusItem> = Vec::new();
510 let yuiignore = paths::load_yuiignore(&source)?;
513
514 let render_report =
517 render::render_all(&source, &config, &yui, &yuiignore, true)?;
518 for rendered in &render_report.diverged {
519 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
523 report.push(StatusItem {
524 src: relative_for_display(&source, &tera_path),
525 dst: rendered.clone(),
526 state: StatusState::RenderDrift,
527 });
528 }
529
530 for m in &mounts {
532 let src_root = source.join(&m.src);
533 if !src_root.is_dir() {
534 warn!("mount src missing: {src_root}");
535 continue;
536 }
537 classify_walk(
538 &src_root,
539 &m.dst,
540 &config,
541 m.strategy,
542 &mut engine,
543 &tera_ctx,
544 &source,
545 &yuiignore,
546 &mut report,
547 )?;
548 }
549
550 report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
551
552 print_status_table(&report, icons, color);
553
554 let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
555
556 println!();
557 let total = report.len();
558 let in_sync = total - drift;
559 if drift == 0 {
560 println!(" {total} entries · all in sync");
561 Ok(())
562 } else {
563 println!(" {total} entries · {in_sync} in sync · {drift} diverged");
564 anyhow::bail!("status: {drift} entries diverged from source")
565 }
566}
567
568#[derive(Debug)]
569struct StatusItem {
570 src: Utf8PathBuf,
572 dst: Utf8PathBuf,
574 state: StatusState,
575}
576
577#[derive(Debug, Clone, Copy)]
578enum StatusState {
579 Link(absorb::AbsorbDecision),
580 RenderDrift,
583}
584
585impl StatusState {
586 fn is_in_sync(self) -> bool {
587 matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
588 }
589}
590
591#[allow(clippy::too_many_arguments)]
592fn classify_walk(
593 src_dir: &Utf8Path,
594 dst_dir: &Utf8Path,
595 config: &Config,
596 strategy: MountStrategy,
597 engine: &mut template::Engine,
598 tera_ctx: &TeraContext,
599 source_root: &Utf8Path,
600 yuiignore: &ignore::gitignore::Gitignore,
601 report: &mut Vec<StatusItem>,
602) -> Result<()> {
603 if paths::is_ignored(yuiignore, source_root, src_dir, true) {
604 return Ok(());
605 }
606
607 let marker_filename = &config.mount.marker_filename;
608
609 if strategy == MountStrategy::Marker {
610 match marker::read_spec(src_dir, marker_filename)? {
611 None => {} Some(MarkerSpec::PassThrough) => {
613 let decision = absorb::classify(src_dir, dst_dir)?;
614 report.push(StatusItem {
615 src: relative_for_display(source_root, src_dir),
616 dst: dst_dir.to_path_buf(),
617 state: StatusState::Link(decision),
618 });
619 return Ok(());
620 }
621 Some(MarkerSpec::Override { links }) => {
622 for link in &links {
623 if let Some(when) = &link.when {
624 if !template::eval_truthy(when, engine, tera_ctx)? {
625 continue;
626 }
627 }
628 let dst_str = engine.render(&link.dst, tera_ctx)?;
629 let dst = paths::expand_tilde(dst_str.trim());
630 let decision = absorb::classify(src_dir, &dst)?;
631 report.push(StatusItem {
632 src: relative_for_display(source_root, src_dir),
633 dst,
634 state: StatusState::Link(decision),
635 });
636 }
637 return Ok(());
638 }
639 }
640 }
641
642 for entry in std::fs::read_dir(src_dir)? {
643 let entry = entry?;
644 let name_os = entry.file_name();
645 let Some(name) = name_os.to_str() else {
646 continue;
647 };
648 if name == marker_filename || name.ends_with(".tera") {
649 continue;
650 }
651 let src_path = src_dir.join(name);
652 let dst_path = dst_dir.join(name);
653 let ft = entry.file_type()?;
654 if paths::is_ignored(yuiignore, source_root, &src_path, ft.is_dir()) {
655 continue;
656 }
657 if ft.is_dir() {
658 classify_walk(
659 &src_path,
660 &dst_path,
661 config,
662 strategy,
663 engine,
664 tera_ctx,
665 source_root,
666 yuiignore,
667 report,
668 )?;
669 } else if ft.is_file() {
670 let decision = absorb::classify(&src_path, &dst_path)?;
671 report.push(StatusItem {
672 src: relative_for_display(source_root, &src_path),
673 dst: dst_path,
674 state: StatusState::Link(decision),
675 });
676 }
677 }
678 Ok(())
679}
680
681fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
682 p.strip_prefix(source_root)
683 .map(Utf8PathBuf::from)
684 .unwrap_or_else(|_| p.to_path_buf())
685}
686
687fn print_status_table(items: &[StatusItem], icons: Icons, color: bool) {
688 let src_w = items
689 .iter()
690 .map(|i| i.src.as_str().chars().count())
691 .max()
692 .unwrap_or(0)
693 .max("SRC".len());
694 let dst_w = items
695 .iter()
696 .map(|i| i.dst.as_str().chars().count())
697 .max()
698 .unwrap_or(0)
699 .max("DST".len());
700 let state_label_w = items
702 .iter()
703 .map(|i| state_label(i.state).len())
704 .max()
705 .unwrap_or(0)
706 .max("STATE".len() - 2); let state_w = state_label_w + 2; print_status_header(state_w, src_w, dst_w, color);
710 let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
711 if color {
712 use owo_colors::OwoColorize as _;
713 println!("{}", sep.dimmed());
714 } else {
715 println!("{sep}");
716 }
717 for item in items {
718 print_status_row(item, icons, state_w, src_w, dst_w, color);
719 }
720}
721
722fn state_label(s: StatusState) -> &'static str {
723 use absorb::AbsorbDecision::*;
724 match s {
725 StatusState::Link(InSync) => "in-sync",
726 StatusState::Link(RelinkOnly) => "relink",
727 StatusState::Link(AutoAbsorb) => "drift (auto)",
728 StatusState::Link(NeedsConfirm) => "drift (anomaly)",
729 StatusState::Link(Restore) => "missing",
730 StatusState::RenderDrift => "render drift",
731 }
732}
733
734fn state_icon(s: StatusState, icons: Icons) -> &'static str {
735 use absorb::AbsorbDecision::*;
736 match s {
737 StatusState::Link(InSync) => icons.ok,
738 StatusState::Link(RelinkOnly) => icons.warn,
739 StatusState::Link(AutoAbsorb) => icons.warn,
740 StatusState::Link(NeedsConfirm) => icons.error,
741 StatusState::Link(Restore) => icons.info,
742 StatusState::RenderDrift => icons.error,
743 }
744}
745
746fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
747 use owo_colors::OwoColorize as _;
748 let line = format!(
751 " {:<state_w$} {:<src_w$} {:<dst_w$}",
752 "STATE", "SRC", "DST"
753 );
754 if color {
755 println!("{}", line.bold());
756 } else {
757 println!("{line}");
758 }
759}
760
761fn render_status_separator(
762 sep_ch: char,
763 state_w: usize,
764 src_w: usize,
765 dst_w: usize,
766 arrow: &str,
767) -> String {
768 let bar = |n: usize| sep_ch.to_string().repeat(n);
769 format!(
770 " {} {} {} {}",
771 bar(state_w),
772 bar(src_w),
773 bar(arrow.chars().count()),
774 bar(dst_w)
775 )
776}
777
778fn print_status_row(
779 item: &StatusItem,
780 icons: Icons,
781 state_w: usize,
782 src_w: usize,
783 dst_w: usize,
784 color: bool,
785) {
786 use owo_colors::OwoColorize as _;
787 let icon = state_icon(item.state, icons);
788 let label = state_label(item.state);
789 let state_text = format!("{icon} {label}");
790 let src_display = item.src.as_str().replace('\\', "/");
791 let dst_display = item.dst.as_str().replace('\\', "/");
792 let arrow = icons.arrow;
793
794 let cell_state = format!("{:<state_w$}", state_text);
795 let cell_src = format!("{:<src_w$}", src_display);
796 let cell_dst = format!("{:<dst_w$}", dst_display);
797
798 if !color {
799 println!(" {cell_state} {cell_src} {arrow} {cell_dst}");
800 return;
801 }
802
803 use absorb::AbsorbDecision::*;
804 let state_colored = match item.state {
805 StatusState::Link(InSync) => cell_state.green().to_string(),
806 StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
807 cell_state.yellow().to_string()
808 }
809 StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
810 StatusState::Link(Restore) => cell_state.cyan().to_string(),
811 StatusState::RenderDrift => cell_state.red().to_string(),
812 };
813 let src_colored = cell_src.cyan().to_string();
814 let arrow_colored = arrow.dimmed().to_string();
815 let dst_colored = cell_dst.dimmed().to_string();
816 println!(" {state_colored} {src_colored} {arrow_colored} {dst_colored}");
817}
818
819pub fn absorb(source: Option<Utf8PathBuf>, target: Utf8PathBuf, dry_run: bool) -> Result<()> {
828 let source = resolve_source(source)?;
829 let target = absolutize(&target)?;
830 let yui = YuiVars::detect(&source);
831 let config = config::load(&source, &yui)?;
832
833 let mut engine = template::Engine::new();
834 let tera_ctx = template::template_context(&yui, &config.vars);
835 let yuiignore = paths::load_yuiignore(&source)?;
838
839 let src_path = match find_source_for_target(
840 &source,
841 &config,
842 &target,
843 &mut engine,
844 &tera_ctx,
845 &yuiignore,
846 )? {
847 Some(s) => s,
848 None => anyhow::bail!(
849 "no mount entry / .yuilink override claims target {target}; \
850 pass a path inside a known dst"
851 ),
852 };
853
854 info!("source for {target}: {src_path}");
855
856 if dry_run {
857 info!("[dry-run] would absorb {target} → {src_path}");
858 return Ok(());
859 }
860
861 let backup_root = source.join(&config.backup.dir);
862 let ctx = ApplyCtx {
863 config: &config,
864 source: &source,
865 yuiignore: &yuiignore,
866 file_mode: resolve_file_mode(config.link.file_mode),
867 dir_mode: resolve_dir_mode(config.link.dir_mode),
868 backup_root: &backup_root,
869 dry_run: false,
870 };
871
872 absorb_target_into_source(&src_path, &target, &ctx)
875}
876
877fn find_source_for_target(
881 source: &Utf8Path,
882 config: &Config,
883 target: &Utf8Path,
884 engine: &mut template::Engine,
885 tera_ctx: &TeraContext,
886 yuiignore: &ignore::gitignore::Gitignore,
887) -> Result<Option<Utf8PathBuf>> {
888 for entry in &config.mount.entry {
890 if let Some(when) = &entry.when {
891 if !template::eval_truthy(when, engine, tera_ctx)? {
892 continue;
893 }
894 }
895 let dst_str = engine.render(&entry.dst, tera_ctx)?;
896 let dst_root = paths::expand_tilde(dst_str.trim());
897 if let Ok(rel) = target.strip_prefix(&dst_root) {
898 let candidate = source.join(&entry.src).join(rel);
899 if paths::is_ignored(yuiignore, source, &candidate, candidate.is_dir()) {
903 continue;
904 }
905 return Ok(Some(candidate));
906 }
907 }
908
909 let walker = paths::source_walker(source).build();
913 let marker_filename = &config.mount.marker_filename;
914 for ent in walker {
915 let ent = match ent {
916 Ok(e) => e,
917 Err(_) => continue,
918 };
919 if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
920 continue;
921 }
922 if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
923 continue;
924 }
925 let dir = match ent.path().parent() {
926 Some(d) => d,
927 None => continue,
928 };
929 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
930 Ok(p) => p,
931 Err(_) => continue,
932 };
933 if paths::is_ignored(yuiignore, source, &dir_utf8, true) {
934 continue;
935 }
936 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
937 Some(s) => s,
938 None => continue,
939 };
940 let MarkerSpec::Override { links } = spec else {
941 continue;
942 };
943 for link in &links {
944 if let Some(when) = &link.when {
945 if !template::eval_truthy(when, engine, tera_ctx)? {
946 continue;
947 }
948 }
949 let dst_str = engine.render(&link.dst, tera_ctx)?;
950 let dst = paths::expand_tilde(dst_str.trim());
951 if target == dst {
952 return Ok(Some(dir_utf8));
953 }
954 if let Ok(rel) = target.strip_prefix(&dst) {
955 return Ok(Some(dir_utf8.join(rel)));
956 }
957 }
958 }
959
960 Ok(None)
961}
962
963pub fn doctor(source: Option<Utf8PathBuf>) -> Result<()> {
964 let yui = YuiVars::detect(Utf8Path::new("."));
965 println!("yui doctor");
966 println!("==========");
967 println!("os: {}", yui.os);
968 println!("arch: {}", yui.arch);
969 println!("user: {}", yui.user);
970 println!("host: {}", yui.host);
971 match resolve_source(source) {
972 Ok(s) => {
973 println!("source: {s}");
974 match config::load(&s, &yui) {
976 Ok(cfg) => println!(
977 "config: ok ({} mount entries, {} render rules)",
978 cfg.mount.entry.len(),
979 cfg.render.rule.len()
980 ),
981 Err(e) => println!("config: ERROR — {e}"),
982 }
983 }
984 Err(e) => println!("source: NOT FOUND — {e}"),
985 }
986 println!();
987 println!("link mode (auto resolves to):");
988 if cfg!(windows) {
989 println!(" files: hardlink");
990 println!(" dirs: junction");
991 } else {
992 println!(" files: symlink");
993 println!(" dirs: symlink");
994 }
995 Ok(())
996}
997
998pub fn gc_backup(_source: Option<Utf8PathBuf>, _older_than: Option<String>) -> Result<()> {
999 todo!("yui gc-backup — clean up old backups")
1000}
1001
1002pub fn hooks_list(source: Option<Utf8PathBuf>) -> Result<()> {
1004 let source = resolve_source(source)?;
1005 let yui = YuiVars::detect(&source);
1006 let config = config::load(&source, &yui)?;
1007 let state = hook::State::load(&source)?;
1008
1009 if config.hook.is_empty() {
1010 println!("(no [[hook]] entries in config)");
1011 return Ok(());
1012 }
1013
1014 for h in &config.hook {
1015 let phase = match h.phase {
1016 HookPhase::Pre => "pre",
1017 HookPhase::Post => "post",
1018 };
1019 let when_run = match h.when_run {
1020 config::WhenRun::Once => "once",
1021 config::WhenRun::Onchange => "onchange",
1022 config::WhenRun::Every => "every",
1023 };
1024 let last = state
1025 .hooks
1026 .get(&h.name)
1027 .and_then(|s| s.last_run_at.as_deref())
1028 .unwrap_or("(never)");
1029 println!(
1030 "{name:<20} phase={phase:<4} when_run={when_run:<8} last_run_at={last}",
1031 name = h.name,
1032 );
1033 if let Some(when) = &h.when {
1034 println!(" when = {when}");
1035 }
1036 println!(" script = {}", h.script);
1037 println!(
1038 " command = {} {}",
1039 h.command,
1040 h.args.join(" ")
1041 );
1042 }
1043 Ok(())
1044}
1045
1046pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
1050 let source = resolve_source(source)?;
1051 let yui = YuiVars::detect(&source);
1052 let config = config::load(&source, &yui)?;
1053 let mut engine = template::Engine::new();
1054 let tera_ctx = template::template_context(&yui, &config.vars);
1055
1056 let targets: Vec<&config::HookConfig> = match &name {
1057 Some(want) => {
1058 let m = config
1059 .hook
1060 .iter()
1061 .find(|h| &h.name == want)
1062 .ok_or_else(|| {
1063 anyhow::anyhow!(
1064 "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
1065 )
1066 })?;
1067 vec![m]
1068 }
1069 None => config.hook.iter().collect(),
1070 };
1071
1072 let mut state = hook::State::load(&source)?;
1073 for h in targets {
1074 let outcome = hook::run_hook(
1075 h,
1076 &source,
1077 &yui,
1078 &config.vars,
1079 &mut engine,
1080 &tera_ctx,
1081 &mut state,
1082 false,
1083 force,
1084 )?;
1085 let label = match outcome {
1086 HookOutcome::Ran => "ran",
1087 HookOutcome::SkippedOnce => "skipped (once: already ran)",
1088 HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
1089 HookOutcome::SkippedWhenFalse => "skipped (when=false)",
1090 HookOutcome::DryRun => "would run (dry-run)",
1091 };
1092 info!("hook[{}]: {label}", h.name);
1093 if outcome == HookOutcome::Ran {
1094 state.save(&source)?;
1095 }
1096 }
1097 Ok(())
1098}
1099
1100fn process_mount(
1105 source: &Utf8Path,
1106 m: &ResolvedMount,
1107 ctx: &ApplyCtx<'_>,
1108 engine: &mut template::Engine,
1109 tera_ctx: &TeraContext,
1110) -> Result<()> {
1111 let src_root = source.join(&m.src);
1112 if !src_root.is_dir() {
1113 warn!("mount src missing: {src_root}");
1114 return Ok(());
1115 }
1116 walk_and_link(&src_root, &m.dst, ctx, m.strategy, engine, tera_ctx)
1117}
1118
1119fn walk_and_link(
1120 src_dir: &Utf8Path,
1121 dst_dir: &Utf8Path,
1122 ctx: &ApplyCtx<'_>,
1123 strategy: MountStrategy,
1124 engine: &mut template::Engine,
1125 tera_ctx: &TeraContext,
1126) -> Result<()> {
1127 if paths::is_ignored(ctx.yuiignore, ctx.source, src_dir, true) {
1130 return Ok(());
1131 }
1132
1133 let marker_filename = &ctx.config.mount.marker_filename;
1134
1135 if strategy == MountStrategy::Marker {
1136 match marker::read_spec(src_dir, marker_filename)? {
1137 None => {} Some(MarkerSpec::PassThrough) => {
1139 link_dir_with_backup(src_dir, dst_dir, ctx)?;
1140 return Ok(());
1141 }
1142 Some(MarkerSpec::Override { links }) => {
1143 let mut linked_any = false;
1144 for link in &links {
1145 if let Some(when) = &link.when {
1149 if !template::eval_truthy(when, engine, tera_ctx)? {
1150 continue;
1151 }
1152 }
1153 let dst_str = engine.render(&link.dst, tera_ctx)?;
1154 let dst = paths::expand_tilde(dst_str.trim());
1155 link_dir_with_backup(src_dir, &dst, ctx)?;
1156 linked_any = true;
1157 }
1158 if !linked_any {
1159 info!("marker override at {src_dir} had no active links — skipping");
1160 }
1161 return Ok(());
1162 }
1163 }
1164 }
1165
1166 for entry in std::fs::read_dir(src_dir)? {
1167 let entry = entry?;
1168 let name_os = entry.file_name();
1169 let Some(name) = name_os.to_str() else {
1170 continue;
1171 };
1172 if name == marker_filename {
1173 continue;
1174 }
1175 if name.ends_with(".tera") {
1176 continue;
1178 }
1179 let src_path = src_dir.join(name);
1180 let dst_path = dst_dir.join(name);
1181 let ft = entry.file_type()?;
1182
1183 if paths::is_ignored(ctx.yuiignore, ctx.source, &src_path, ft.is_dir()) {
1184 continue;
1185 }
1186
1187 if ft.is_dir() {
1188 walk_and_link(&src_path, &dst_path, ctx, strategy, engine, tera_ctx)?;
1189 } else if ft.is_file() {
1190 link_file_with_backup(&src_path, &dst_path, ctx)?;
1191 }
1192 }
1193 Ok(())
1194}
1195
1196fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1197 use absorb::AbsorbDecision::*;
1198
1199 let decision = absorb::classify(src, dst)?;
1200
1201 if ctx.dry_run {
1202 info!("[dry-run] {decision:?}: {src} → {dst}");
1203 return Ok(());
1204 }
1205
1206 match decision {
1207 InSync => {
1208 Ok(())
1210 }
1211 Restore => {
1212 info!("link: {src} → {dst}");
1213 link::link_file(src, dst, ctx.file_mode)?;
1214 Ok(())
1215 }
1216 RelinkOnly => {
1217 info!("relink: {src} → {dst}");
1220 link::unlink(dst)?;
1221 link::link_file(src, dst, ctx.file_mode)?;
1222 Ok(())
1223 }
1224 AutoAbsorb => {
1225 if !ctx.config.absorb.auto {
1228 return handle_anomaly(
1229 src,
1230 dst,
1231 ctx,
1232 "absorb.auto = false; treating divergence as anomaly",
1233 );
1234 }
1235 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
1236 return handle_anomaly(
1237 src,
1238 dst,
1239 ctx,
1240 "source repo is dirty; deferring auto-absorb",
1241 );
1242 }
1243 absorb_target_into_source(src, dst, ctx)
1244 }
1245 NeedsConfirm => handle_anomaly(
1246 src,
1247 dst,
1248 ctx,
1249 "anomaly: source equals/newer than target but content differs",
1250 ),
1251 }
1252}
1253
1254fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1258 info!("absorb: {dst} → {src}");
1259 backup_existing(src, ctx.backup_root, false)?;
1260 std::fs::copy(dst, src)?;
1261 link::unlink(dst)?;
1262 link::link_file(src, dst, ctx.file_mode)?;
1263 Ok(())
1264}
1265
1266fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
1272 use crate::config::AnomalyAction::*;
1273 match ctx.config.absorb.on_anomaly {
1274 Skip => {
1275 warn!("anomaly skip: {dst} ({reason})");
1276 Ok(())
1277 }
1278 Force => {
1279 warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
1280 absorb_target_into_source(src, dst, ctx)
1281 }
1282 Ask => {
1283 use std::io::IsTerminal;
1284 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
1285 if prompt_absorb_with_diff(src, dst, reason)? {
1286 absorb_target_into_source(src, dst, ctx)
1287 } else {
1288 warn!("anomaly skipped by user: {dst}");
1289 Ok(())
1290 }
1291 } else {
1292 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
1293 Ok(())
1294 }
1295 }
1296 }
1297}
1298
1299fn prompt_absorb_with_diff(src: &Utf8Path, dst: &Utf8Path, reason: &str) -> Result<bool> {
1300 use std::io::Write as _;
1301 let src_content = std::fs::read_to_string(src).unwrap_or_default();
1302 let dst_content = std::fs::read_to_string(dst).unwrap_or_default();
1303 eprintln!();
1304 eprintln!("anomaly: {reason}");
1305 eprintln!(" src: {src}");
1306 eprintln!(" dst: {dst}");
1307 eprintln!();
1308 eprintln!("--- diff (- source, + target) ---");
1309 let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
1310 for change in diff.iter_all_changes() {
1311 let sign = match change.tag() {
1312 similar::ChangeTag::Delete => "-",
1313 similar::ChangeTag::Insert => "+",
1314 similar::ChangeTag::Equal => " ",
1315 };
1316 eprint!("{sign}{change}");
1317 }
1318 eprintln!();
1319 eprint!("absorb target into source? [y/N]: ");
1320 std::io::stderr().flush().ok();
1325 let mut input = String::new();
1326 std::io::stdin().read_line(&mut input)?;
1327 let answer = input.trim();
1328 Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
1329}
1330
1331fn source_repo_is_clean(source: &Utf8Path) -> bool {
1336 match crate::git::is_clean(source) {
1337 Ok(b) => b,
1338 Err(e) => {
1339 warn!("git clean check failed at {source}: {e} — treating as clean");
1340 true
1341 }
1342 }
1343}
1344
1345fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1346 use absorb::AbsorbDecision::*;
1347 let decision = absorb::classify(src, dst)?;
1348
1349 if ctx.dry_run {
1350 info!("[dry-run] dir {decision:?}: {src} → {dst}");
1351 return Ok(());
1352 }
1353
1354 match decision {
1355 InSync => Ok(()),
1356 Restore => {
1357 info!("link dir: {src} → {dst}");
1358 link::link_dir(src, dst, ctx.dir_mode)?;
1359 Ok(())
1360 }
1361 _ => {
1362 backup_existing(dst, ctx.backup_root, true)?;
1368 link::unlink(dst)?;
1369 info!("relink dir: {src} → {dst}");
1370 link::link_dir(src, dst, ctx.dir_mode)?;
1371 Ok(())
1372 }
1373 }
1374}
1375
1376fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
1377 let abs_target = absolutize(target)?;
1378 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
1379 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
1380 info!("backup → {bp}");
1381 if is_dir {
1382 backup::backup_dir(target, &bp)?;
1383 } else {
1384 backup::backup_file(target, &bp)?;
1385 }
1386 Ok(())
1387}
1388
1389fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
1390 if let Some(s) = source {
1391 return absolutize(&s);
1392 }
1393 if let Ok(s) = std::env::var("YUI_SOURCE") {
1394 return absolutize(Utf8Path::new(&s));
1395 }
1396 let cwd = current_dir_utf8()?;
1397 for ancestor in cwd.ancestors() {
1398 if ancestor.join("config.toml").is_file() {
1399 return Ok(ancestor.to_path_buf());
1400 }
1401 }
1402 if let Some(home) = paths::home_dir() {
1403 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
1404 let p = home.join(c);
1405 if p.join("config.toml").is_file() {
1406 return Ok(p);
1407 }
1408 }
1409 }
1410 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
1411}
1412
1413fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
1414 let expanded = paths::expand_tilde(p.as_str());
1416 if expanded.is_absolute() {
1417 return Ok(expanded);
1418 }
1419 let cwd = current_dir_utf8()?;
1420 Ok(cwd.join(expanded))
1421}
1422
1423fn current_dir_utf8() -> Result<Utf8PathBuf> {
1424 let cwd = std::env::current_dir().context("getting cwd")?;
1425 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
1426}
1427
1428const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
1432
1433[vars]
1434# user-defined values; templates can reference these as {{ vars.foo }}
1435
1436# [link]
1437# file_mode = "auto" # auto | symlink | hardlink
1438# dir_mode = "auto" # auto | symlink | junction
1439
1440[mount]
1441default_strategy = "marker"
1442
1443[[mount.entry]]
1444src = "home"
1445# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
1446dst = "~"
1447
1448# [[mount.entry]]
1449# src = "appdata"
1450# dst = "{{ env(name='APPDATA') }}"
1451# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
1452# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
1453# when = "yui.os == 'windows'"
1454"#;
1455
1456const SKELETON_GITIGNORE: &str = r#"# yui internals (regenerable, do not commit)
1457/.yui/
1458
1459# >>> yui rendered (auto-managed, do not edit) >>>
1460# <<< yui rendered (auto-managed) <<<
1461
1462# config.local.toml is per-machine; commit a config.local.example.toml instead.
1463config.local.toml
1464"#;
1465
1466#[cfg(test)]
1467mod tests {
1468 use super::*;
1469 use tempfile::TempDir;
1470
1471 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
1472 Utf8PathBuf::from_path_buf(p).unwrap()
1473 }
1474
1475 fn toml_path(p: &Utf8Path) -> String {
1477 p.as_str().replace('\\', "/")
1478 }
1479
1480 #[test]
1481 fn apply_links_a_raw_file() {
1482 let tmp = TempDir::new().unwrap();
1483 let source = utf8(tmp.path().join("dotfiles"));
1484 let target = utf8(tmp.path().join("target"));
1485 std::fs::create_dir_all(source.join("home")).unwrap();
1486 std::fs::create_dir_all(&target).unwrap();
1487 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
1488
1489 let cfg = format!(
1490 r#"
1491[[mount.entry]]
1492src = "home"
1493dst = "{}"
1494"#,
1495 toml_path(&target)
1496 );
1497 std::fs::write(source.join("config.toml"), cfg).unwrap();
1498
1499 apply(Some(source), false).unwrap();
1500
1501 let linked = target.join(".bashrc");
1502 assert!(linked.exists(), "expected {linked} to exist");
1503 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
1504 }
1505
1506 #[test]
1507 fn apply_with_marker_links_whole_directory() {
1508 let tmp = TempDir::new().unwrap();
1509 let source = utf8(tmp.path().join("dotfiles"));
1510 let target = utf8(tmp.path().join("target"));
1511 let nvim_src = source.join("home/nvim");
1512 std::fs::create_dir_all(&nvim_src).unwrap();
1513 std::fs::create_dir_all(&target).unwrap();
1514 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
1515 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
1516 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
1517
1518 let cfg = format!(
1519 r#"
1520[[mount.entry]]
1521src = "home"
1522dst = "{}"
1523"#,
1524 toml_path(&target)
1525 );
1526 std::fs::write(source.join("config.toml"), cfg).unwrap();
1527
1528 apply(Some(source.clone()), false).unwrap();
1529
1530 let nvim_dst = target.join("nvim");
1531 assert!(nvim_dst.exists());
1532 assert_eq!(
1533 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
1534 "-- hi\n"
1535 );
1536 }
1540
1541 #[test]
1542 fn apply_dry_run_does_not_write() {
1543 let tmp = TempDir::new().unwrap();
1544 let source = utf8(tmp.path().join("dotfiles"));
1545 let target = utf8(tmp.path().join("target"));
1546 std::fs::create_dir_all(source.join("home")).unwrap();
1547 std::fs::create_dir_all(&target).unwrap();
1548 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
1549
1550 let cfg = format!(
1551 r#"
1552[[mount.entry]]
1553src = "home"
1554dst = "{}"
1555"#,
1556 toml_path(&target)
1557 );
1558 std::fs::write(source.join("config.toml"), cfg).unwrap();
1559
1560 apply(Some(source), true).unwrap();
1561
1562 assert!(!target.join(".bashrc").exists());
1563 }
1564
1565 #[test]
1566 fn apply_renders_templates_then_links_rendered_outputs() {
1567 let tmp = TempDir::new().unwrap();
1568 let source = utf8(tmp.path().join("dotfiles"));
1569 let target = utf8(tmp.path().join("target"));
1570 std::fs::create_dir_all(source.join("home")).unwrap();
1571 std::fs::create_dir_all(&target).unwrap();
1572 std::fs::write(
1573 source.join("home/.gitconfig.tera"),
1574 "[user]\n os = {{ yui.os }}\n",
1575 )
1576 .unwrap();
1577 std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
1578
1579 let cfg = format!(
1580 r#"
1581[[mount.entry]]
1582src = "home"
1583dst = "{}"
1584"#,
1585 toml_path(&target)
1586 );
1587 std::fs::write(source.join("config.toml"), cfg).unwrap();
1588
1589 apply(Some(source.clone()), false).unwrap();
1590
1591 assert!(target.join(".bashrc").exists());
1593 assert!(source.join("home/.gitconfig").exists());
1595 assert!(target.join(".gitconfig").exists());
1596 assert!(!target.join(".gitconfig.tera").exists());
1598 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
1600 assert!(linked.contains("os = "));
1601 }
1602
1603 #[test]
1604 fn apply_marker_override_links_to_custom_dst() {
1605 let tmp = TempDir::new().unwrap();
1606 let source = utf8(tmp.path().join("dotfiles"));
1607 let target_a = utf8(tmp.path().join("target_a"));
1608 let target_b = utf8(tmp.path().join("target_b"));
1609 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
1610 std::fs::create_dir_all(&target_a).unwrap();
1611 std::fs::create_dir_all(&target_b).unwrap();
1612 std::fs::write(
1613 source.join("home/.config/nvim/init.lua"),
1614 "-- nvim config\n",
1615 )
1616 .unwrap();
1617
1618 std::fs::write(
1621 source.join("home/.config/nvim/.yuilink"),
1622 format!(
1623 r#"
1624[[link]]
1625dst = "{}/nvim"
1626
1627[[link]]
1628dst = "{}/nvim"
1629when = "{{{{ yui.os == '{}' }}}}"
1630"#,
1631 toml_path(&target_a),
1632 toml_path(&target_b),
1633 std::env::consts::OS
1634 ),
1635 )
1636 .unwrap();
1637
1638 let parent_target = utf8(tmp.path().join("parent_target"));
1639 std::fs::create_dir_all(&parent_target).unwrap();
1640 let cfg = format!(
1641 r#"
1642[[mount.entry]]
1643src = "home"
1644dst = "{}"
1645"#,
1646 toml_path(&parent_target)
1647 );
1648 std::fs::write(source.join("config.toml"), cfg).unwrap();
1649
1650 apply(Some(source.clone()), false).unwrap();
1651
1652 assert!(
1654 target_a.join("nvim/init.lua").exists(),
1655 "target_a/nvim/init.lua should be reachable through the link"
1656 );
1657 assert!(
1658 target_b.join("nvim/init.lua").exists(),
1659 "target_b/nvim/init.lua should be reachable through the link"
1660 );
1661 assert!(
1664 !parent_target.join(".config/nvim").exists(),
1665 "parent mount should have skipped the marker-claimed sub-dir"
1666 );
1667 }
1668
1669 #[test]
1670 fn apply_marker_override_skips_inactive_link() {
1671 let tmp = TempDir::new().unwrap();
1672 let source = utf8(tmp.path().join("dotfiles"));
1673 let target_inactive = utf8(tmp.path().join("inactive"));
1674 let parent_target = utf8(tmp.path().join("parent"));
1675 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
1676 std::fs::create_dir_all(&parent_target).unwrap();
1677 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
1678
1679 std::fs::write(
1681 source.join("home/.config/nvim/.yuilink"),
1682 format!(
1683 r#"
1684[[link]]
1685dst = "{}/nvim"
1686when = "{{{{ yui.os == 'no-such-os' }}}}"
1687"#,
1688 toml_path(&target_inactive)
1689 ),
1690 )
1691 .unwrap();
1692
1693 let cfg = format!(
1694 r#"
1695[[mount.entry]]
1696src = "home"
1697dst = "{}"
1698"#,
1699 toml_path(&parent_target)
1700 );
1701 std::fs::write(source.join("config.toml"), cfg).unwrap();
1702
1703 apply(Some(source.clone()), false).unwrap();
1704
1705 assert!(!target_inactive.join("nvim").exists());
1707 assert!(!parent_target.join(".config/nvim").exists());
1710 }
1711
1712 #[test]
1713 fn list_shows_mount_entries_and_marker_overrides() {
1714 let tmp = TempDir::new().unwrap();
1715 let source = utf8(tmp.path().join("dotfiles"));
1716 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
1717 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
1718 std::fs::write(
1719 source.join("home/.config/nvim/.yuilink"),
1720 r#"
1721[[link]]
1722dst = "/custom/nvim"
1723"#,
1724 )
1725 .unwrap();
1726 std::fs::write(
1727 source.join("config.toml"),
1728 r#"
1729[[mount.entry]]
1730src = "home"
1731dst = "/h"
1732"#,
1733 )
1734 .unwrap();
1735
1736 list(Some(source), false, None, true).unwrap();
1739 }
1740
1741 #[test]
1742 fn status_reports_in_sync_after_apply() {
1743 let tmp = TempDir::new().unwrap();
1744 let source = utf8(tmp.path().join("dotfiles"));
1745 let target = utf8(tmp.path().join("target"));
1746 std::fs::create_dir_all(source.join("home")).unwrap();
1747 std::fs::create_dir_all(&target).unwrap();
1748 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
1749 let cfg = format!(
1750 r#"
1751[[mount.entry]]
1752src = "home"
1753dst = "{}"
1754"#,
1755 toml_path(&target)
1756 );
1757 std::fs::write(source.join("config.toml"), cfg).unwrap();
1758 apply(Some(source.clone()), false).unwrap();
1760 status(Some(source), None, true).unwrap();
1762 }
1763
1764 #[test]
1765 fn status_reports_template_drift() {
1766 let tmp = TempDir::new().unwrap();
1767 let source = utf8(tmp.path().join("dotfiles"));
1768 let target = utf8(tmp.path().join("target"));
1769 std::fs::create_dir_all(source.join("home")).unwrap();
1770 std::fs::create_dir_all(&target).unwrap();
1771 std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
1774 std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
1775
1776 let cfg = format!(
1777 r#"
1778[[mount.entry]]
1779src = "home"
1780dst = "{}"
1781"#,
1782 toml_path(&target)
1783 );
1784 std::fs::write(source.join("config.toml"), cfg).unwrap();
1785
1786 let err = status(Some(source), None, true).unwrap_err();
1787 assert!(format!("{err}").contains("diverged"));
1788 }
1789
1790 #[test]
1791 fn status_fails_when_target_missing() {
1792 let tmp = TempDir::new().unwrap();
1793 let source = utf8(tmp.path().join("dotfiles"));
1794 let target = utf8(tmp.path().join("target"));
1795 std::fs::create_dir_all(source.join("home")).unwrap();
1796 std::fs::create_dir_all(&target).unwrap();
1797 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
1798 let cfg = format!(
1799 r#"
1800[[mount.entry]]
1801src = "home"
1802dst = "{}"
1803"#,
1804 toml_path(&target)
1805 );
1806 std::fs::write(source.join("config.toml"), cfg).unwrap();
1807 let err = status(Some(source), None, true).unwrap_err();
1809 assert!(format!("{err}").contains("diverged"));
1810 }
1811
1812 #[test]
1813 fn strip_braces_removes_outer_template_braces() {
1814 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
1815 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
1816 assert_eq!(strip_braces(" {{x}} "), "x");
1817 }
1818
1819 #[test]
1820 fn apply_aborts_on_render_drift() {
1821 let tmp = TempDir::new().unwrap();
1822 let source = utf8(tmp.path().join("dotfiles"));
1823 let target = utf8(tmp.path().join("target"));
1824 std::fs::create_dir_all(source.join("home")).unwrap();
1825 std::fs::create_dir_all(&target).unwrap();
1826 std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
1827 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
1828
1829 let cfg = format!(
1830 r#"
1831[[mount.entry]]
1832src = "home"
1833dst = "{}"
1834"#,
1835 toml_path(&target)
1836 );
1837 std::fs::write(source.join("config.toml"), cfg).unwrap();
1838
1839 let err = apply(Some(source.clone()), false).unwrap_err();
1840 assert!(format!("{err}").contains("drift"));
1841 assert_eq!(
1843 std::fs::read_to_string(source.join("home/foo")).unwrap(),
1844 "manually edited"
1845 );
1846 assert!(!target.join("foo").exists());
1848 }
1849
1850 #[test]
1851 fn init_creates_skeleton_when_dir_empty() {
1852 let tmp = TempDir::new().unwrap();
1853 let dir = utf8(tmp.path().join("new_dotfiles"));
1854 init(Some(dir.clone()), false).unwrap();
1855 assert!(dir.join("config.toml").is_file());
1856 assert!(dir.join(".gitignore").is_file());
1857 }
1858
1859 #[test]
1860 fn init_refuses_to_overwrite_existing_config() {
1861 let tmp = TempDir::new().unwrap();
1862 let dir = utf8(tmp.path().join("dotfiles"));
1863 std::fs::create_dir_all(&dir).unwrap();
1864 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
1865 let err = init(Some(dir), false).unwrap_err();
1866 assert!(format!("{err}").contains("already exists"));
1867 }
1868
1869 fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
1872 let source = utf8(tmp.path().join("dotfiles"));
1873 let target = utf8(tmp.path().join("target"));
1874 std::fs::create_dir_all(source.join("home")).unwrap();
1875 std::fs::create_dir_all(&target).unwrap();
1876 let cfg = format!(
1877 r#"
1878[[mount.entry]]
1879src = "home"
1880dst = "{}"
1881"#,
1882 toml_path(&target)
1883 );
1884 std::fs::write(source.join("config.toml"), cfg).unwrap();
1885 (source, target)
1886 }
1887
1888 fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
1889 std::fs::write(path, body).unwrap();
1890 let f = std::fs::OpenOptions::new()
1891 .write(true)
1892 .open(path)
1893 .expect("open writable");
1894 f.set_modified(when).expect("set_modified");
1895 }
1896
1897 #[test]
1898 fn apply_target_newer_absorbs_target_into_source() {
1899 let tmp = TempDir::new().unwrap();
1903 let (source, target) = setup_minimal_dotfiles(&tmp);
1904
1905 let now = std::time::SystemTime::now();
1906 let past = now - std::time::Duration::from_secs(120);
1907 write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
1908 write_with_mtime(&target.join(".bashrc"), "user's edit", now);
1910
1911 apply(Some(source.clone()), false).unwrap();
1912
1913 assert_eq!(
1915 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
1916 "user's edit"
1917 );
1918 assert_eq!(
1920 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
1921 "user's edit"
1922 );
1923 let backup_root = source.join(".yui/backup");
1925 let mut found_old = false;
1926 for entry in walkdir(&backup_root) {
1927 if let Ok(s) = std::fs::read_to_string(&entry) {
1928 if s == "default from repo" {
1929 found_old = true;
1930 break;
1931 }
1932 }
1933 }
1934 assert!(found_old, "expected backup containing 'default from repo'");
1935 }
1936
1937 #[test]
1938 fn apply_in_sync_target_is_a_no_op() {
1939 let tmp = TempDir::new().unwrap();
1942 let (source, target) = setup_minimal_dotfiles(&tmp);
1943 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
1944 apply(Some(source.clone()), false).unwrap();
1945 let backup_root = source.join(".yui/backup");
1946 let backup_count_after_first = walkdir(&backup_root).len();
1947
1948 apply(Some(source.clone()), false).unwrap();
1950 assert_eq!(
1951 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
1952 "echo hi\n"
1953 );
1954 let backup_count_after_second = walkdir(&backup_root).len();
1955 assert_eq!(
1956 backup_count_after_first, backup_count_after_second,
1957 "second apply on an in-sync tree should not produce backups"
1958 );
1959 }
1960
1961 #[test]
1962 fn apply_skip_policy_leaves_anomaly_alone() {
1963 let tmp = TempDir::new().unwrap();
1966 let source = utf8(tmp.path().join("dotfiles"));
1967 let target = utf8(tmp.path().join("target"));
1968 std::fs::create_dir_all(source.join("home")).unwrap();
1969 std::fs::create_dir_all(&target).unwrap();
1970 let cfg = format!(
1971 r#"
1972[absorb]
1973on_anomaly = "skip"
1974
1975[[mount.entry]]
1976src = "home"
1977dst = "{}"
1978"#,
1979 toml_path(&target)
1980 );
1981 std::fs::write(source.join("config.toml"), cfg).unwrap();
1982
1983 let now = std::time::SystemTime::now();
1984 let past = now - std::time::Duration::from_secs(120);
1985 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
1986 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
1987
1988 apply(Some(source.clone()), false).unwrap();
1989
1990 assert_eq!(
1992 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
1993 "user's edit (older)"
1994 );
1995 assert_eq!(
1997 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
1998 "fresh from upstream"
1999 );
2000 }
2001
2002 #[test]
2003 fn apply_force_policy_absorbs_anomaly_anyway() {
2004 let tmp = TempDir::new().unwrap();
2006 let source = utf8(tmp.path().join("dotfiles"));
2007 let target = utf8(tmp.path().join("target"));
2008 std::fs::create_dir_all(source.join("home")).unwrap();
2009 std::fs::create_dir_all(&target).unwrap();
2010 let cfg = format!(
2011 r#"
2012[absorb]
2013on_anomaly = "force"
2014
2015[[mount.entry]]
2016src = "home"
2017dst = "{}"
2018"#,
2019 toml_path(&target)
2020 );
2021 std::fs::write(source.join("config.toml"), cfg).unwrap();
2022
2023 let now = std::time::SystemTime::now();
2024 let past = now - std::time::Duration::from_secs(120);
2025 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
2026 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
2027
2028 apply(Some(source.clone()), false).unwrap();
2029
2030 assert_eq!(
2032 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2033 "user's edit (older)"
2034 );
2035 assert_eq!(
2036 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2037 "user's edit (older)"
2038 );
2039 }
2040
2041 #[test]
2042 fn manual_absorb_command_pulls_target_into_source() {
2043 let tmp = TempDir::new().unwrap();
2045 let source = utf8(tmp.path().join("dotfiles"));
2046 let target = utf8(tmp.path().join("target"));
2047 std::fs::create_dir_all(source.join("home")).unwrap();
2048 std::fs::create_dir_all(&target).unwrap();
2049 let cfg = format!(
2051 r#"
2052[absorb]
2053on_anomaly = "skip"
2054
2055[[mount.entry]]
2056src = "home"
2057dst = "{}"
2058"#,
2059 toml_path(&target)
2060 );
2061 std::fs::write(source.join("config.toml"), cfg).unwrap();
2062 std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
2063 std::fs::write(source.join("home/.bashrc"), "default").unwrap();
2064
2065 absorb(
2067 Some(source.clone()),
2068 target.join(".bashrc"),
2069 false,
2070 )
2071 .unwrap();
2072
2073 assert_eq!(
2075 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2076 "user picked this"
2077 );
2078 }
2079
2080 #[test]
2081 fn manual_absorb_errors_when_target_outside_known_mounts() {
2082 let tmp = TempDir::new().unwrap();
2083 let (source, _target) = setup_minimal_dotfiles(&tmp);
2084 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
2085 let stranger = utf8(tmp.path().join("not-managed/foo"));
2086 std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
2087 std::fs::write(&stranger, "not yui's").unwrap();
2088 let err = absorb(Some(source), stranger, false).unwrap_err();
2089 assert!(format!("{err}").contains("no mount entry"));
2090 }
2091
2092 #[test]
2093 fn yuiignore_excludes_file_from_linking() {
2094 let tmp = TempDir::new().unwrap();
2095 let (source, target) = setup_minimal_dotfiles(&tmp);
2096 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
2097 std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
2098 std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
2100 apply(Some(source.clone()), false).unwrap();
2101 assert!(target.join(".bashrc").exists());
2102 assert!(
2103 !target.join("lock.json").exists(),
2104 "yuiignore should keep lock.json out of target"
2105 );
2106 }
2107
2108 #[test]
2109 fn yuiignore_excludes_directory_subtree() {
2110 let tmp = TempDir::new().unwrap();
2111 let (source, target) = setup_minimal_dotfiles(&tmp);
2112 std::fs::create_dir_all(source.join("home/cache")).unwrap();
2113 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
2114 std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
2115 std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
2116 std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
2118 apply(Some(source.clone()), false).unwrap();
2119 assert!(target.join(".bashrc").exists());
2120 assert!(
2121 !target.join("cache").exists(),
2122 "yuiignore'd subtree should not appear in target"
2123 );
2124 }
2125
2126 #[test]
2127 fn yuiignore_negation_re_includes_file() {
2128 let tmp = TempDir::new().unwrap();
2129 let (source, target) = setup_minimal_dotfiles(&tmp);
2130 std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
2131 std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
2132 std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
2134 apply(Some(source.clone()), false).unwrap();
2135 assert!(target.join("keep.cache").exists());
2136 assert!(!target.join("drop.cache").exists());
2137 }
2138
2139 #[test]
2140 fn yuiignore_skips_template_in_render() {
2141 let tmp = TempDir::new().unwrap();
2142 let source = utf8(tmp.path().join("dotfiles"));
2143 let target = utf8(tmp.path().join("target"));
2144 std::fs::create_dir_all(source.join("home")).unwrap();
2145 std::fs::create_dir_all(&target).unwrap();
2146 std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
2147 std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
2148 let cfg = format!(
2149 r#"
2150[[mount.entry]]
2151src = "home"
2152dst = "{}"
2153"#,
2154 toml_path(&target)
2155 );
2156 std::fs::write(source.join("config.toml"), cfg).unwrap();
2157 apply(Some(source.clone()), false).unwrap();
2158 assert!(!source.join("home/note").exists());
2160 assert!(!target.join("note").exists());
2161 assert!(!target.join("note.tera").exists());
2162 }
2163
2164 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
2165 let mut out = Vec::new();
2166 let mut stack = vec![root.to_path_buf()];
2167 while let Some(dir) = stack.pop() {
2168 let Ok(entries) = std::fs::read_dir(&dir) else {
2169 continue;
2170 };
2171 for e in entries.flatten() {
2172 let p = utf8(e.path());
2173 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
2174 stack.push(p);
2175 } else {
2176 out.push(p);
2177 }
2178 }
2179 }
2180 out
2181 }
2182}