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, IconsMode, MountStrategy};
14use crate::icons::Icons;
15use crate::link::{self, EffectiveDirMode, EffectiveFileMode, resolve_dir_mode, resolve_file_mode};
16use crate::marker::{self, MarkerSpec};
17use crate::mount::{self, ResolvedMount};
18use crate::render::{self, RenderReport};
19use crate::template;
20use crate::vars::YuiVars;
21use crate::{absorb, backup, paths};
22
23pub fn init(source: Option<Utf8PathBuf>, _git_hooks: bool) -> Result<()> {
30 let dir = match source {
31 Some(s) => absolutize(&s)?,
32 None => current_dir_utf8()?,
33 };
34 std::fs::create_dir_all(&dir)?;
35 let config_path = dir.join("config.toml");
36 if config_path.exists() {
37 anyhow::bail!("config.toml already exists at {config_path}");
38 }
39 std::fs::write(&config_path, SKELETON_CONFIG)?;
40 let gitignore_path = dir.join(".gitignore");
41 if !gitignore_path.exists() {
42 std::fs::write(&gitignore_path, SKELETON_GITIGNORE)?;
43 }
44 info!("initialized yui source repo at {dir}");
45 info!("created: {config_path}");
46 info!("next: edit config.toml, then run `yui apply`");
47 Ok(())
48}
49
50pub fn apply(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
51 let source = resolve_source(source)?;
52 let yui = YuiVars::detect(&source);
53 let config = config::load(&source, &yui)?;
54
55 let render_report = render::render_all(&source, &config, &yui, dry_run)?;
57 log_render_report(&render_report);
58 if render_report.has_drift() {
59 anyhow::bail!(
60 "render drift detected ({} file(s)); reflect target edits back into the .tera before re-running apply",
61 render_report.diverged.len()
62 );
63 }
64
65 let mut engine = template::Engine::new();
67 let tera_ctx = template::template_context(&yui, &config.vars);
68 let mounts = mount::resolve(
69 &config.mount.entry,
70 config.mount.default_strategy,
71 &mut engine,
72 &tera_ctx,
73 )?;
74
75 let backup_root = source.join(&config.backup.dir);
76 let ctx = ApplyCtx {
77 config: &config,
78 source: &source,
79 file_mode: resolve_file_mode(config.link.file_mode),
80 dir_mode: resolve_dir_mode(config.link.dir_mode),
81 backup_root: &backup_root,
82 dry_run,
83 };
84
85 info!("source: {source}");
86 info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
87 if dry_run {
88 info!("dry-run: nothing will be written");
89 }
90
91 for m in &mounts {
92 info!("mount: {} → {}", m.src, m.dst);
93 process_mount(&source, m, &ctx, &mut engine, &tera_ctx)?;
94 }
95 Ok(())
96}
97
98fn log_render_report(r: &RenderReport) {
99 if !r.written.is_empty() {
100 info!("rendered {} new file(s)", r.written.len());
101 }
102 if !r.unchanged.is_empty() {
103 info!("rendered {} file(s) unchanged", r.unchanged.len());
104 }
105 if !r.skipped_when_false.is_empty() {
106 info!(
107 "skipped {} template(s) (when=false)",
108 r.skipped_when_false.len()
109 );
110 }
111 for d in &r.diverged {
112 warn!("rendered file diverged from template: {d}");
113 }
114}
115
116struct ApplyCtx<'a> {
118 config: &'a Config,
119 source: &'a Utf8Path,
121 file_mode: EffectiveFileMode,
122 dir_mode: EffectiveDirMode,
123 backup_root: &'a Utf8Path,
124 dry_run: bool,
125}
126
127pub fn list(
133 source: Option<Utf8PathBuf>,
134 all: bool,
135 icons_override: Option<IconsMode>,
136 no_color: bool,
137) -> Result<()> {
138 let source = resolve_source(source)?;
139 let yui = YuiVars::detect(&source);
140 let config = config::load(&source, &yui)?;
141
142 let icons_mode = icons_override.unwrap_or(config.ui.icons);
143 let icons = Icons::for_mode(icons_mode);
144 let color = !no_color && supports_color_stdout();
145
146 let items = collect_list_items(&source, &config, &yui)?;
147 let displayed: Vec<&ListItem> = if all {
148 items.iter().collect()
149 } else {
150 items.iter().filter(|i| i.active).collect()
151 };
152
153 print_list_table(&displayed, icons, color);
154
155 let total = items.len();
156 let active = items.iter().filter(|i| i.active).count();
157 let inactive = total - active;
158 println!();
159 if all {
160 println!(" {total} entries · {active} active · {inactive} inactive");
161 } else {
162 println!(
163 " {} of {} entries shown ({} inactive hidden — use --all)",
164 active, total, inactive
165 );
166 }
167 Ok(())
168}
169
170#[derive(Debug)]
171struct ListItem {
172 src: Utf8PathBuf,
173 dst: String,
174 when: Option<String>,
175 active: bool,
176}
177
178fn collect_list_items(source: &Utf8Path, config: &Config, yui: &YuiVars) -> Result<Vec<ListItem>> {
179 let mut engine = template::Engine::new();
180 let tera_ctx = template::template_context(yui, &config.vars);
181 let mut items = Vec::new();
182
183 for entry in &config.mount.entry {
185 let active = match &entry.when {
186 None => true,
187 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
188 };
189 let dst = engine
190 .render(&entry.dst, &tera_ctx)
191 .map(|s| paths::expand_tilde(s.trim()).to_string())
192 .unwrap_or_else(|_| entry.dst.clone());
193 items.push(ListItem {
194 src: entry.src.clone(),
195 dst,
196 when: entry.when.clone(),
197 active,
198 });
199 }
200
201 let walker = paths::source_walker(source).build();
203 let marker_filename = &config.mount.marker_filename;
204 for entry in walker {
205 let entry = match entry {
206 Ok(e) => e,
207 Err(_) => continue,
208 };
209 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
210 continue;
211 }
212 if entry.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
213 continue;
214 }
215 let dir = match entry.path().parent() {
216 Some(d) => d,
217 None => continue,
218 };
219 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
220 Ok(p) => p,
221 Err(_) => continue,
222 };
223 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
224 Some(s) => s,
225 None => continue,
226 };
227 let MarkerSpec::Override { links } = spec else {
228 continue; };
230 let rel = dir_utf8
231 .strip_prefix(source)
232 .map(Utf8PathBuf::from)
233 .unwrap_or(dir_utf8);
234 for link in &links {
235 let active = match &link.when {
236 None => true,
237 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
238 };
239 let dst = engine
240 .render(&link.dst, &tera_ctx)
241 .map(|s| paths::expand_tilde(s.trim()).to_string())
242 .unwrap_or_else(|_| link.dst.clone());
243 items.push(ListItem {
244 src: rel.clone(),
245 dst,
246 when: link.when.clone(),
247 active,
248 });
249 }
250 }
251
252 items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
253 Ok(items)
254}
255
256fn supports_color_stdout() -> bool {
257 use std::io::IsTerminal;
258 std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
259}
260
261fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
262 let src_w = items
263 .iter()
264 .map(|i| i.src.as_str().chars().count())
265 .max()
266 .unwrap_or(0)
267 .max("SRC".len());
268 let dst_w = items
269 .iter()
270 .map(|i| i.dst.chars().count())
271 .max()
272 .unwrap_or(0)
273 .max("DST".len());
274
275 let status_w = "STATUS".len();
276 let arrow_w = icons.arrow.chars().count();
277
278 print_header(status_w, src_w, arrow_w, dst_w, color);
280
281 let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
283 if color {
284 use owo_colors::OwoColorize as _;
285 println!("{}", sep.dimmed());
286 } else {
287 println!("{sep}");
288 }
289
290 for item in items {
292 print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
293 }
294}
295
296fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
297 use owo_colors::OwoColorize as _;
298 let mut line = String::new();
299 let _ = write!(
300 &mut line,
301 " {:<status_w$} {:<src_w$} {:<arrow_w$} {:<dst_w$} WHEN",
302 "STATUS", "SRC", "", "DST"
303 );
304 if color {
305 println!("{}", line.bold());
306 } else {
307 println!("{line}");
308 }
309}
310
311fn render_separator(
312 sep_ch: char,
313 status_w: usize,
314 src_w: usize,
315 arrow_w: usize,
316 dst_w: usize,
317) -> String {
318 let bar = |n: usize| sep_ch.to_string().repeat(n);
319 format!(
320 " {} {} {} {} {}",
321 bar(status_w),
322 bar(src_w),
323 bar(arrow_w),
324 bar(dst_w),
325 bar("WHEN".len())
326 )
327}
328
329fn print_row(
330 item: &ListItem,
331 icons: Icons,
332 status_w: usize,
333 src_w: usize,
334 arrow_w: usize,
335 dst_w: usize,
336 color: bool,
337) {
338 use owo_colors::OwoColorize as _;
339 let status = if item.active {
340 icons.active
341 } else {
342 icons.inactive
343 };
344 let when_str = item
345 .when
346 .as_deref()
347 .map(strip_braces)
348 .unwrap_or_else(|| "(always)".to_string());
349
350 let src_display = item.src.as_str().replace('\\', "/");
352 let src = src_display.as_str();
353 let dst = &item.dst;
354 let arrow = icons.arrow;
355
356 let cell_status = format!("{:<status_w$}", status);
361 let cell_src = format!("{:<src_w$}", src);
362 let cell_arrow = format!("{:<arrow_w$}", arrow);
363 let cell_dst = format!("{:<dst_w$}", dst);
364
365 if !color {
366 println!(" {cell_status} {cell_src} {cell_arrow} {cell_dst} {when_str}");
367 return;
368 }
369
370 if item.active {
371 println!(
372 " {} {} {} {} {}",
373 cell_status.green(),
374 cell_src.cyan(),
375 cell_arrow.dimmed(),
376 cell_dst.green(),
377 when_str.dimmed()
378 );
379 } else {
380 println!(
381 " {} {} {} {} {}",
382 cell_status.red().dimmed(),
383 cell_src.dimmed(),
384 cell_arrow.dimmed(),
385 cell_dst.dimmed(),
386 when_str.dimmed()
387 );
388 }
389}
390
391fn strip_braces(expr: &str) -> String {
394 let trimmed = expr.trim();
395 if let Some(inner) = trimmed
396 .strip_prefix("{{")
397 .and_then(|s| s.strip_suffix("}}"))
398 {
399 inner.trim().to_string()
400 } else {
401 trimmed.to_string()
402 }
403}
404
405pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
406 let source = resolve_source(source)?;
407 let yui = YuiVars::detect(&source);
408 let config = config::load(&source, &yui)?;
409 let report = render::render_all(&source, &config, &yui, dry_run || check)?;
411 log_render_report(&report);
412 if check && report.has_drift() {
413 anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
414 }
415 Ok(())
416}
417
418pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
419 apply(source, dry_run)
421}
422
423pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
424 let _source = resolve_source(source)?;
425 if paths_arg.is_empty() {
426 anyhow::bail!("yui unlink: provide at least one target path");
427 }
428 for p in paths_arg {
429 let abs = absolutize(&p)?;
430 info!("unlink: {abs}");
431 link::unlink(&abs)?;
432 }
433 Ok(())
434}
435
436pub fn status(
449 source: Option<Utf8PathBuf>,
450 icons_override: Option<IconsMode>,
451 no_color: bool,
452) -> Result<()> {
453 let source = resolve_source(source)?;
454 let yui = YuiVars::detect(&source);
455 let config = config::load(&source, &yui)?;
456
457 let mut engine = template::Engine::new();
458 let tera_ctx = template::template_context(&yui, &config.vars);
459 let mounts = mount::resolve(
460 &config.mount.entry,
461 config.mount.default_strategy,
462 &mut engine,
463 &tera_ctx,
464 )?;
465
466 let icons_mode = icons_override.unwrap_or(config.ui.icons);
467 let icons = Icons::for_mode(icons_mode);
468 let color = !no_color && supports_color_stdout();
469
470 let mut report: Vec<StatusItem> = Vec::new();
471
472 let render_report = render::render_all(&source, &config, &yui, true)?;
475 for rendered in &render_report.diverged {
476 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
480 report.push(StatusItem {
481 src: relative_for_display(&source, &tera_path),
482 dst: rendered.clone(),
483 state: StatusState::RenderDrift,
484 });
485 }
486
487 for m in &mounts {
489 let src_root = source.join(&m.src);
490 if !src_root.is_dir() {
491 warn!("mount src missing: {src_root}");
492 continue;
493 }
494 classify_walk(
495 &src_root,
496 &m.dst,
497 &config,
498 m.strategy,
499 &mut engine,
500 &tera_ctx,
501 &source,
502 &mut report,
503 )?;
504 }
505
506 report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
507
508 print_status_table(&report, icons, color);
509
510 let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
511
512 println!();
513 let total = report.len();
514 let in_sync = total - drift;
515 if drift == 0 {
516 println!(" {total} entries · all in sync");
517 Ok(())
518 } else {
519 println!(" {total} entries · {in_sync} in sync · {drift} diverged");
520 anyhow::bail!("status: {drift} entries diverged from source")
521 }
522}
523
524#[derive(Debug)]
525struct StatusItem {
526 src: Utf8PathBuf,
528 dst: Utf8PathBuf,
530 state: StatusState,
531}
532
533#[derive(Debug, Clone, Copy)]
534enum StatusState {
535 Link(absorb::AbsorbDecision),
536 RenderDrift,
539}
540
541impl StatusState {
542 fn is_in_sync(self) -> bool {
543 matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
544 }
545}
546
547#[allow(clippy::too_many_arguments)]
548fn classify_walk(
549 src_dir: &Utf8Path,
550 dst_dir: &Utf8Path,
551 config: &Config,
552 strategy: MountStrategy,
553 engine: &mut template::Engine,
554 tera_ctx: &TeraContext,
555 source_root: &Utf8Path,
556 report: &mut Vec<StatusItem>,
557) -> Result<()> {
558 let marker_filename = &config.mount.marker_filename;
559
560 if strategy == MountStrategy::Marker {
561 match marker::read_spec(src_dir, marker_filename)? {
562 None => {} Some(MarkerSpec::PassThrough) => {
564 let decision = absorb::classify(src_dir, dst_dir)?;
565 report.push(StatusItem {
566 src: relative_for_display(source_root, src_dir),
567 dst: dst_dir.to_path_buf(),
568 state: StatusState::Link(decision),
569 });
570 return Ok(());
571 }
572 Some(MarkerSpec::Override { links }) => {
573 for link in &links {
574 if let Some(when) = &link.when {
575 if !template::eval_truthy(when, engine, tera_ctx)? {
576 continue;
577 }
578 }
579 let dst_str = engine.render(&link.dst, tera_ctx)?;
580 let dst = paths::expand_tilde(dst_str.trim());
581 let decision = absorb::classify(src_dir, &dst)?;
582 report.push(StatusItem {
583 src: relative_for_display(source_root, src_dir),
584 dst,
585 state: StatusState::Link(decision),
586 });
587 }
588 return Ok(());
589 }
590 }
591 }
592
593 for entry in std::fs::read_dir(src_dir)? {
594 let entry = entry?;
595 let name_os = entry.file_name();
596 let Some(name) = name_os.to_str() else {
597 continue;
598 };
599 if name == marker_filename || name.ends_with(".tera") {
600 continue;
601 }
602 let src_path = src_dir.join(name);
603 let dst_path = dst_dir.join(name);
604 let ft = entry.file_type()?;
605 if ft.is_dir() {
606 classify_walk(
607 &src_path,
608 &dst_path,
609 config,
610 strategy,
611 engine,
612 tera_ctx,
613 source_root,
614 report,
615 )?;
616 } else if ft.is_file() {
617 let decision = absorb::classify(&src_path, &dst_path)?;
618 report.push(StatusItem {
619 src: relative_for_display(source_root, &src_path),
620 dst: dst_path,
621 state: StatusState::Link(decision),
622 });
623 }
624 }
625 Ok(())
626}
627
628fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
629 p.strip_prefix(source_root)
630 .map(Utf8PathBuf::from)
631 .unwrap_or_else(|_| p.to_path_buf())
632}
633
634fn print_status_table(items: &[StatusItem], icons: Icons, color: bool) {
635 let src_w = items
636 .iter()
637 .map(|i| i.src.as_str().chars().count())
638 .max()
639 .unwrap_or(0)
640 .max("SRC".len());
641 let dst_w = items
642 .iter()
643 .map(|i| i.dst.as_str().chars().count())
644 .max()
645 .unwrap_or(0)
646 .max("DST".len());
647 let state_label_w = items
649 .iter()
650 .map(|i| state_label(i.state).len())
651 .max()
652 .unwrap_or(0)
653 .max("STATE".len() - 2); let state_w = state_label_w + 2; print_status_header(state_w, src_w, dst_w, color);
657 let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
658 if color {
659 use owo_colors::OwoColorize as _;
660 println!("{}", sep.dimmed());
661 } else {
662 println!("{sep}");
663 }
664 for item in items {
665 print_status_row(item, icons, state_w, src_w, dst_w, color);
666 }
667}
668
669fn state_label(s: StatusState) -> &'static str {
670 use absorb::AbsorbDecision::*;
671 match s {
672 StatusState::Link(InSync) => "in-sync",
673 StatusState::Link(RelinkOnly) => "relink",
674 StatusState::Link(AutoAbsorb) => "drift (auto)",
675 StatusState::Link(NeedsConfirm) => "drift (anomaly)",
676 StatusState::Link(Restore) => "missing",
677 StatusState::RenderDrift => "render drift",
678 }
679}
680
681fn state_icon(s: StatusState, icons: Icons) -> &'static str {
682 use absorb::AbsorbDecision::*;
683 match s {
684 StatusState::Link(InSync) => icons.ok,
685 StatusState::Link(RelinkOnly) => icons.warn,
686 StatusState::Link(AutoAbsorb) => icons.warn,
687 StatusState::Link(NeedsConfirm) => icons.error,
688 StatusState::Link(Restore) => icons.info,
689 StatusState::RenderDrift => icons.error,
690 }
691}
692
693fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
694 use owo_colors::OwoColorize as _;
695 let line = format!(
698 " {:<state_w$} {:<src_w$} {:<dst_w$}",
699 "STATE", "SRC", "DST"
700 );
701 if color {
702 println!("{}", line.bold());
703 } else {
704 println!("{line}");
705 }
706}
707
708fn render_status_separator(
709 sep_ch: char,
710 state_w: usize,
711 src_w: usize,
712 dst_w: usize,
713 arrow: &str,
714) -> String {
715 let bar = |n: usize| sep_ch.to_string().repeat(n);
716 format!(
717 " {} {} {} {}",
718 bar(state_w),
719 bar(src_w),
720 bar(arrow.chars().count()),
721 bar(dst_w)
722 )
723}
724
725fn print_status_row(
726 item: &StatusItem,
727 icons: Icons,
728 state_w: usize,
729 src_w: usize,
730 dst_w: usize,
731 color: bool,
732) {
733 use owo_colors::OwoColorize as _;
734 let icon = state_icon(item.state, icons);
735 let label = state_label(item.state);
736 let state_text = format!("{icon} {label}");
737 let src_display = item.src.as_str().replace('\\', "/");
738 let dst_display = item.dst.as_str().replace('\\', "/");
739 let arrow = icons.arrow;
740
741 let cell_state = format!("{:<state_w$}", state_text);
742 let cell_src = format!("{:<src_w$}", src_display);
743 let cell_dst = format!("{:<dst_w$}", dst_display);
744
745 if !color {
746 println!(" {cell_state} {cell_src} {arrow} {cell_dst}");
747 return;
748 }
749
750 use absorb::AbsorbDecision::*;
751 let state_colored = match item.state {
752 StatusState::Link(InSync) => cell_state.green().to_string(),
753 StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
754 cell_state.yellow().to_string()
755 }
756 StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
757 StatusState::Link(Restore) => cell_state.cyan().to_string(),
758 StatusState::RenderDrift => cell_state.red().to_string(),
759 };
760 let src_colored = cell_src.cyan().to_string();
761 let arrow_colored = arrow.dimmed().to_string();
762 let dst_colored = cell_dst.dimmed().to_string();
763 println!(" {state_colored} {src_colored} {arrow_colored} {dst_colored}");
764}
765
766pub fn absorb(source: Option<Utf8PathBuf>, target: Utf8PathBuf, dry_run: bool) -> Result<()> {
775 let source = resolve_source(source)?;
776 let target = absolutize(&target)?;
777 let yui = YuiVars::detect(&source);
778 let config = config::load(&source, &yui)?;
779
780 let mut engine = template::Engine::new();
781 let tera_ctx = template::template_context(&yui, &config.vars);
782
783 let src_path = match find_source_for_target(&source, &config, &target, &mut engine, &tera_ctx)?
784 {
785 Some(s) => s,
786 None => anyhow::bail!(
787 "no mount entry / .yuilink override claims target {target}; \
788 pass a path inside a known dst"
789 ),
790 };
791
792 info!("source for {target}: {src_path}");
793
794 if dry_run {
795 info!("[dry-run] would absorb {target} → {src_path}");
796 return Ok(());
797 }
798
799 let backup_root = source.join(&config.backup.dir);
800 let ctx = ApplyCtx {
801 config: &config,
802 source: &source,
803 file_mode: resolve_file_mode(config.link.file_mode),
804 dir_mode: resolve_dir_mode(config.link.dir_mode),
805 backup_root: &backup_root,
806 dry_run: false,
807 };
808
809 absorb_target_into_source(&src_path, &target, &ctx)
812}
813
814fn find_source_for_target(
818 source: &Utf8Path,
819 config: &Config,
820 target: &Utf8Path,
821 engine: &mut template::Engine,
822 tera_ctx: &TeraContext,
823) -> Result<Option<Utf8PathBuf>> {
824 for entry in &config.mount.entry {
826 if let Some(when) = &entry.when {
827 if !template::eval_truthy(when, engine, tera_ctx)? {
828 continue;
829 }
830 }
831 let dst_str = engine.render(&entry.dst, tera_ctx)?;
832 let dst_root = paths::expand_tilde(dst_str.trim());
833 if let Ok(rel) = target.strip_prefix(&dst_root) {
834 return Ok(Some(source.join(&entry.src).join(rel)));
835 }
836 }
837
838 let walker = paths::source_walker(source).build();
842 let marker_filename = &config.mount.marker_filename;
843 for ent in walker {
844 let ent = match ent {
845 Ok(e) => e,
846 Err(_) => continue,
847 };
848 if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
849 continue;
850 }
851 if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
852 continue;
853 }
854 let dir = match ent.path().parent() {
855 Some(d) => d,
856 None => continue,
857 };
858 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
859 Ok(p) => p,
860 Err(_) => continue,
861 };
862 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
863 Some(s) => s,
864 None => continue,
865 };
866 let MarkerSpec::Override { links } = spec else {
867 continue;
868 };
869 for link in &links {
870 if let Some(when) = &link.when {
871 if !template::eval_truthy(when, engine, tera_ctx)? {
872 continue;
873 }
874 }
875 let dst_str = engine.render(&link.dst, tera_ctx)?;
876 let dst = paths::expand_tilde(dst_str.trim());
877 if target == dst {
878 return Ok(Some(dir_utf8));
879 }
880 if let Ok(rel) = target.strip_prefix(&dst) {
881 return Ok(Some(dir_utf8.join(rel)));
882 }
883 }
884 }
885
886 Ok(None)
887}
888
889pub fn doctor(source: Option<Utf8PathBuf>) -> Result<()> {
890 let yui = YuiVars::detect(Utf8Path::new("."));
891 println!("yui doctor");
892 println!("==========");
893 println!("os: {}", yui.os);
894 println!("arch: {}", yui.arch);
895 println!("user: {}", yui.user);
896 println!("host: {}", yui.host);
897 match resolve_source(source) {
898 Ok(s) => {
899 println!("source: {s}");
900 match config::load(&s, &yui) {
902 Ok(cfg) => println!(
903 "config: ok ({} mount entries, {} render rules)",
904 cfg.mount.entry.len(),
905 cfg.render.rule.len()
906 ),
907 Err(e) => println!("config: ERROR — {e}"),
908 }
909 }
910 Err(e) => println!("source: NOT FOUND — {e}"),
911 }
912 println!();
913 println!("link mode (auto resolves to):");
914 if cfg!(windows) {
915 println!(" files: hardlink");
916 println!(" dirs: junction");
917 } else {
918 println!(" files: symlink");
919 println!(" dirs: symlink");
920 }
921 Ok(())
922}
923
924pub fn gc_backup(_source: Option<Utf8PathBuf>, _older_than: Option<String>) -> Result<()> {
925 todo!("yui gc-backup — clean up old backups")
926}
927
928fn process_mount(
933 source: &Utf8Path,
934 m: &ResolvedMount,
935 ctx: &ApplyCtx<'_>,
936 engine: &mut template::Engine,
937 tera_ctx: &TeraContext,
938) -> Result<()> {
939 let src_root = source.join(&m.src);
940 if !src_root.is_dir() {
941 warn!("mount src missing: {src_root}");
942 return Ok(());
943 }
944 walk_and_link(&src_root, &m.dst, ctx, m.strategy, engine, tera_ctx)
945}
946
947fn walk_and_link(
948 src_dir: &Utf8Path,
949 dst_dir: &Utf8Path,
950 ctx: &ApplyCtx<'_>,
951 strategy: MountStrategy,
952 engine: &mut template::Engine,
953 tera_ctx: &TeraContext,
954) -> Result<()> {
955 let marker_filename = &ctx.config.mount.marker_filename;
956
957 if strategy == MountStrategy::Marker {
958 match marker::read_spec(src_dir, marker_filename)? {
959 None => {} Some(MarkerSpec::PassThrough) => {
961 link_dir_with_backup(src_dir, dst_dir, ctx)?;
962 return Ok(());
963 }
964 Some(MarkerSpec::Override { links }) => {
965 let mut linked_any = false;
966 for link in &links {
967 if let Some(when) = &link.when {
971 if !template::eval_truthy(when, engine, tera_ctx)? {
972 continue;
973 }
974 }
975 let dst_str = engine.render(&link.dst, tera_ctx)?;
976 let dst = paths::expand_tilde(dst_str.trim());
977 link_dir_with_backup(src_dir, &dst, ctx)?;
978 linked_any = true;
979 }
980 if !linked_any {
981 info!("marker override at {src_dir} had no active links — skipping");
982 }
983 return Ok(());
984 }
985 }
986 }
987
988 for entry in std::fs::read_dir(src_dir)? {
989 let entry = entry?;
990 let name_os = entry.file_name();
991 let Some(name) = name_os.to_str() else {
992 continue;
993 };
994 if name == marker_filename {
995 continue;
996 }
997 if name.ends_with(".tera") {
998 continue;
1000 }
1001
1002 let src_path = src_dir.join(name);
1003 let dst_path = dst_dir.join(name);
1004 let ft = entry.file_type()?;
1005
1006 if ft.is_dir() {
1007 walk_and_link(&src_path, &dst_path, ctx, strategy, engine, tera_ctx)?;
1008 } else if ft.is_file() {
1009 link_file_with_backup(&src_path, &dst_path, ctx)?;
1010 }
1011 }
1012 Ok(())
1013}
1014
1015fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1016 use absorb::AbsorbDecision::*;
1017
1018 let decision = absorb::classify(src, dst)?;
1019
1020 if ctx.dry_run {
1021 info!("[dry-run] {decision:?}: {src} → {dst}");
1022 return Ok(());
1023 }
1024
1025 match decision {
1026 InSync => {
1027 Ok(())
1029 }
1030 Restore => {
1031 info!("link: {src} → {dst}");
1032 link::link_file(src, dst, ctx.file_mode)?;
1033 Ok(())
1034 }
1035 RelinkOnly => {
1036 info!("relink: {src} → {dst}");
1039 link::unlink(dst)?;
1040 link::link_file(src, dst, ctx.file_mode)?;
1041 Ok(())
1042 }
1043 AutoAbsorb => {
1044 if !ctx.config.absorb.auto {
1047 return handle_anomaly(
1048 src,
1049 dst,
1050 ctx,
1051 "absorb.auto = false; treating divergence as anomaly",
1052 );
1053 }
1054 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
1055 return handle_anomaly(
1056 src,
1057 dst,
1058 ctx,
1059 "source repo is dirty; deferring auto-absorb",
1060 );
1061 }
1062 absorb_target_into_source(src, dst, ctx)
1063 }
1064 NeedsConfirm => handle_anomaly(
1065 src,
1066 dst,
1067 ctx,
1068 "anomaly: source equals/newer than target but content differs",
1069 ),
1070 }
1071}
1072
1073fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1077 info!("absorb: {dst} → {src}");
1078 backup_existing(src, ctx.backup_root, false)?;
1079 std::fs::copy(dst, src)?;
1080 link::unlink(dst)?;
1081 link::link_file(src, dst, ctx.file_mode)?;
1082 Ok(())
1083}
1084
1085fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
1091 use crate::config::AnomalyAction::*;
1092 match ctx.config.absorb.on_anomaly {
1093 Skip => {
1094 warn!("anomaly skip: {dst} ({reason})");
1095 Ok(())
1096 }
1097 Force => {
1098 warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
1099 absorb_target_into_source(src, dst, ctx)
1100 }
1101 Ask => {
1102 use std::io::IsTerminal;
1103 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
1104 if prompt_absorb_with_diff(src, dst, reason)? {
1105 absorb_target_into_source(src, dst, ctx)
1106 } else {
1107 warn!("anomaly skipped by user: {dst}");
1108 Ok(())
1109 }
1110 } else {
1111 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
1112 Ok(())
1113 }
1114 }
1115 }
1116}
1117
1118fn prompt_absorb_with_diff(src: &Utf8Path, dst: &Utf8Path, reason: &str) -> Result<bool> {
1119 use std::io::Write as _;
1120 let src_content = std::fs::read_to_string(src).unwrap_or_default();
1121 let dst_content = std::fs::read_to_string(dst).unwrap_or_default();
1122 eprintln!();
1123 eprintln!("anomaly: {reason}");
1124 eprintln!(" src: {src}");
1125 eprintln!(" dst: {dst}");
1126 eprintln!();
1127 eprintln!("--- diff (- source, + target) ---");
1128 let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
1129 for change in diff.iter_all_changes() {
1130 let sign = match change.tag() {
1131 similar::ChangeTag::Delete => "-",
1132 similar::ChangeTag::Insert => "+",
1133 similar::ChangeTag::Equal => " ",
1134 };
1135 eprint!("{sign}{change}");
1136 }
1137 eprintln!();
1138 eprint!("absorb target into source? [y/N]: ");
1139 std::io::stderr().flush().ok();
1144 let mut input = String::new();
1145 std::io::stdin().read_line(&mut input)?;
1146 let answer = input.trim();
1147 Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
1148}
1149
1150fn source_repo_is_clean(source: &Utf8Path) -> bool {
1155 match crate::git::is_clean(source) {
1156 Ok(b) => b,
1157 Err(e) => {
1158 warn!("git clean check failed at {source}: {e} — treating as clean");
1159 true
1160 }
1161 }
1162}
1163
1164fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1165 use absorb::AbsorbDecision::*;
1166 let decision = absorb::classify(src, dst)?;
1167
1168 if ctx.dry_run {
1169 info!("[dry-run] dir {decision:?}: {src} → {dst}");
1170 return Ok(());
1171 }
1172
1173 match decision {
1174 InSync => Ok(()),
1175 Restore => {
1176 info!("link dir: {src} → {dst}");
1177 link::link_dir(src, dst, ctx.dir_mode)?;
1178 Ok(())
1179 }
1180 _ => {
1181 backup_existing(dst, ctx.backup_root, true)?;
1187 link::unlink(dst)?;
1188 info!("relink dir: {src} → {dst}");
1189 link::link_dir(src, dst, ctx.dir_mode)?;
1190 Ok(())
1191 }
1192 }
1193}
1194
1195fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
1196 let abs_target = absolutize(target)?;
1197 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
1198 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
1199 info!("backup → {bp}");
1200 if is_dir {
1201 backup::backup_dir(target, &bp)?;
1202 } else {
1203 backup::backup_file(target, &bp)?;
1204 }
1205 Ok(())
1206}
1207
1208fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
1209 if let Some(s) = source {
1210 return absolutize(&s);
1211 }
1212 if let Ok(s) = std::env::var("YUI_SOURCE") {
1213 return absolutize(Utf8Path::new(&s));
1214 }
1215 let cwd = current_dir_utf8()?;
1216 for ancestor in cwd.ancestors() {
1217 if ancestor.join("config.toml").is_file() {
1218 return Ok(ancestor.to_path_buf());
1219 }
1220 }
1221 if let Some(home) = paths::home_dir() {
1222 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
1223 let p = home.join(c);
1224 if p.join("config.toml").is_file() {
1225 return Ok(p);
1226 }
1227 }
1228 }
1229 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
1230}
1231
1232fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
1233 let expanded = paths::expand_tilde(p.as_str());
1235 if expanded.is_absolute() {
1236 return Ok(expanded);
1237 }
1238 let cwd = current_dir_utf8()?;
1239 Ok(cwd.join(expanded))
1240}
1241
1242fn current_dir_utf8() -> Result<Utf8PathBuf> {
1243 let cwd = std::env::current_dir().context("getting cwd")?;
1244 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
1245}
1246
1247const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
1251
1252[vars]
1253# user-defined values; templates can reference these as {{ vars.foo }}
1254
1255# [link]
1256# file_mode = "auto" # auto | symlink | hardlink
1257# dir_mode = "auto" # auto | symlink | junction
1258
1259[mount]
1260default_strategy = "marker"
1261
1262[[mount.entry]]
1263src = "home"
1264# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
1265dst = "~"
1266
1267# [[mount.entry]]
1268# src = "appdata"
1269# dst = "{{ env(name='APPDATA') }}"
1270# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
1271# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
1272# when = "yui.os == 'windows'"
1273"#;
1274
1275const SKELETON_GITIGNORE: &str = r#"# yui internals (regenerable, do not commit)
1276/.yui/
1277
1278# >>> yui rendered (auto-managed, do not edit) >>>
1279# <<< yui rendered (auto-managed) <<<
1280
1281# config.local.toml is per-machine; commit a config.local.example.toml instead.
1282config.local.toml
1283"#;
1284
1285#[cfg(test)]
1286mod tests {
1287 use super::*;
1288 use tempfile::TempDir;
1289
1290 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
1291 Utf8PathBuf::from_path_buf(p).unwrap()
1292 }
1293
1294 fn toml_path(p: &Utf8Path) -> String {
1296 p.as_str().replace('\\', "/")
1297 }
1298
1299 #[test]
1300 fn apply_links_a_raw_file() {
1301 let tmp = TempDir::new().unwrap();
1302 let source = utf8(tmp.path().join("dotfiles"));
1303 let target = utf8(tmp.path().join("target"));
1304 std::fs::create_dir_all(source.join("home")).unwrap();
1305 std::fs::create_dir_all(&target).unwrap();
1306 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
1307
1308 let cfg = format!(
1309 r#"
1310[[mount.entry]]
1311src = "home"
1312dst = "{}"
1313"#,
1314 toml_path(&target)
1315 );
1316 std::fs::write(source.join("config.toml"), cfg).unwrap();
1317
1318 apply(Some(source), false).unwrap();
1319
1320 let linked = target.join(".bashrc");
1321 assert!(linked.exists(), "expected {linked} to exist");
1322 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
1323 }
1324
1325 #[test]
1326 fn apply_with_marker_links_whole_directory() {
1327 let tmp = TempDir::new().unwrap();
1328 let source = utf8(tmp.path().join("dotfiles"));
1329 let target = utf8(tmp.path().join("target"));
1330 let nvim_src = source.join("home/nvim");
1331 std::fs::create_dir_all(&nvim_src).unwrap();
1332 std::fs::create_dir_all(&target).unwrap();
1333 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
1334 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
1335 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
1336
1337 let cfg = format!(
1338 r#"
1339[[mount.entry]]
1340src = "home"
1341dst = "{}"
1342"#,
1343 toml_path(&target)
1344 );
1345 std::fs::write(source.join("config.toml"), cfg).unwrap();
1346
1347 apply(Some(source.clone()), false).unwrap();
1348
1349 let nvim_dst = target.join("nvim");
1350 assert!(nvim_dst.exists());
1351 assert_eq!(
1352 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
1353 "-- hi\n"
1354 );
1355 }
1359
1360 #[test]
1361 fn apply_dry_run_does_not_write() {
1362 let tmp = TempDir::new().unwrap();
1363 let source = utf8(tmp.path().join("dotfiles"));
1364 let target = utf8(tmp.path().join("target"));
1365 std::fs::create_dir_all(source.join("home")).unwrap();
1366 std::fs::create_dir_all(&target).unwrap();
1367 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
1368
1369 let cfg = format!(
1370 r#"
1371[[mount.entry]]
1372src = "home"
1373dst = "{}"
1374"#,
1375 toml_path(&target)
1376 );
1377 std::fs::write(source.join("config.toml"), cfg).unwrap();
1378
1379 apply(Some(source), true).unwrap();
1380
1381 assert!(!target.join(".bashrc").exists());
1382 }
1383
1384 #[test]
1385 fn apply_renders_templates_then_links_rendered_outputs() {
1386 let tmp = TempDir::new().unwrap();
1387 let source = utf8(tmp.path().join("dotfiles"));
1388 let target = utf8(tmp.path().join("target"));
1389 std::fs::create_dir_all(source.join("home")).unwrap();
1390 std::fs::create_dir_all(&target).unwrap();
1391 std::fs::write(
1392 source.join("home/.gitconfig.tera"),
1393 "[user]\n os = {{ yui.os }}\n",
1394 )
1395 .unwrap();
1396 std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
1397
1398 let cfg = format!(
1399 r#"
1400[[mount.entry]]
1401src = "home"
1402dst = "{}"
1403"#,
1404 toml_path(&target)
1405 );
1406 std::fs::write(source.join("config.toml"), cfg).unwrap();
1407
1408 apply(Some(source.clone()), false).unwrap();
1409
1410 assert!(target.join(".bashrc").exists());
1412 assert!(source.join("home/.gitconfig").exists());
1414 assert!(target.join(".gitconfig").exists());
1415 assert!(!target.join(".gitconfig.tera").exists());
1417 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
1419 assert!(linked.contains("os = "));
1420 }
1421
1422 #[test]
1423 fn apply_marker_override_links_to_custom_dst() {
1424 let tmp = TempDir::new().unwrap();
1425 let source = utf8(tmp.path().join("dotfiles"));
1426 let target_a = utf8(tmp.path().join("target_a"));
1427 let target_b = utf8(tmp.path().join("target_b"));
1428 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
1429 std::fs::create_dir_all(&target_a).unwrap();
1430 std::fs::create_dir_all(&target_b).unwrap();
1431 std::fs::write(
1432 source.join("home/.config/nvim/init.lua"),
1433 "-- nvim config\n",
1434 )
1435 .unwrap();
1436
1437 std::fs::write(
1440 source.join("home/.config/nvim/.yuilink"),
1441 format!(
1442 r#"
1443[[link]]
1444dst = "{}/nvim"
1445
1446[[link]]
1447dst = "{}/nvim"
1448when = "{{{{ yui.os == '{}' }}}}"
1449"#,
1450 toml_path(&target_a),
1451 toml_path(&target_b),
1452 std::env::consts::OS
1453 ),
1454 )
1455 .unwrap();
1456
1457 let parent_target = utf8(tmp.path().join("parent_target"));
1458 std::fs::create_dir_all(&parent_target).unwrap();
1459 let cfg = format!(
1460 r#"
1461[[mount.entry]]
1462src = "home"
1463dst = "{}"
1464"#,
1465 toml_path(&parent_target)
1466 );
1467 std::fs::write(source.join("config.toml"), cfg).unwrap();
1468
1469 apply(Some(source.clone()), false).unwrap();
1470
1471 assert!(
1473 target_a.join("nvim/init.lua").exists(),
1474 "target_a/nvim/init.lua should be reachable through the link"
1475 );
1476 assert!(
1477 target_b.join("nvim/init.lua").exists(),
1478 "target_b/nvim/init.lua should be reachable through the link"
1479 );
1480 assert!(
1483 !parent_target.join(".config/nvim").exists(),
1484 "parent mount should have skipped the marker-claimed sub-dir"
1485 );
1486 }
1487
1488 #[test]
1489 fn apply_marker_override_skips_inactive_link() {
1490 let tmp = TempDir::new().unwrap();
1491 let source = utf8(tmp.path().join("dotfiles"));
1492 let target_inactive = utf8(tmp.path().join("inactive"));
1493 let parent_target = utf8(tmp.path().join("parent"));
1494 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
1495 std::fs::create_dir_all(&parent_target).unwrap();
1496 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
1497
1498 std::fs::write(
1500 source.join("home/.config/nvim/.yuilink"),
1501 format!(
1502 r#"
1503[[link]]
1504dst = "{}/nvim"
1505when = "{{{{ yui.os == 'no-such-os' }}}}"
1506"#,
1507 toml_path(&target_inactive)
1508 ),
1509 )
1510 .unwrap();
1511
1512 let cfg = format!(
1513 r#"
1514[[mount.entry]]
1515src = "home"
1516dst = "{}"
1517"#,
1518 toml_path(&parent_target)
1519 );
1520 std::fs::write(source.join("config.toml"), cfg).unwrap();
1521
1522 apply(Some(source.clone()), false).unwrap();
1523
1524 assert!(!target_inactive.join("nvim").exists());
1526 assert!(!parent_target.join(".config/nvim").exists());
1529 }
1530
1531 #[test]
1532 fn list_shows_mount_entries_and_marker_overrides() {
1533 let tmp = TempDir::new().unwrap();
1534 let source = utf8(tmp.path().join("dotfiles"));
1535 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
1536 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
1537 std::fs::write(
1538 source.join("home/.config/nvim/.yuilink"),
1539 r#"
1540[[link]]
1541dst = "/custom/nvim"
1542"#,
1543 )
1544 .unwrap();
1545 std::fs::write(
1546 source.join("config.toml"),
1547 r#"
1548[[mount.entry]]
1549src = "home"
1550dst = "/h"
1551"#,
1552 )
1553 .unwrap();
1554
1555 list(Some(source), false, None, true).unwrap();
1558 }
1559
1560 #[test]
1561 fn status_reports_in_sync_after_apply() {
1562 let tmp = TempDir::new().unwrap();
1563 let source = utf8(tmp.path().join("dotfiles"));
1564 let target = utf8(tmp.path().join("target"));
1565 std::fs::create_dir_all(source.join("home")).unwrap();
1566 std::fs::create_dir_all(&target).unwrap();
1567 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
1568 let cfg = format!(
1569 r#"
1570[[mount.entry]]
1571src = "home"
1572dst = "{}"
1573"#,
1574 toml_path(&target)
1575 );
1576 std::fs::write(source.join("config.toml"), cfg).unwrap();
1577 apply(Some(source.clone()), false).unwrap();
1579 status(Some(source), None, true).unwrap();
1581 }
1582
1583 #[test]
1584 fn status_reports_template_drift() {
1585 let tmp = TempDir::new().unwrap();
1586 let source = utf8(tmp.path().join("dotfiles"));
1587 let target = utf8(tmp.path().join("target"));
1588 std::fs::create_dir_all(source.join("home")).unwrap();
1589 std::fs::create_dir_all(&target).unwrap();
1590 std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
1593 std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
1594
1595 let cfg = format!(
1596 r#"
1597[[mount.entry]]
1598src = "home"
1599dst = "{}"
1600"#,
1601 toml_path(&target)
1602 );
1603 std::fs::write(source.join("config.toml"), cfg).unwrap();
1604
1605 let err = status(Some(source), None, true).unwrap_err();
1606 assert!(format!("{err}").contains("diverged"));
1607 }
1608
1609 #[test]
1610 fn status_fails_when_target_missing() {
1611 let tmp = TempDir::new().unwrap();
1612 let source = utf8(tmp.path().join("dotfiles"));
1613 let target = utf8(tmp.path().join("target"));
1614 std::fs::create_dir_all(source.join("home")).unwrap();
1615 std::fs::create_dir_all(&target).unwrap();
1616 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
1617 let cfg = format!(
1618 r#"
1619[[mount.entry]]
1620src = "home"
1621dst = "{}"
1622"#,
1623 toml_path(&target)
1624 );
1625 std::fs::write(source.join("config.toml"), cfg).unwrap();
1626 let err = status(Some(source), None, true).unwrap_err();
1628 assert!(format!("{err}").contains("diverged"));
1629 }
1630
1631 #[test]
1632 fn strip_braces_removes_outer_template_braces() {
1633 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
1634 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
1635 assert_eq!(strip_braces(" {{x}} "), "x");
1636 }
1637
1638 #[test]
1639 fn apply_aborts_on_render_drift() {
1640 let tmp = TempDir::new().unwrap();
1641 let source = utf8(tmp.path().join("dotfiles"));
1642 let target = utf8(tmp.path().join("target"));
1643 std::fs::create_dir_all(source.join("home")).unwrap();
1644 std::fs::create_dir_all(&target).unwrap();
1645 std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
1646 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
1647
1648 let cfg = format!(
1649 r#"
1650[[mount.entry]]
1651src = "home"
1652dst = "{}"
1653"#,
1654 toml_path(&target)
1655 );
1656 std::fs::write(source.join("config.toml"), cfg).unwrap();
1657
1658 let err = apply(Some(source.clone()), false).unwrap_err();
1659 assert!(format!("{err}").contains("drift"));
1660 assert_eq!(
1662 std::fs::read_to_string(source.join("home/foo")).unwrap(),
1663 "manually edited"
1664 );
1665 assert!(!target.join("foo").exists());
1667 }
1668
1669 #[test]
1670 fn init_creates_skeleton_when_dir_empty() {
1671 let tmp = TempDir::new().unwrap();
1672 let dir = utf8(tmp.path().join("new_dotfiles"));
1673 init(Some(dir.clone()), false).unwrap();
1674 assert!(dir.join("config.toml").is_file());
1675 assert!(dir.join(".gitignore").is_file());
1676 }
1677
1678 #[test]
1679 fn init_refuses_to_overwrite_existing_config() {
1680 let tmp = TempDir::new().unwrap();
1681 let dir = utf8(tmp.path().join("dotfiles"));
1682 std::fs::create_dir_all(&dir).unwrap();
1683 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
1684 let err = init(Some(dir), false).unwrap_err();
1685 assert!(format!("{err}").contains("already exists"));
1686 }
1687
1688 fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
1691 let source = utf8(tmp.path().join("dotfiles"));
1692 let target = utf8(tmp.path().join("target"));
1693 std::fs::create_dir_all(source.join("home")).unwrap();
1694 std::fs::create_dir_all(&target).unwrap();
1695 let cfg = format!(
1696 r#"
1697[[mount.entry]]
1698src = "home"
1699dst = "{}"
1700"#,
1701 toml_path(&target)
1702 );
1703 std::fs::write(source.join("config.toml"), cfg).unwrap();
1704 (source, target)
1705 }
1706
1707 fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
1708 std::fs::write(path, body).unwrap();
1709 let f = std::fs::OpenOptions::new()
1710 .write(true)
1711 .open(path)
1712 .expect("open writable");
1713 f.set_modified(when).expect("set_modified");
1714 }
1715
1716 #[test]
1717 fn apply_target_newer_absorbs_target_into_source() {
1718 let tmp = TempDir::new().unwrap();
1722 let (source, target) = setup_minimal_dotfiles(&tmp);
1723
1724 let now = std::time::SystemTime::now();
1725 let past = now - std::time::Duration::from_secs(120);
1726 write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
1727 write_with_mtime(&target.join(".bashrc"), "user's edit", now);
1729
1730 apply(Some(source.clone()), false).unwrap();
1731
1732 assert_eq!(
1734 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
1735 "user's edit"
1736 );
1737 assert_eq!(
1739 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
1740 "user's edit"
1741 );
1742 let backup_root = source.join(".yui/backup");
1744 let mut found_old = false;
1745 for entry in walkdir(&backup_root) {
1746 if let Ok(s) = std::fs::read_to_string(&entry) {
1747 if s == "default from repo" {
1748 found_old = true;
1749 break;
1750 }
1751 }
1752 }
1753 assert!(found_old, "expected backup containing 'default from repo'");
1754 }
1755
1756 #[test]
1757 fn apply_in_sync_target_is_a_no_op() {
1758 let tmp = TempDir::new().unwrap();
1761 let (source, target) = setup_minimal_dotfiles(&tmp);
1762 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
1763 apply(Some(source.clone()), false).unwrap();
1764 let backup_root = source.join(".yui/backup");
1765 let backup_count_after_first = walkdir(&backup_root).len();
1766
1767 apply(Some(source.clone()), false).unwrap();
1769 assert_eq!(
1770 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
1771 "echo hi\n"
1772 );
1773 let backup_count_after_second = walkdir(&backup_root).len();
1774 assert_eq!(
1775 backup_count_after_first, backup_count_after_second,
1776 "second apply on an in-sync tree should not produce backups"
1777 );
1778 }
1779
1780 #[test]
1781 fn apply_skip_policy_leaves_anomaly_alone() {
1782 let tmp = TempDir::new().unwrap();
1785 let source = utf8(tmp.path().join("dotfiles"));
1786 let target = utf8(tmp.path().join("target"));
1787 std::fs::create_dir_all(source.join("home")).unwrap();
1788 std::fs::create_dir_all(&target).unwrap();
1789 let cfg = format!(
1790 r#"
1791[absorb]
1792on_anomaly = "skip"
1793
1794[[mount.entry]]
1795src = "home"
1796dst = "{}"
1797"#,
1798 toml_path(&target)
1799 );
1800 std::fs::write(source.join("config.toml"), cfg).unwrap();
1801
1802 let now = std::time::SystemTime::now();
1803 let past = now - std::time::Duration::from_secs(120);
1804 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
1805 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
1806
1807 apply(Some(source.clone()), false).unwrap();
1808
1809 assert_eq!(
1811 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
1812 "user's edit (older)"
1813 );
1814 assert_eq!(
1816 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
1817 "fresh from upstream"
1818 );
1819 }
1820
1821 #[test]
1822 fn apply_force_policy_absorbs_anomaly_anyway() {
1823 let tmp = TempDir::new().unwrap();
1825 let source = utf8(tmp.path().join("dotfiles"));
1826 let target = utf8(tmp.path().join("target"));
1827 std::fs::create_dir_all(source.join("home")).unwrap();
1828 std::fs::create_dir_all(&target).unwrap();
1829 let cfg = format!(
1830 r#"
1831[absorb]
1832on_anomaly = "force"
1833
1834[[mount.entry]]
1835src = "home"
1836dst = "{}"
1837"#,
1838 toml_path(&target)
1839 );
1840 std::fs::write(source.join("config.toml"), cfg).unwrap();
1841
1842 let now = std::time::SystemTime::now();
1843 let past = now - std::time::Duration::from_secs(120);
1844 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
1845 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
1846
1847 apply(Some(source.clone()), false).unwrap();
1848
1849 assert_eq!(
1851 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
1852 "user's edit (older)"
1853 );
1854 assert_eq!(
1855 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
1856 "user's edit (older)"
1857 );
1858 }
1859
1860 #[test]
1861 fn manual_absorb_command_pulls_target_into_source() {
1862 let tmp = TempDir::new().unwrap();
1864 let source = utf8(tmp.path().join("dotfiles"));
1865 let target = utf8(tmp.path().join("target"));
1866 std::fs::create_dir_all(source.join("home")).unwrap();
1867 std::fs::create_dir_all(&target).unwrap();
1868 let cfg = format!(
1870 r#"
1871[absorb]
1872on_anomaly = "skip"
1873
1874[[mount.entry]]
1875src = "home"
1876dst = "{}"
1877"#,
1878 toml_path(&target)
1879 );
1880 std::fs::write(source.join("config.toml"), cfg).unwrap();
1881 std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
1882 std::fs::write(source.join("home/.bashrc"), "default").unwrap();
1883
1884 absorb(
1886 Some(source.clone()),
1887 target.join(".bashrc"),
1888 false,
1889 )
1890 .unwrap();
1891
1892 assert_eq!(
1894 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
1895 "user picked this"
1896 );
1897 }
1898
1899 #[test]
1900 fn manual_absorb_errors_when_target_outside_known_mounts() {
1901 let tmp = TempDir::new().unwrap();
1902 let (source, _target) = setup_minimal_dotfiles(&tmp);
1903 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
1904 let stranger = utf8(tmp.path().join("not-managed/foo"));
1905 std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
1906 std::fs::write(&stranger, "not yui's").unwrap();
1907 let err = absorb(Some(source), stranger, false).unwrap_err();
1908 assert!(format!("{err}").contains("no mount entry"));
1909 }
1910
1911 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
1912 let mut out = Vec::new();
1913 let mut stack = vec![root.to_path_buf()];
1914 while let Some(dir) = stack.pop() {
1915 let Ok(entries) = std::fs::read_dir(&dir) else {
1916 continue;
1917 };
1918 for e in entries.flatten() {
1919 let p = utf8(e.path());
1920 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
1921 stack.push(p);
1922 } else {
1923 out.push(p);
1924 }
1925 }
1926 }
1927 out
1928 }
1929}