1use std::cell::Cell;
6use 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::secret;
21use crate::template;
22use crate::vars::YuiVars;
23use crate::vault;
24use crate::{absorb, backup, paths};
25
26pub fn init(source: Option<Utf8PathBuf>, git_hooks: bool) -> Result<()> {
33 let dir = match source {
34 Some(s) => absolutize(&s)?,
35 None => current_dir_utf8()?,
36 };
37 std::fs::create_dir_all(&dir)?;
38 let config_path = dir.join("config.toml");
39 let scaffolded = if !config_path.exists() {
40 std::fs::write(&config_path, SKELETON_CONFIG)?;
41 info!("initialized yui source repo at {dir}");
42 info!("created: {config_path}");
43 true
44 } else if git_hooks {
45 info!(
50 "config.toml already exists at {config_path} \
51 — skipping scaffold, installing git hooks only"
52 );
53 false
54 } else {
55 anyhow::bail!("config.toml already exists at {config_path}");
56 };
57
58 ensure_gitignore_yui_entries(&dir)?;
65
66 if git_hooks {
67 install_git_hooks(&dir)?;
68 }
69 if scaffolded {
70 info!("next: edit config.toml, then run `yui apply`");
71 }
72 Ok(())
73}
74
75const YUI_REQUIRED_GITIGNORE: &[&str] = &[
80 "/.yui/state.json",
81 "/.yui/state.json.tmp",
82 "/.yui/backup/",
83 "config.local.toml",
84];
85
86fn ensure_gitignore_yui_entries(dir: &Utf8Path) -> Result<()> {
92 let path = dir.join(".gitignore");
93 if !path.exists() {
94 std::fs::write(&path, SKELETON_GITIGNORE)?;
95 info!("created: {path}");
96 return Ok(());
97 }
98 let existing = std::fs::read_to_string(&path)?;
99 let missing: Vec<&str> = YUI_REQUIRED_GITIGNORE
100 .iter()
101 .copied()
102 .filter(|entry| !existing.lines().any(|line| line.trim() == *entry))
103 .collect();
104 if missing.is_empty() {
105 return Ok(());
106 }
107 let mut next = existing;
108 if !next.is_empty() && !next.ends_with('\n') {
109 next.push('\n');
110 }
111 if !next.is_empty() {
112 next.push('\n');
113 }
114 next.push_str("# yui per-machine state and backups (added by `yui init`).\n");
115 for entry in &missing {
116 next.push_str(entry);
117 next.push('\n');
118 }
119 std::fs::write(&path, next)?;
120 info!(
121 "updated .gitignore: appended {} yui entr{} ({})",
122 missing.len(),
123 if missing.len() == 1 { "y" } else { "ies" },
124 missing.join(", ")
125 );
126 Ok(())
127}
128
129fn install_git_hooks(source: &Utf8Path) -> Result<()> {
143 let out = std::process::Command::new("git")
144 .args(["rev-parse", "--git-path", "hooks"])
145 .current_dir(source.as_std_path())
146 .output()
147 .with_context(|| format!("git rev-parse --git-path hooks in {source}"))?;
148 if !out.status.success() {
149 let stderr = String::from_utf8_lossy(&out.stderr);
150 anyhow::bail!(
151 "--git-hooks: {source} doesn't look like a git repo \
152 (run `git init` first). git: {}",
153 stderr.trim()
154 );
155 }
156 let raw = String::from_utf8(out.stdout)?;
157 let hooks_dir = {
158 let p = Utf8PathBuf::from(raw.trim());
159 if p.is_absolute() { p } else { source.join(p) }
160 };
161 std::fs::create_dir_all(&hooks_dir).with_context(|| format!("mkdir -p {hooks_dir}"))?;
162
163 for (name, body) in [("pre-commit", PRE_COMMIT_HOOK), ("pre-push", PRE_PUSH_HOOK)] {
164 let path = hooks_dir.join(name);
165 if path.exists() {
166 warn!("--git-hooks: {path} already exists — leaving it alone");
167 continue;
168 }
169 std::fs::write(&path, body).with_context(|| format!("write hook {path}"))?;
170 #[cfg(unix)]
171 {
172 use std::os::unix::fs::PermissionsExt;
173 let mut perms = std::fs::metadata(&path)?.permissions();
174 perms.set_mode(0o755);
175 std::fs::set_permissions(&path, perms)?;
176 }
177 info!("installed: {path}");
178 }
179 Ok(())
180}
181
182const PRE_COMMIT_HOOK: &str = r#"#!/bin/sh
183# Installed by `yui init --git-hooks`.
184# Reject the commit if any `*.tera` template would render to something
185# that diverges from the rendered output staged alongside it. Run
186# `yui apply` (or `yui render`) to refresh and re-commit.
187exec yui render --check
188"#;
189
190const PRE_PUSH_HOOK: &str = r#"#!/bin/sh
191# Installed by `yui init --git-hooks`.
192# Same render-drift check as pre-commit, mirrored on push so a
193# `--no-verify` commit doesn't sneak diverged state to the remote.
194exec yui render --check
195"#;
196
197pub fn apply(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
198 let source = resolve_source(source)?;
199 let yui = YuiVars::detect(&source);
200 let config = config::load(&source, &yui)?;
201
202 let mut engine = template::Engine::new();
203 let tera_ctx = template::template_context(&yui, &config.vars);
204
205 hook::run_phase(
208 &config,
209 &source,
210 &yui,
211 &mut engine,
212 &tera_ctx,
213 HookPhase::Pre,
214 dry_run,
215 )?;
216
217 let secret_report = secret::decrypt_all(&source, &config, dry_run)?;
223 log_secret_report(&secret_report);
224 if secret_report.has_drift() {
225 anyhow::bail!(
226 "secret drift detected ({} file(s)); the plaintext sibling diverged \
227 from the canonical .age — run `yui secret encrypt <path>` to roll \
228 the edit back into ciphertext before re-running apply",
229 secret_report.diverged.len()
230 );
231 }
232
233 let render_report = render::render_all(&source, &config, &yui, dry_run)?;
240 log_render_report(&render_report);
241 let render_quit: Cell<bool> = Cell::new(false);
242 if render_report.has_drift() && !dry_run {
243 resolve_render_drift(&render_report, &render_quit)?;
244 }
245 if render_quit.get() {
246 info!("user quit during render drift resolution; skipping link pass");
247 return Ok(());
248 }
249
250 if !dry_run && config.render.manage_gitignore {
257 let mut managed: Vec<Utf8PathBuf> = render::report_managed_paths(&render_report)
258 .into_iter()
259 .chain(secret_report.managed_paths().cloned())
260 .collect();
261 managed.sort();
262 managed.dedup();
263 render::write_managed_section(&source, &managed)?;
264 }
265
266 let mounts = mount::resolve(
268 &source,
269 &config.mount.entry,
270 config.mount.default_strategy,
271 &mut engine,
272 &tera_ctx,
273 )?;
274
275 let backup_root = source.join(&config.backup.dir);
276 let ctx = ApplyCtx {
277 config: &config,
278 source: &source,
279 file_mode: resolve_file_mode(config.link.file_mode),
280 dir_mode: resolve_dir_mode(config.link.dir_mode),
281 backup_root: &backup_root,
282 dry_run,
283 sticky_anomaly: Cell::new(None),
284 quit_requested: Cell::new(false),
285 };
286
287 info!("source: {source}");
288 info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
289 if dry_run {
290 info!("dry-run: nothing will be written");
291 }
292
293 let mut yuiignore = paths::YuiIgnoreStack::new();
297 yuiignore.push_dir(&source)?;
298 let walk_result = (|| -> Result<()> {
299 for m in &mounts {
300 info!("mount: {} → {}", m.src, m.dst);
301 process_mount(m, &ctx, &mut engine, &tera_ctx, &mut yuiignore)?;
302 }
303 Ok(())
304 })();
305 yuiignore.pop_dir(&source);
306 walk_result?;
307
308 hook::run_phase(
310 &config,
311 &source,
312 &yui,
313 &mut engine,
314 &tera_ctx,
315 HookPhase::Post,
316 dry_run,
317 )?;
318 Ok(())
319}
320
321fn log_render_report(r: &RenderReport) {
322 if !r.written.is_empty() {
323 info!("rendered {} new file(s)", r.written.len());
324 }
325 if !r.unchanged.is_empty() {
326 info!("rendered {} file(s) unchanged", r.unchanged.len());
327 }
328 if !r.skipped_when_false.is_empty() {
329 info!(
330 "skipped {} template(s) (when=false)",
331 r.skipped_when_false.len()
332 );
333 }
334 for d in &r.diverged {
335 warn!("rendered file diverged from template: {}", d.rendered_path);
336 }
337}
338
339fn log_secret_report(r: &secret::SecretReport) {
340 if !r.written.is_empty() {
341 info!("decrypted {} secret file(s)", r.written.len());
342 }
343 if !r.unchanged.is_empty() {
344 info!("decrypted {} secret(s) unchanged", r.unchanged.len());
345 }
346 for d in &r.diverged {
347 warn!("plaintext sibling diverged from .age: {d}");
348 }
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363enum AnomalyChoice {
364 Absorb,
366 Overwrite,
368 Skip,
370 Quit,
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
382enum RenderDriftChoice {
383 Overwrite,
385 Skip,
387 Quit,
389}
390
391struct ApplyCtx<'a> {
392 config: &'a Config,
393 source: &'a Utf8Path,
395 file_mode: EffectiveFileMode,
396 dir_mode: EffectiveDirMode,
397 backup_root: &'a Utf8Path,
398 dry_run: bool,
399 sticky_anomaly: Cell<Option<AnomalyChoice>>,
402 quit_requested: Cell<bool>,
406}
407
408pub fn list(
414 source: Option<Utf8PathBuf>,
415 all: bool,
416 icons_override: Option<IconsMode>,
417 no_color: bool,
418) -> Result<()> {
419 let source = resolve_source(source)?;
420 let yui = YuiVars::detect(&source);
421 let config = config::load(&source, &yui)?;
422
423 let icons_mode = icons_override.unwrap_or(config.ui.icons);
424 let icons = Icons::for_mode(icons_mode);
425 let color = !no_color && supports_color_stdout();
426
427 let items = collect_list_items(&source, &config, &yui)?;
428 let displayed: Vec<&ListItem> = if all {
429 items.iter().collect()
430 } else {
431 items.iter().filter(|i| i.active).collect()
432 };
433
434 print_list_table(&displayed, icons, color);
435
436 let total = items.len();
437 let active = items.iter().filter(|i| i.active).count();
438 let inactive = total - active;
439 println!();
440 if all {
441 println!(" {total} entries · {active} active · {inactive} inactive");
442 } else {
443 println!(
444 " {} of {} entries shown ({} inactive hidden — use --all)",
445 active, total, inactive
446 );
447 }
448 Ok(())
449}
450
451#[derive(Debug)]
452struct ListItem {
453 src: Utf8PathBuf,
454 dst: String,
455 when: Option<String>,
456 active: bool,
457}
458
459fn collect_list_items(source: &Utf8Path, config: &Config, yui: &YuiVars) -> Result<Vec<ListItem>> {
460 let mut engine = template::Engine::new();
461 let tera_ctx = template::template_context(yui, &config.vars);
462 let mut items = Vec::new();
463
464 for entry in &config.mount.entry {
466 let active = match &entry.when {
467 None => true,
468 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
469 };
470 let dst = engine
471 .render(&entry.dst, &tera_ctx)
472 .map(|s| paths::expand_tilde(s.trim()).to_string())
473 .unwrap_or_else(|_| entry.dst.clone());
474 items.push(ListItem {
475 src: entry.src.clone(),
476 dst,
477 when: entry.when.clone(),
478 active,
479 });
480 }
481
482 let walker = paths::source_walker(source).build();
484 let marker_filename = &config.mount.marker_filename;
485 for entry in walker {
486 let entry = match entry {
487 Ok(e) => e,
488 Err(_) => continue,
489 };
490 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
491 continue;
492 }
493 if entry.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
494 continue;
495 }
496 let dir = match entry.path().parent() {
497 Some(d) => d,
498 None => continue,
499 };
500 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
501 Ok(p) => p,
502 Err(_) => continue,
503 };
504 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
508 Some(s) => s,
509 None => continue,
510 };
511 let MarkerSpec::Explicit { links } = spec else {
512 continue; };
514 let rel = dir_utf8
515 .strip_prefix(source)
516 .map(Utf8PathBuf::from)
517 .unwrap_or(dir_utf8);
518 for link in &links {
519 let active = match &link.when {
520 None => true,
521 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
522 };
523 let dst = engine
524 .render(&link.dst, &tera_ctx)
525 .map(|s| paths::expand_tilde(s.trim()).to_string())
526 .unwrap_or_else(|_| link.dst.clone());
527 let src_display = match &link.src {
532 Some(filename) => rel.join(filename),
533 None => rel.clone(),
534 };
535 items.push(ListItem {
536 src: src_display,
537 dst,
538 when: link.when.clone(),
539 active,
540 });
541 }
542 }
543
544 items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
545 Ok(items)
546}
547
548fn supports_color_stdout() -> bool {
549 use std::io::IsTerminal;
550 std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
551}
552
553fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
554 let src_w = items
555 .iter()
556 .map(|i| i.src.as_str().chars().count())
557 .max()
558 .unwrap_or(0)
559 .max("SRC".len());
560 let dst_w = items
561 .iter()
562 .map(|i| i.dst.chars().count())
563 .max()
564 .unwrap_or(0)
565 .max("DST".len());
566
567 let status_w = "STATUS".len();
568 let arrow_w = icons.arrow.chars().count();
569
570 print_header(status_w, src_w, arrow_w, dst_w, color);
572
573 let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
575 if color {
576 use owo_colors::OwoColorize as _;
577 println!("{}", sep.dimmed());
578 } else {
579 println!("{sep}");
580 }
581
582 for item in items {
584 print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
585 }
586}
587
588fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
589 use owo_colors::OwoColorize as _;
590 let mut line = String::new();
591 let _ = write!(
592 &mut line,
593 " {:<status_w$} {:<src_w$} {:<arrow_w$} {:<dst_w$} WHEN",
594 "STATUS", "SRC", "", "DST"
595 );
596 if color {
597 println!("{}", line.bold());
598 } else {
599 println!("{line}");
600 }
601}
602
603fn render_separator(
604 sep_ch: char,
605 status_w: usize,
606 src_w: usize,
607 arrow_w: usize,
608 dst_w: usize,
609) -> String {
610 let bar = |n: usize| sep_ch.to_string().repeat(n);
611 format!(
612 " {} {} {} {} {}",
613 bar(status_w),
614 bar(src_w),
615 bar(arrow_w),
616 bar(dst_w),
617 bar("WHEN".len())
618 )
619}
620
621fn print_row(
622 item: &ListItem,
623 icons: Icons,
624 status_w: usize,
625 src_w: usize,
626 arrow_w: usize,
627 dst_w: usize,
628 color: bool,
629) {
630 use owo_colors::OwoColorize as _;
631 let status = if item.active {
632 icons.active
633 } else {
634 icons.inactive
635 };
636 let when_str = item
637 .when
638 .as_deref()
639 .map(strip_braces)
640 .unwrap_or_else(|| "(always)".to_string());
641
642 let src_display = item.src.as_str().replace('\\', "/");
644 let src = src_display.as_str();
645 let dst = &item.dst;
646 let arrow = icons.arrow;
647
648 let cell_status = format!("{:<status_w$}", status);
653 let cell_src = format!("{:<src_w$}", src);
654 let cell_arrow = format!("{:<arrow_w$}", arrow);
655 let cell_dst = format!("{:<dst_w$}", dst);
656
657 if !color {
658 println!(" {cell_status} {cell_src} {cell_arrow} {cell_dst} {when_str}");
659 return;
660 }
661
662 if item.active {
663 println!(
664 " {} {} {} {} {}",
665 cell_status.green(),
666 cell_src.cyan(),
667 cell_arrow.dimmed(),
668 cell_dst.green(),
669 when_str.dimmed()
670 );
671 } else {
672 println!(
673 " {} {} {} {} {}",
674 cell_status.red().dimmed(),
675 cell_src.dimmed(),
676 cell_arrow.dimmed(),
677 cell_dst.dimmed(),
678 when_str.dimmed()
679 );
680 }
681}
682
683fn strip_braces(expr: &str) -> String {
686 let trimmed = expr.trim();
687 if let Some(inner) = trimmed
688 .strip_prefix("{{")
689 .and_then(|s| s.strip_suffix("}}"))
690 {
691 inner.trim().to_string()
692 } else {
693 trimmed.to_string()
694 }
695}
696
697pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
698 let source = resolve_source(source)?;
699 let yui = YuiVars::detect(&source);
700 let config = config::load(&source, &yui)?;
701 let effective_dry_run = dry_run || check;
703 let report = render::render_all(&source, &config, &yui, effective_dry_run)?;
704 log_render_report(&report);
705 if !effective_dry_run && config.render.manage_gitignore {
710 let managed = render::report_managed_paths(&report);
711 render::write_managed_section(&source, &managed)?;
712 }
713 if check && report.has_drift() {
714 anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
715 }
716 Ok(())
717}
718
719pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
720 apply(source, dry_run)
722}
723
724pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
725 let _source = resolve_source(source)?;
726 if paths_arg.is_empty() {
727 anyhow::bail!("yui unlink: provide at least one target path");
728 }
729 for p in paths_arg {
730 let abs = absolutize(&p)?;
731 info!("unlink: {abs}");
732 link::unlink(&abs)?;
733 }
734 Ok(())
735}
736
737pub fn secret_init(source: Option<Utf8PathBuf>, comment: Option<String>) -> Result<()> {
765 let source = resolve_source(source)?;
766 let yui = YuiVars::detect(&source);
767 let config = config::load(&source, &yui)?;
768
769 let identity_path = paths::expand_tilde(&config.secrets.identity);
771 if identity_path.exists() {
772 anyhow::bail!(
773 "identity file already exists at {identity_path}; \
774 refusing to overwrite. Delete it first if you really \
775 mean to start fresh (you'll lose access to existing \
776 .age files encrypted to its public key)."
777 );
778 }
779
780 let (secret, public) = secret::generate_x25519_keypair();
784 let now = jiff::Zoned::now().to_string();
785 let body = format!(
786 "# created: {now}\n\
787 # public key: {public}\n\
788 {secret}\n"
789 );
790 secret::write_private_file(&identity_path, body.as_bytes())?;
793 info!("wrote identity file: {identity_path}");
794
795 let config_path = source.join("config.toml");
800 let comment = comment.unwrap_or_else(|| format!("{} {}", yui.host, yui.user));
801 let entry_comment = format!("{comment} — added by `yui secret init` on {now}");
802 let config_existing = match std::fs::read_to_string(&config_path) {
803 Ok(s) => s,
804 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
805 Err(e) => anyhow::bail!("read {config_path}: {e}"),
806 };
807 let updated_config = append_recipient_to_config(&config_existing, &entry_comment, &public)?;
808 std::fs::write(&config_path, updated_config)?;
809 info!("appended public key to {config_path}");
810 println!();
811 println!(" age identity: {identity_path}");
812 println!(" public key: {public}");
813 println!();
814 println!(
815 " Next: encrypt a file with `yui secret encrypt <path>`. \
816 The plaintext sibling will be auto-decrypted on every `yui apply`."
817 );
818 Ok(())
819}
820
821fn append_recipient_to_config(existing: &str, comment: &str, public: &str) -> Result<String> {
835 use toml_edit::{Array, DocumentMut, Item, Table, Value};
836
837 let mut doc: DocumentMut = if existing.trim().is_empty() {
838 DocumentMut::new()
839 } else {
840 existing
841 .parse()
842 .map_err(|e| anyhow::anyhow!("config.toml is not valid TOML: {e}"))?
843 };
844
845 if !doc.contains_key("secrets") {
847 let mut t = Table::new();
848 t.set_implicit(false);
849 doc.insert("secrets", Item::Table(t));
850 }
851 let secrets = doc["secrets"].as_table_mut().ok_or_else(|| {
852 anyhow::anyhow!("[secrets] in config.toml is not a table — refusing to clobber")
853 })?;
854
855 if !secrets.contains_key("recipients") {
857 secrets.insert("recipients", Item::Value(Value::Array(Array::new())));
858 }
859 let recipients = secrets["recipients"]
860 .as_array_mut()
861 .ok_or_else(|| anyhow::anyhow!("[secrets].recipients is not an array"))?;
862
863 let already_present = recipients.iter().any(|v| v.as_str() == Some(public));
865 if already_present {
866 return Ok(doc.to_string());
867 }
868
869 let mut value = Value::from(public);
873 let prefix = format!("\n # {comment}\n ");
874 *value.decor_mut() = toml_edit::Decor::new(prefix, "");
875 recipients.push_formatted(value);
876 recipients.set_trailing("\n");
880 recipients.set_trailing_comma(true);
881
882 Ok(doc.to_string())
883}
884
885pub fn secret_encrypt(
889 source: Option<Utf8PathBuf>,
890 path: Utf8PathBuf,
891 force: bool,
892 rm_plaintext: bool,
893) -> Result<()> {
894 let source = resolve_source(source)?;
895 let yui = YuiVars::detect(&source);
896 let config = config::load(&source, &yui)?;
897
898 if !config.secrets.enabled() {
899 anyhow::bail!(
900 "no recipients configured — run `yui secret init` to generate \
901 a keypair, or add at least one entry to `[secrets] recipients`."
902 );
903 }
904
905 let plaintext_path = if path.is_absolute() {
909 path.clone()
910 } else {
911 absolutize(&path)?
912 };
913 if !plaintext_path.is_file() {
914 anyhow::bail!("plaintext file not found: {plaintext_path}");
915 }
916 let cipher_path = Utf8PathBuf::from(format!("{plaintext_path}.age"));
917 if cipher_path.exists() && !force {
918 anyhow::bail!("{cipher_path} already exists; pass --force to overwrite");
919 }
920
921 let plaintext = std::fs::read(&plaintext_path)?;
922 let recipients = secret::parse_passkey_recipients(&config.secrets.recipients)?;
930 let cipher = secret::encrypt_to_passkeys(&plaintext, &recipients)?;
931 std::fs::write(&cipher_path, &cipher)?;
932 info!("encrypted {plaintext_path} → {cipher_path}");
933
934 if config.render.manage_gitignore && plaintext_path.starts_with(&source) {
943 render::add_to_managed_section(&source, &plaintext_path)?;
944 }
945 info!("run `yui apply` to refresh links and the rest of the managed section");
946
947 if rm_plaintext {
948 if plaintext_path.starts_with(&source) {
951 std::fs::remove_file(&plaintext_path)?;
952 info!("removed plaintext: {plaintext_path}");
953 } else {
954 warn!(
955 "plaintext lives outside source ({plaintext_path}); \
956 skipping --rm-plaintext as a safety check"
957 );
958 }
959 }
960 Ok(())
961}
962
963pub fn secret_store(source: Option<Utf8PathBuf>, force: bool) -> Result<()> {
973 let source = resolve_source(source)?;
974 let yui = YuiVars::detect(&source);
975 let config = config::load(&source, &yui)?;
976
977 let vault_cfg = config.secrets.vault.as_ref().ok_or_else(|| {
978 anyhow::anyhow!(
979 "[secrets.vault] is not configured — set provider \
980 (\"bitwarden\" or \"1password\") and item before \
981 calling store"
982 )
983 })?;
984
985 let identity_path = paths::expand_tilde(&config.secrets.identity);
986 if !identity_path.is_file() {
987 anyhow::bail!(
988 "no X25519 identity at {identity_path}; run `yui secret init` first \
989 (store needs that file's content to push to the vault)"
990 );
991 }
992 let plaintext = std::fs::read(&identity_path)?;
993 secret::validate_x25519_identity_bytes(&plaintext)?;
998
999 let vault = vault::driver(vault_cfg);
1000 vault.precheck()?;
1005 info!(
1006 "pushing X25519 identity to {} item {:?}",
1007 vault.provider_name(),
1008 config::VAULT_ITEM_NAME
1009 );
1010 vault.store(config::VAULT_ITEM_NAME, &plaintext, force)?;
1011
1012 println!();
1013 println!(
1014 " X25519 identity pushed to {} item {:?}",
1015 vault.provider_name(),
1016 config::VAULT_ITEM_NAME
1017 );
1018 println!(" On a new machine, run `yui secret unlock`.");
1019 Ok(())
1020}
1021
1022pub fn secret_unlock(source: Option<Utf8PathBuf>) -> Result<()> {
1028 let source = resolve_source(source)?;
1029 let yui = YuiVars::detect(&source);
1030 let config = config::load(&source, &yui)?;
1031
1032 let vault_cfg = config.secrets.vault.as_ref().ok_or_else(|| {
1033 anyhow::anyhow!(
1034 "[secrets.vault] is not configured — nothing to unlock. \
1035 Run `yui secret init` + `yui secret store` on an existing \
1036 machine first, then commit + push the config."
1037 )
1038 })?;
1039 let identity_path = paths::expand_tilde(&config.secrets.identity);
1040 if identity_path.exists() {
1041 anyhow::bail!(
1042 "{identity_path} already exists — refusing to clobber a live \
1043 X25519 identity. Delete it first if you really mean to \
1044 re-unlock from scratch."
1045 );
1046 }
1047
1048 let vault = vault::driver(vault_cfg);
1049 vault.precheck()?;
1050 info!(
1051 "fetching X25519 identity from {} item {:?}",
1052 vault.provider_name(),
1053 config::VAULT_ITEM_NAME
1054 );
1055 let plaintext = vault.fetch(config::VAULT_ITEM_NAME)?;
1056
1057 secret::validate_x25519_identity_bytes(&plaintext)?;
1063
1064 secret::write_private_file(&identity_path, &plaintext)?;
1066 info!("wrote X25519 identity: {identity_path}");
1067 println!();
1068 println!(" X25519 identity restored at {identity_path}");
1069 println!(" Run `yui apply` next.");
1070 Ok(())
1071}
1072
1073pub fn update(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
1084 let source = resolve_source(source)?;
1085 if !crate::git::is_clean(&source)? {
1086 anyhow::bail!(
1087 "source repo {source} has uncommitted changes — \
1088 commit or stash before `yui update` (or run \
1089 `git pull` + `yui apply` manually if you know what \
1090 you're doing)"
1091 );
1092 }
1093 info!("git pull --ff-only at {source}");
1094 let status = std::process::Command::new("git")
1095 .arg("-C")
1096 .arg(source.as_str())
1097 .arg("pull")
1098 .arg("--ff-only")
1099 .status()
1100 .map_err(|e| anyhow::anyhow!("invoking git: {e}"))?;
1101 if !status.success() {
1102 anyhow::bail!("git pull --ff-only failed at {source}");
1103 }
1104 apply(Some(source), dry_run)
1105}
1106
1107pub fn unmanaged(
1118 source: Option<Utf8PathBuf>,
1119 icons_override: Option<IconsMode>,
1120 no_color: bool,
1121) -> Result<()> {
1122 let source = resolve_source(source)?;
1123 let yui = YuiVars::detect(&source);
1124 let config = config::load(&source, &yui)?;
1125
1126 let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
1127 let color = !no_color && supports_color_stdout();
1128
1129 let mut engine = template::Engine::new();
1144 let tera_ctx = template::template_context(&yui, &config.vars);
1145 let mount_srcs: Vec<Utf8PathBuf> = config
1146 .mount
1147 .entry
1148 .iter()
1149 .map(|e| -> Result<Utf8PathBuf> {
1150 let rendered = engine.render(e.src.as_str(), &tera_ctx)?;
1151 Ok(paths::resolve_mount_src(&source, rendered.trim()))
1152 })
1153 .collect::<Result<_>>()?;
1154
1155 let mut items: Vec<Utf8PathBuf> = Vec::new();
1156 let walker = paths::source_walker(&source).build();
1157 for entry in walker {
1158 let entry = match entry {
1159 Ok(e) => e,
1160 Err(_) => continue,
1161 };
1162 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
1163 continue;
1164 }
1165 let std_path = entry.path();
1166 let path = match Utf8PathBuf::from_path_buf(std_path.to_path_buf()) {
1167 Ok(p) => p,
1168 Err(_) => continue,
1169 };
1170 if is_repo_meta(&path, &source, &config.mount.marker_filename) {
1174 continue;
1175 }
1176 if mount_srcs.iter().any(|m| path.starts_with(m)) {
1177 continue;
1178 }
1179 items.push(path);
1180 }
1181 items.sort();
1182
1183 if items.is_empty() {
1184 println!(" no unmanaged files under {source}");
1185 return Ok(());
1186 }
1187
1188 print_unmanaged_table(&items, &source, color);
1189 println!();
1190 println!(" {} unmanaged file(s)", items.len());
1191 Ok(())
1192}
1193
1194fn is_repo_meta(path: &Utf8Path, source: &Utf8Path, marker_filename: &str) -> bool {
1210 let Some(name) = path.file_name() else {
1211 return false;
1212 };
1213 if name.ends_with(".tera") {
1214 return true;
1215 }
1216 if name == marker_filename || name == ".yuiignore" {
1217 return true;
1218 }
1219 let parent = path.parent().unwrap_or(Utf8Path::new(""));
1220 let at_root = parent == source;
1221 if at_root && name == ".gitignore" {
1222 return true;
1223 }
1224 if at_root && (name == "config.toml" || name == "config.local.toml") {
1225 return true;
1226 }
1227 if at_root
1228 && name.starts_with("config.")
1229 && (name.ends_with(".toml") || name.ends_with(".example.toml"))
1230 {
1231 return true;
1232 }
1233 false
1234}
1235
1236fn print_unmanaged_table(items: &[Utf8PathBuf], source: &Utf8Path, color: bool) {
1237 use owo_colors::OwoColorize as _;
1238 if color {
1239 println!(" {}", "PATH (relative to source)".dimmed());
1240 } else {
1241 println!(" PATH (relative to source)");
1242 }
1243 for p in items {
1244 let rel = p
1245 .strip_prefix(source)
1246 .map(Utf8PathBuf::from)
1247 .unwrap_or_else(|_| p.clone());
1248 if color {
1249 println!(" {}", rel.cyan());
1250 } else {
1251 println!(" {rel}");
1252 }
1253 }
1254}
1255
1256pub fn diff(
1264 source: Option<Utf8PathBuf>,
1265 icons_override: Option<IconsMode>,
1266 no_color: bool,
1267) -> Result<()> {
1268 let source = resolve_source(source)?;
1269 let yui = YuiVars::detect(&source);
1270 let config = config::load(&source, &yui)?;
1271 let mut engine = template::Engine::new();
1272 let tera_ctx = template::template_context(&yui, &config.vars);
1273 let mounts = mount::resolve(
1274 &source,
1275 &config.mount.entry,
1276 config.mount.default_strategy,
1277 &mut engine,
1278 &tera_ctx,
1279 )?;
1280
1281 let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
1282 let color = !no_color && supports_color_stdout();
1283
1284 let mut report: Vec<StatusItem> = Vec::new();
1286 let mut yuiignore = paths::YuiIgnoreStack::new();
1287 yuiignore.push_dir(&source)?;
1288 let walk_result = (|| -> Result<()> {
1289 for m in &mounts {
1290 let src_root = m.src.clone();
1291 if !src_root.is_dir() {
1292 continue;
1293 }
1294 classify_walk(
1295 &src_root,
1296 &m.dst,
1297 &config,
1298 m.strategy,
1299 &mut engine,
1300 &tera_ctx,
1301 &source,
1302 &mut yuiignore,
1303 &mut report,
1304 )?;
1305 }
1306 Ok(())
1307 })();
1308 yuiignore.pop_dir(&source);
1309 walk_result?;
1310
1311 let render_report = render::render_all(&source, &config, &yui, true)?;
1313 for entry in &render_report.diverged {
1314 report.push(StatusItem {
1315 src: entry.tera_path.clone(),
1316 dst: entry.rendered_path.clone(),
1317 state: StatusState::RenderDrift,
1318 });
1319 }
1320
1321 let mut printed = 0usize;
1322 for item in &report {
1323 if !diff_worth_printing(&item.state) {
1324 continue;
1325 }
1326 let src_abs = resolve_diff_src(item, &source);
1327 print_unified_diff(
1328 &src_abs,
1329 &item.dst,
1330 &item.state,
1331 &source,
1332 &config,
1333 &yui,
1334 color,
1335 );
1336 printed += 1;
1337 }
1338
1339 if printed == 0 {
1340 println!(" no diff — every entry is in sync (or only needs a relink)");
1341 } else {
1342 println!();
1343 println!(
1344 " {printed} entr{} with content drift",
1345 if printed == 1 { "y" } else { "ies" }
1346 );
1347 }
1348 Ok(())
1349}
1350
1351fn resolve_diff_src(item: &StatusItem, source: &Utf8Path) -> Utf8PathBuf {
1363 match item.state {
1364 StatusState::RenderDrift => item.src.clone(),
1365 StatusState::Link(_) => source.join(&item.src),
1366 }
1367}
1368
1369fn diff_worth_printing(state: &StatusState) -> bool {
1370 use absorb::AbsorbDecision::*;
1371 match state {
1372 StatusState::Link(InSync) => false,
1373 StatusState::Link(Restore) => false, StatusState::Link(RelinkOnly) => false, StatusState::Link(_) => true,
1376 StatusState::RenderDrift => true,
1377 }
1378}
1379
1380fn print_unified_diff(
1388 src: &Utf8Path,
1389 dst: &Utf8Path,
1390 state: &StatusState,
1391 source_root: &Utf8Path,
1392 config: &Config,
1393 yui: &YuiVars,
1394 color: bool,
1395) {
1396 use owo_colors::OwoColorize as _;
1397
1398 let header = match state {
1399 StatusState::RenderDrift => format!("--- render drift: {src} (template) vs {dst}"),
1400 _ => format!("--- {src} → {dst}"),
1401 };
1402 if color {
1403 println!("{}", header.bold());
1404 } else {
1405 println!("{header}");
1406 }
1407
1408 if src.is_dir() || dst.is_dir() {
1409 println!("(directory entry — content listing skipped)");
1410 println!();
1411 return;
1412 }
1413
1414 let src_content = match state {
1419 StatusState::RenderDrift => match render::render_to_string(src, source_root, config, yui) {
1420 Ok(Some(s)) => s,
1421 Ok(None) => {
1422 println!(
1423 "(template would be skipped on this host — drift will resolve on next render)"
1424 );
1425 println!();
1426 return;
1427 }
1428 Err(e) => {
1429 println!("(error rendering template: {e})");
1430 println!();
1431 return;
1432 }
1433 },
1434 _ => match read_text_for_diff(src) {
1435 DiffSide::Text(s) => s,
1436 DiffSide::Binary => {
1437 println!("(binary file or non-UTF-8 content — diff skipped)");
1438 println!();
1439 return;
1440 }
1441 },
1442 };
1443 let dst_content = match read_text_for_diff(dst) {
1444 DiffSide::Text(s) => s,
1445 DiffSide::Binary => {
1446 println!("(binary file or non-UTF-8 content — diff skipped)");
1447 println!();
1448 return;
1449 }
1450 };
1451 print_unified_text_diff(
1452 &src_content,
1453 &dst_content,
1454 src.as_str(),
1455 dst.as_str(),
1456 color,
1457 );
1458 println!();
1459}
1460
1461fn print_unified_text_diff(src: &str, dst: &str, src_label: &str, dst_label: &str, color: bool) {
1470 use owo_colors::OwoColorize as _;
1471 let diff = similar::TextDiff::from_lines(src, dst);
1472 let formatted = diff.unified_diff().header(src_label, dst_label).to_string();
1473 for line in formatted.lines() {
1474 if !color {
1475 println!("{line}");
1476 } else if line.starts_with("+++") || line.starts_with("---") {
1477 println!("{}", line.dimmed());
1478 } else if line.starts_with("@@") {
1479 println!("{}", line.cyan());
1480 } else if line.starts_with('+') {
1481 println!("{}", line.green());
1482 } else if line.starts_with('-') {
1483 println!("{}", line.red());
1484 } else {
1485 println!("{line}");
1486 }
1487 }
1488}
1489
1490enum DiffSide {
1496 Text(String),
1497 Binary,
1498}
1499
1500fn read_text_for_diff(p: &Utf8Path) -> DiffSide {
1501 match std::fs::read_to_string(p) {
1502 Ok(s) => DiffSide::Text(s),
1503 Err(e) if e.kind() == std::io::ErrorKind::InvalidData => DiffSide::Binary,
1504 Err(_) => DiffSide::Text(String::new()),
1505 }
1506}
1507
1508pub fn status(
1521 source: Option<Utf8PathBuf>,
1522 icons_override: Option<IconsMode>,
1523 no_color: bool,
1524) -> Result<()> {
1525 let source = resolve_source(source)?;
1526 let yui = YuiVars::detect(&source);
1527 let config = config::load(&source, &yui)?;
1528
1529 let mut engine = template::Engine::new();
1530 let tera_ctx = template::template_context(&yui, &config.vars);
1531 let mounts = mount::resolve(
1532 &source,
1533 &config.mount.entry,
1534 config.mount.default_strategy,
1535 &mut engine,
1536 &tera_ctx,
1537 )?;
1538
1539 let icons_mode = icons_override.unwrap_or(config.ui.icons);
1540 let icons = Icons::for_mode(icons_mode);
1541 let color = !no_color && supports_color_stdout();
1542
1543 let mut report: Vec<StatusItem> = Vec::new();
1544
1545 let render_report = render::render_all(&source, &config, &yui, true)?;
1548 for entry in &render_report.diverged {
1549 report.push(StatusItem {
1553 src: relative_for_display(&source, &entry.tera_path),
1554 dst: entry.rendered_path.clone(),
1555 state: StatusState::RenderDrift,
1556 });
1557 }
1558
1559 let mut yuiignore = paths::YuiIgnoreStack::new();
1563 yuiignore.push_dir(&source)?;
1564 let walk_result = (|| -> Result<()> {
1565 for m in &mounts {
1566 let src_root = m.src.clone();
1567 if !src_root.is_dir() {
1568 warn!("mount src missing: {src_root}");
1569 continue;
1570 }
1571 classify_walk(
1572 &src_root,
1573 &m.dst,
1574 &config,
1575 m.strategy,
1576 &mut engine,
1577 &tera_ctx,
1578 &source,
1579 &mut yuiignore,
1580 &mut report,
1581 )?;
1582 }
1583 Ok(())
1584 })();
1585 yuiignore.pop_dir(&source);
1586 walk_result?;
1587
1588 report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
1589
1590 print_status_table(&report, icons, color);
1591
1592 let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
1593
1594 println!();
1595 let total = report.len();
1596 let in_sync = total - drift;
1597 if drift == 0 {
1598 println!(" {total} entries · all in sync");
1599 Ok(())
1600 } else {
1601 println!(" {total} entries · {in_sync} in sync · {drift} diverged");
1602 anyhow::bail!("status: {drift} entries diverged from source")
1603 }
1604}
1605
1606#[derive(Debug)]
1607struct StatusItem {
1608 src: Utf8PathBuf,
1610 dst: Utf8PathBuf,
1612 state: StatusState,
1613}
1614
1615#[derive(Debug, Clone, Copy)]
1616enum StatusState {
1617 Link(absorb::AbsorbDecision),
1618 RenderDrift,
1621}
1622
1623impl StatusState {
1624 fn is_in_sync(self) -> bool {
1625 matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
1626 }
1627}
1628
1629#[allow(clippy::too_many_arguments)]
1630fn classify_walk(
1631 src_dir: &Utf8Path,
1632 dst_dir: &Utf8Path,
1633 config: &Config,
1634 strategy: MountStrategy,
1635 engine: &mut template::Engine,
1636 tera_ctx: &TeraContext,
1637 source_root: &Utf8Path,
1638 yuiignore: &mut paths::YuiIgnoreStack,
1639 report: &mut Vec<StatusItem>,
1640) -> Result<()> {
1641 classify_walk_inner(
1642 src_dir,
1643 dst_dir,
1644 config,
1645 strategy,
1646 engine,
1647 tera_ctx,
1648 source_root,
1649 yuiignore,
1650 report,
1651 false,
1652 )
1653}
1654
1655#[allow(clippy::too_many_arguments)]
1656fn classify_walk_inner(
1657 src_dir: &Utf8Path,
1658 dst_dir: &Utf8Path,
1659 config: &Config,
1660 strategy: MountStrategy,
1661 engine: &mut template::Engine,
1662 tera_ctx: &TeraContext,
1663 source_root: &Utf8Path,
1664 yuiignore: &mut paths::YuiIgnoreStack,
1665 report: &mut Vec<StatusItem>,
1666 parent_covered: bool,
1667) -> Result<()> {
1668 if yuiignore.is_ignored(src_dir, true) {
1669 return Ok(());
1670 }
1671 yuiignore.push_dir(src_dir)?;
1674 let result = classify_walk_inner_body(
1675 src_dir,
1676 dst_dir,
1677 config,
1678 strategy,
1679 engine,
1680 tera_ctx,
1681 source_root,
1682 yuiignore,
1683 report,
1684 parent_covered,
1685 );
1686 yuiignore.pop_dir(src_dir);
1687 result
1688}
1689
1690#[allow(clippy::too_many_arguments)]
1691fn classify_walk_inner_body(
1692 src_dir: &Utf8Path,
1693 dst_dir: &Utf8Path,
1694 config: &Config,
1695 strategy: MountStrategy,
1696 engine: &mut template::Engine,
1697 tera_ctx: &TeraContext,
1698 source_root: &Utf8Path,
1699 yuiignore: &mut paths::YuiIgnoreStack,
1700 report: &mut Vec<StatusItem>,
1701 parent_covered: bool,
1702) -> Result<()> {
1703 let marker_filename = &config.mount.marker_filename;
1704 let mut covered = parent_covered;
1705
1706 if strategy == MountStrategy::Marker {
1707 match marker::read_spec(src_dir, marker_filename)? {
1708 None => {}
1709 Some(MarkerSpec::PassThrough) => {
1710 let decision = absorb::classify(src_dir, dst_dir)?;
1711 report.push(StatusItem {
1712 src: relative_for_display(source_root, src_dir),
1713 dst: dst_dir.to_path_buf(),
1714 state: StatusState::Link(decision),
1715 });
1716 covered = true;
1717 }
1718 Some(MarkerSpec::Explicit { links }) => {
1719 let mut emitted_dir_link = false;
1720 for link in &links {
1721 if let Some(when) = &link.when {
1722 if !template::eval_truthy(when, engine, tera_ctx)? {
1723 continue;
1724 }
1725 }
1726 let dst_str = engine.render(&link.dst, tera_ctx)?;
1727 let dst = paths::expand_tilde(dst_str.trim());
1728 if let Some(filename) = &link.src {
1729 let file_src = src_dir.join(filename);
1730 if !file_src.is_file() {
1731 anyhow::bail!(
1732 "marker at {src_dir}: [[link]] src={filename:?} \
1733 not found"
1734 );
1735 }
1736 let decision = absorb::classify(&file_src, &dst)?;
1737 report.push(StatusItem {
1738 src: relative_for_display(source_root, &file_src),
1739 dst,
1740 state: StatusState::Link(decision),
1741 });
1742 } else {
1743 let decision = absorb::classify(src_dir, &dst)?;
1744 report.push(StatusItem {
1745 src: relative_for_display(source_root, src_dir),
1746 dst,
1747 state: StatusState::Link(decision),
1748 });
1749 emitted_dir_link = true;
1750 }
1751 }
1752 if emitted_dir_link {
1753 covered = true;
1754 }
1755 }
1756 }
1757 }
1758
1759 for entry in std::fs::read_dir(src_dir)? {
1760 let entry = entry?;
1761 let name_os = entry.file_name();
1762 let Some(name) = name_os.to_str() else {
1763 continue;
1764 };
1765 if name == marker_filename || name.ends_with(".tera") {
1766 continue;
1767 }
1768 let src_path = src_dir.join(name);
1769 let dst_path = dst_dir.join(name);
1770 let ft = entry.file_type()?;
1771 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
1772 continue;
1773 }
1774 if ft.is_dir() {
1775 classify_walk_inner(
1776 &src_path,
1777 &dst_path,
1778 config,
1779 strategy,
1780 engine,
1781 tera_ctx,
1782 source_root,
1783 yuiignore,
1784 report,
1785 covered,
1786 )?;
1787 } else if ft.is_file() && !covered {
1788 let decision = absorb::classify(&src_path, &dst_path)?;
1789 report.push(StatusItem {
1790 src: relative_for_display(source_root, &src_path),
1791 dst: dst_path,
1792 state: StatusState::Link(decision),
1793 });
1794 }
1795 }
1796 Ok(())
1797}
1798
1799fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
1800 p.strip_prefix(source_root)
1801 .map(Utf8PathBuf::from)
1802 .unwrap_or_else(|_| p.to_path_buf())
1803}
1804
1805fn print_status_table(items: &[StatusItem], icons: Icons, color: bool) {
1806 let src_w = items
1807 .iter()
1808 .map(|i| i.src.as_str().chars().count())
1809 .max()
1810 .unwrap_or(0)
1811 .max("SRC".len());
1812 let dst_w = items
1813 .iter()
1814 .map(|i| i.dst.as_str().chars().count())
1815 .max()
1816 .unwrap_or(0)
1817 .max("DST".len());
1818 let state_label_w = items
1820 .iter()
1821 .map(|i| state_label(i.state).len())
1822 .max()
1823 .unwrap_or(0)
1824 .max("STATE".len() - 2); let state_w = state_label_w + 2; print_status_header(state_w, src_w, dst_w, color);
1828 let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
1829 if color {
1830 use owo_colors::OwoColorize as _;
1831 println!("{}", sep.dimmed());
1832 } else {
1833 println!("{sep}");
1834 }
1835 for item in items {
1836 print_status_row(item, icons, state_w, src_w, dst_w, color);
1837 }
1838}
1839
1840fn state_label(s: StatusState) -> &'static str {
1841 use absorb::AbsorbDecision::*;
1842 match s {
1843 StatusState::Link(InSync) => "in-sync",
1844 StatusState::Link(RelinkOnly) => "relink",
1845 StatusState::Link(AutoAbsorb) => "drift (auto)",
1846 StatusState::Link(NeedsConfirm) => "drift (anomaly)",
1847 StatusState::Link(Restore) => "missing",
1848 StatusState::RenderDrift => "render drift",
1849 }
1850}
1851
1852fn state_icon(s: StatusState, icons: Icons) -> &'static str {
1853 use absorb::AbsorbDecision::*;
1854 match s {
1855 StatusState::Link(InSync) => icons.ok,
1856 StatusState::Link(RelinkOnly) => icons.warn,
1857 StatusState::Link(AutoAbsorb) => icons.warn,
1858 StatusState::Link(NeedsConfirm) => icons.error,
1859 StatusState::Link(Restore) => icons.info,
1860 StatusState::RenderDrift => icons.error,
1861 }
1862}
1863
1864fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
1865 use owo_colors::OwoColorize as _;
1866 let line = format!(
1869 " {:<state_w$} {:<src_w$} {:<dst_w$}",
1870 "STATE", "SRC", "DST"
1871 );
1872 if color {
1873 println!("{}", line.bold());
1874 } else {
1875 println!("{line}");
1876 }
1877}
1878
1879fn render_status_separator(
1880 sep_ch: char,
1881 state_w: usize,
1882 src_w: usize,
1883 dst_w: usize,
1884 arrow: &str,
1885) -> String {
1886 let bar = |n: usize| sep_ch.to_string().repeat(n);
1887 format!(
1888 " {} {} {} {}",
1889 bar(state_w),
1890 bar(src_w),
1891 bar(arrow.chars().count()),
1892 bar(dst_w)
1893 )
1894}
1895
1896fn print_status_row(
1897 item: &StatusItem,
1898 icons: Icons,
1899 state_w: usize,
1900 src_w: usize,
1901 dst_w: usize,
1902 color: bool,
1903) {
1904 use owo_colors::OwoColorize as _;
1905 let icon = state_icon(item.state, icons);
1906 let label = state_label(item.state);
1907 let state_text = format!("{icon} {label}");
1908 let src_display = item.src.as_str().replace('\\', "/");
1909 let dst_display = item.dst.as_str().replace('\\', "/");
1910 let arrow = icons.arrow;
1911
1912 let cell_state = format!("{:<state_w$}", state_text);
1913 let cell_src = format!("{:<src_w$}", src_display);
1914 let cell_dst = format!("{:<dst_w$}", dst_display);
1915
1916 if !color {
1917 println!(" {cell_state} {cell_src} {arrow} {cell_dst}");
1918 return;
1919 }
1920
1921 use absorb::AbsorbDecision::*;
1922 let state_colored = match item.state {
1923 StatusState::Link(InSync) => cell_state.green().to_string(),
1924 StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
1925 cell_state.yellow().to_string()
1926 }
1927 StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
1928 StatusState::Link(Restore) => cell_state.cyan().to_string(),
1929 StatusState::RenderDrift => cell_state.red().to_string(),
1930 };
1931 let src_colored = cell_src.cyan().to_string();
1932 let arrow_colored = arrow.dimmed().to_string();
1933 let dst_colored = cell_dst.dimmed().to_string();
1934 println!(" {state_colored} {src_colored} {arrow_colored} {dst_colored}");
1935}
1936
1937pub fn absorb(
1951 source: Option<Utf8PathBuf>,
1952 target: Utf8PathBuf,
1953 dry_run: bool,
1954 yes: bool,
1955) -> Result<()> {
1956 let source = resolve_source(source)?;
1957 let target = absolutize(&target)?;
1958 let yui = YuiVars::detect(&source);
1959 let config = config::load(&source, &yui)?;
1960
1961 let mut engine = template::Engine::new();
1962 let tera_ctx = template::template_context(&yui, &config.vars);
1963
1964 let src_path = match find_source_for_target(&source, &config, &target, &mut engine, &tera_ctx)?
1965 {
1966 Some(s) => s,
1967 None => anyhow::bail!(
1968 "no mount entry / .yuilink override claims target {target}; \
1969 pass a path inside a known dst"
1970 ),
1971 };
1972
1973 info!("source for {target}: {src_path}");
1974
1975 print_absorb_diff(&src_path, &target);
1980
1981 if dry_run {
1982 info!("[dry-run] would absorb {target} → {src_path}");
1983 return Ok(());
1984 }
1985
1986 if !yes {
1987 use std::io::IsTerminal;
1988 if !std::io::stdin().is_terminal() {
1989 anyhow::bail!(
1990 "manual absorb refuses to run off-TTY without --yes \
1991 (would silently overwrite {src_path})"
1992 );
1993 }
1994 if !prompt_yes_no("absorb target into source?")? {
1995 warn!("manual absorb cancelled by user: {target}");
1996 return Ok(());
1997 }
1998 }
1999
2000 let backup_root = source.join(&config.backup.dir);
2001 let ctx = ApplyCtx {
2002 config: &config,
2003 source: &source,
2004 file_mode: resolve_file_mode(config.link.file_mode),
2005 dir_mode: resolve_dir_mode(config.link.dir_mode),
2006 backup_root: &backup_root,
2007 dry_run: false,
2008 sticky_anomaly: Cell::new(None),
2009 quit_requested: Cell::new(false),
2010 };
2011
2012 absorb_target_into_source(&src_path, &target, &ctx)
2015}
2016
2017fn print_absorb_diff(src: &Utf8Path, dst: &Utf8Path) {
2022 use owo_colors::OwoColorize as _;
2023 use std::io::IsTerminal;
2024
2025 let color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none();
2028
2029 eprintln!();
2030 if color {
2031 eprintln!(
2032 "{} {} {}",
2033 "── unified diff ──".bold(),
2034 "[-] src".red().bold(),
2035 "[+] dst".green().bold()
2036 );
2037 eprintln!(" {} {}", "[-] src:".red(), src);
2038 eprintln!(" {} {}", "[+] dst:".green(), dst);
2039 } else {
2040 eprintln!("── unified diff ── [-] src [+] dst");
2041 eprintln!(" [-] src: {src}");
2042 eprintln!(" [+] dst: {dst}");
2043 }
2044 eprintln!();
2045
2046 if src.is_dir() || dst.is_dir() {
2047 eprintln!("(directory absorb — content listing skipped)");
2048 eprintln!();
2049 return;
2050 }
2051 let src_content = match read_text_for_diff(src) {
2052 DiffSide::Text(s) => s,
2053 DiffSide::Binary => {
2054 eprintln!("(binary file or non-UTF-8 content — diff skipped)");
2055 eprintln!();
2056 return;
2057 }
2058 };
2059 let dst_content = match read_text_for_diff(dst) {
2060 DiffSide::Text(s) => s,
2061 DiffSide::Binary => {
2062 eprintln!("(binary file or non-UTF-8 content — diff skipped)");
2063 eprintln!();
2064 return;
2065 }
2066 };
2067
2068 let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
2069 for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
2073 let header = hunk.header().to_string();
2074 if color {
2075 eprintln!("{}", header.cyan());
2076 } else {
2077 eprintln!("{header}");
2078 }
2079 for change in hunk.iter_changes() {
2080 let line = change.value();
2081 let line = line.strip_suffix('\n').unwrap_or(line);
2082 match change.tag() {
2083 similar::ChangeTag::Delete => {
2084 if color {
2085 eprintln!("{} {}", "-".red().bold(), line.red());
2086 } else {
2087 eprintln!("- {line}");
2088 }
2089 }
2090 similar::ChangeTag::Insert => {
2091 if color {
2092 eprintln!("{} {}", "+".green().bold(), line.green());
2093 } else {
2094 eprintln!("+ {line}");
2095 }
2096 }
2097 similar::ChangeTag::Equal => {
2098 if color {
2099 eprintln!(" {}", line.dimmed());
2100 } else {
2101 eprintln!(" {line}");
2102 }
2103 }
2104 }
2105 }
2106 }
2107 eprintln!();
2108}
2109
2110fn prompt_yes_no(question: &str) -> Result<bool> {
2111 use std::io::Write as _;
2112 eprint!("{question} [y/N]: ");
2113 std::io::stderr().flush().ok();
2114 let mut input = String::new();
2115 std::io::stdin().read_line(&mut input)?;
2116 let answer = input.trim();
2117 Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
2118}
2119
2120fn find_source_for_target(
2124 source: &Utf8Path,
2125 config: &Config,
2126 target: &Utf8Path,
2127 engine: &mut template::Engine,
2128 tera_ctx: &TeraContext,
2129) -> Result<Option<Utf8PathBuf>> {
2130 for entry in &config.mount.entry {
2132 if let Some(when) = &entry.when {
2133 if !template::eval_truthy(when, engine, tera_ctx)? {
2134 continue;
2135 }
2136 }
2137 let dst_str = engine.render(&entry.dst, tera_ctx)?;
2138 let dst_root = paths::expand_tilde(dst_str.trim());
2139 if let Ok(rel) = target.strip_prefix(&dst_root) {
2140 let src_str = engine.render(entry.src.as_str(), tera_ctx)?;
2141 let candidate = paths::resolve_mount_src(source, src_str.trim()).join(rel);
2142 if paths::is_ignored_at(source, &candidate, candidate.is_dir())? {
2147 continue;
2148 }
2149 return Ok(Some(candidate));
2150 }
2151 }
2152
2153 let walker = paths::source_walker(source).build();
2159 let marker_filename = &config.mount.marker_filename;
2160 for ent in walker {
2161 let ent = match ent {
2162 Ok(e) => e,
2163 Err(_) => continue,
2164 };
2165 if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
2166 continue;
2167 }
2168 if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
2169 continue;
2170 }
2171 let dir = match ent.path().parent() {
2172 Some(d) => d,
2173 None => continue,
2174 };
2175 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
2176 Ok(p) => p,
2177 Err(_) => continue,
2178 };
2179 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
2180 Some(s) => s,
2181 None => continue,
2182 };
2183 let MarkerSpec::Explicit { links } = spec else {
2184 continue;
2185 };
2186 for link in &links {
2187 if let Some(when) = &link.when {
2188 if !template::eval_truthy(when, engine, tera_ctx)? {
2189 continue;
2190 }
2191 }
2192 let dst_str = engine.render(&link.dst, tera_ctx)?;
2193 let dst = paths::expand_tilde(dst_str.trim());
2194 if let Some(filename) = &link.src {
2201 let file_src = dir_utf8.join(filename);
2202 if !file_src.is_file() {
2203 anyhow::bail!(
2204 "marker at {dir_utf8}: [[link]] src={filename:?} \
2205 not found"
2206 );
2207 }
2208 if target == dst {
2209 return Ok(Some(file_src));
2210 }
2211 continue;
2212 }
2213 if target == dst {
2214 return Ok(Some(dir_utf8));
2215 }
2216 if let Ok(rel) = target.strip_prefix(&dst) {
2217 return Ok(Some(dir_utf8.join(rel)));
2218 }
2219 }
2220 }
2221
2222 Ok(None)
2223}
2224
2225pub fn doctor(
2226 source: Option<Utf8PathBuf>,
2227 icons_override: Option<IconsMode>,
2228 no_color: bool,
2229) -> Result<()> {
2230 use owo_colors::OwoColorize as _;
2231
2232 let resolved_source = resolve_source(source);
2237
2238 let yui = match &resolved_source {
2243 Ok(s) => YuiVars::detect(s),
2244 Err(_) => YuiVars::detect(Utf8Path::new(".")),
2245 };
2246
2247 let cfg_res = match &resolved_source {
2252 Ok(s) => Some(config::load(s, &yui)),
2253 Err(_) => None,
2254 };
2255 let cfg = cfg_res.as_ref().and_then(|r| r.as_ref().ok());
2256 let icons_mode = icons_override
2257 .or_else(|| cfg.map(|c| c.ui.icons))
2258 .unwrap_or_default();
2259 let icons = Icons::for_mode(icons_mode);
2260 let color = !no_color && supports_color_stdout();
2261
2262 let mut probes: Vec<Probe> = Vec::new();
2263
2264 probes.push(Probe::group("identity"));
2266 probes.push(Probe::ok("os/arch", format!("{} / {}", yui.os, yui.arch)));
2267 probes.push(Probe::ok("user@host", format!("{}@{}", yui.user, yui.host)));
2268
2269 probes.push(Probe::group("repo"));
2271 let mut have_source = false;
2272 match &resolved_source {
2273 Ok(s) => {
2274 have_source = true;
2275 probes.push(Probe::ok("source", s.to_string()));
2276 match cfg_res.as_ref().expect("cfg_res set when source is Ok") {
2277 Ok(c) => {
2278 probes.push(Probe::ok(
2279 "config",
2280 format!(
2281 "{} mount{} · {} hook{} · {} render rule{}",
2282 c.mount.entry.len(),
2283 plural(c.mount.entry.len()),
2284 c.hook.len(),
2285 plural(c.hook.len()),
2286 c.render.rule.len(),
2287 plural(c.render.rule.len()),
2288 ),
2289 ));
2290 }
2291 Err(e) => probes.push(Probe::error("config", format!("{e}"))),
2292 }
2293 match crate::git::is_clean(s) {
2297 Ok(true) => probes.push(Probe::ok("git", "clean")),
2298 Ok(false) => probes.push(Probe::warn(
2299 "git",
2300 "uncommitted changes — `[absorb] require_clean_git` will defer auto-absorb",
2301 )),
2302 Err(_) => probes.push(Probe::warn(
2303 "git",
2304 "no git repo (auto-absorb still works; commit history won't track drift)",
2305 )),
2306 }
2307 }
2308 Err(e) => {
2309 probes.push(Probe::error("source", format!("not found — {e}")));
2310 }
2311 }
2312
2313 probes.push(Probe::group("links"));
2315 if cfg!(windows) {
2316 probes.push(Probe::ok(
2317 "default mode",
2318 "files=hardlink, dirs=junction (no admin needed)",
2319 ));
2320 } else {
2321 probes.push(Probe::ok("default mode", "files=symlink, dirs=symlink"));
2322 }
2323
2324 if have_source {
2326 if let (Ok(s), Some(c)) = (&resolved_source, cfg) {
2327 probes.push(Probe::group("hooks"));
2328 if c.hook.is_empty() {
2329 probes.push(Probe::ok("hooks", "(none configured)"));
2330 } else {
2331 let mut missing = 0usize;
2332 for h in &c.hook {
2333 if !s.join(&h.script).is_file() {
2334 missing += 1;
2335 probes.push(Probe::error(
2336 format!("hook[{}]", h.name),
2337 format!("script not found at {}", h.script),
2338 ));
2339 }
2340 }
2341 if missing == 0 {
2342 probes.push(Probe::ok(
2343 "scripts",
2344 format!(
2345 "{} hook{} configured, all scripts present",
2346 c.hook.len(),
2347 plural(c.hook.len())
2348 ),
2349 ));
2350 }
2351 }
2352 }
2353 }
2354
2355 if let Some(home) = paths::home_dir() {
2357 let chezmoi_src = home.join(".local/share/chezmoi");
2358 if chezmoi_src.is_dir() {
2359 probes.push(Probe::group("chezmoi"));
2360 probes.push(Probe::warn(
2361 "legacy source",
2362 format!(
2363 "{chezmoi_src} still exists — yui doesn't use it, safe to archive once your migration has settled"
2364 ),
2365 ));
2366 }
2367 }
2368
2369 println!();
2371 if color {
2372 println!(" {}", "yui doctor".bold().underline());
2373 } else {
2374 println!(" yui doctor");
2375 }
2376 println!();
2377 for probe in &probes {
2378 probe.print(&icons, color);
2379 }
2380
2381 let errors = probes.iter().filter(|p| p.is_error()).count();
2382 let warns = probes.iter().filter(|p| p.is_warn()).count();
2383 let oks = probes.iter().filter(|p| p.is_ok()).count();
2384 println!();
2385 let summary = format!("{oks} ok · {warns} warn · {errors} error");
2386 if color {
2387 if errors > 0 {
2388 println!(" {}", summary.red().bold());
2389 } else if warns > 0 {
2390 println!(" {}", summary.yellow());
2391 } else {
2392 println!(" {}", summary.green());
2393 }
2394 } else {
2395 println!(" {summary}");
2396 }
2397
2398 if errors > 0 {
2399 anyhow::bail!("doctor: {errors} probe(s) failed");
2400 }
2401 Ok(())
2402}
2403
2404#[derive(Debug)]
2405enum Probe {
2406 Group(&'static str),
2408 Ok {
2409 label: String,
2410 detail: String,
2411 },
2412 Warn {
2413 label: String,
2414 detail: String,
2415 },
2416 Error {
2417 label: String,
2418 detail: String,
2419 },
2420}
2421
2422impl Probe {
2423 fn group(label: &'static str) -> Self {
2424 Self::Group(label)
2425 }
2426 fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
2427 Self::Ok {
2428 label: label.into(),
2429 detail: detail.into(),
2430 }
2431 }
2432 fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
2433 Self::Warn {
2434 label: label.into(),
2435 detail: detail.into(),
2436 }
2437 }
2438 fn error(label: impl Into<String>, detail: impl Into<String>) -> Self {
2439 Self::Error {
2440 label: label.into(),
2441 detail: detail.into(),
2442 }
2443 }
2444 fn is_ok(&self) -> bool {
2445 matches!(self, Self::Ok { .. })
2446 }
2447 fn is_warn(&self) -> bool {
2448 matches!(self, Self::Warn { .. })
2449 }
2450 fn is_error(&self) -> bool {
2451 matches!(self, Self::Error { .. })
2452 }
2453 fn print(&self, icons: &Icons, color: bool) {
2454 use owo_colors::OwoColorize as _;
2455 match self {
2456 Self::Group(name) => {
2457 println!();
2458 if color {
2459 println!(" {}", name.cyan().bold());
2460 } else {
2461 println!(" {name}");
2462 }
2463 }
2464 Self::Ok { label, detail } => {
2465 let icon = icons.ok;
2466 let padded = format!("{label:<14}");
2470 if color {
2471 println!(
2472 " {} {} {}",
2473 icon.green(),
2474 padded.bold(),
2475 detail.dimmed()
2476 );
2477 } else {
2478 println!(" {icon} {padded} {detail}");
2479 }
2480 }
2481 Self::Warn { label, detail } => {
2482 let icon = icons.warn;
2483 let padded = format!("{label:<14}");
2484 if color {
2485 println!(
2486 " {} {} {}",
2487 icon.yellow(),
2488 padded.bold().yellow(),
2489 detail
2490 );
2491 } else {
2492 println!(" {icon} {padded} {detail}");
2493 }
2494 }
2495 Self::Error { label, detail } => {
2496 let icon = icons.error;
2497 let padded = format!("{label:<14}");
2498 if color {
2499 println!(
2500 " {} {} {}",
2501 icon.red().bold(),
2502 padded.bold().red(),
2503 detail.red()
2504 );
2505 } else {
2506 println!(" {icon} {padded} {detail}");
2507 }
2508 }
2509 }
2510 }
2511}
2512
2513fn plural(n: usize) -> &'static str {
2514 if n == 1 { "" } else { "s" }
2515}
2516
2517pub fn gc_backup(
2537 source: Option<Utf8PathBuf>,
2538 older_than: Option<String>,
2539 dry_run: bool,
2540 icons_override: Option<IconsMode>,
2541 no_color: bool,
2542) -> Result<()> {
2543 let source = resolve_source(source)?;
2544 let yui = YuiVars::detect(&source);
2545 let config = config::load(&source, &yui)?;
2546 let backup_root = source.join(&config.backup.dir);
2547 let icons_mode = icons_override.unwrap_or(config.ui.icons);
2548 let icons = Icons::for_mode(icons_mode);
2549 let color = !no_color && supports_color_stdout();
2550
2551 if !backup_root.is_dir() {
2552 println!(" no backup tree at {backup_root}");
2553 return Ok(());
2554 }
2555
2556 let mut entries = walk_gc_backups(&backup_root)?;
2557 if entries.is_empty() {
2558 println!(" no yui-stamped backups under {backup_root}");
2559 return Ok(());
2560 }
2561 entries.sort_by_key(|e| e.ts);
2563 let now = jiff::Zoned::now();
2564
2565 match older_than {
2566 None => {
2567 let refs: Vec<&BackupEntry> = entries.iter().collect();
2568 print_gc_table(&refs, &backup_root, &now, icons, color);
2569 println!();
2570 println!(
2571 " {} entries · {} total — pass --older-than DUR (e.g. 30d) to delete",
2572 entries.len(),
2573 format_bytes(entries.iter().map(|e| e.size_bytes).sum())
2574 );
2575 Ok(())
2576 }
2577 Some(dur_str) => {
2578 let span = parse_human_duration(&dur_str)?;
2579 let cutoff = now
2580 .checked_sub(span)
2581 .map_err(|e| anyhow::anyhow!("invalid duration {dur_str:?}: {e}"))?;
2582 let cutoff_dt = cutoff.datetime();
2583
2584 let total_before: u64 = entries.iter().map(|e| e.size_bytes).sum();
2585 let to_delete: Vec<&BackupEntry> =
2586 entries.iter().filter(|e| e.ts < cutoff_dt).collect();
2587
2588 if to_delete.is_empty() {
2589 println!(
2590 " no backups older than {dur_str} (oldest: {})",
2591 format_age(entries[0].ts, &now)
2592 );
2593 return Ok(());
2594 }
2595
2596 print_gc_table(&to_delete, &backup_root, &now, icons, color);
2597 println!();
2598 let total_freed: u64 = to_delete.iter().map(|e| e.size_bytes).sum();
2599
2600 if dry_run {
2601 println!(
2602 " [dry-run] would remove {} of {} entries · would free {} of {}",
2603 to_delete.len(),
2604 entries.len(),
2605 format_bytes(total_freed),
2606 format_bytes(total_before),
2607 );
2608 return Ok(());
2609 }
2610
2611 for entry in &to_delete {
2612 match entry.kind {
2613 BackupKind::File => std::fs::remove_file(&entry.path)?,
2614 BackupKind::Dir => std::fs::remove_dir_all(&entry.path)?,
2615 }
2616 if let Some(parent) = entry.path.parent() {
2617 cleanup_empty_parents(parent, &backup_root);
2618 }
2619 }
2620 println!(
2621 " removed {} of {} entries · freed {} (was {}, now {})",
2622 to_delete.len(),
2623 entries.len(),
2624 format_bytes(total_freed),
2625 format_bytes(total_before),
2626 format_bytes(total_before - total_freed),
2627 );
2628 Ok(())
2629 }
2630 }
2631}
2632
2633#[derive(Debug)]
2634struct BackupEntry {
2635 path: Utf8PathBuf,
2636 ts: jiff::civil::DateTime,
2637 kind: BackupKind,
2638 size_bytes: u64,
2639}
2640
2641#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2642enum BackupKind {
2643 File,
2644 Dir,
2645}
2646
2647fn walk_gc_backups(root: &Utf8Path) -> Result<Vec<BackupEntry>> {
2652 let mut out = Vec::new();
2653 walk_gc_backups_rec(root, &mut out)?;
2654 Ok(out)
2655}
2656
2657fn walk_gc_backups_rec(dir: &Utf8Path, out: &mut Vec<BackupEntry>) -> Result<()> {
2658 for entry in std::fs::read_dir(dir)? {
2659 let entry = entry?;
2660 let name_os = entry.file_name();
2661 let Some(name) = name_os.to_str() else {
2662 continue;
2663 };
2664 let path = dir.join(name);
2665 let ft = entry.file_type()?;
2666 if ft.is_dir() {
2667 if let Some(ts) = parse_backup_suffix(name) {
2668 let size = dir_size(&path)?;
2669 out.push(BackupEntry {
2670 path,
2671 ts,
2672 kind: BackupKind::Dir,
2673 size_bytes: size,
2674 });
2675 } else {
2676 walk_gc_backups_rec(&path, out)?;
2677 }
2678 } else if ft.is_file() {
2679 if let Some(ts) = parse_backup_suffix(name) {
2682 let size = entry.metadata()?.len();
2683 out.push(BackupEntry {
2684 path,
2685 ts,
2686 kind: BackupKind::File,
2687 size_bytes: size,
2688 });
2689 }
2690 }
2691 }
2692 Ok(())
2693}
2694
2695fn dir_size(dir: &Utf8Path) -> Result<u64> {
2696 let mut total: u64 = 0;
2697 for entry in std::fs::read_dir(dir)? {
2698 let entry = entry?;
2699 let ft = entry.file_type()?;
2700 if ft.is_dir() {
2701 let p = match Utf8PathBuf::from_path_buf(entry.path()) {
2702 Ok(p) => p,
2703 Err(_) => continue,
2704 };
2705 total = total.saturating_add(dir_size(&p)?);
2706 } else if ft.is_file() {
2707 total = total.saturating_add(entry.metadata()?.len());
2708 }
2709 }
2710 Ok(total)
2711}
2712
2713fn cleanup_empty_parents(start: &Utf8Path, root: &Utf8Path) {
2717 let mut cur = start.to_path_buf();
2718 loop {
2719 if cur == *root {
2720 return;
2721 }
2722 if std::fs::remove_dir(&cur).is_err() {
2724 return;
2725 }
2726 match cur.parent() {
2727 Some(p) => cur = p.to_path_buf(),
2728 None => return,
2729 }
2730 }
2731}
2732
2733fn parse_backup_suffix(name: &str) -> Option<jiff::civil::DateTime> {
2739 if let Some(ts) = parse_ts_at_end(name) {
2740 return Some(ts);
2741 }
2742 if let Some((before, _ext)) = name.rsplit_once('.') {
2745 if let Some(ts) = parse_ts_at_end(before) {
2746 return Some(ts);
2747 }
2748 }
2749 None
2750}
2751
2752fn parse_ts_at_end(s: &str) -> Option<jiff::civil::DateTime> {
2753 if s.len() < 20 {
2755 return None;
2756 }
2757 let split_at = s.len() - 19;
2758 if s.as_bytes()[split_at] != b'_' {
2759 return None;
2760 }
2761 parse_ts(&s[split_at + 1..])
2762}
2763
2764fn parse_ts(s: &str) -> Option<jiff::civil::DateTime> {
2766 if s.len() != 18 || s.as_bytes()[8] != b'_' {
2767 return None;
2768 }
2769 for (i, &b) in s.as_bytes().iter().enumerate() {
2770 if i == 8 {
2771 continue;
2772 }
2773 if !b.is_ascii_digit() {
2774 return None;
2775 }
2776 }
2777 let year: i16 = s[0..4].parse().ok()?;
2778 let month: i8 = s[4..6].parse().ok()?;
2779 let day: i8 = s[6..8].parse().ok()?;
2780 let hour: i8 = s[9..11].parse().ok()?;
2781 let minute: i8 = s[11..13].parse().ok()?;
2782 let second: i8 = s[13..15].parse().ok()?;
2783 let ms: i32 = s[15..18].parse().ok()?;
2784 jiff::civil::DateTime::new(year, month, day, hour, minute, second, ms * 1_000_000).ok()
2785}
2786
2787fn parse_human_duration(s: &str) -> Result<jiff::Span> {
2796 let s = s.trim();
2797 let split = s
2798 .bytes()
2799 .position(|b| b.is_ascii_alphabetic())
2800 .ok_or_else(|| anyhow::anyhow!("invalid duration {s:?}: missing unit (e.g. 30d, 2w)"))?;
2801 let n: i64 = s[..split]
2802 .trim()
2803 .parse()
2804 .map_err(|_| anyhow::anyhow!("invalid duration {s:?}: bad leading number"))?;
2805 if n < 0 {
2806 anyhow::bail!("invalid duration {s:?}: negative durations don't make sense");
2807 }
2808 let unit = s[split..].to_ascii_lowercase();
2809 let span = match unit.as_str() {
2810 "y" | "yr" | "year" | "years" => jiff::Span::new().years(n),
2811 "mo" | "month" | "months" => jiff::Span::new().months(n),
2812 "w" | "wk" | "week" | "weeks" => jiff::Span::new().weeks(n),
2813 "d" | "day" | "days" => jiff::Span::new().days(n),
2814 "h" | "hr" | "hour" | "hours" => jiff::Span::new().hours(n),
2815 "m" | "min" | "minute" | "minutes" => jiff::Span::new().minutes(n),
2816 other => {
2817 anyhow::bail!(
2818 "invalid duration {s:?}: unknown unit {other:?} \
2819 (use y / mo / w / d / h / m)"
2820 )
2821 }
2822 };
2823 Ok(span)
2824}
2825
2826fn format_bytes(n: u64) -> String {
2827 const KIB: u64 = 1024;
2828 const MIB: u64 = KIB * 1024;
2829 const GIB: u64 = MIB * 1024;
2830 if n >= GIB {
2831 format!("{:.1} GiB", n as f64 / GIB as f64)
2832 } else if n >= MIB {
2833 format!("{:.1} MiB", n as f64 / MIB as f64)
2834 } else if n >= KIB {
2835 format!("{:.1} KiB", n as f64 / KIB as f64)
2836 } else {
2837 format!("{n} B")
2838 }
2839}
2840
2841fn format_age(ts: jiff::civil::DateTime, now: &jiff::Zoned) -> String {
2842 let Ok(ts_zoned) = ts.to_zoned(now.time_zone().clone()) else {
2843 return "?".into();
2844 };
2845 let secs = match (now - &ts_zoned).total(jiff::Unit::Second) {
2846 Ok(s) => s as i64,
2847 Err(_) => return "?".into(),
2848 };
2849 if secs < 0 {
2850 return "future".into();
2851 }
2852 if secs < 60 {
2853 format!("{secs}s")
2854 } else if secs < 3600 {
2855 format!("{}m", secs / 60)
2856 } else if secs < 86_400 {
2857 format!("{}h", secs / 3600)
2858 } else if secs < 86_400 * 30 {
2859 format!("{}d", secs / 86_400)
2860 } else if secs < 86_400 * 365 {
2861 format!("{}mo", secs / (86_400 * 30))
2862 } else {
2863 format!("{}y", secs / (86_400 * 365))
2864 }
2865}
2866
2867fn print_gc_table(
2874 entries: &[&BackupEntry],
2875 backup_root: &Utf8Path,
2876 now: &jiff::Zoned,
2877 _icons: Icons,
2878 color: bool,
2879) {
2880 use owo_colors::OwoColorize as _;
2881
2882 let rows: Vec<(String, String, String)> = entries
2883 .iter()
2884 .map(|e| {
2885 let rel = e
2886 .path
2887 .strip_prefix(backup_root)
2888 .map(Utf8PathBuf::from)
2889 .unwrap_or_else(|_| e.path.clone());
2890 let path_disp = match e.kind {
2891 BackupKind::Dir => format!("{rel}/"),
2892 BackupKind::File => rel.to_string(),
2893 };
2894 (format_age(e.ts, now), format_bytes(e.size_bytes), path_disp)
2895 })
2896 .collect();
2897
2898 let age_w = rows.iter().map(|r| r.0.len()).max().unwrap_or(3);
2899 let size_w = rows.iter().map(|r| r.1.len()).max().unwrap_or(4);
2900
2901 if color {
2902 println!(
2903 " {:<age_w$} {:>size_w$} {}",
2904 "AGE".dimmed(),
2905 "SIZE".dimmed(),
2906 "PATH".dimmed(),
2907 );
2908 } else {
2909 println!(" {:<age_w$} {:>size_w$} PATH", "AGE", "SIZE");
2910 }
2911 for (age, size, path) in &rows {
2912 if color {
2913 println!(
2914 " {:<age_w$} {:>size_w$} {}",
2915 age.yellow(),
2916 size,
2917 path.cyan(),
2918 );
2919 } else {
2920 println!(" {:<age_w$} {:>size_w$} {}", age, size, path);
2921 }
2922 }
2923}
2924
2925pub fn hooks_list(
2927 source: Option<Utf8PathBuf>,
2928 icons_override: Option<IconsMode>,
2929 no_color: bool,
2930) -> Result<()> {
2931 let source = resolve_source(source)?;
2932 let yui = YuiVars::detect(&source);
2933 let config = config::load(&source, &yui)?;
2934 let state = hook::State::load(&source)?;
2935
2936 let icons_mode = icons_override.unwrap_or(config.ui.icons);
2937 let icons = Icons::for_mode(icons_mode);
2938 let color = !no_color && supports_color_stdout();
2939
2940 if config.hook.is_empty() {
2941 println!("(no [[hook]] entries in config)");
2942 return Ok(());
2943 }
2944
2945 let mut engine = template::Engine::new();
2949 let tera_ctx = template::template_context(&yui, &config.vars);
2950 let rows: Vec<HookRow> = config
2951 .hook
2952 .iter()
2953 .map(|h| -> Result<HookRow> {
2954 let active = match &h.when {
2958 None => true,
2959 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
2960 };
2961 let last_run_at = state.hooks.get(&h.name).and_then(|s| s.last_run_at.clone());
2962 Ok(HookRow {
2963 name: h.name.clone(),
2964 phase: match h.phase {
2965 HookPhase::Pre => "pre",
2966 HookPhase::Post => "post",
2967 },
2968 when_run: match h.when_run {
2969 config::WhenRun::Once => "once",
2970 config::WhenRun::Onchange => "onchange",
2971 config::WhenRun::Every => "every",
2972 },
2973 last_run_at,
2974 when: h.when.clone(),
2975 active,
2976 })
2977 })
2978 .collect::<Result<Vec<_>>>()?;
2979
2980 print_hooks_table(&rows, icons, color);
2981
2982 let total = rows.len();
2983 let active = rows.iter().filter(|r| r.active).count();
2984 let inactive = total - active;
2985 let ran = rows.iter().filter(|r| r.last_run_at.is_some()).count();
2986 let never = total - ran;
2987 println!();
2988 println!(
2989 " {total} hooks · {active} active · {inactive} inactive · {ran} ran · {never} never run"
2990 );
2991
2992 Ok(())
2993}
2994
2995#[derive(Debug)]
2996struct HookRow {
2997 name: String,
2998 phase: &'static str,
2999 when_run: &'static str,
3000 last_run_at: Option<String>,
3001 when: Option<String>,
3002 active: bool,
3003}
3004
3005fn print_hooks_table(rows: &[HookRow], icons: Icons, color: bool) {
3006 use owo_colors::OwoColorize as _;
3007 use std::fmt::Write as _;
3008
3009 let name_w = rows
3010 .iter()
3011 .map(|r| r.name.chars().count())
3012 .max()
3013 .unwrap_or(0)
3014 .max("NAME".len());
3015 let phase_w = rows
3016 .iter()
3017 .map(|r| r.phase.len())
3018 .max()
3019 .unwrap_or(0)
3020 .max("PHASE".len());
3021 let when_run_w = rows
3022 .iter()
3023 .map(|r| r.when_run.len())
3024 .max()
3025 .unwrap_or(0)
3026 .max("WHEN_RUN".len());
3027 let last_w = rows
3028 .iter()
3029 .map(|r| {
3030 r.last_run_at
3031 .as_deref()
3032 .map(|s| s.chars().count())
3033 .unwrap_or("(never)".len())
3034 })
3035 .max()
3036 .unwrap_or(0)
3037 .max("LAST_RUN".len());
3038 let status_w = "STATUS".len();
3039
3040 let mut header = String::new();
3042 let _ = write!(
3043 &mut header,
3044 " {:<status_w$} {:<name_w$} {:<phase_w$} {:<when_run_w$} {:<last_w$} WHEN",
3045 "STATUS", "NAME", "PHASE", "WHEN_RUN", "LAST_RUN"
3046 );
3047 if color {
3048 println!("{}", header.bold());
3049 } else {
3050 println!("{header}");
3051 }
3052
3053 let bar = |n: usize| icons.sep.to_string().repeat(n);
3055 let sep = format!(
3056 " {} {} {} {} {} {}",
3057 bar(status_w),
3058 bar(name_w),
3059 bar(phase_w),
3060 bar(when_run_w),
3061 bar(last_w),
3062 bar("WHEN".len())
3063 );
3064 if color {
3065 println!("{}", sep.dimmed());
3066 } else {
3067 println!("{sep}");
3068 }
3069
3070 for r in rows {
3072 let (icon, ran) = match (r.active, r.last_run_at.is_some()) {
3077 (false, _) => (icons.inactive, false),
3078 (true, true) => (icons.active, true),
3079 (true, false) => (icons.info, false),
3080 };
3081 let last = r.last_run_at.as_deref().unwrap_or("(never)");
3082 let when_str = r
3083 .when
3084 .as_deref()
3085 .map(strip_braces)
3086 .unwrap_or_else(|| "(always)".to_string());
3087
3088 let cell_status = format!("{icon:<status_w$}");
3089 let cell_name = format!("{:<name_w$}", r.name);
3090 let cell_phase = format!("{:<phase_w$}", r.phase);
3091 let cell_when_run = format!("{:<when_run_w$}", r.when_run);
3092 let cell_last = format!("{last:<last_w$}");
3093
3094 if !color {
3095 println!(
3096 " {cell_status} {cell_name} {cell_phase} {cell_when_run} {cell_last} {when_str}"
3097 );
3098 continue;
3099 }
3100
3101 if !r.active {
3105 println!(
3106 " {} {} {} {} {} {}",
3107 cell_status.dimmed(),
3108 cell_name.dimmed(),
3109 cell_phase.dimmed(),
3110 cell_when_run.dimmed(),
3111 cell_last.dimmed(),
3112 when_str.dimmed()
3113 );
3114 } else if ran {
3115 println!(
3116 " {} {} {} {} {} {}",
3117 cell_status.green(),
3118 cell_name.cyan().bold(),
3119 cell_phase.dimmed(),
3120 cell_when_run.dimmed(),
3121 cell_last.green(),
3122 when_str.dimmed()
3123 );
3124 } else {
3125 println!(
3126 " {} {} {} {} {} {}",
3127 cell_status.yellow(),
3128 cell_name.cyan().bold(),
3129 cell_phase.dimmed(),
3130 cell_when_run.dimmed(),
3131 cell_last.yellow(),
3132 when_str.dimmed()
3133 );
3134 }
3135 }
3136}
3137
3138pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
3142 let source = resolve_source(source)?;
3143 let yui = YuiVars::detect(&source);
3144 let config = config::load(&source, &yui)?;
3145 let mut engine = template::Engine::new();
3146 let tera_ctx = template::template_context(&yui, &config.vars);
3147
3148 let targets: Vec<&config::HookConfig> = match &name {
3149 Some(want) => {
3150 let m = config
3151 .hook
3152 .iter()
3153 .find(|h| &h.name == want)
3154 .ok_or_else(|| {
3155 anyhow::anyhow!(
3156 "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
3157 )
3158 })?;
3159 vec![m]
3160 }
3161 None => config.hook.iter().collect(),
3162 };
3163
3164 let mut state = hook::State::load(&source)?;
3165 for h in targets {
3166 let outcome = hook::run_hook(
3167 h,
3168 &source,
3169 &yui,
3170 &config.vars,
3171 &mut engine,
3172 &tera_ctx,
3173 &mut state,
3174 false,
3175 force,
3176 )?;
3177 let label = match outcome {
3178 HookOutcome::Ran => "ran",
3179 HookOutcome::SkippedOnce => "skipped (once: already ran)",
3180 HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
3181 HookOutcome::SkippedWhenFalse => "skipped (when=false)",
3182 HookOutcome::DryRun => "would run (dry-run)",
3183 };
3184 info!("hook[{}]: {label}", h.name);
3185 if outcome == HookOutcome::Ran {
3186 state.save(&source)?;
3187 }
3188 }
3189 Ok(())
3190}
3191
3192#[allow(clippy::too_many_arguments)]
3197fn process_mount(
3198 m: &ResolvedMount,
3199 ctx: &ApplyCtx<'_>,
3200 engine: &mut template::Engine,
3201 tera_ctx: &TeraContext,
3202 yuiignore: &mut paths::YuiIgnoreStack,
3203) -> Result<()> {
3204 let src_root = m.src.clone();
3207 if !src_root.is_dir() {
3208 warn!("mount src missing: {src_root}");
3209 return Ok(());
3210 }
3211 walk_and_link(
3212 &src_root, &m.dst, ctx, m.strategy, engine, tera_ctx, yuiignore, false,
3213 )
3214}
3215
3216#[allow(clippy::too_many_arguments)]
3217fn walk_and_link(
3218 src_dir: &Utf8Path,
3219 dst_dir: &Utf8Path,
3220 ctx: &ApplyCtx<'_>,
3221 strategy: MountStrategy,
3222 engine: &mut template::Engine,
3223 tera_ctx: &TeraContext,
3224 yuiignore: &mut paths::YuiIgnoreStack,
3225 parent_covered: bool,
3226) -> Result<()> {
3227 if yuiignore.is_ignored(src_dir, true) {
3230 return Ok(());
3231 }
3232 yuiignore.push_dir(src_dir)?;
3235 let result = walk_and_link_body(
3236 src_dir,
3237 dst_dir,
3238 ctx,
3239 strategy,
3240 engine,
3241 tera_ctx,
3242 yuiignore,
3243 parent_covered,
3244 );
3245 yuiignore.pop_dir(src_dir);
3246 result
3247}
3248
3249#[allow(clippy::too_many_arguments)]
3250fn walk_and_link_body(
3251 src_dir: &Utf8Path,
3252 dst_dir: &Utf8Path,
3253 ctx: &ApplyCtx<'_>,
3254 strategy: MountStrategy,
3255 engine: &mut template::Engine,
3256 tera_ctx: &TeraContext,
3257 yuiignore: &mut paths::YuiIgnoreStack,
3258 parent_covered: bool,
3259) -> Result<()> {
3260 let marker_filename = &ctx.config.mount.marker_filename;
3261 let mut covered = parent_covered;
3262
3263 if strategy == MountStrategy::Marker {
3264 match marker::read_spec(src_dir, marker_filename)? {
3265 None => {} Some(MarkerSpec::PassThrough) => {
3267 link_dir_with_backup(src_dir, dst_dir, ctx)?;
3271 covered = true;
3272 }
3273 Some(MarkerSpec::Explicit { links }) => {
3274 let mut emitted_dir_link = false;
3275 let mut emitted_any = false;
3276 for link in &links {
3277 if let Some(when) = &link.when {
3280 if !template::eval_truthy(when, engine, tera_ctx)? {
3281 continue;
3282 }
3283 }
3284 let dst_str = engine.render(&link.dst, tera_ctx)?;
3285 let dst = paths::expand_tilde(dst_str.trim());
3286 if let Some(filename) = &link.src {
3287 let file_src = src_dir.join(filename);
3288 if !file_src.is_file() {
3289 anyhow::bail!(
3290 "marker at {src_dir}: [[link]] src={filename:?} \
3291 not found"
3292 );
3293 }
3294 link_file_with_backup(&file_src, &dst, ctx)?;
3295 } else {
3296 link_dir_with_backup(src_dir, &dst, ctx)?;
3297 emitted_dir_link = true;
3298 }
3299 emitted_any = true;
3300 }
3301 if !emitted_any {
3302 info!(
3307 "marker at {src_dir} had no active links \
3308 — falling back to defaults"
3309 );
3310 }
3311 if emitted_dir_link {
3312 covered = true;
3313 }
3314 }
3315 }
3316 }
3317
3318 for entry in std::fs::read_dir(src_dir)? {
3319 let entry = entry?;
3320 let name_os = entry.file_name();
3321 let Some(name) = name_os.to_str() else {
3322 continue;
3323 };
3324 if name == marker_filename {
3325 continue;
3326 }
3327 if name.ends_with(".tera") {
3328 continue;
3330 }
3331 let src_path = src_dir.join(name);
3332 let dst_path = dst_dir.join(name);
3333 let ft = entry.file_type()?;
3334
3335 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
3336 continue;
3337 }
3338
3339 if ft.is_dir() {
3340 walk_and_link(
3341 &src_path, &dst_path, ctx, strategy, engine, tera_ctx, yuiignore, covered,
3342 )?;
3343 } else if ft.is_file() {
3344 if !covered {
3350 link_file_with_backup(&src_path, &dst_path, ctx)?;
3351 }
3352 }
3353 }
3354 Ok(())
3355}
3356
3357fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3358 use absorb::AbsorbDecision::*;
3359
3360 if ctx.quit_requested.get() {
3361 return Ok(());
3362 }
3363
3364 let decision = absorb::classify(src, dst)?;
3365
3366 if ctx.dry_run {
3367 info!("[dry-run] {decision:?}: {src} → {dst}");
3368 return Ok(());
3369 }
3370
3371 match decision {
3372 InSync => {
3373 Ok(())
3375 }
3376 Restore => {
3377 info!("link: {src} → {dst}");
3378 link::link_file(src, dst, ctx.file_mode)?;
3379 Ok(())
3380 }
3381 RelinkOnly => {
3382 info!("relink: {src} → {dst}");
3385 link::unlink(dst)?;
3386 link::link_file(src, dst, ctx.file_mode)?;
3387 Ok(())
3388 }
3389 AutoAbsorb => {
3390 if !ctx.config.absorb.auto {
3393 return handle_anomaly(
3394 src,
3395 dst,
3396 ctx,
3397 "absorb.auto = false; treating divergence as anomaly",
3398 );
3399 }
3400 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
3401 return handle_anomaly(
3402 src,
3403 dst,
3404 ctx,
3405 "source repo is dirty; deferring auto-absorb",
3406 );
3407 }
3408 absorb_target_into_source(src, dst, ctx)
3409 }
3410 NeedsConfirm => handle_anomaly(
3411 src,
3412 dst,
3413 ctx,
3414 "anomaly: source equals/newer than target but content differs",
3415 ),
3416 }
3417}
3418
3419fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3423 info!("absorb: {dst} → {src}");
3424 backup_existing(src, ctx.backup_root, false)?;
3425 std::fs::copy(dst, src)?;
3426 link::unlink(dst)?;
3427 link::link_file(src, dst, ctx.file_mode)?;
3428 Ok(())
3429}
3430
3431fn overwrite_source_into_target(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3437 info!("overwrite: {src} → {dst}");
3438 backup_existing(dst, ctx.backup_root, false)?;
3439 link::unlink(dst)?;
3440 link::link_file(src, dst, ctx.file_mode)?;
3441 Ok(())
3442}
3443
3444fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
3450 use crate::config::AnomalyAction::*;
3451 match ctx.config.absorb.on_anomaly {
3452 Skip => {
3453 warn!("anomaly skip: {dst} ({reason})");
3454 Ok(())
3455 }
3456 Force => {
3457 warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
3458 absorb_target_into_source(src, dst, ctx)
3459 }
3460 Ask => match prompt_anomaly(ctx, src, dst, reason)? {
3461 AnomalyChoice::Absorb => absorb_target_into_source(src, dst, ctx),
3462 AnomalyChoice::Overwrite => overwrite_source_into_target(src, dst, ctx),
3463 AnomalyChoice::Skip => {
3464 warn!("anomaly skipped by user: {dst}");
3465 Ok(())
3466 }
3467 AnomalyChoice::Quit => {
3468 warn!("anomaly: user requested quit; stopping apply at {dst}");
3469 ctx.quit_requested.set(true);
3470 Ok(())
3471 }
3472 },
3473 }
3474}
3475
3476fn prompt_anomaly(
3492 ctx: &ApplyCtx<'_>,
3493 src: &Utf8Path,
3494 dst: &Utf8Path,
3495 reason: &str,
3496) -> Result<AnomalyChoice> {
3497 if ctx.quit_requested.get() {
3502 return Ok(AnomalyChoice::Quit);
3503 }
3504 if let Some(c) = ctx.sticky_anomaly.get() {
3505 return Ok(c);
3506 }
3507
3508 use std::io::IsTerminal;
3509 use std::io::Write as _;
3510 if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
3511 return Ok(AnomalyChoice::Skip);
3512 }
3513
3514 eprintln!();
3515 eprintln!("anomaly: {reason}");
3516 eprintln!(" src: {src}");
3517 eprintln!(" dst: {dst}");
3518 print_absorb_diff(src, dst);
3519
3520 loop {
3521 eprintln!(" [a/A] absorb target → source (this / all remaining)");
3522 eprintln!(" [o/O] overwrite source → target (this / all remaining)");
3523 eprintln!(" [s/S] skip leave as-is (this / all remaining)");
3524 eprintln!(" [d] diff re-show the diff");
3525 eprintln!(" [q] quit skip this and stop apply");
3526 eprint!("choice [s]: ");
3527 std::io::stderr().flush().ok();
3528
3529 let mut input = String::new();
3530 std::io::stdin().read_line(&mut input)?;
3531 let trimmed = input.trim();
3532 let choice = match trimmed {
3536 "" | "s" | "n" => AnomalyChoice::Skip,
3537 "a" | "y" => AnomalyChoice::Absorb,
3538 "o" => AnomalyChoice::Overwrite,
3539 "q" => AnomalyChoice::Quit,
3540 "A" => {
3541 ctx.sticky_anomaly.set(Some(AnomalyChoice::Absorb));
3542 AnomalyChoice::Absorb
3543 }
3544 "O" => {
3545 ctx.sticky_anomaly.set(Some(AnomalyChoice::Overwrite));
3546 AnomalyChoice::Overwrite
3547 }
3548 "S" => {
3549 ctx.sticky_anomaly.set(Some(AnomalyChoice::Skip));
3550 AnomalyChoice::Skip
3551 }
3552 "d" => {
3553 print_absorb_diff(src, dst);
3554 continue;
3555 }
3556 other => {
3557 eprintln!("unknown choice: {other:?}");
3558 continue;
3559 }
3560 };
3561 return Ok(choice);
3562 }
3563}
3564
3565fn resolve_render_drift(report: &render::RenderReport, quit_flag: &Cell<bool>) -> Result<()> {
3575 let sticky: Cell<Option<RenderDriftChoice>> = Cell::new(None);
3576
3577 for entry in &report.diverged {
3578 if quit_flag.get() {
3579 break;
3583 }
3584
3585 let choice = match sticky.get() {
3586 Some(c) => c,
3587 None => prompt_render_drift(entry, &sticky, quit_flag)?,
3588 };
3589
3590 match choice {
3591 RenderDriftChoice::Overwrite => {
3592 info!(
3593 "render overwrite: {} → {}",
3594 entry.tera_path, entry.rendered_path
3595 );
3596 if let Some(parent) = entry.rendered_path.parent() {
3597 std::fs::create_dir_all(parent)?;
3598 }
3599 std::fs::write(&entry.rendered_path, &entry.fresh_body)
3600 .with_context(|| format!("writing fresh render to {}", entry.rendered_path))?;
3601 }
3602 RenderDriftChoice::Skip => {
3603 warn!(
3604 "render drift skipped by user: {} (rendered file left as-is)",
3605 entry.rendered_path
3606 );
3607 }
3608 RenderDriftChoice::Quit => {
3609 warn!("render drift quit: leaving {} as-is", entry.rendered_path);
3610 }
3611 }
3612 }
3613
3614 Ok(())
3615}
3616
3617fn prompt_render_drift(
3629 entry: &render::DivergedEntry,
3630 sticky: &Cell<Option<RenderDriftChoice>>,
3631 quit_flag: &Cell<bool>,
3632) -> Result<RenderDriftChoice> {
3633 use std::io::IsTerminal;
3634 use std::io::Write as _;
3635 if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
3636 return Ok(RenderDriftChoice::Skip);
3637 }
3638
3639 let default = match (entry.tera_mtime, entry.rendered_mtime) {
3640 (Some(t), Some(r)) if t > r => RenderDriftChoice::Overwrite,
3641 _ => RenderDriftChoice::Skip,
3642 };
3643 let default_label = match default {
3644 RenderDriftChoice::Overwrite => "o",
3645 _ => "s",
3646 };
3647
3648 eprintln!();
3649 eprintln!("render drift: on-disk rendered file diverged from .tera output");
3650 eprintln!(" src (.tera): {}", entry.tera_path);
3651 eprintln!(" dst (rendered): {}", entry.rendered_path);
3652 print_render_drift_diff(entry);
3653
3654 loop {
3655 eprintln!(" [o/O] overwrite .tera output → rendered (this / all remaining)");
3656 eprintln!(" [s/S] skip leave as-is (this / all remaining)");
3657 eprintln!(" [d] diff re-show the diff");
3658 eprintln!(" [q] quit skip this and stop apply");
3659 eprint!("choice [{default_label}]: ");
3660 std::io::stderr().flush().ok();
3661
3662 let mut input = String::new();
3663 std::io::stdin().read_line(&mut input)?;
3664 let trimmed = input.trim();
3665 let choice = match trimmed {
3666 "" => default,
3667 "s" | "n" => RenderDriftChoice::Skip,
3668 "o" | "y" => RenderDriftChoice::Overwrite,
3669 "q" => {
3670 quit_flag.set(true);
3671 RenderDriftChoice::Quit
3672 }
3673 "O" => {
3674 sticky.set(Some(RenderDriftChoice::Overwrite));
3675 RenderDriftChoice::Overwrite
3676 }
3677 "S" => {
3678 sticky.set(Some(RenderDriftChoice::Skip));
3679 RenderDriftChoice::Skip
3680 }
3681 "d" => {
3682 print_render_drift_diff(entry);
3683 continue;
3684 }
3685 other => {
3686 eprintln!("unknown choice: {other:?}");
3687 continue;
3688 }
3689 };
3690 return Ok(choice);
3691 }
3692}
3693
3694fn print_render_drift_diff(entry: &render::DivergedEntry) {
3699 use owo_colors::OwoColorize as _;
3700 use std::io::IsTerminal;
3701
3702 let color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none();
3703
3704 eprintln!();
3705 if color {
3706 eprintln!(
3707 "{} {} {}",
3708 "── unified diff ──".bold(),
3709 "[-] rendered (on disk)".red().bold(),
3710 "[+] fresh (.tera output)".green().bold()
3711 );
3712 eprintln!(" {} {}", "[-] rendered:".red(), entry.rendered_path);
3713 eprintln!(" {} {}", "[+] .tera: ".green(), entry.tera_path);
3714 } else {
3715 eprintln!("── unified diff ── [-] rendered (on disk) [+] fresh (.tera output)");
3716 eprintln!(" [-] rendered: {}", entry.rendered_path);
3717 eprintln!(" [+] .tera: {}", entry.tera_path);
3718 }
3719 eprintln!();
3720
3721 let rendered = match read_text_for_diff(&entry.rendered_path) {
3725 DiffSide::Text(s) => s,
3726 DiffSide::Binary => {
3727 eprintln!("(binary file or non-UTF-8 content — diff skipped)");
3728 eprintln!();
3729 return;
3730 }
3731 };
3732
3733 let diff = similar::TextDiff::from_lines(rendered.as_str(), entry.fresh_body.as_str());
3734 for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
3735 let header = hunk.header().to_string();
3736 if color {
3737 eprintln!("{}", header.cyan());
3738 } else {
3739 eprintln!("{header}");
3740 }
3741 for change in hunk.iter_changes() {
3742 let line = change.value();
3743 let line = line.strip_suffix('\n').unwrap_or(line);
3744 match change.tag() {
3745 similar::ChangeTag::Delete => {
3746 if color {
3747 eprintln!("{} {}", "-".red().bold(), line.red());
3748 } else {
3749 eprintln!("- {line}");
3750 }
3751 }
3752 similar::ChangeTag::Insert => {
3753 if color {
3754 eprintln!("{} {}", "+".green().bold(), line.green());
3755 } else {
3756 eprintln!("+ {line}");
3757 }
3758 }
3759 similar::ChangeTag::Equal => {
3760 if color {
3761 eprintln!(" {}", line.dimmed());
3762 } else {
3763 eprintln!(" {line}");
3764 }
3765 }
3766 }
3767 }
3768 }
3769 eprintln!();
3770}
3771
3772fn source_repo_is_clean(source: &Utf8Path) -> bool {
3777 match crate::git::is_clean(source) {
3778 Ok(b) => b,
3779 Err(e) => {
3780 warn!("git clean check failed at {source}: {e} — treating as clean");
3781 true
3782 }
3783 }
3784}
3785
3786fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3787 use absorb::AbsorbDecision::*;
3788
3789 if ctx.quit_requested.get() {
3790 return Ok(());
3791 }
3792
3793 let decision = absorb::classify(src, dst)?;
3794
3795 if ctx.dry_run {
3796 info!("[dry-run] dir {decision:?}: {src} → {dst}");
3797 return Ok(());
3798 }
3799
3800 match decision {
3801 InSync => Ok(()),
3802 Restore => {
3803 info!("link dir: {src} → {dst}");
3804 link::link_dir(src, dst, ctx.dir_mode)?;
3805 Ok(())
3806 }
3807 RelinkOnly => {
3808 info!("relink dir: {src} → {dst}");
3813 remove_dir_link_or_real(dst)?;
3814 link::link_dir(src, dst, ctx.dir_mode)?;
3815 Ok(())
3816 }
3817 AutoAbsorb | NeedsConfirm => {
3818 if !ctx.config.absorb.auto {
3839 return handle_anomaly_dir(
3840 src,
3841 dst,
3842 ctx,
3843 "absorb.auto = false; treating divergence as anomaly",
3844 );
3845 }
3846 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
3847 return handle_anomaly_dir(
3848 src,
3849 dst,
3850 ctx,
3851 "source repo is dirty; deferring auto-absorb",
3852 );
3853 }
3854 absorb_target_dir_into_source(src, dst, ctx)
3855 }
3856 }
3857}
3858
3859fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
3869 if let Err(unlink_err) = link::unlink(dst) {
3870 let meta = std::fs::symlink_metadata(dst)
3871 .with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
3872 let ft = meta.file_type();
3873 if ft.is_dir() && !ft.is_symlink() {
3874 std::fs::remove_dir_all(dst).with_context(|| {
3875 format!(
3876 "remove_dir_all({dst}) after link::unlink failed: \
3877 {unlink_err}"
3878 )
3879 })?;
3880 } else {
3881 return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
3882 }
3883 }
3884 Ok(())
3885}
3886
3887fn merge_dir_target_into_source(
3897 target: &Utf8Path,
3898 source: &Utf8Path,
3899 ctx: &ApplyCtx<'_>,
3900) -> Result<()> {
3901 for entry in std::fs::read_dir(target)? {
3902 if ctx.quit_requested.get() {
3909 return Ok(());
3910 }
3911 let entry = entry?;
3912 let name_os = entry.file_name();
3913 let Some(name) = name_os.to_str() else {
3914 continue;
3915 };
3916 let target_path = target.join(name);
3917 let source_path = source.join(name);
3918 let ft = entry.file_type()?;
3919
3920 if ft.is_dir() && !ft.is_symlink() {
3921 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
3927 let sft = src_meta.file_type();
3928 if !sft.is_dir() || sft.is_symlink() {
3929 link::unlink(&source_path).with_context(|| {
3930 format!("remove conflicting source entry before dir merge: {source_path}")
3931 })?;
3932 }
3933 }
3934 if !source_path.exists() {
3935 std::fs::create_dir_all(&source_path).with_context(|| {
3936 format!("create_dir_all({source_path}) during target→source merge")
3937 })?;
3938 }
3939 merge_dir_target_into_source(&target_path, &source_path, ctx)?;
3940 } else if ft.is_file() {
3941 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
3945 let sft = src_meta.file_type();
3946 if sft.is_dir() && !sft.is_symlink() {
3947 remove_dir_link_or_real(&source_path).with_context(|| {
3948 format!("remove conflicting source dir before file merge: {source_path}")
3949 })?;
3950 } else if sft.is_symlink() {
3951 link::unlink(&source_path).with_context(|| {
3952 format!(
3953 "remove conflicting source symlink before file merge: {source_path}"
3954 )
3955 })?;
3956 }
3957 }
3958 if let Some(parent) = source_path.parent() {
3959 if !parent.exists() {
3960 std::fs::create_dir_all(parent)?;
3961 }
3962 }
3963 if source_path.is_file() {
3977 merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
3978 } else {
3979 std::fs::copy(&target_path, &source_path)
3980 .with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
3981 }
3982 } else {
3983 warn!(
3984 "merge: skipping non-regular entry {target_path} \
3985 (symlink / junction / special — content not copied)"
3986 );
3987 }
3988 }
3989 Ok(())
3990}
3991
3992fn merge_resolve_file_conflict(
4006 target_path: &Utf8Path,
4007 source_path: &Utf8Path,
4008 ctx: &ApplyCtx<'_>,
4009) -> Result<()> {
4010 use absorb::AbsorbDecision::*;
4011 let decision = absorb::classify(source_path, target_path)?;
4012 match decision {
4013 InSync | RelinkOnly => Ok(()),
4014 AutoAbsorb => {
4015 std::fs::copy(target_path, source_path).with_context(|| {
4016 format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
4017 })?;
4018 Ok(())
4019 }
4020 Restore => {
4021 unreachable!(
4028 "merge_resolve_file_conflict reached with both files present, \
4029 but classify returned Restore (target {target_path} / source {source_path})"
4030 )
4031 }
4032 NeedsConfirm => {
4033 use crate::config::AnomalyAction::*;
4034 match ctx.config.absorb.on_anomaly {
4035 Skip => {
4036 warn!(
4037 "merge anomaly skip: {target_path} (source-newer / content drift) \
4038 — keeping source version, target version dropped"
4039 );
4040 Ok(())
4041 }
4042 Force => {
4043 warn!(
4044 "merge anomaly force: {target_path} \
4045 (source-newer / content drift) — overwriting source"
4046 );
4047 std::fs::copy(target_path, source_path)?;
4048 Ok(())
4049 }
4050 Ask => {
4051 let choice = prompt_anomaly(
4052 ctx,
4053 source_path,
4054 target_path,
4055 "merge: file content differs and source is newer",
4056 )?;
4057 match choice {
4058 AnomalyChoice::Absorb => {
4059 std::fs::copy(target_path, source_path)?;
4060 Ok(())
4061 }
4062 AnomalyChoice::Overwrite => {
4063 backup_existing(target_path, ctx.backup_root, false)?;
4070 std::fs::copy(source_path, target_path)?;
4071 Ok(())
4072 }
4073 AnomalyChoice::Skip => {
4074 warn!("merge: kept source version by user choice: {source_path}");
4075 Ok(())
4076 }
4077 AnomalyChoice::Quit => {
4078 warn!("merge: user requested quit; stopping at {target_path}");
4079 ctx.quit_requested.set(true);
4080 Ok(())
4081 }
4082 }
4083 }
4084 }
4085 }
4086 }
4087}
4088
4089fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
4096 info!("absorb dir: {dst} → {src}");
4097 backup_existing(src, ctx.backup_root, true)?;
4098 merge_dir_target_into_source(dst, src, ctx)?;
4099 if ctx.quit_requested.get() {
4106 warn!(
4107 "absorb dir interrupted by user quit: {dst} \
4108 — leaving target tree intact; source backup at {}",
4109 ctx.backup_root
4110 );
4111 return Ok(());
4112 }
4113 remove_dir_link_or_real(dst)?;
4116 link::link_dir(src, dst, ctx.dir_mode)?;
4117 Ok(())
4118}
4119
4120fn overwrite_source_dir_into_target(
4125 src: &Utf8Path,
4126 dst: &Utf8Path,
4127 ctx: &ApplyCtx<'_>,
4128) -> Result<()> {
4129 info!("overwrite dir: {src} → {dst}");
4130 backup_existing(dst, ctx.backup_root, true)?;
4131 remove_dir_link_or_real(dst)?;
4132 link::link_dir(src, dst, ctx.dir_mode)?;
4133 Ok(())
4134}
4135
4136fn handle_anomaly_dir(
4140 src: &Utf8Path,
4141 dst: &Utf8Path,
4142 ctx: &ApplyCtx<'_>,
4143 reason: &str,
4144) -> Result<()> {
4145 use crate::config::AnomalyAction::*;
4146 match ctx.config.absorb.on_anomaly {
4147 Skip => {
4148 warn!("anomaly skip dir: {dst} ({reason})");
4149 Ok(())
4150 }
4151 Force => {
4152 warn!(
4153 "anomaly force dir: {dst} ({reason}) \
4154 — absorbing target into source"
4155 );
4156 absorb_target_dir_into_source(src, dst, ctx)
4157 }
4158 Ask => match prompt_anomaly(ctx, src, dst, reason)? {
4159 AnomalyChoice::Absorb => absorb_target_dir_into_source(src, dst, ctx),
4160 AnomalyChoice::Overwrite => overwrite_source_dir_into_target(src, dst, ctx),
4161 AnomalyChoice::Skip => {
4162 warn!("anomaly skipped by user: {dst}");
4163 Ok(())
4164 }
4165 AnomalyChoice::Quit => {
4166 warn!("anomaly dir: user requested quit; stopping apply at {dst}");
4167 ctx.quit_requested.set(true);
4168 Ok(())
4169 }
4170 },
4171 }
4172}
4173
4174fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
4175 let abs_target = absolutize(target)?;
4176 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
4177 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
4178 info!("backup → {bp}");
4179 if is_dir {
4180 backup::backup_dir(target, &bp)?;
4181 } else {
4182 backup::backup_file(target, &bp)?;
4183 }
4184 Ok(())
4185}
4186
4187fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
4188 if let Some(s) = source {
4189 return absolutize(&s);
4190 }
4191 if let Ok(s) = std::env::var("YUI_SOURCE") {
4192 return absolutize(Utf8Path::new(&s));
4193 }
4194 let cwd = current_dir_utf8()?;
4195 for ancestor in cwd.ancestors() {
4196 if ancestor.join("config.toml").is_file() {
4197 return Ok(ancestor.to_path_buf());
4198 }
4199 }
4200 if let Some(home) = paths::home_dir() {
4201 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
4202 let p = home.join(c);
4203 if p.join("config.toml").is_file() {
4204 return Ok(p);
4205 }
4206 }
4207 }
4208 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
4209}
4210
4211fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
4212 let expanded = paths::expand_tilde(p.as_str());
4214 if expanded.is_absolute() {
4215 return Ok(expanded);
4216 }
4217 let cwd = current_dir_utf8()?;
4218 Ok(cwd.join(expanded))
4219}
4220
4221fn current_dir_utf8() -> Result<Utf8PathBuf> {
4222 let cwd = std::env::current_dir().context("getting cwd")?;
4223 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
4224}
4225
4226const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
4230
4231[vars]
4232# user-defined values; templates can reference these as {{ vars.foo }}
4233
4234# [link]
4235# file_mode = "auto" # auto | symlink | hardlink
4236# dir_mode = "auto" # auto | symlink | junction
4237
4238[mount]
4239default_strategy = "marker"
4240
4241[[mount.entry]]
4242src = "home"
4243# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
4244dst = "~"
4245
4246# [[mount.entry]]
4247# src = "appdata"
4248# dst = "{{ env(name='APPDATA') }}"
4249# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
4250# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
4251# when = "yui.os == 'windows'"
4252"#;
4253
4254const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
4255# .yui/bin/ is intentionally tracked — it holds your hook scripts.
4256/.yui/state.json
4257/.yui/state.json.tmp
4258/.yui/backup/
4259
4260# >>> yui rendered (auto-managed, do not edit) >>>
4261# <<< yui rendered (auto-managed) <<<
4262
4263# config.local.toml is per-machine; commit a config.local.example.toml instead.
4264config.local.toml
4265"#;
4266
4267#[cfg(test)]
4268mod tests {
4269 use super::*;
4270 use tempfile::TempDir;
4271
4272 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
4273 Utf8PathBuf::from_path_buf(p).unwrap()
4274 }
4275
4276 fn toml_path(p: &Utf8Path) -> String {
4278 p.as_str().replace('\\', "/")
4279 }
4280
4281 #[test]
4282 fn apply_links_a_raw_file() {
4283 let tmp = TempDir::new().unwrap();
4284 let source = utf8(tmp.path().join("dotfiles"));
4285 let target = utf8(tmp.path().join("target"));
4286 std::fs::create_dir_all(source.join("home")).unwrap();
4287 std::fs::create_dir_all(&target).unwrap();
4288 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4289
4290 let cfg = format!(
4291 r#"
4292[[mount.entry]]
4293src = "home"
4294dst = "{}"
4295"#,
4296 toml_path(&target)
4297 );
4298 std::fs::write(source.join("config.toml"), cfg).unwrap();
4299
4300 apply(Some(source), false).unwrap();
4301
4302 let linked = target.join(".bashrc");
4303 assert!(linked.exists(), "expected {linked} to exist");
4304 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
4305 }
4306
4307 #[test]
4308 fn apply_with_marker_links_whole_directory() {
4309 let tmp = TempDir::new().unwrap();
4310 let source = utf8(tmp.path().join("dotfiles"));
4311 let target = utf8(tmp.path().join("target"));
4312 let nvim_src = source.join("home/nvim");
4313 std::fs::create_dir_all(&nvim_src).unwrap();
4314 std::fs::create_dir_all(&target).unwrap();
4315 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
4316 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
4317 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
4318
4319 let cfg = format!(
4320 r#"
4321[[mount.entry]]
4322src = "home"
4323dst = "{}"
4324"#,
4325 toml_path(&target)
4326 );
4327 std::fs::write(source.join("config.toml"), cfg).unwrap();
4328
4329 apply(Some(source.clone()), false).unwrap();
4330
4331 let nvim_dst = target.join("nvim");
4332 assert!(nvim_dst.exists());
4333 assert_eq!(
4334 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
4335 "-- hi\n"
4336 );
4337 }
4341
4342 #[test]
4343 fn apply_dry_run_does_not_write() {
4344 let tmp = TempDir::new().unwrap();
4345 let source = utf8(tmp.path().join("dotfiles"));
4346 let target = utf8(tmp.path().join("target"));
4347 std::fs::create_dir_all(source.join("home")).unwrap();
4348 std::fs::create_dir_all(&target).unwrap();
4349 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
4350
4351 let cfg = format!(
4352 r#"
4353[[mount.entry]]
4354src = "home"
4355dst = "{}"
4356"#,
4357 toml_path(&target)
4358 );
4359 std::fs::write(source.join("config.toml"), cfg).unwrap();
4360
4361 apply(Some(source), true).unwrap();
4362
4363 assert!(!target.join(".bashrc").exists());
4364 }
4365
4366 #[test]
4367 fn apply_renders_templates_then_links_rendered_outputs() {
4368 let tmp = TempDir::new().unwrap();
4369 let source = utf8(tmp.path().join("dotfiles"));
4370 let target = utf8(tmp.path().join("target"));
4371 std::fs::create_dir_all(source.join("home")).unwrap();
4372 std::fs::create_dir_all(&target).unwrap();
4373 std::fs::write(
4374 source.join("home/.gitconfig.tera"),
4375 "[user]\n os = {{ yui.os }}\n",
4376 )
4377 .unwrap();
4378 std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
4379
4380 let cfg = format!(
4381 r#"
4382[[mount.entry]]
4383src = "home"
4384dst = "{}"
4385"#,
4386 toml_path(&target)
4387 );
4388 std::fs::write(source.join("config.toml"), cfg).unwrap();
4389
4390 apply(Some(source.clone()), false).unwrap();
4391
4392 assert!(target.join(".bashrc").exists());
4394 assert!(source.join("home/.gitconfig").exists());
4396 assert!(target.join(".gitconfig").exists());
4397 assert!(!target.join(".gitconfig.tera").exists());
4399 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
4401 assert!(linked.contains("os = "));
4402 }
4403
4404 #[test]
4405 fn apply_marker_override_links_to_custom_dst() {
4406 let tmp = TempDir::new().unwrap();
4407 let source = utf8(tmp.path().join("dotfiles"));
4408 let target_a = utf8(tmp.path().join("target_a"));
4409 let target_b = utf8(tmp.path().join("target_b"));
4410 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
4411 std::fs::create_dir_all(&target_a).unwrap();
4412 std::fs::create_dir_all(&target_b).unwrap();
4413 std::fs::write(
4414 source.join("home/.config/nvim/init.lua"),
4415 "-- nvim config\n",
4416 )
4417 .unwrap();
4418
4419 std::fs::write(
4422 source.join("home/.config/nvim/.yuilink"),
4423 format!(
4424 r#"
4425[[link]]
4426dst = "{}/nvim"
4427
4428[[link]]
4429dst = "{}/nvim"
4430when = "{{{{ yui.os == '{}' }}}}"
4431"#,
4432 toml_path(&target_a),
4433 toml_path(&target_b),
4434 std::env::consts::OS
4435 ),
4436 )
4437 .unwrap();
4438
4439 let parent_target = utf8(tmp.path().join("parent_target"));
4440 std::fs::create_dir_all(&parent_target).unwrap();
4441 let cfg = format!(
4442 r#"
4443[[mount.entry]]
4444src = "home"
4445dst = "{}"
4446"#,
4447 toml_path(&parent_target)
4448 );
4449 std::fs::write(source.join("config.toml"), cfg).unwrap();
4450
4451 apply(Some(source.clone()), false).unwrap();
4452
4453 assert!(
4455 target_a.join("nvim/init.lua").exists(),
4456 "target_a/nvim/init.lua should be reachable through the link"
4457 );
4458 assert!(
4459 target_b.join("nvim/init.lua").exists(),
4460 "target_b/nvim/init.lua should be reachable through the link"
4461 );
4462 assert!(
4465 !parent_target.join(".config/nvim").exists(),
4466 "parent mount should have skipped the marker-claimed sub-dir"
4467 );
4468 }
4469
4470 #[test]
4471 fn apply_marker_inactive_link_falls_through_to_default() {
4472 let tmp = TempDir::new().unwrap();
4477 let source = utf8(tmp.path().join("dotfiles"));
4478 let target_inactive = utf8(tmp.path().join("inactive"));
4479 let parent_target = utf8(tmp.path().join("parent"));
4480 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
4481 std::fs::create_dir_all(&parent_target).unwrap();
4482 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
4483
4484 std::fs::write(
4486 source.join("home/.config/nvim/.yuilink"),
4487 format!(
4488 r#"
4489[[link]]
4490dst = "{}/nvim"
4491when = "{{{{ yui.os == 'no-such-os' }}}}"
4492"#,
4493 toml_path(&target_inactive)
4494 ),
4495 )
4496 .unwrap();
4497
4498 let cfg = format!(
4499 r#"
4500[[mount.entry]]
4501src = "home"
4502dst = "{}"
4503"#,
4504 toml_path(&parent_target)
4505 );
4506 std::fs::write(source.join("config.toml"), cfg).unwrap();
4507
4508 apply(Some(source.clone()), false).unwrap();
4509
4510 assert!(!target_inactive.join("nvim").exists());
4512 assert!(parent_target.join(".config/nvim/init.lua").exists());
4515 }
4516
4517 #[test]
4518 fn list_shows_mount_entries_and_marker_overrides() {
4519 let tmp = TempDir::new().unwrap();
4520 let source = utf8(tmp.path().join("dotfiles"));
4521 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
4522 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
4523 std::fs::write(
4524 source.join("home/.config/nvim/.yuilink"),
4525 r#"
4526[[link]]
4527dst = "/custom/nvim"
4528"#,
4529 )
4530 .unwrap();
4531 std::fs::write(
4532 source.join("config.toml"),
4533 r#"
4534[[mount.entry]]
4535src = "home"
4536dst = "/h"
4537"#,
4538 )
4539 .unwrap();
4540
4541 list(Some(source), false, None, true).unwrap();
4544 }
4545
4546 #[test]
4547 fn status_reports_in_sync_after_apply() {
4548 let tmp = TempDir::new().unwrap();
4549 let source = utf8(tmp.path().join("dotfiles"));
4550 let target = utf8(tmp.path().join("target"));
4551 std::fs::create_dir_all(source.join("home")).unwrap();
4552 std::fs::create_dir_all(&target).unwrap();
4553 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4554 let cfg = format!(
4555 r#"
4556[[mount.entry]]
4557src = "home"
4558dst = "{}"
4559"#,
4560 toml_path(&target)
4561 );
4562 std::fs::write(source.join("config.toml"), cfg).unwrap();
4563 apply(Some(source.clone()), false).unwrap();
4565 status(Some(source), None, true).unwrap();
4567 }
4568
4569 #[test]
4570 fn status_reports_template_drift() {
4571 let tmp = TempDir::new().unwrap();
4572 let source = utf8(tmp.path().join("dotfiles"));
4573 let target = utf8(tmp.path().join("target"));
4574 std::fs::create_dir_all(source.join("home")).unwrap();
4575 std::fs::create_dir_all(&target).unwrap();
4576 std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
4579 std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
4580
4581 let cfg = format!(
4582 r#"
4583[[mount.entry]]
4584src = "home"
4585dst = "{}"
4586"#,
4587 toml_path(&target)
4588 );
4589 std::fs::write(source.join("config.toml"), cfg).unwrap();
4590
4591 let err = status(Some(source), None, true).unwrap_err();
4592 assert!(format!("{err}").contains("diverged"));
4593 }
4594
4595 #[test]
4596 fn status_fails_when_target_missing() {
4597 let tmp = TempDir::new().unwrap();
4598 let source = utf8(tmp.path().join("dotfiles"));
4599 let target = utf8(tmp.path().join("target"));
4600 std::fs::create_dir_all(source.join("home")).unwrap();
4601 std::fs::create_dir_all(&target).unwrap();
4602 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4603 let cfg = format!(
4604 r#"
4605[[mount.entry]]
4606src = "home"
4607dst = "{}"
4608"#,
4609 toml_path(&target)
4610 );
4611 std::fs::write(source.join("config.toml"), cfg).unwrap();
4612 let err = status(Some(source), None, true).unwrap_err();
4614 assert!(format!("{err}").contains("diverged"));
4615 }
4616
4617 #[test]
4618 fn strip_braces_removes_outer_template_braces() {
4619 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
4620 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
4621 assert_eq!(strip_braces(" {{x}} "), "x");
4622 }
4623
4624 #[test]
4625 fn apply_skips_render_drift_off_tty() {
4626 let tmp = TempDir::new().unwrap();
4632 let source = utf8(tmp.path().join("dotfiles"));
4633 let target = utf8(tmp.path().join("target"));
4634 std::fs::create_dir_all(source.join("home")).unwrap();
4635 std::fs::create_dir_all(&target).unwrap();
4636 std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
4637 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
4638
4639 let cfg = format!(
4640 r#"
4641[[mount.entry]]
4642src = "home"
4643dst = "{}"
4644"#,
4645 toml_path(&target)
4646 );
4647 std::fs::write(source.join("config.toml"), cfg).unwrap();
4648
4649 apply(Some(source.clone()), false).unwrap();
4650 assert_eq!(
4652 std::fs::read_to_string(source.join("home/foo")).unwrap(),
4653 "manually edited"
4654 );
4655 assert_eq!(
4658 std::fs::read_to_string(target.join("foo")).unwrap(),
4659 "manually edited"
4660 );
4661 }
4662
4663 #[test]
4664 fn init_creates_skeleton_when_dir_empty() {
4665 let tmp = TempDir::new().unwrap();
4666 let dir = utf8(tmp.path().join("new_dotfiles"));
4667 init(Some(dir.clone()), false).unwrap();
4668 assert!(dir.join("config.toml").is_file());
4669 assert!(dir.join(".gitignore").is_file());
4670 }
4671
4672 #[test]
4673 fn init_refuses_to_overwrite_existing_config() {
4674 let tmp = TempDir::new().unwrap();
4675 let dir = utf8(tmp.path().join("dotfiles"));
4676 std::fs::create_dir_all(&dir).unwrap();
4677 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
4678 let err = init(Some(dir), false).unwrap_err();
4679 assert!(format!("{err}").contains("already exists"));
4680 }
4681
4682 #[test]
4688 fn init_appends_missing_gitignore_entries_into_existing_file() {
4689 let tmp = TempDir::new().unwrap();
4690 let dir = utf8(tmp.path().join("dotfiles"));
4691 std::fs::create_dir_all(&dir).unwrap();
4692 let user_gitignore = "# user entries\n*.swp\nnode_modules/\n";
4694 std::fs::write(dir.join(".gitignore"), user_gitignore).unwrap();
4695
4696 init(Some(dir.clone()), false).unwrap();
4697
4698 let body = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
4699 assert!(body.contains("*.swp"));
4701 assert!(body.contains("node_modules/"));
4702 assert!(body.contains("/.yui/state.json"));
4704 assert!(body.contains("/.yui/backup/"));
4705 assert!(body.contains("config.local.toml"));
4706 let before_rerun = body.clone();
4708 std::fs::remove_file(dir.join("config.toml")).unwrap();
4711 init(Some(dir.clone()), false).unwrap();
4712 let after_rerun = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
4713 assert_eq!(
4714 before_rerun, after_rerun,
4715 "init must be idempotent when the gitignore already has every yui entry"
4716 );
4717 }
4718
4719 #[test]
4725 fn init_with_git_hooks_installs_into_existing_repo() {
4726 let tmp = TempDir::new().unwrap();
4727 let dir = utf8(tmp.path().join("dotfiles"));
4728 std::fs::create_dir_all(&dir).unwrap();
4729 let st = std::process::Command::new("git")
4730 .args(["init", "-q"])
4731 .current_dir(dir.as_std_path())
4732 .status()
4733 .expect("git init");
4734 if !st.success() {
4735 return;
4736 }
4737 let user_config = "# user already wrote this\n";
4739 std::fs::write(dir.join("config.toml"), user_config).unwrap();
4740
4741 init(Some(dir.clone()), true).unwrap();
4743
4744 assert_eq!(
4745 std::fs::read_to_string(dir.join("config.toml")).unwrap(),
4746 user_config
4747 );
4748 assert!(dir.join(".git/hooks/pre-commit").is_file());
4749 assert!(dir.join(".git/hooks/pre-push").is_file());
4750 }
4751
4752 #[test]
4757 fn init_with_git_hooks_writes_pre_commit_and_pre_push() {
4758 let tmp = TempDir::new().unwrap();
4759 let dir = utf8(tmp.path().join("dotfiles"));
4760 std::fs::create_dir_all(&dir).unwrap();
4761 let st = std::process::Command::new("git")
4763 .args(["init", "-q"])
4764 .current_dir(dir.as_std_path())
4765 .status()
4766 .expect("git init");
4767 if !st.success() {
4768 eprintln!("skipping: git not available");
4770 return;
4771 }
4772 init(Some(dir.clone()), true).unwrap();
4773
4774 let pre_commit = dir.join(".git/hooks/pre-commit");
4775 let pre_push = dir.join(".git/hooks/pre-push");
4776 assert!(pre_commit.is_file(), "pre-commit hook should be written");
4777 assert!(pre_push.is_file(), "pre-push hook should be written");
4778
4779 let body = std::fs::read_to_string(&pre_commit).unwrap();
4780 assert!(
4781 body.contains("yui render --check"),
4782 "pre-commit hook should call `yui render --check`, got: {body}"
4783 );
4784 }
4785
4786 #[test]
4790 fn init_with_git_hooks_errors_outside_a_git_repo() {
4791 let tmp = TempDir::new().unwrap();
4792 let dir = utf8(tmp.path().join("not-a-repo"));
4793 std::fs::create_dir_all(&dir).unwrap();
4794 let err = init(Some(dir), true).unwrap_err();
4795 let msg = format!("{err:#}");
4796 assert!(
4797 msg.contains("git repo") || msg.contains("git rev-parse"),
4798 "expected error to mention the git issue, got: {msg}"
4799 );
4800 }
4801
4802 #[test]
4805 fn init_with_git_hooks_does_not_clobber_existing_hooks() {
4806 let tmp = TempDir::new().unwrap();
4807 let dir = utf8(tmp.path().join("dotfiles"));
4808 std::fs::create_dir_all(&dir).unwrap();
4809 let st = std::process::Command::new("git")
4810 .args(["init", "-q"])
4811 .current_dir(dir.as_std_path())
4812 .status()
4813 .expect("git init");
4814 if !st.success() {
4815 return;
4816 }
4817 let hooks = dir.join(".git/hooks");
4818 std::fs::create_dir_all(&hooks).unwrap();
4819 std::fs::write(hooks.join("pre-commit"), "#! /bin/sh\nexit 0\n").unwrap();
4820
4821 init(Some(dir.clone()), true).unwrap();
4822
4823 let pc = std::fs::read_to_string(hooks.join("pre-commit")).unwrap();
4825 assert!(
4826 !pc.contains("yui render --check"),
4827 "existing pre-commit must not be overwritten"
4828 );
4829 let pp = std::fs::read_to_string(hooks.join("pre-push")).unwrap();
4830 assert!(
4831 pp.contains("yui render --check"),
4832 "missing pre-push should be written: {pp}"
4833 );
4834 }
4835
4836 fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
4839 let source = utf8(tmp.path().join("dotfiles"));
4840 let target = utf8(tmp.path().join("target"));
4841 std::fs::create_dir_all(source.join("home")).unwrap();
4842 std::fs::create_dir_all(&target).unwrap();
4843 let cfg = format!(
4844 r#"
4845[[mount.entry]]
4846src = "home"
4847dst = "{}"
4848"#,
4849 toml_path(&target)
4850 );
4851 std::fs::write(source.join("config.toml"), cfg).unwrap();
4852 (source, target)
4853 }
4854
4855 fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
4856 std::fs::write(path, body).unwrap();
4857 let f = std::fs::OpenOptions::new()
4858 .write(true)
4859 .open(path)
4860 .expect("open writable");
4861 f.set_modified(when).expect("set_modified");
4862 }
4863
4864 #[test]
4865 fn apply_target_newer_absorbs_target_into_source() {
4866 let tmp = TempDir::new().unwrap();
4870 let (source, target) = setup_minimal_dotfiles(&tmp);
4871
4872 let now = std::time::SystemTime::now();
4873 let past = now - std::time::Duration::from_secs(120);
4874 write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
4875 write_with_mtime(&target.join(".bashrc"), "user's edit", now);
4877
4878 apply(Some(source.clone()), false).unwrap();
4879
4880 assert_eq!(
4882 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4883 "user's edit"
4884 );
4885 assert_eq!(
4887 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4888 "user's edit"
4889 );
4890 let backup_root = source.join(".yui/backup");
4892 let mut found_old = false;
4893 for entry in walkdir(&backup_root) {
4894 if let Ok(s) = std::fs::read_to_string(&entry) {
4895 if s == "default from repo" {
4896 found_old = true;
4897 break;
4898 }
4899 }
4900 }
4901 assert!(found_old, "expected backup containing 'default from repo'");
4902 }
4903
4904 #[test]
4905 fn apply_in_sync_target_is_a_no_op() {
4906 let tmp = TempDir::new().unwrap();
4909 let (source, target) = setup_minimal_dotfiles(&tmp);
4910 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4911 apply(Some(source.clone()), false).unwrap();
4912 let backup_root = source.join(".yui/backup");
4913 let backup_count_after_first = walkdir(&backup_root).len();
4914
4915 apply(Some(source.clone()), false).unwrap();
4917 assert_eq!(
4918 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4919 "echo hi\n"
4920 );
4921 let backup_count_after_second = walkdir(&backup_root).len();
4922 assert_eq!(
4923 backup_count_after_first, backup_count_after_second,
4924 "second apply on an in-sync tree should not produce backups"
4925 );
4926 }
4927
4928 #[test]
4929 fn apply_skip_policy_leaves_anomaly_alone() {
4930 let tmp = TempDir::new().unwrap();
4933 let source = utf8(tmp.path().join("dotfiles"));
4934 let target = utf8(tmp.path().join("target"));
4935 std::fs::create_dir_all(source.join("home")).unwrap();
4936 std::fs::create_dir_all(&target).unwrap();
4937 let cfg = format!(
4938 r#"
4939[absorb]
4940on_anomaly = "skip"
4941
4942[[mount.entry]]
4943src = "home"
4944dst = "{}"
4945"#,
4946 toml_path(&target)
4947 );
4948 std::fs::write(source.join("config.toml"), cfg).unwrap();
4949
4950 let now = std::time::SystemTime::now();
4951 let past = now - std::time::Duration::from_secs(120);
4952 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4953 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4954
4955 apply(Some(source.clone()), false).unwrap();
4956
4957 assert_eq!(
4959 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4960 "user's edit (older)"
4961 );
4962 assert_eq!(
4964 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4965 "fresh from upstream"
4966 );
4967 }
4968
4969 #[test]
4970 fn apply_force_policy_absorbs_anomaly_anyway() {
4971 let tmp = TempDir::new().unwrap();
4973 let source = utf8(tmp.path().join("dotfiles"));
4974 let target = utf8(tmp.path().join("target"));
4975 std::fs::create_dir_all(source.join("home")).unwrap();
4976 std::fs::create_dir_all(&target).unwrap();
4977 let cfg = format!(
4978 r#"
4979[absorb]
4980on_anomaly = "force"
4981
4982[[mount.entry]]
4983src = "home"
4984dst = "{}"
4985"#,
4986 toml_path(&target)
4987 );
4988 std::fs::write(source.join("config.toml"), cfg).unwrap();
4989
4990 let now = std::time::SystemTime::now();
4991 let past = now - std::time::Duration::from_secs(120);
4992 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4993 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4994
4995 apply(Some(source.clone()), false).unwrap();
4996
4997 assert_eq!(
4999 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
5000 "user's edit (older)"
5001 );
5002 assert_eq!(
5003 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
5004 "user's edit (older)"
5005 );
5006 }
5007
5008 #[test]
5020 fn apply_absorbs_non_empty_target_dir_target_wins() {
5021 let tmp = TempDir::new().unwrap();
5022 let source = utf8(tmp.path().join("dotfiles"));
5023 let target = utf8(tmp.path().join("target"));
5024 std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
5025 std::fs::create_dir_all(target.join(".config/app")).unwrap();
5026 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
5029 std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
5030 std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
5032 std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
5035 std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
5036
5037 let cfg = format!(
5038 r#"
5039[absorb]
5040on_anomaly = "force"
5041
5042[[mount.entry]]
5043src = "home"
5044dst = "{}"
5045"#,
5046 toml_path(&target)
5047 );
5048 std::fs::write(source.join("config.toml"), cfg).unwrap();
5049
5050 apply(Some(source.clone()), false).unwrap();
5052
5053 assert_eq!(
5055 std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
5056 "target side"
5057 );
5058 assert_eq!(
5060 std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
5061 "{}"
5062 );
5063 let backup_root = source.join(".yui/backup");
5066 let mut backup_files: Vec<String> = Vec::new();
5067 for entry in walkdir(&backup_root) {
5068 if let Some(n) = entry.file_name() {
5069 backup_files.push(n.to_string());
5070 }
5071 }
5072 assert!(
5073 backup_files.iter().any(|f| f == "config.toml"),
5074 "expected source's config.toml to land in the backup tree, got {backup_files:?}"
5075 );
5076 assert!(
5078 source.join("home/.config/app/source-only.toml").exists(),
5079 "source-only file should survive a target-wins merge"
5080 );
5081 assert!(
5083 source.join("home/.config/app/state.json").exists(),
5084 "target-only state.json should be merged into source"
5085 );
5086 }
5087
5088 #[test]
5094 fn marker_dir_absorbs_with_default_ask_policy() {
5095 let tmp = TempDir::new().unwrap();
5096 let source = utf8(tmp.path().join("dotfiles"));
5097 let target = utf8(tmp.path().join("target"));
5098 std::fs::create_dir_all(source.join("home/.config")).unwrap();
5099 std::fs::create_dir_all(target.join(".config/gh")).unwrap();
5100 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
5102 std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\n").unwrap();
5104
5105 let cfg = format!(
5109 r#"
5110[[mount.entry]]
5111src = "home"
5112dst = "{}"
5113"#,
5114 toml_path(&target)
5115 );
5116 std::fs::write(source.join("config.toml"), cfg).unwrap();
5117
5118 apply(Some(source.clone()), false).unwrap();
5122
5123 assert!(target.join(".config/gh/hosts.yml").exists());
5126 assert!(source.join("home/.config/gh/hosts.yml").exists());
5127 }
5128
5129 #[test]
5135 fn merge_handles_file_vs_dir_collisions_target_wins() {
5136 let tmp = TempDir::new().unwrap();
5137 let source = utf8(tmp.path().join("dotfiles"));
5138 let target = utf8(tmp.path().join("target"));
5139 std::fs::create_dir_all(source.join("home/.config/foo")).unwrap();
5140 std::fs::create_dir_all(target.join(".config")).unwrap();
5141 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
5142
5143 std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
5145 std::fs::write(target.join(".config/foo"), "target file body").unwrap();
5146 std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
5148 std::fs::create_dir_all(target.join(".config/bar")).unwrap();
5149 std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
5150
5151 let cfg = format!(
5152 r#"
5153[absorb]
5154on_anomaly = "force"
5155
5156[[mount.entry]]
5157src = "home"
5158dst = "{}"
5159"#,
5160 toml_path(&target)
5161 );
5162 std::fs::write(source.join("config.toml"), cfg).unwrap();
5163 apply(Some(source.clone()), false).unwrap();
5164
5165 let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
5169 assert!(foo_meta.file_type().is_file(), "foo should be a file");
5170 assert_eq!(
5171 std::fs::read_to_string(target.join(".config/foo")).unwrap(),
5172 "target file body"
5173 );
5174 let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
5176 assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
5177 assert_eq!(
5178 std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
5179 "target nested"
5180 );
5181 }
5182
5183 #[test]
5187 fn merge_per_file_target_newer_auto_absorbs() {
5188 let tmp = TempDir::new().unwrap();
5189 let source = utf8(tmp.path().join("dotfiles"));
5190 let target = utf8(tmp.path().join("target"));
5191 std::fs::create_dir_all(source.join("home/.config")).unwrap();
5192 std::fs::create_dir_all(target.join(".config")).unwrap();
5193 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
5194
5195 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
5197 write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
5198 std::fs::write(target.join(".config/app.toml"), "user's live edit").unwrap();
5199
5200 let cfg = format!(
5204 r#"
5205[[mount.entry]]
5206src = "home"
5207dst = "{}"
5208"#,
5209 toml_path(&target)
5210 );
5211 std::fs::write(source.join("config.toml"), cfg).unwrap();
5212 apply(Some(source.clone()), false).unwrap();
5213
5214 assert_eq!(
5216 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
5217 "user's live edit"
5218 );
5219 }
5220
5221 #[test]
5227 fn merge_per_file_source_newer_skip_keeps_source() {
5228 let tmp = TempDir::new().unwrap();
5229 let source = utf8(tmp.path().join("dotfiles"));
5230 let target = utf8(tmp.path().join("target"));
5231 std::fs::create_dir_all(source.join("home/.config")).unwrap();
5232 std::fs::create_dir_all(target.join(".config")).unwrap();
5233 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
5234
5235 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
5237 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
5238 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
5239
5240 let cfg = format!(
5241 r#"
5242[absorb]
5243on_anomaly = "skip"
5244
5245[[mount.entry]]
5246src = "home"
5247dst = "{}"
5248"#,
5249 toml_path(&target)
5250 );
5251 std::fs::write(source.join("config.toml"), cfg).unwrap();
5252 apply(Some(source.clone()), false).unwrap();
5253
5254 assert_eq!(
5257 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
5258 "fresh source"
5259 );
5260 }
5261
5262 #[test]
5265 fn merge_per_file_source_newer_force_overwrites_source() {
5266 let tmp = TempDir::new().unwrap();
5267 let source = utf8(tmp.path().join("dotfiles"));
5268 let target = utf8(tmp.path().join("target"));
5269 std::fs::create_dir_all(source.join("home/.config")).unwrap();
5270 std::fs::create_dir_all(target.join(".config")).unwrap();
5271 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
5272
5273 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
5274 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
5275 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
5276
5277 let cfg = format!(
5278 r#"
5279[absorb]
5280on_anomaly = "force"
5281
5282[[mount.entry]]
5283src = "home"
5284dst = "{}"
5285"#,
5286 toml_path(&target)
5287 );
5288 std::fs::write(source.join("config.toml"), cfg).unwrap();
5289 apply(Some(source.clone()), false).unwrap();
5290
5291 assert_eq!(
5293 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
5294 "old target"
5295 );
5296 }
5297
5298 #[test]
5303 fn merge_per_file_identical_content_is_noop() {
5304 let tmp = TempDir::new().unwrap();
5305 let source = utf8(tmp.path().join("dotfiles"));
5306 let target = utf8(tmp.path().join("target"));
5307 std::fs::create_dir_all(source.join("home/.config")).unwrap();
5308 std::fs::create_dir_all(target.join(".config")).unwrap();
5309 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
5310 std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
5311 std::fs::write(target.join(".config/app.toml"), "same").unwrap();
5312
5313 let cfg = format!(
5316 r#"
5317[[mount.entry]]
5318src = "home"
5319dst = "{}"
5320"#,
5321 toml_path(&target)
5322 );
5323 std::fs::write(source.join("config.toml"), cfg).unwrap();
5324 apply(Some(source.clone()), false).unwrap();
5325
5326 assert_eq!(
5327 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
5328 "same"
5329 );
5330 }
5331
5332 #[test]
5333 fn manual_absorb_command_pulls_target_into_source() {
5334 let tmp = TempDir::new().unwrap();
5336 let source = utf8(tmp.path().join("dotfiles"));
5337 let target = utf8(tmp.path().join("target"));
5338 std::fs::create_dir_all(source.join("home")).unwrap();
5339 std::fs::create_dir_all(&target).unwrap();
5340 let cfg = format!(
5342 r#"
5343[absorb]
5344on_anomaly = "skip"
5345
5346[[mount.entry]]
5347src = "home"
5348dst = "{}"
5349"#,
5350 toml_path(&target)
5351 );
5352 std::fs::write(source.join("config.toml"), cfg).unwrap();
5353 std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
5354 std::fs::write(source.join("home/.bashrc"), "default").unwrap();
5355
5356 absorb(
5359 Some(source.clone()),
5360 target.join(".bashrc"),
5361 false,
5362 true,
5363 )
5364 .unwrap();
5365
5366 assert_eq!(
5368 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
5369 "user picked this"
5370 );
5371 }
5372
5373 #[test]
5374 fn manual_absorb_errors_when_target_outside_known_mounts() {
5375 let tmp = TempDir::new().unwrap();
5376 let (source, _target) = setup_minimal_dotfiles(&tmp);
5377 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5378 let stranger = utf8(tmp.path().join("not-managed/foo"));
5379 std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
5380 std::fs::write(&stranger, "not yui's").unwrap();
5381 let err = absorb(Some(source), stranger, false, true).unwrap_err();
5382 assert!(format!("{err}").contains("no mount entry"));
5383 }
5384
5385 #[test]
5386 fn yuiignore_excludes_file_from_linking() {
5387 let tmp = TempDir::new().unwrap();
5388 let (source, target) = setup_minimal_dotfiles(&tmp);
5389 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
5390 std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
5391 std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
5393 apply(Some(source.clone()), false).unwrap();
5394 assert!(target.join(".bashrc").exists());
5395 assert!(
5396 !target.join("lock.json").exists(),
5397 "yuiignore should keep lock.json out of target"
5398 );
5399 }
5400
5401 #[test]
5402 fn yuiignore_excludes_directory_subtree() {
5403 let tmp = TempDir::new().unwrap();
5404 let (source, target) = setup_minimal_dotfiles(&tmp);
5405 std::fs::create_dir_all(source.join("home/cache")).unwrap();
5406 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
5407 std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
5408 std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
5409 std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
5411 apply(Some(source.clone()), false).unwrap();
5412 assert!(target.join(".bashrc").exists());
5413 assert!(
5414 !target.join("cache").exists(),
5415 "yuiignore'd subtree should not appear in target"
5416 );
5417 }
5418
5419 #[test]
5420 fn yuiignore_negation_re_includes_file() {
5421 let tmp = TempDir::new().unwrap();
5422 let (source, target) = setup_minimal_dotfiles(&tmp);
5423 std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
5424 std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
5425 std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
5427 apply(Some(source.clone()), false).unwrap();
5428 assert!(target.join("keep.cache").exists());
5429 assert!(!target.join("drop.cache").exists());
5430 }
5431
5432 #[test]
5437 fn nested_yuiignore_only_affects_its_subtree() {
5438 let tmp = TempDir::new().unwrap();
5439 let (source, target) = setup_minimal_dotfiles(&tmp);
5440 std::fs::create_dir_all(source.join("home/inner")).unwrap();
5441 std::fs::write(source.join("home/secret.txt"), "outer keep").unwrap();
5442 std::fs::write(source.join("home/inner/secret.txt"), "inner drop").unwrap();
5443 std::fs::write(source.join("home/inner/keep.txt"), "inner keep").unwrap();
5444 std::fs::write(source.join("home/inner/.yuiignore"), "secret*\n").unwrap();
5446 apply(Some(source.clone()), false).unwrap();
5447 assert!(
5448 target.join("secret.txt").exists(),
5449 "outer secret.txt is outside the nested .yuiignore scope"
5450 );
5451 assert!(target.join("inner/keep.txt").exists());
5452 assert!(
5453 !target.join("inner/secret.txt").exists(),
5454 "inner secret.txt should be excluded by the nested .yuiignore"
5455 );
5456 }
5457
5458 #[test]
5462 fn nested_yuiignore_negation_overrides_root_rule() {
5463 let tmp = TempDir::new().unwrap();
5464 let (source, target) = setup_minimal_dotfiles(&tmp);
5465 std::fs::create_dir_all(source.join("home/keepers")).unwrap();
5466 std::fs::write(source.join("home/drop.lock"), "outer drop").unwrap();
5467 std::fs::write(source.join("home/keepers/wanted.lock"), "inner keep").unwrap();
5468 std::fs::write(source.join(".yuiignore"), "*.lock\n").unwrap();
5469 std::fs::write(source.join("home/keepers/.yuiignore"), "!*.lock\n").unwrap();
5471 apply(Some(source.clone()), false).unwrap();
5472 assert!(
5473 !target.join("drop.lock").exists(),
5474 "root rule still drops outer .lock file"
5475 );
5476 assert!(
5477 target.join("keepers/wanted.lock").exists(),
5478 "nested negation re-includes .lock under keepers/"
5479 );
5480 }
5481
5482 #[test]
5486 fn nested_yuiignore_status_walk_scoped() {
5487 let tmp = TempDir::new().unwrap();
5488 let (source, _target) = setup_minimal_dotfiles(&tmp);
5489 std::fs::create_dir_all(source.join("home/a")).unwrap();
5490 std::fs::create_dir_all(source.join("home/b")).unwrap();
5491 std::fs::write(source.join("home/a/foo.txt"), "a-foo").unwrap();
5492 std::fs::write(source.join("home/b/foo.txt"), "b-foo").unwrap();
5493 std::fs::write(source.join("home/a/.yuiignore"), "foo.txt\n").unwrap();
5495 apply(Some(source.clone()), false).unwrap();
5496 let res = status(Some(source), None, true);
5498 assert!(res.is_ok() || matches!(&res, Err(e) if format!("{e}").contains("diverged")));
5499 }
5500
5501 #[test]
5502 fn yuiignore_skips_template_in_render() {
5503 let tmp = TempDir::new().unwrap();
5504 let source = utf8(tmp.path().join("dotfiles"));
5505 let target = utf8(tmp.path().join("target"));
5506 std::fs::create_dir_all(source.join("home")).unwrap();
5507 std::fs::create_dir_all(&target).unwrap();
5508 std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
5509 std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
5510 let cfg = format!(
5511 r#"
5512[[mount.entry]]
5513src = "home"
5514dst = "{}"
5515"#,
5516 toml_path(&target)
5517 );
5518 std::fs::write(source.join("config.toml"), cfg).unwrap();
5519 apply(Some(source.clone()), false).unwrap();
5520 assert!(!source.join("home/note").exists());
5522 assert!(!target.join("note").exists());
5523 assert!(!target.join("note.tera").exists());
5524 }
5525
5526 #[test]
5535 fn apply_decrypts_age_files_to_sibling_and_links() {
5536 let tmp = TempDir::new().unwrap();
5537 let source = utf8(tmp.path().join("dotfiles"));
5538 let target = utf8(tmp.path().join("target"));
5539 std::fs::create_dir_all(source.join("home/.ssh")).unwrap();
5540 std::fs::create_dir_all(&target).unwrap();
5541
5542 let identity_path = utf8(tmp.path().join("age.txt"));
5545 let (secret, public) = secret::generate_x25519_keypair();
5546 std::fs::write(&identity_path, format!("{secret}\n")).unwrap();
5547
5548 let recipient = secret::parse_x25519_recipient(&public).unwrap();
5550 let cipher = secret::encrypt_x25519(b"-- super secret key --\n", &[recipient]).unwrap();
5551 std::fs::write(source.join("home/.ssh/id_ed25519.age"), &cipher).unwrap();
5552
5553 let cfg = format!(
5555 r#"
5556[[mount.entry]]
5557src = "home"
5558dst = "{}"
5559
5560[secrets]
5561identity = "{}"
5562recipients = ["{}"]
5563"#,
5564 toml_path(&target),
5565 toml_path(&identity_path),
5566 public
5567 );
5568 std::fs::write(source.join("config.toml"), cfg).unwrap();
5569
5570 apply(Some(source.clone()), false).unwrap();
5571
5572 assert!(source.join("home/.ssh/id_ed25519").exists());
5574 let target_bytes = std::fs::read(target.join(".ssh/id_ed25519")).unwrap();
5576 assert_eq!(target_bytes, b"-- super secret key --\n");
5577 let gi = std::fs::read_to_string(source.join(".gitignore")).unwrap();
5579 assert!(
5580 gi.contains("home/.ssh/id_ed25519"),
5581 ".gitignore should list the decrypted plaintext sibling: {gi}"
5582 );
5583 }
5586
5587 #[test]
5592 fn apply_bails_on_secret_drift() {
5593 let tmp = TempDir::new().unwrap();
5594 let source = utf8(tmp.path().join("dotfiles"));
5595 let target = utf8(tmp.path().join("target"));
5596 std::fs::create_dir_all(source.join("home")).unwrap();
5597 std::fs::create_dir_all(&target).unwrap();
5598
5599 let identity_path = utf8(tmp.path().join("age.txt"));
5600 let (secret_key, public) = secret::generate_x25519_keypair();
5601 std::fs::write(&identity_path, format!("{secret_key}\n")).unwrap();
5602
5603 let recipient = secret::parse_x25519_recipient(&public).unwrap();
5604 let cipher = secret::encrypt_x25519(b"v1 content\n", &[recipient]).unwrap();
5605 std::fs::write(source.join("home/secret.age"), &cipher).unwrap();
5606 std::fs::write(source.join("home/secret"), "edited locally\n").unwrap();
5608
5609 let cfg = format!(
5610 r#"
5611[[mount.entry]]
5612src = "home"
5613dst = "{}"
5614
5615[secrets]
5616identity = "{}"
5617recipients = ["{}"]
5618"#,
5619 toml_path(&target),
5620 toml_path(&identity_path),
5621 public
5622 );
5623 std::fs::write(source.join("config.toml"), cfg).unwrap();
5624
5625 let err = apply(Some(source.clone()), false).unwrap_err();
5626 assert!(
5627 format!("{err:#}").contains("secret drift"),
5628 "expected secret drift error, got: {err:#}"
5629 );
5630 }
5631
5632 #[test]
5635 fn append_recipient_creates_secrets_table_when_missing() {
5636 let result =
5637 append_recipient_to_config("", "host alice", "age1abcrecipientpublickey").unwrap();
5638 let parsed: toml::Table = toml::from_str(&result).unwrap();
5640 let secrets = parsed.get("secrets").and_then(|v| v.as_table()).unwrap();
5641 let recipients = secrets
5642 .get("recipients")
5643 .and_then(|v| v.as_array())
5644 .unwrap();
5645 assert_eq!(recipients.len(), 1);
5646 assert_eq!(recipients[0].as_str(), Some("age1abcrecipientpublickey"));
5647 }
5648
5649 #[test]
5650 fn append_recipient_preserves_existing_other_tables() {
5651 let existing = r#"
5655[vars]
5656greet = "hi"
5657
5658[secrets]
5659recipients = ["age1machine_a"]
5660
5661[ui]
5662icons = "ascii"
5663"#;
5664 let result = append_recipient_to_config(existing, "host b", "age1machine_b").unwrap();
5665 let parsed: toml::Table = toml::from_str(&result).unwrap();
5666 assert!(parsed.get("vars").is_some());
5668 assert!(parsed.get("secrets").is_some());
5669 assert!(parsed.get("ui").is_some());
5670 let recipients = parsed["secrets"]["recipients"].as_array().unwrap();
5672 assert_eq!(recipients.len(), 2);
5673 let pubs: Vec<&str> = recipients.iter().filter_map(|v| v.as_str()).collect();
5674 assert!(pubs.contains(&"age1machine_a"));
5675 assert!(pubs.contains(&"age1machine_b"));
5676 }
5677
5678 #[test]
5679 fn append_recipient_is_idempotent_on_duplicate() {
5680 let existing = r#"[secrets]
5681recipients = ["age1same"]
5682"#;
5683 let result = append_recipient_to_config(existing, "anyone", "age1same").unwrap();
5684 let parsed: toml::Table = toml::from_str(&result).unwrap();
5685 let recipients = parsed["secrets"]["recipients"].as_array().unwrap();
5686 assert_eq!(recipients.len(), 1, "duplicate must not be appended twice");
5687 }
5688
5689 #[test]
5690 fn append_recipient_creates_recipients_array_when_secrets_table_empty() {
5691 let existing = r#"[secrets]
5694identity = "~/.config/yui/age.txt"
5695"#;
5696 let result = append_recipient_to_config(existing, "h", "age1new").unwrap();
5697 let parsed: toml::Table = toml::from_str(&result).unwrap();
5698 let secrets = parsed["secrets"].as_table().unwrap();
5699 assert_eq!(
5700 secrets["identity"].as_str(),
5701 Some("~/.config/yui/age.txt"),
5702 "existing identity field must survive"
5703 );
5704 let recipients = secrets["recipients"].as_array().unwrap();
5705 assert_eq!(recipients.len(), 1);
5706 assert_eq!(recipients[0].as_str(), Some("age1new"));
5707 }
5708
5709 #[test]
5713 fn apply_without_recipients_skips_secret_walker() {
5714 let tmp = TempDir::new().unwrap();
5715 let (source, _target) = setup_minimal_dotfiles(&tmp);
5716 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5718 std::fs::write(source.join("home/some.junk.age"), b"not actually a cipher").unwrap();
5722 apply(Some(source.clone()), false).unwrap();
5723 }
5724
5725 #[test]
5729 fn nested_marker_accumulates_extra_dst() {
5730 let tmp = TempDir::new().unwrap();
5731 let source = utf8(tmp.path().join("dotfiles"));
5732 let parent_target = utf8(tmp.path().join("home"));
5733 let extra_target = utf8(tmp.path().join("extra"));
5734 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
5735 std::fs::create_dir_all(&parent_target).unwrap();
5736 std::fs::create_dir_all(&extra_target).unwrap();
5737 std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
5738
5739 std::fs::write(
5741 source.join("home/.config/.yuilink"),
5742 format!(
5743 r#"
5744[[link]]
5745dst = "{}/.config"
5746"#,
5747 toml_path(&parent_target)
5748 ),
5749 )
5750 .unwrap();
5751 std::fs::write(
5754 source.join("home/.config/nvim/.yuilink"),
5755 format!(
5756 r#"
5757[[link]]
5758dst = "{}/nvim"
5759when = "{{{{ yui.os == '{}' }}}}"
5760"#,
5761 toml_path(&extra_target),
5762 std::env::consts::OS
5763 ),
5764 )
5765 .unwrap();
5766
5767 let cfg = format!(
5768 r#"
5769[[mount.entry]]
5770src = "home"
5771dst = "{}"
5772"#,
5773 toml_path(&parent_target)
5774 );
5775 std::fs::write(source.join("config.toml"), cfg).unwrap();
5776
5777 apply(Some(source.clone()), false).unwrap();
5778
5779 assert!(parent_target.join(".config/nvim/init.lua").exists());
5782 assert!(extra_target.join("nvim/init.lua").exists());
5783 }
5784
5785 #[test]
5790 fn marker_file_link_targets_specific_file() {
5791 let tmp = TempDir::new().unwrap();
5792 let source = utf8(tmp.path().join("dotfiles"));
5793 let parent_target = utf8(tmp.path().join("home"));
5794 let docs_target = utf8(tmp.path().join("docs"));
5795 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
5796 std::fs::create_dir_all(&parent_target).unwrap();
5797 std::fs::create_dir_all(&docs_target).unwrap();
5798 std::fs::write(
5799 source.join("home/.config/powershell/profile.ps1"),
5800 "# profile\n",
5801 )
5802 .unwrap();
5803 std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
5804
5805 std::fs::write(
5808 source.join("home/.config/powershell/.yuilink"),
5809 format!(
5810 r#"
5811[[link]]
5812src = "profile.ps1"
5813dst = "{}/Microsoft.PowerShell_profile.ps1"
5814"#,
5815 toml_path(&docs_target)
5816 ),
5817 )
5818 .unwrap();
5819
5820 let cfg = format!(
5821 r#"
5822[[mount.entry]]
5823src = "home"
5824dst = "{}"
5825"#,
5826 toml_path(&parent_target)
5827 );
5828 std::fs::write(source.join("config.toml"), cfg).unwrap();
5829
5830 apply(Some(source.clone()), false).unwrap();
5831
5832 assert!(
5834 docs_target
5835 .join("Microsoft.PowerShell_profile.ps1")
5836 .exists()
5837 );
5838 assert!(
5841 parent_target
5842 .join(".config/powershell/profile.ps1")
5843 .exists()
5844 );
5845 assert!(parent_target.join(".config/powershell/extra.txt").exists());
5846 }
5847
5848 #[test]
5851 fn marker_file_link_missing_src_errors() {
5852 let tmp = TempDir::new().unwrap();
5853 let source = utf8(tmp.path().join("dotfiles"));
5854 let parent_target = utf8(tmp.path().join("home"));
5855 let docs_target = utf8(tmp.path().join("docs"));
5856 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
5857 std::fs::create_dir_all(&parent_target).unwrap();
5858 std::fs::create_dir_all(&docs_target).unwrap();
5859
5860 std::fs::write(
5861 source.join("home/.config/powershell/.yuilink"),
5862 format!(
5863 r#"
5864[[link]]
5865src = "missing.ps1"
5866dst = "{}/profile.ps1"
5867"#,
5868 toml_path(&docs_target)
5869 ),
5870 )
5871 .unwrap();
5872
5873 let cfg = format!(
5874 r#"
5875[[mount.entry]]
5876src = "home"
5877dst = "{}"
5878"#,
5879 toml_path(&parent_target)
5880 );
5881 std::fs::write(source.join("config.toml"), cfg).unwrap();
5882
5883 let err = apply(Some(source.clone()), false).unwrap_err();
5884 assert!(format!("{err:#}").contains("missing.ps1"));
5885 }
5886
5887 #[test]
5896 fn unmanaged_finds_files_outside_any_mount() {
5897 let tmp = TempDir::new().unwrap();
5898 let (source, _target) = setup_minimal_dotfiles(&tmp);
5899 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5901 std::fs::write(source.join("orphan.txt"), "y").unwrap();
5903 std::fs::create_dir_all(source.join("notes")).unwrap();
5904 std::fs::write(source.join("notes/scratch.md"), "z").unwrap();
5905
5906 unmanaged(Some(source.clone()), None, true).unwrap();
5908
5909 let yui = YuiVars::detect(&source);
5911 let cfg = config::load(&source, &yui).unwrap();
5912 let mount_srcs: Vec<Utf8PathBuf> = cfg
5913 .mount
5914 .entry
5915 .iter()
5916 .map(|m| source.join(&m.src))
5917 .collect();
5918 let walker = paths::source_walker(&source).build();
5919 let mut unmanaged_paths = Vec::new();
5920 for entry in walker.flatten() {
5921 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
5922 continue;
5923 }
5924 let p = match Utf8PathBuf::from_path_buf(entry.path().to_path_buf()) {
5925 Ok(p) => p,
5926 Err(_) => continue,
5927 };
5928 if is_repo_meta(&p, &source, &cfg.mount.marker_filename) {
5929 continue;
5930 }
5931 if mount_srcs.iter().any(|m| p.starts_with(m)) {
5932 continue;
5933 }
5934 unmanaged_paths.push(p);
5935 }
5936 let names: Vec<String> = unmanaged_paths
5937 .iter()
5938 .filter_map(|p| p.file_name().map(String::from))
5939 .collect();
5940 assert!(names.contains(&"orphan.txt".into()));
5941 assert!(names.contains(&"scratch.md".into()));
5942 assert!(!names.contains(&".bashrc".into()), "mount-claimed file");
5943 assert!(!names.contains(&"config.toml".into()), "repo meta");
5944 }
5945
5946 #[test]
5947 fn is_repo_meta_recognises_yui_scaffold() {
5948 let source = Utf8Path::new("/dot");
5949 assert!(is_repo_meta(
5951 Utf8Path::new("/dot/config.toml"),
5952 source,
5953 ".yuilink",
5954 ));
5955 assert!(is_repo_meta(
5956 Utf8Path::new("/dot/config.local.toml"),
5957 source,
5958 ".yuilink",
5959 ));
5960 assert!(is_repo_meta(
5961 Utf8Path::new("/dot/config.linux.toml"),
5962 source,
5963 ".yuilink",
5964 ));
5965 assert!(is_repo_meta(
5966 Utf8Path::new("/dot/config.local.example.toml"),
5967 source,
5968 ".yuilink",
5969 ));
5970 assert!(is_repo_meta(
5972 Utf8Path::new("/dot/.gitignore"),
5973 source,
5974 ".yuilink",
5975 ));
5976 assert!(is_repo_meta(
5978 Utf8Path::new("/dot/home/.config/foo/.yuilink"),
5979 source,
5980 ".yuilink",
5981 ));
5982 assert!(is_repo_meta(
5983 Utf8Path::new("/dot/home/.gitconfig.tera"),
5984 source,
5985 ".yuilink",
5986 ));
5987 assert!(!is_repo_meta(
5989 Utf8Path::new("/dot/home/.config/myapp/config.toml"),
5990 source,
5991 ".yuilink",
5992 ));
5993 assert!(!is_repo_meta(
5997 Utf8Path::new("/dot/home/.config/git/.gitignore"),
5998 source,
5999 ".yuilink",
6000 ));
6001 }
6002
6003 #[test]
6010 fn unmanaged_respects_inactive_mount_entries() {
6011 let tmp = TempDir::new().unwrap();
6012 let source = utf8(tmp.path().join("dotfiles"));
6013 let target = utf8(tmp.path().join("target"));
6014 std::fs::create_dir_all(source.join("home_active")).unwrap();
6015 std::fs::create_dir_all(source.join("home_other_os")).unwrap();
6016 std::fs::create_dir_all(&target).unwrap();
6017 std::fs::write(source.join("home_active/.bashrc"), "active").unwrap();
6018 std::fs::write(source.join("home_other_os/.bashrc"), "inactive").unwrap();
6019 let cfg = format!(
6021 r#"
6022[[mount.entry]]
6023src = "home_active"
6024dst = "{target}"
6025
6026[[mount.entry]]
6027src = "home_other_os"
6028dst = "{target}"
6029when = "yui.os == 'definitely_not_a_real_os'"
6030"#,
6031 target = toml_path(&target)
6032 );
6033 std::fs::write(source.join("config.toml"), cfg).unwrap();
6034
6035 let yui = YuiVars::detect(&source);
6039 let cfg = config::load(&source, &yui).unwrap();
6040 let mount_srcs: Vec<Utf8PathBuf> = cfg
6041 .mount
6042 .entry
6043 .iter()
6044 .map(|m| source.join(&m.src))
6045 .collect();
6046 let inactive_file = source.join("home_other_os/.bashrc");
6047 let claimed = mount_srcs.iter().any(|m| inactive_file.starts_with(m));
6048 assert!(
6049 claimed,
6050 "raw config.mount.entry should claim files even under inactive mounts"
6051 );
6052 }
6053
6054 #[test]
6059 fn diff_shows_drift_skips_in_sync() {
6060 let tmp = TempDir::new().unwrap();
6061 let (source, target) = setup_minimal_dotfiles(&tmp);
6062 std::fs::write(source.join("home/.bashrc"), "first\nsecond\n").unwrap();
6063 apply(Some(source.clone()), false).unwrap();
6065 std::fs::remove_file(target.join(".bashrc")).unwrap();
6067 std::fs::write(target.join(".bashrc"), "first\nEDITED\n").unwrap();
6068
6069 diff(Some(source.clone()), None, true).unwrap();
6072 }
6073
6074 #[test]
6079 fn read_text_for_diff_classifies_correctly() {
6080 let tmp = TempDir::new().unwrap();
6081 let root = utf8(tmp.path().to_path_buf());
6082 let txt = root.join("a.txt");
6084 std::fs::write(&txt, "hello\n").unwrap();
6085 match read_text_for_diff(&txt) {
6086 DiffSide::Text(s) => assert_eq!(s, "hello\n"),
6087 DiffSide::Binary => panic!("text file misclassified as binary"),
6088 }
6089 let bin = root.join("b.bin");
6091 std::fs::write(&bin, [0xff, 0xfe, 0x00, 0xff]).unwrap();
6092 assert!(matches!(read_text_for_diff(&bin), DiffSide::Binary));
6093 let missing = root.join("missing.txt");
6095 match read_text_for_diff(&missing) {
6096 DiffSide::Text(s) => assert!(s.is_empty()),
6097 DiffSide::Binary => panic!("missing file misclassified as binary"),
6098 }
6099 }
6100
6101 #[test]
6108 fn diff_render_drift_uses_rendered_output_not_raw_template() {
6109 let tmp = TempDir::new().unwrap();
6110 let (source, _target) = setup_minimal_dotfiles(&tmp);
6111 std::fs::write(source.join("home/note.tera"), "os = {{ yui.os }}\n").unwrap();
6114 std::fs::write(source.join("home/note"), "os = ancient\n").unwrap();
6115 let yui = YuiVars::detect(&source);
6117 let cfg = config::load(&source, &yui).unwrap();
6118 let rendered =
6119 render::render_to_string(&source.join("home/note.tera"), &source, &cfg, &yui)
6120 .unwrap()
6121 .expect("template should render on this host");
6122 assert!(rendered.starts_with("os = "));
6123 assert!(
6124 !rendered.contains("{{"),
6125 "rendered output must not contain raw Tera tags"
6126 );
6127 }
6128
6129 #[test]
6137 fn resolve_diff_src_absolutizes_link_rows() {
6138 let source = Utf8Path::new("/dot");
6139 let link_item = StatusItem {
6140 src: Utf8PathBuf::from("home/.bashrc"),
6141 dst: Utf8PathBuf::from("/h/u/.bashrc"),
6142 state: StatusState::Link(absorb::AbsorbDecision::AutoAbsorb),
6143 };
6144 assert_eq!(
6145 resolve_diff_src(&link_item, source),
6146 Utf8PathBuf::from("/dot/home/.bashrc"),
6147 );
6148 let render_item = StatusItem {
6149 src: Utf8PathBuf::from("/dot/home/foo.tera"),
6150 dst: Utf8PathBuf::from("/dot/home/foo"),
6151 state: StatusState::RenderDrift,
6152 };
6153 assert_eq!(
6154 resolve_diff_src(&render_item, source),
6155 Utf8PathBuf::from("/dot/home/foo.tera"),
6156 );
6157 }
6158
6159 #[test]
6160 fn diff_classifier_skips_uninteresting_states() {
6161 use absorb::AbsorbDecision::*;
6162 assert!(!diff_worth_printing(&StatusState::Link(InSync)));
6164 assert!(!diff_worth_printing(&StatusState::Link(Restore)));
6165 assert!(!diff_worth_printing(&StatusState::Link(RelinkOnly)));
6166 assert!(diff_worth_printing(&StatusState::Link(AutoAbsorb)));
6168 assert!(diff_worth_printing(&StatusState::Link(NeedsConfirm)));
6169 assert!(diff_worth_printing(&StatusState::RenderDrift));
6170 }
6171
6172 #[test]
6183 fn update_errors_when_source_is_not_a_git_repo() {
6184 let tmp = TempDir::new().unwrap();
6185 let source = utf8(tmp.path().join("dotfiles"));
6186 std::fs::create_dir_all(&source).unwrap();
6187 std::fs::write(source.join("config.toml"), "").unwrap();
6188 let err = update(Some(source), false).unwrap_err();
6190 let msg = format!("{err:#}");
6191 assert!(
6192 msg.contains("not a git repository")
6193 || msg.contains("uncommitted")
6194 || msg.contains("git"),
6195 "unexpected error: {msg}",
6196 );
6197 }
6198
6199 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
6200 let mut out = Vec::new();
6201 let mut stack = vec![root.to_path_buf()];
6202 while let Some(dir) = stack.pop() {
6203 let Ok(entries) = std::fs::read_dir(&dir) else {
6204 continue;
6205 };
6206 for e in entries.flatten() {
6207 let p = utf8(e.path());
6208 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
6209 stack.push(p);
6210 } else {
6211 out.push(p);
6212 }
6213 }
6214 }
6215 out
6216 }
6217
6218 #[test]
6223 fn parse_backup_suffix_recognises_file_with_extension() {
6224 let dt = parse_backup_suffix("foo_20260429_143022123.yml").unwrap();
6225 assert_eq!(dt.year(), 2026);
6226 assert_eq!(dt.month(), 4);
6227 assert_eq!(dt.day(), 29);
6228 assert_eq!(dt.hour(), 14);
6229 assert_eq!(dt.minute(), 30);
6230 assert_eq!(dt.second(), 22);
6231 }
6232
6233 #[test]
6234 fn parse_backup_suffix_recognises_dotfile_no_extension() {
6235 let dt = parse_backup_suffix(".gitconfig_20260429_143022123").unwrap();
6236 assert_eq!(dt.year(), 2026);
6237 }
6238
6239 #[test]
6240 fn parse_backup_suffix_recognises_directory_form() {
6241 let dt = parse_backup_suffix("nvim_20260429_143022123").unwrap();
6242 assert_eq!(dt.day(), 29);
6243 }
6244
6245 #[test]
6246 fn parse_backup_suffix_recognises_multi_dot_filename() {
6247 let dt = parse_backup_suffix("archive.tar.gz_20260429_143022123.gz").unwrap();
6249 assert_eq!(dt.month(), 4);
6250 }
6251
6252 #[test]
6253 fn parse_backup_suffix_rejects_non_yui_names() {
6254 assert!(parse_backup_suffix("README.md").is_none());
6255 assert!(parse_backup_suffix("notes_2026.txt").is_none());
6256 assert!(parse_backup_suffix("almost_20260429_14302212").is_none()); assert!(parse_backup_suffix("almost_20260429-143022123").is_none()); assert!(parse_backup_suffix("_20260429_143022123").is_none());
6260 }
6261
6262 #[test]
6263 fn parse_human_duration_basic_units() {
6264 let s = parse_human_duration("30d").unwrap();
6265 assert_eq!(s.get_days(), 30);
6266 let s = parse_human_duration("2w").unwrap();
6267 assert_eq!(s.get_weeks(), 2);
6268 let s = parse_human_duration("12h").unwrap();
6269 assert_eq!(s.get_hours(), 12);
6270 let s = parse_human_duration("5m").unwrap();
6272 assert_eq!(s.get_minutes(), 5);
6273 let s = parse_human_duration("6mo").unwrap();
6274 assert_eq!(s.get_months(), 6);
6275 let s = parse_human_duration("1y").unwrap();
6276 assert_eq!(s.get_years(), 1);
6277 }
6278
6279 #[test]
6280 fn parse_human_duration_case_insensitive_and_whitespace() {
6281 let s = parse_human_duration(" 90D ").unwrap();
6282 assert_eq!(s.get_days(), 90);
6283 let s = parse_human_duration("3WEEKS").unwrap();
6284 assert_eq!(s.get_weeks(), 3);
6285 }
6286
6287 #[test]
6288 fn parse_human_duration_rejects_garbage() {
6289 assert!(parse_human_duration("").is_err());
6290 assert!(parse_human_duration("d30").is_err());
6291 assert!(parse_human_duration("30").is_err()); assert!(parse_human_duration("30x").is_err()); assert!(parse_human_duration("-1d").is_err()); }
6295
6296 #[test]
6300 fn walk_gc_backups_collects_files_and_dir_snapshots() {
6301 let tmp = TempDir::new().unwrap();
6302 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
6303 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
6304 std::fs::write(
6306 root.join("C/Users/u/.config/foo_20260429_143022123.yml"),
6307 "old yml",
6308 )
6309 .unwrap();
6310 std::fs::create_dir_all(root.join("C/Users/u/nvim_20260101_000000000/lua")).unwrap();
6312 std::fs::write(
6313 root.join("C/Users/u/nvim_20260101_000000000/init.lua"),
6314 "ok",
6315 )
6316 .unwrap();
6317 std::fs::write(
6318 root.join("C/Users/u/nvim_20260101_000000000/lua/x.lua"),
6319 "kk",
6320 )
6321 .unwrap();
6322 std::fs::write(root.join("C/Users/u/.config/README.md"), "user note").unwrap();
6324
6325 let entries = walk_gc_backups(&root).unwrap();
6326 assert_eq!(entries.len(), 2, "two backup roots, not three");
6327 let kinds: Vec<_> = entries.iter().map(|e| e.kind).collect();
6328 assert!(kinds.contains(&BackupKind::File));
6329 assert!(kinds.contains(&BackupKind::Dir));
6330 let dir_entry = entries.iter().find(|e| e.kind == BackupKind::Dir).unwrap();
6332 assert!(dir_entry.size_bytes >= 4); }
6334
6335 #[test]
6336 fn cleanup_empty_parents_stops_at_root_and_at_non_empty() {
6337 let tmp = TempDir::new().unwrap();
6338 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
6339 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
6340 std::fs::write(root.join("C/Users/u/sibling_keep"), "x").unwrap();
6341
6342 cleanup_empty_parents(&root.join("C/Users/u/.config"), &root);
6346
6347 assert!(!root.join("C/Users/u/.config").exists(), "empty leaf gone");
6348 assert!(root.join("C/Users/u").exists(), "stops at non-empty parent");
6349 assert!(root.exists(), "backup root preserved");
6350 }
6351
6352 #[test]
6354 fn gc_backup_survey_keeps_all_entries() {
6355 let tmp = TempDir::new().unwrap();
6356 let source = utf8(tmp.path().join("dotfiles"));
6357 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
6358 std::fs::write(source.join("config.toml"), "").unwrap();
6359 let backup = source.join(".yui/backup");
6360 std::fs::write(backup.join("a_20260101_000000000.txt"), "old").unwrap();
6361 std::fs::write(backup.join("b_20260415_120000000.txt"), "fresh").unwrap();
6362
6363 gc_backup(Some(source.clone()), None, false, None, true).unwrap();
6364
6365 assert!(backup.join("a_20260101_000000000.txt").exists());
6367 assert!(backup.join("b_20260415_120000000.txt").exists());
6368 }
6369
6370 #[test]
6373 fn gc_backup_prune_removes_old_files_only() {
6374 let tmp = TempDir::new().unwrap();
6375 let source = utf8(tmp.path().join("dotfiles"));
6376 std::fs::create_dir_all(source.join(".yui/backup/sub")).unwrap();
6377 std::fs::write(source.join("config.toml"), "").unwrap();
6378 let backup = source.join(".yui/backup");
6379
6380 std::fs::write(backup.join("sub/old_20200101_000000000.txt"), "old").unwrap();
6382 let tomorrow = jiff::Zoned::now()
6384 .checked_add(jiff::Span::new().days(1))
6385 .unwrap();
6386 let bdt = jiff::fmt::strtime::BrokenDownTime::from(&tomorrow);
6387 let future_ts = bdt.to_string("%Y%m%d_%H%M%S%3f").unwrap();
6388 std::fs::write(backup.join(format!("fresh_{future_ts}.txt")), "fresh").unwrap();
6389 std::fs::write(backup.join("notes.md"), "mine").unwrap();
6391
6392 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
6393
6394 assert!(!backup.join("sub/old_20200101_000000000.txt").exists());
6395 assert!(!backup.join("sub").exists(), "empty parent removed");
6397 assert!(backup.exists());
6399 assert!(backup.join(format!("fresh_{future_ts}.txt")).exists());
6400 assert!(backup.join("notes.md").exists(), "user file untouched");
6401 }
6402
6403 #[test]
6405 fn gc_backup_dry_run_does_not_delete() {
6406 let tmp = TempDir::new().unwrap();
6407 let source = utf8(tmp.path().join("dotfiles"));
6408 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
6409 std::fs::write(source.join("config.toml"), "").unwrap();
6410 let backup = source.join(".yui/backup");
6411 std::fs::write(backup.join("old_20200101_000000000.txt"), "old").unwrap();
6412
6413 gc_backup(Some(source.clone()), Some("30d".into()), true, None, true).unwrap();
6414
6415 assert!(
6416 backup.join("old_20200101_000000000.txt").exists(),
6417 "dry-run keeps everything in place"
6418 );
6419 }
6420
6421 #[test]
6425 fn gc_backup_prune_handles_directory_snapshot() {
6426 let tmp = TempDir::new().unwrap();
6427 let source = utf8(tmp.path().join("dotfiles"));
6428 std::fs::create_dir_all(source.join(".yui/backup/mirror/u")).unwrap();
6429 std::fs::write(source.join("config.toml"), "").unwrap();
6430 let backup = source.join(".yui/backup");
6431 let snap = backup.join("mirror/u/nvim_20200101_000000000");
6432 std::fs::create_dir_all(snap.join("lua")).unwrap();
6433 std::fs::write(snap.join("init.lua"), "x").unwrap();
6434 std::fs::write(snap.join("lua/y.lua"), "y").unwrap();
6435
6436 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
6437
6438 assert!(!snap.exists(), "dir snapshot removed wholesale");
6439 assert!(!backup.join("mirror").exists(), "empty mirror chain pruned");
6440 assert!(backup.exists(), "backup root preserved");
6441 }
6442
6443 fn ctx_for_test(tmp: &TempDir) -> (Config, Utf8PathBuf, Utf8PathBuf) {
6448 let source = utf8(tmp.path().join("src"));
6449 let backup_root = source.join(".yui/backup");
6450 std::fs::create_dir_all(&source).unwrap();
6451 let cfg = Config::default();
6452 (cfg, source, backup_root)
6453 }
6454
6455 #[test]
6456 fn prompt_anomaly_short_circuits_on_quit_requested() {
6457 let tmp = TempDir::new().unwrap();
6462 let (cfg, source, backup_root) = ctx_for_test(&tmp);
6463 let src_file = source.join("a");
6464 let dst_file = utf8(tmp.path().join("dst"));
6465 std::fs::write(&src_file, "X").unwrap();
6466 std::fs::write(&dst_file, "Y").unwrap();
6467
6468 let ctx = ApplyCtx {
6469 config: &cfg,
6470 source: &source,
6471 file_mode: resolve_file_mode(cfg.link.file_mode),
6472 dir_mode: resolve_dir_mode(cfg.link.dir_mode),
6473 backup_root: &backup_root,
6474 dry_run: false,
6475 sticky_anomaly: Cell::new(None),
6476 quit_requested: Cell::new(true),
6477 };
6478
6479 let got = prompt_anomaly(&ctx, &src_file, &dst_file, "test").unwrap();
6480 assert_eq!(got, AnomalyChoice::Quit);
6481 }
6482
6483 #[test]
6484 fn prompt_anomaly_short_circuits_on_sticky_choice() {
6485 let tmp = TempDir::new().unwrap();
6490 let (cfg, source, backup_root) = ctx_for_test(&tmp);
6491 let src_file = source.join("a");
6492 let dst_file = utf8(tmp.path().join("dst"));
6493 std::fs::write(&src_file, "X").unwrap();
6494 std::fs::write(&dst_file, "Y").unwrap();
6495
6496 let ctx = ApplyCtx {
6497 config: &cfg,
6498 source: &source,
6499 file_mode: resolve_file_mode(cfg.link.file_mode),
6500 dir_mode: resolve_dir_mode(cfg.link.dir_mode),
6501 backup_root: &backup_root,
6502 dry_run: false,
6503 sticky_anomaly: Cell::new(Some(AnomalyChoice::Overwrite)),
6504 quit_requested: Cell::new(false),
6505 };
6506
6507 let got = prompt_anomaly(&ctx, &src_file, &dst_file, "test").unwrap();
6508 assert_eq!(got, AnomalyChoice::Overwrite);
6509 }
6510
6511 #[test]
6512 fn overwrite_source_into_target_replaces_target_and_backs_up() {
6513 let tmp = TempDir::new().unwrap();
6518 let (cfg, source, backup_root) = ctx_for_test(&tmp);
6519 let src_file = source.join("a");
6520 let dst_file = utf8(tmp.path().join("dst"));
6521 std::fs::write(&src_file, "from source").unwrap();
6522 std::fs::write(&dst_file, "diverged target content").unwrap();
6523
6524 let ctx = ApplyCtx {
6525 config: &cfg,
6526 source: &source,
6527 file_mode: resolve_file_mode(cfg.link.file_mode),
6528 dir_mode: resolve_dir_mode(cfg.link.dir_mode),
6529 backup_root: &backup_root,
6530 dry_run: false,
6531 sticky_anomaly: Cell::new(None),
6532 quit_requested: Cell::new(false),
6533 };
6534
6535 overwrite_source_into_target(&src_file, &dst_file, &ctx).unwrap();
6536
6537 assert_eq!(std::fs::read_to_string(&dst_file).unwrap(), "from source");
6539 assert_eq!(std::fs::read_to_string(&src_file).unwrap(), "from source");
6541 let mut found_old = false;
6543 for entry in walkdir(&backup_root) {
6544 if let Ok(s) = std::fs::read_to_string(&entry) {
6545 if s == "diverged target content" {
6546 found_old = true;
6547 break;
6548 }
6549 }
6550 }
6551 assert!(
6552 found_old,
6553 "expected backup containing target's diverged content"
6554 );
6555 }
6556
6557 #[test]
6558 fn link_file_with_backup_short_circuits_when_quit_requested() {
6559 let tmp = TempDir::new().unwrap();
6565 let (mut cfg, source, backup_root) = ctx_for_test(&tmp);
6566 cfg.absorb.on_anomaly = crate::config::AnomalyAction::Force;
6567
6568 let src_file = source.join("a");
6569 let dst_file = utf8(tmp.path().join("dst"));
6570 let now = std::time::SystemTime::now();
6571 let past = now - std::time::Duration::from_secs(120);
6572 write_with_mtime(&dst_file, "target old", past);
6573 write_with_mtime(&src_file, "source new", now);
6574 let dst_before = std::fs::read_to_string(&dst_file).unwrap();
6575 let src_before = std::fs::read_to_string(&src_file).unwrap();
6576
6577 let ctx = ApplyCtx {
6578 config: &cfg,
6579 source: &source,
6580 file_mode: resolve_file_mode(cfg.link.file_mode),
6581 dir_mode: resolve_dir_mode(cfg.link.dir_mode),
6582 backup_root: &backup_root,
6583 dry_run: false,
6584 sticky_anomaly: Cell::new(None),
6585 quit_requested: Cell::new(true),
6586 };
6587
6588 link_file_with_backup(&src_file, &dst_file, &ctx).unwrap();
6589
6590 assert_eq!(std::fs::read_to_string(&dst_file).unwrap(), dst_before);
6591 assert_eq!(std::fs::read_to_string(&src_file).unwrap(), src_before);
6592 assert!(
6593 !backup_root.exists() || walkdir(&backup_root).is_empty(),
6594 "no backup should be produced when quit is requested"
6595 );
6596 }
6597}