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::{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 file_mode: resolve_file_mode(config.link.file_mode),
79 dir_mode: resolve_dir_mode(config.link.dir_mode),
80 backup_root: &backup_root,
81 dry_run,
82 };
83
84 info!("source: {source}");
85 info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
86 if dry_run {
87 info!("dry-run: nothing will be written");
88 }
89
90 for m in &mounts {
91 info!("mount: {} → {}", m.src, m.dst);
92 process_mount(&source, m, &ctx, &mut engine, &tera_ctx)?;
93 }
94 Ok(())
95}
96
97fn log_render_report(r: &RenderReport) {
98 if !r.written.is_empty() {
99 info!("rendered {} new file(s)", r.written.len());
100 }
101 if !r.unchanged.is_empty() {
102 info!("rendered {} file(s) unchanged", r.unchanged.len());
103 }
104 if !r.skipped_when_false.is_empty() {
105 info!(
106 "skipped {} template(s) (when=false)",
107 r.skipped_when_false.len()
108 );
109 }
110 for d in &r.diverged {
111 warn!("rendered file diverged from template: {d}");
112 }
113}
114
115struct ApplyCtx<'a> {
117 config: &'a Config,
118 file_mode: EffectiveFileMode,
119 dir_mode: EffectiveDirMode,
120 backup_root: &'a Utf8Path,
121 dry_run: bool,
122}
123
124pub fn list(
130 source: Option<Utf8PathBuf>,
131 all: bool,
132 icons_override: Option<IconsMode>,
133 no_color: bool,
134) -> Result<()> {
135 let source = resolve_source(source)?;
136 let yui = YuiVars::detect(&source);
137 let config = config::load(&source, &yui)?;
138
139 let icons_mode = icons_override.unwrap_or(config.ui.icons);
140 let icons = Icons::for_mode(icons_mode);
141 let color = !no_color && supports_color_stdout();
142
143 let items = collect_list_items(&source, &config, &yui)?;
144 let displayed: Vec<&ListItem> = if all {
145 items.iter().collect()
146 } else {
147 items.iter().filter(|i| i.active).collect()
148 };
149
150 print_list_table(&displayed, icons, color);
151
152 let total = items.len();
153 let active = items.iter().filter(|i| i.active).count();
154 let inactive = total - active;
155 println!();
156 if all {
157 println!(" {total} entries · {active} active · {inactive} inactive");
158 } else {
159 println!(
160 " {} of {} entries shown ({} inactive hidden — use --all)",
161 active, total, inactive
162 );
163 }
164 Ok(())
165}
166
167#[derive(Debug)]
168struct ListItem {
169 src: Utf8PathBuf,
170 dst: String,
171 when: Option<String>,
172 active: bool,
173}
174
175fn collect_list_items(source: &Utf8Path, config: &Config, yui: &YuiVars) -> Result<Vec<ListItem>> {
176 let mut engine = template::Engine::new();
177 let tera_ctx = template::template_context(yui, &config.vars);
178 let mut items = Vec::new();
179
180 for entry in &config.mount.entry {
182 let active = match &entry.when {
183 None => true,
184 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
185 };
186 let dst = engine
187 .render(&entry.dst, &tera_ctx)
188 .map(|s| s.trim().to_string())
189 .unwrap_or_else(|_| entry.dst.clone());
190 items.push(ListItem {
191 src: entry.src.clone(),
192 dst,
193 when: entry.when.clone(),
194 active,
195 });
196 }
197
198 let walker = ignore::WalkBuilder::new(source)
200 .hidden(false)
201 .git_ignore(false)
202 .ignore(false)
203 .build();
204 let marker_filename = &config.mount.marker_filename;
205 for entry in walker {
206 let entry = match entry {
207 Ok(e) => e,
208 Err(_) => continue,
209 };
210 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
211 continue;
212 }
213 if entry.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
214 continue;
215 }
216 let dir = match entry.path().parent() {
217 Some(d) => d,
218 None => continue,
219 };
220 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
221 Ok(p) => p,
222 Err(_) => continue,
223 };
224 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
225 Some(s) => s,
226 None => continue,
227 };
228 let MarkerSpec::Override { links } = spec else {
229 continue; };
231 let rel = dir_utf8
232 .strip_prefix(source)
233 .map(Utf8PathBuf::from)
234 .unwrap_or(dir_utf8);
235 for link in &links {
236 let active = match &link.when {
237 None => true,
238 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
239 };
240 let dst = engine
241 .render(&link.dst, &tera_ctx)
242 .map(|s| s.trim().to_string())
243 .unwrap_or_else(|_| link.dst.clone());
244 items.push(ListItem {
245 src: rel.clone(),
246 dst,
247 when: link.when.clone(),
248 active,
249 });
250 }
251 }
252
253 items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
254 Ok(items)
255}
256
257fn supports_color_stdout() -> bool {
258 use std::io::IsTerminal;
259 std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
260}
261
262fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
263 let src_w = items
264 .iter()
265 .map(|i| i.src.as_str().chars().count())
266 .max()
267 .unwrap_or(0)
268 .max("SRC".len());
269 let dst_w = items
270 .iter()
271 .map(|i| i.dst.chars().count())
272 .max()
273 .unwrap_or(0)
274 .max("DST".len());
275
276 let status_w = "STATUS".len();
277 let arrow_w = icons.arrow.chars().count();
278
279 print_header(status_w, src_w, arrow_w, dst_w, color);
281
282 let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
284 if color {
285 use owo_colors::OwoColorize as _;
286 println!("{}", sep.dimmed());
287 } else {
288 println!("{sep}");
289 }
290
291 for item in items {
293 print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
294 }
295}
296
297fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
298 use owo_colors::OwoColorize as _;
299 let mut line = String::new();
300 let _ = write!(
301 &mut line,
302 " {:<status_w$} {:<src_w$} {:<arrow_w$} {:<dst_w$} WHEN",
303 "STATUS", "SRC", "", "DST"
304 );
305 if color {
306 println!("{}", line.bold());
307 } else {
308 println!("{line}");
309 }
310}
311
312fn render_separator(
313 sep_ch: char,
314 status_w: usize,
315 src_w: usize,
316 arrow_w: usize,
317 dst_w: usize,
318) -> String {
319 let bar = |n: usize| sep_ch.to_string().repeat(n);
320 format!(
321 " {} {} {} {} {}",
322 bar(status_w),
323 bar(src_w),
324 bar(arrow_w),
325 bar(dst_w),
326 bar("WHEN".len())
327 )
328}
329
330fn print_row(
331 item: &ListItem,
332 icons: Icons,
333 status_w: usize,
334 src_w: usize,
335 arrow_w: usize,
336 dst_w: usize,
337 color: bool,
338) {
339 use owo_colors::OwoColorize as _;
340 let status = if item.active {
341 icons.active
342 } else {
343 icons.inactive
344 };
345 let when_str = item
346 .when
347 .as_deref()
348 .map(strip_braces)
349 .unwrap_or_else(|| "(always)".to_string());
350
351 let src_display = item.src.as_str().replace('\\', "/");
353 let src = src_display.as_str();
354 let dst = &item.dst;
355 let arrow = icons.arrow;
356
357 let cell_status = format!("{:<status_w$}", status);
362 let cell_src = format!("{:<src_w$}", src);
363 let cell_arrow = format!("{:<arrow_w$}", arrow);
364 let cell_dst = format!("{:<dst_w$}", dst);
365
366 if !color {
367 println!(" {cell_status} {cell_src} {cell_arrow} {cell_dst} {when_str}");
368 return;
369 }
370
371 if item.active {
372 println!(
373 " {} {} {} {} {}",
374 cell_status.green(),
375 cell_src.cyan(),
376 cell_arrow.dimmed(),
377 cell_dst.green(),
378 when_str.dimmed()
379 );
380 } else {
381 println!(
382 " {} {} {} {} {}",
383 cell_status.red().dimmed(),
384 cell_src.dimmed(),
385 cell_arrow.dimmed(),
386 cell_dst.dimmed(),
387 when_str.dimmed()
388 );
389 }
390}
391
392fn strip_braces(expr: &str) -> String {
395 let trimmed = expr.trim();
396 if let Some(inner) = trimmed
397 .strip_prefix("{{")
398 .and_then(|s| s.strip_suffix("}}"))
399 {
400 inner.trim().to_string()
401 } else {
402 trimmed.to_string()
403 }
404}
405
406pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
407 let source = resolve_source(source)?;
408 let yui = YuiVars::detect(&source);
409 let config = config::load(&source, &yui)?;
410 let report = render::render_all(&source, &config, &yui, dry_run || check)?;
412 log_render_report(&report);
413 if check && report.has_drift() {
414 anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
415 }
416 Ok(())
417}
418
419pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
420 apply(source, dry_run)
422}
423
424pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
425 let _source = resolve_source(source)?;
426 if paths_arg.is_empty() {
427 anyhow::bail!("yui unlink: provide at least one target path");
428 }
429 for p in paths_arg {
430 let abs = absolutize(&p)?;
431 info!("unlink: {abs}");
432 link::unlink(&abs)?;
433 }
434 Ok(())
435}
436
437pub fn status(_source: Option<Utf8PathBuf>) -> Result<()> {
438 todo!("yui status — drift detection (needs absorb classifier)")
439}
440
441pub fn absorb(_source: Option<Utf8PathBuf>, _target: Utf8PathBuf, _dry_run: bool) -> Result<()> {
442 todo!("yui absorb — manual absorb (needs absorb classifier)")
443}
444
445pub fn doctor(source: Option<Utf8PathBuf>) -> Result<()> {
446 let yui = YuiVars::detect(Utf8Path::new("."));
447 println!("yui doctor");
448 println!("==========");
449 println!("os: {}", yui.os);
450 println!("arch: {}", yui.arch);
451 println!("user: {}", yui.user);
452 println!("host: {}", yui.host);
453 match resolve_source(source) {
454 Ok(s) => {
455 println!("source: {s}");
456 match config::load(&s, &yui) {
458 Ok(cfg) => println!(
459 "config: ok ({} mount entries, {} render rules)",
460 cfg.mount.entry.len(),
461 cfg.render.rule.len()
462 ),
463 Err(e) => println!("config: ERROR — {e}"),
464 }
465 }
466 Err(e) => println!("source: NOT FOUND — {e}"),
467 }
468 println!();
469 println!("link mode (auto resolves to):");
470 if cfg!(windows) {
471 println!(" files: hardlink");
472 println!(" dirs: junction");
473 } else {
474 println!(" files: symlink");
475 println!(" dirs: symlink");
476 }
477 Ok(())
478}
479
480pub fn gc_backup(_source: Option<Utf8PathBuf>, _older_than: Option<String>) -> Result<()> {
481 todo!("yui gc-backup — clean up old backups")
482}
483
484fn process_mount(
489 source: &Utf8Path,
490 m: &ResolvedMount,
491 ctx: &ApplyCtx<'_>,
492 engine: &mut template::Engine,
493 tera_ctx: &TeraContext,
494) -> Result<()> {
495 let src_root = source.join(&m.src);
496 if !src_root.is_dir() {
497 warn!("mount src missing: {src_root}");
498 return Ok(());
499 }
500 walk_and_link(&src_root, &m.dst, ctx, m.strategy, engine, tera_ctx)
501}
502
503fn walk_and_link(
504 src_dir: &Utf8Path,
505 dst_dir: &Utf8Path,
506 ctx: &ApplyCtx<'_>,
507 strategy: MountStrategy,
508 engine: &mut template::Engine,
509 tera_ctx: &TeraContext,
510) -> Result<()> {
511 let marker_filename = &ctx.config.mount.marker_filename;
512
513 if strategy == MountStrategy::Marker {
514 match marker::read_spec(src_dir, marker_filename)? {
515 None => {} Some(MarkerSpec::PassThrough) => {
517 link_dir_with_backup(src_dir, dst_dir, ctx)?;
518 return Ok(());
519 }
520 Some(MarkerSpec::Override { links }) => {
521 let mut linked_any = false;
522 for link in &links {
523 if let Some(when) = &link.when {
527 if !template::eval_truthy(when, engine, tera_ctx)? {
528 continue;
529 }
530 }
531 let dst_str = engine.render(&link.dst, tera_ctx)?;
532 let dst = Utf8PathBuf::from(dst_str.trim());
533 link_dir_with_backup(src_dir, &dst, ctx)?;
534 linked_any = true;
535 }
536 if !linked_any {
537 info!("marker override at {src_dir} had no active links — skipping");
538 }
539 return Ok(());
540 }
541 }
542 }
543
544 for entry in std::fs::read_dir(src_dir)? {
545 let entry = entry?;
546 let name_os = entry.file_name();
547 let Some(name) = name_os.to_str() else {
548 continue;
549 };
550 if name == marker_filename {
551 continue;
552 }
553 if name.ends_with(".tera") {
554 continue;
556 }
557
558 let src_path = src_dir.join(name);
559 let dst_path = dst_dir.join(name);
560 let ft = entry.file_type()?;
561
562 if ft.is_dir() {
563 walk_and_link(&src_path, &dst_path, ctx, strategy, engine, tera_ctx)?;
564 } else if ft.is_file() {
565 link_file_with_backup(&src_path, &dst_path, ctx)?;
566 }
567 }
568 Ok(())
569}
570
571fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
572 if ctx.dry_run {
573 info!("[dry-run] link file: {src} → {dst}");
574 return Ok(());
575 }
576 if std::fs::symlink_metadata(dst).is_ok() {
577 backup_existing(dst, ctx.backup_root, false)?;
578 link::unlink(dst)?;
579 }
580 info!("link file: {src} → {dst}");
581 link::link_file(src, dst, ctx.file_mode)?;
582 Ok(())
583}
584
585fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
586 if ctx.dry_run {
587 info!("[dry-run] link dir: {src} → {dst}");
588 return Ok(());
589 }
590 if std::fs::symlink_metadata(dst).is_ok() {
591 backup_existing(dst, ctx.backup_root, true)?;
592 link::unlink(dst)?;
593 }
594 info!("link dir: {src} → {dst}");
595 link::link_dir(src, dst, ctx.dir_mode)?;
596 Ok(())
597}
598
599fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
600 let abs_target = absolutize(target)?;
601 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
602 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
603 info!("backup → {bp}");
604 if is_dir {
605 backup::backup_dir(target, &bp)?;
606 } else {
607 backup::backup_file(target, &bp)?;
608 }
609 Ok(())
610}
611
612fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
613 if let Some(s) = source {
614 return absolutize(&s);
615 }
616 if let Ok(s) = std::env::var("YUI_SOURCE") {
617 return absolutize(Utf8Path::new(&s));
618 }
619 let cwd = current_dir_utf8()?;
620 for ancestor in cwd.ancestors() {
621 if ancestor.join("config.toml").is_file() {
622 return Ok(ancestor.to_path_buf());
623 }
624 }
625 if let Some(home) = home_dir() {
626 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
627 let p = home.join(c);
628 if p.join("config.toml").is_file() {
629 return Ok(p);
630 }
631 }
632 }
633 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
634}
635
636fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
637 if p.is_absolute() {
638 return Ok(p.to_path_buf());
639 }
640 let cwd = current_dir_utf8()?;
641 Ok(cwd.join(p))
642}
643
644fn current_dir_utf8() -> Result<Utf8PathBuf> {
645 let cwd = std::env::current_dir().context("getting cwd")?;
646 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
647}
648
649fn home_dir() -> Option<Utf8PathBuf> {
650 std::env::var("HOME")
651 .ok()
652 .or_else(|| std::env::var("USERPROFILE").ok())
653 .map(Utf8PathBuf::from)
654}
655
656const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
657
658[vars]
659# user-defined values; templates can reference these as {{ vars.foo }}
660
661# [link]
662# file_mode = "auto" # auto | symlink | hardlink
663# dir_mode = "auto" # auto | symlink | junction
664
665[mount]
666default_strategy = "marker"
667
668[[mount.entry]]
669src = "home"
670dst = "{{ env(name='HOME') | default(value=env(name='USERPROFILE')) }}"
671
672# [[mount.entry]]
673# src = "appdata"
674# dst = "{{ env(name='APPDATA') }}"
675# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
676# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
677# when = "yui.os == 'windows'"
678"#;
679
680const SKELETON_GITIGNORE: &str = r#"# yui internals (regenerable, do not commit)
681/.yui/
682
683# >>> yui rendered (auto-managed, do not edit) >>>
684# <<< yui rendered (auto-managed) <<<
685
686# config.local.toml is per-machine; commit a config.local.example.toml instead.
687config.local.toml
688"#;
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693 use tempfile::TempDir;
694
695 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
696 Utf8PathBuf::from_path_buf(p).unwrap()
697 }
698
699 fn toml_path(p: &Utf8Path) -> String {
701 p.as_str().replace('\\', "/")
702 }
703
704 #[test]
705 fn apply_links_a_raw_file() {
706 let tmp = TempDir::new().unwrap();
707 let source = utf8(tmp.path().join("dotfiles"));
708 let target = utf8(tmp.path().join("target"));
709 std::fs::create_dir_all(source.join("home")).unwrap();
710 std::fs::create_dir_all(&target).unwrap();
711 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
712
713 let cfg = format!(
714 r#"
715[[mount.entry]]
716src = "home"
717dst = "{}"
718"#,
719 toml_path(&target)
720 );
721 std::fs::write(source.join("config.toml"), cfg).unwrap();
722
723 apply(Some(source), false).unwrap();
724
725 let linked = target.join(".bashrc");
726 assert!(linked.exists(), "expected {linked} to exist");
727 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
728 }
729
730 #[test]
731 fn apply_with_marker_links_whole_directory() {
732 let tmp = TempDir::new().unwrap();
733 let source = utf8(tmp.path().join("dotfiles"));
734 let target = utf8(tmp.path().join("target"));
735 let nvim_src = source.join("home/nvim");
736 std::fs::create_dir_all(&nvim_src).unwrap();
737 std::fs::create_dir_all(&target).unwrap();
738 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
739 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
740 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
741
742 let cfg = format!(
743 r#"
744[[mount.entry]]
745src = "home"
746dst = "{}"
747"#,
748 toml_path(&target)
749 );
750 std::fs::write(source.join("config.toml"), cfg).unwrap();
751
752 apply(Some(source.clone()), false).unwrap();
753
754 let nvim_dst = target.join("nvim");
755 assert!(nvim_dst.exists());
756 assert_eq!(
757 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
758 "-- hi\n"
759 );
760 }
764
765 #[test]
766 fn apply_dry_run_does_not_write() {
767 let tmp = TempDir::new().unwrap();
768 let source = utf8(tmp.path().join("dotfiles"));
769 let target = utf8(tmp.path().join("target"));
770 std::fs::create_dir_all(source.join("home")).unwrap();
771 std::fs::create_dir_all(&target).unwrap();
772 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
773
774 let cfg = format!(
775 r#"
776[[mount.entry]]
777src = "home"
778dst = "{}"
779"#,
780 toml_path(&target)
781 );
782 std::fs::write(source.join("config.toml"), cfg).unwrap();
783
784 apply(Some(source), true).unwrap();
785
786 assert!(!target.join(".bashrc").exists());
787 }
788
789 #[test]
790 fn apply_renders_templates_then_links_rendered_outputs() {
791 let tmp = TempDir::new().unwrap();
792 let source = utf8(tmp.path().join("dotfiles"));
793 let target = utf8(tmp.path().join("target"));
794 std::fs::create_dir_all(source.join("home")).unwrap();
795 std::fs::create_dir_all(&target).unwrap();
796 std::fs::write(
797 source.join("home/.gitconfig.tera"),
798 "[user]\n os = {{ yui.os }}\n",
799 )
800 .unwrap();
801 std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
802
803 let cfg = format!(
804 r#"
805[[mount.entry]]
806src = "home"
807dst = "{}"
808"#,
809 toml_path(&target)
810 );
811 std::fs::write(source.join("config.toml"), cfg).unwrap();
812
813 apply(Some(source.clone()), false).unwrap();
814
815 assert!(target.join(".bashrc").exists());
817 assert!(source.join("home/.gitconfig").exists());
819 assert!(target.join(".gitconfig").exists());
820 assert!(!target.join(".gitconfig.tera").exists());
822 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
824 assert!(linked.contains("os = "));
825 }
826
827 #[test]
828 fn apply_marker_override_links_to_custom_dst() {
829 let tmp = TempDir::new().unwrap();
830 let source = utf8(tmp.path().join("dotfiles"));
831 let target_a = utf8(tmp.path().join("target_a"));
832 let target_b = utf8(tmp.path().join("target_b"));
833 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
834 std::fs::create_dir_all(&target_a).unwrap();
835 std::fs::create_dir_all(&target_b).unwrap();
836 std::fs::write(
837 source.join("home/.config/nvim/init.lua"),
838 "-- nvim config\n",
839 )
840 .unwrap();
841
842 std::fs::write(
845 source.join("home/.config/nvim/.yuilink"),
846 format!(
847 r#"
848[[link]]
849dst = "{}/nvim"
850
851[[link]]
852dst = "{}/nvim"
853when = "{{{{ yui.os == '{}' }}}}"
854"#,
855 toml_path(&target_a),
856 toml_path(&target_b),
857 std::env::consts::OS
858 ),
859 )
860 .unwrap();
861
862 let parent_target = utf8(tmp.path().join("parent_target"));
863 std::fs::create_dir_all(&parent_target).unwrap();
864 let cfg = format!(
865 r#"
866[[mount.entry]]
867src = "home"
868dst = "{}"
869"#,
870 toml_path(&parent_target)
871 );
872 std::fs::write(source.join("config.toml"), cfg).unwrap();
873
874 apply(Some(source.clone()), false).unwrap();
875
876 assert!(
878 target_a.join("nvim/init.lua").exists(),
879 "target_a/nvim/init.lua should be reachable through the link"
880 );
881 assert!(
882 target_b.join("nvim/init.lua").exists(),
883 "target_b/nvim/init.lua should be reachable through the link"
884 );
885 assert!(
888 !parent_target.join(".config/nvim").exists(),
889 "parent mount should have skipped the marker-claimed sub-dir"
890 );
891 }
892
893 #[test]
894 fn apply_marker_override_skips_inactive_link() {
895 let tmp = TempDir::new().unwrap();
896 let source = utf8(tmp.path().join("dotfiles"));
897 let target_inactive = utf8(tmp.path().join("inactive"));
898 let parent_target = utf8(tmp.path().join("parent"));
899 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
900 std::fs::create_dir_all(&parent_target).unwrap();
901 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
902
903 std::fs::write(
905 source.join("home/.config/nvim/.yuilink"),
906 format!(
907 r#"
908[[link]]
909dst = "{}/nvim"
910when = "{{{{ yui.os == 'no-such-os' }}}}"
911"#,
912 toml_path(&target_inactive)
913 ),
914 )
915 .unwrap();
916
917 let cfg = format!(
918 r#"
919[[mount.entry]]
920src = "home"
921dst = "{}"
922"#,
923 toml_path(&parent_target)
924 );
925 std::fs::write(source.join("config.toml"), cfg).unwrap();
926
927 apply(Some(source.clone()), false).unwrap();
928
929 assert!(!target_inactive.join("nvim").exists());
931 assert!(!parent_target.join(".config/nvim").exists());
934 }
935
936 #[test]
937 fn list_shows_mount_entries_and_marker_overrides() {
938 let tmp = TempDir::new().unwrap();
939 let source = utf8(tmp.path().join("dotfiles"));
940 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
941 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
942 std::fs::write(
943 source.join("home/.config/nvim/.yuilink"),
944 r#"
945[[link]]
946dst = "/custom/nvim"
947"#,
948 )
949 .unwrap();
950 std::fs::write(
951 source.join("config.toml"),
952 r#"
953[[mount.entry]]
954src = "home"
955dst = "/h"
956"#,
957 )
958 .unwrap();
959
960 list(Some(source), false, None, true).unwrap();
963 }
964
965 #[test]
966 fn strip_braces_removes_outer_template_braces() {
967 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
968 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
969 assert_eq!(strip_braces(" {{x}} "), "x");
970 }
971
972 #[test]
973 fn apply_aborts_on_render_drift() {
974 let tmp = TempDir::new().unwrap();
975 let source = utf8(tmp.path().join("dotfiles"));
976 let target = utf8(tmp.path().join("target"));
977 std::fs::create_dir_all(source.join("home")).unwrap();
978 std::fs::create_dir_all(&target).unwrap();
979 std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
980 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
981
982 let cfg = format!(
983 r#"
984[[mount.entry]]
985src = "home"
986dst = "{}"
987"#,
988 toml_path(&target)
989 );
990 std::fs::write(source.join("config.toml"), cfg).unwrap();
991
992 let err = apply(Some(source.clone()), false).unwrap_err();
993 assert!(format!("{err}").contains("drift"));
994 assert_eq!(
996 std::fs::read_to_string(source.join("home/foo")).unwrap(),
997 "manually edited"
998 );
999 assert!(!target.join("foo").exists());
1001 }
1002
1003 #[test]
1004 fn init_creates_skeleton_when_dir_empty() {
1005 let tmp = TempDir::new().unwrap();
1006 let dir = utf8(tmp.path().join("new_dotfiles"));
1007 init(Some(dir.clone()), false).unwrap();
1008 assert!(dir.join("config.toml").is_file());
1009 assert!(dir.join(".gitignore").is_file());
1010 }
1011
1012 #[test]
1013 fn init_refuses_to_overwrite_existing_config() {
1014 let tmp = TempDir::new().unwrap();
1015 let dir = utf8(tmp.path().join("dotfiles"));
1016 std::fs::create_dir_all(&dir).unwrap();
1017 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
1018 let err = init(Some(dir), false).unwrap_err();
1019 assert!(format!("{err}").contains("already exists"));
1020 }
1021
1022 #[test]
1023 fn apply_with_existing_target_backs_up() {
1024 let tmp = TempDir::new().unwrap();
1025 let source = utf8(tmp.path().join("dotfiles"));
1026 let target = utf8(tmp.path().join("target"));
1027 std::fs::create_dir_all(source.join("home")).unwrap();
1028 std::fs::create_dir_all(&target).unwrap();
1029 std::fs::write(source.join("home/.bashrc"), "new content").unwrap();
1030 std::fs::write(target.join(".bashrc"), "old content").unwrap();
1032
1033 let cfg = format!(
1034 r#"
1035[[mount.entry]]
1036src = "home"
1037dst = "{}"
1038"#,
1039 toml_path(&target)
1040 );
1041 std::fs::write(source.join("config.toml"), cfg).unwrap();
1042
1043 apply(Some(source.clone()), false).unwrap();
1044
1045 assert_eq!(
1047 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
1048 "new content"
1049 );
1050
1051 let backup_root = source.join(".yui/backup");
1053 assert!(backup_root.exists(), "backup root should exist");
1054 let mut found_old = false;
1055 for entry in walkdir(&backup_root) {
1056 if let Ok(s) = std::fs::read_to_string(&entry) {
1057 if s == "old content" {
1058 found_old = true;
1059 break;
1060 }
1061 }
1062 }
1063 assert!(found_old, "expected backup containing 'old content'");
1064 }
1065
1066 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
1067 let mut out = Vec::new();
1068 let mut stack = vec![root.to_path_buf()];
1069 while let Some(dir) = stack.pop() {
1070 let Ok(entries) = std::fs::read_dir(&dir) else {
1071 continue;
1072 };
1073 for e in entries.flatten() {
1074 let p = utf8(e.path());
1075 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
1076 stack.push(p);
1077 } else {
1078 out.push(p);
1079 }
1080 }
1081 }
1082 out
1083 }
1084}