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)?;
235 log_render_report(&render_report);
236 if render_report.has_drift() {
237 anyhow::bail!(
238 "render drift detected ({} file(s)); reflect target edits back into the .tera before re-running apply",
239 render_report.diverged.len()
240 );
241 }
242
243 if !dry_run && config.render.manage_gitignore {
250 let mut managed: Vec<Utf8PathBuf> = render::report_managed_paths(&render_report)
251 .into_iter()
252 .chain(secret_report.managed_paths().cloned())
253 .collect();
254 managed.sort();
255 managed.dedup();
256 render::write_managed_section(&source, &managed)?;
257 }
258
259 let mounts = mount::resolve(
261 &source,
262 &config.mount.entry,
263 config.mount.default_strategy,
264 &mut engine,
265 &tera_ctx,
266 )?;
267
268 let backup_root = source.join(&config.backup.dir);
269 let ctx = ApplyCtx {
270 config: &config,
271 source: &source,
272 file_mode: resolve_file_mode(config.link.file_mode),
273 dir_mode: resolve_dir_mode(config.link.dir_mode),
274 backup_root: &backup_root,
275 dry_run,
276 sticky_anomaly: Cell::new(None),
277 quit_requested: Cell::new(false),
278 };
279
280 info!("source: {source}");
281 info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
282 if dry_run {
283 info!("dry-run: nothing will be written");
284 }
285
286 let mut yuiignore = paths::YuiIgnoreStack::new();
290 yuiignore.push_dir(&source)?;
291 let walk_result = (|| -> Result<()> {
292 for m in &mounts {
293 info!("mount: {} → {}", m.src, m.dst);
294 process_mount(m, &ctx, &mut engine, &tera_ctx, &mut yuiignore)?;
295 }
296 Ok(())
297 })();
298 yuiignore.pop_dir(&source);
299 walk_result?;
300
301 hook::run_phase(
303 &config,
304 &source,
305 &yui,
306 &mut engine,
307 &tera_ctx,
308 HookPhase::Post,
309 dry_run,
310 )?;
311 Ok(())
312}
313
314fn log_render_report(r: &RenderReport) {
315 if !r.written.is_empty() {
316 info!("rendered {} new file(s)", r.written.len());
317 }
318 if !r.unchanged.is_empty() {
319 info!("rendered {} file(s) unchanged", r.unchanged.len());
320 }
321 if !r.skipped_when_false.is_empty() {
322 info!(
323 "skipped {} template(s) (when=false)",
324 r.skipped_when_false.len()
325 );
326 }
327 for d in &r.diverged {
328 warn!("rendered file diverged from template: {d}");
329 }
330}
331
332fn log_secret_report(r: &secret::SecretReport) {
333 if !r.written.is_empty() {
334 info!("decrypted {} secret file(s)", r.written.len());
335 }
336 if !r.unchanged.is_empty() {
337 info!("decrypted {} secret(s) unchanged", r.unchanged.len());
338 }
339 for d in &r.diverged {
340 warn!("plaintext sibling diverged from .age: {d}");
341 }
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356enum AnomalyChoice {
357 Absorb,
359 Overwrite,
361 Skip,
363 Quit,
365}
366
367struct ApplyCtx<'a> {
368 config: &'a Config,
369 source: &'a Utf8Path,
371 file_mode: EffectiveFileMode,
372 dir_mode: EffectiveDirMode,
373 backup_root: &'a Utf8Path,
374 dry_run: bool,
375 sticky_anomaly: Cell<Option<AnomalyChoice>>,
378 quit_requested: Cell<bool>,
382}
383
384pub fn list(
390 source: Option<Utf8PathBuf>,
391 all: bool,
392 icons_override: Option<IconsMode>,
393 no_color: bool,
394) -> Result<()> {
395 let source = resolve_source(source)?;
396 let yui = YuiVars::detect(&source);
397 let config = config::load(&source, &yui)?;
398
399 let icons_mode = icons_override.unwrap_or(config.ui.icons);
400 let icons = Icons::for_mode(icons_mode);
401 let color = !no_color && supports_color_stdout();
402
403 let items = collect_list_items(&source, &config, &yui)?;
404 let displayed: Vec<&ListItem> = if all {
405 items.iter().collect()
406 } else {
407 items.iter().filter(|i| i.active).collect()
408 };
409
410 print_list_table(&displayed, icons, color);
411
412 let total = items.len();
413 let active = items.iter().filter(|i| i.active).count();
414 let inactive = total - active;
415 println!();
416 if all {
417 println!(" {total} entries · {active} active · {inactive} inactive");
418 } else {
419 println!(
420 " {} of {} entries shown ({} inactive hidden — use --all)",
421 active, total, inactive
422 );
423 }
424 Ok(())
425}
426
427#[derive(Debug)]
428struct ListItem {
429 src: Utf8PathBuf,
430 dst: String,
431 when: Option<String>,
432 active: bool,
433}
434
435fn collect_list_items(source: &Utf8Path, config: &Config, yui: &YuiVars) -> Result<Vec<ListItem>> {
436 let mut engine = template::Engine::new();
437 let tera_ctx = template::template_context(yui, &config.vars);
438 let mut items = Vec::new();
439
440 for entry in &config.mount.entry {
442 let active = match &entry.when {
443 None => true,
444 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
445 };
446 let dst = engine
447 .render(&entry.dst, &tera_ctx)
448 .map(|s| paths::expand_tilde(s.trim()).to_string())
449 .unwrap_or_else(|_| entry.dst.clone());
450 items.push(ListItem {
451 src: entry.src.clone(),
452 dst,
453 when: entry.when.clone(),
454 active,
455 });
456 }
457
458 let walker = paths::source_walker(source).build();
460 let marker_filename = &config.mount.marker_filename;
461 for entry in walker {
462 let entry = match entry {
463 Ok(e) => e,
464 Err(_) => continue,
465 };
466 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
467 continue;
468 }
469 if entry.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
470 continue;
471 }
472 let dir = match entry.path().parent() {
473 Some(d) => d,
474 None => continue,
475 };
476 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
477 Ok(p) => p,
478 Err(_) => continue,
479 };
480 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
484 Some(s) => s,
485 None => continue,
486 };
487 let MarkerSpec::Explicit { links } = spec else {
488 continue; };
490 let rel = dir_utf8
491 .strip_prefix(source)
492 .map(Utf8PathBuf::from)
493 .unwrap_or(dir_utf8);
494 for link in &links {
495 let active = match &link.when {
496 None => true,
497 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
498 };
499 let dst = engine
500 .render(&link.dst, &tera_ctx)
501 .map(|s| paths::expand_tilde(s.trim()).to_string())
502 .unwrap_or_else(|_| link.dst.clone());
503 let src_display = match &link.src {
508 Some(filename) => rel.join(filename),
509 None => rel.clone(),
510 };
511 items.push(ListItem {
512 src: src_display,
513 dst,
514 when: link.when.clone(),
515 active,
516 });
517 }
518 }
519
520 items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
521 Ok(items)
522}
523
524fn supports_color_stdout() -> bool {
525 use std::io::IsTerminal;
526 std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
527}
528
529fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
530 let src_w = items
531 .iter()
532 .map(|i| i.src.as_str().chars().count())
533 .max()
534 .unwrap_or(0)
535 .max("SRC".len());
536 let dst_w = items
537 .iter()
538 .map(|i| i.dst.chars().count())
539 .max()
540 .unwrap_or(0)
541 .max("DST".len());
542
543 let status_w = "STATUS".len();
544 let arrow_w = icons.arrow.chars().count();
545
546 print_header(status_w, src_w, arrow_w, dst_w, color);
548
549 let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
551 if color {
552 use owo_colors::OwoColorize as _;
553 println!("{}", sep.dimmed());
554 } else {
555 println!("{sep}");
556 }
557
558 for item in items {
560 print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
561 }
562}
563
564fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
565 use owo_colors::OwoColorize as _;
566 let mut line = String::new();
567 let _ = write!(
568 &mut line,
569 " {:<status_w$} {:<src_w$} {:<arrow_w$} {:<dst_w$} WHEN",
570 "STATUS", "SRC", "", "DST"
571 );
572 if color {
573 println!("{}", line.bold());
574 } else {
575 println!("{line}");
576 }
577}
578
579fn render_separator(
580 sep_ch: char,
581 status_w: usize,
582 src_w: usize,
583 arrow_w: usize,
584 dst_w: usize,
585) -> String {
586 let bar = |n: usize| sep_ch.to_string().repeat(n);
587 format!(
588 " {} {} {} {} {}",
589 bar(status_w),
590 bar(src_w),
591 bar(arrow_w),
592 bar(dst_w),
593 bar("WHEN".len())
594 )
595}
596
597fn print_row(
598 item: &ListItem,
599 icons: Icons,
600 status_w: usize,
601 src_w: usize,
602 arrow_w: usize,
603 dst_w: usize,
604 color: bool,
605) {
606 use owo_colors::OwoColorize as _;
607 let status = if item.active {
608 icons.active
609 } else {
610 icons.inactive
611 };
612 let when_str = item
613 .when
614 .as_deref()
615 .map(strip_braces)
616 .unwrap_or_else(|| "(always)".to_string());
617
618 let src_display = item.src.as_str().replace('\\', "/");
620 let src = src_display.as_str();
621 let dst = &item.dst;
622 let arrow = icons.arrow;
623
624 let cell_status = format!("{:<status_w$}", status);
629 let cell_src = format!("{:<src_w$}", src);
630 let cell_arrow = format!("{:<arrow_w$}", arrow);
631 let cell_dst = format!("{:<dst_w$}", dst);
632
633 if !color {
634 println!(" {cell_status} {cell_src} {cell_arrow} {cell_dst} {when_str}");
635 return;
636 }
637
638 if item.active {
639 println!(
640 " {} {} {} {} {}",
641 cell_status.green(),
642 cell_src.cyan(),
643 cell_arrow.dimmed(),
644 cell_dst.green(),
645 when_str.dimmed()
646 );
647 } else {
648 println!(
649 " {} {} {} {} {}",
650 cell_status.red().dimmed(),
651 cell_src.dimmed(),
652 cell_arrow.dimmed(),
653 cell_dst.dimmed(),
654 when_str.dimmed()
655 );
656 }
657}
658
659fn strip_braces(expr: &str) -> String {
662 let trimmed = expr.trim();
663 if let Some(inner) = trimmed
664 .strip_prefix("{{")
665 .and_then(|s| s.strip_suffix("}}"))
666 {
667 inner.trim().to_string()
668 } else {
669 trimmed.to_string()
670 }
671}
672
673pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
674 let source = resolve_source(source)?;
675 let yui = YuiVars::detect(&source);
676 let config = config::load(&source, &yui)?;
677 let effective_dry_run = dry_run || check;
679 let report = render::render_all(&source, &config, &yui, effective_dry_run)?;
680 log_render_report(&report);
681 if !effective_dry_run && config.render.manage_gitignore {
686 let managed = render::report_managed_paths(&report);
687 render::write_managed_section(&source, &managed)?;
688 }
689 if check && report.has_drift() {
690 anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
691 }
692 Ok(())
693}
694
695pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
696 apply(source, dry_run)
698}
699
700pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
701 let _source = resolve_source(source)?;
702 if paths_arg.is_empty() {
703 anyhow::bail!("yui unlink: provide at least one target path");
704 }
705 for p in paths_arg {
706 let abs = absolutize(&p)?;
707 info!("unlink: {abs}");
708 link::unlink(&abs)?;
709 }
710 Ok(())
711}
712
713pub fn secret_init(source: Option<Utf8PathBuf>, comment: Option<String>) -> Result<()> {
741 let source = resolve_source(source)?;
742 let yui = YuiVars::detect(&source);
743 let config = config::load(&source, &yui)?;
744
745 let identity_path = paths::expand_tilde(&config.secrets.identity);
747 if identity_path.exists() {
748 anyhow::bail!(
749 "identity file already exists at {identity_path}; \
750 refusing to overwrite. Delete it first if you really \
751 mean to start fresh (you'll lose access to existing \
752 .age files encrypted to its public key)."
753 );
754 }
755
756 let (secret, public) = secret::generate_x25519_keypair();
760 let now = jiff::Zoned::now().to_string();
761 let body = format!(
762 "# created: {now}\n\
763 # public key: {public}\n\
764 {secret}\n"
765 );
766 secret::write_private_file(&identity_path, body.as_bytes())?;
769 info!("wrote identity file: {identity_path}");
770
771 let config_path = source.join("config.toml");
776 let comment = comment.unwrap_or_else(|| format!("{} {}", yui.host, yui.user));
777 let entry_comment = format!("{comment} — added by `yui secret init` on {now}");
778 let config_existing = match std::fs::read_to_string(&config_path) {
779 Ok(s) => s,
780 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
781 Err(e) => anyhow::bail!("read {config_path}: {e}"),
782 };
783 let updated_config = append_recipient_to_config(&config_existing, &entry_comment, &public)?;
784 std::fs::write(&config_path, updated_config)?;
785 info!("appended public key to {config_path}");
786 println!();
787 println!(" age identity: {identity_path}");
788 println!(" public key: {public}");
789 println!();
790 println!(
791 " Next: encrypt a file with `yui secret encrypt <path>`. \
792 The plaintext sibling will be auto-decrypted on every `yui apply`."
793 );
794 Ok(())
795}
796
797fn append_recipient_to_config(existing: &str, comment: &str, public: &str) -> Result<String> {
811 use toml_edit::{Array, DocumentMut, Item, Table, Value};
812
813 let mut doc: DocumentMut = if existing.trim().is_empty() {
814 DocumentMut::new()
815 } else {
816 existing
817 .parse()
818 .map_err(|e| anyhow::anyhow!("config.toml is not valid TOML: {e}"))?
819 };
820
821 if !doc.contains_key("secrets") {
823 let mut t = Table::new();
824 t.set_implicit(false);
825 doc.insert("secrets", Item::Table(t));
826 }
827 let secrets = doc["secrets"].as_table_mut().ok_or_else(|| {
828 anyhow::anyhow!("[secrets] in config.toml is not a table — refusing to clobber")
829 })?;
830
831 if !secrets.contains_key("recipients") {
833 secrets.insert("recipients", Item::Value(Value::Array(Array::new())));
834 }
835 let recipients = secrets["recipients"]
836 .as_array_mut()
837 .ok_or_else(|| anyhow::anyhow!("[secrets].recipients is not an array"))?;
838
839 let already_present = recipients.iter().any(|v| v.as_str() == Some(public));
841 if already_present {
842 return Ok(doc.to_string());
843 }
844
845 let mut value = Value::from(public);
849 let prefix = format!("\n # {comment}\n ");
850 *value.decor_mut() = toml_edit::Decor::new(prefix, "");
851 recipients.push_formatted(value);
852 recipients.set_trailing("\n");
856 recipients.set_trailing_comma(true);
857
858 Ok(doc.to_string())
859}
860
861pub fn secret_encrypt(
865 source: Option<Utf8PathBuf>,
866 path: Utf8PathBuf,
867 force: bool,
868 rm_plaintext: bool,
869) -> Result<()> {
870 let source = resolve_source(source)?;
871 let yui = YuiVars::detect(&source);
872 let config = config::load(&source, &yui)?;
873
874 if !config.secrets.enabled() {
875 anyhow::bail!(
876 "no recipients configured — run `yui secret init` to generate \
877 a keypair, or add at least one entry to `[secrets] recipients`."
878 );
879 }
880
881 let plaintext_path = if path.is_absolute() {
885 path.clone()
886 } else {
887 absolutize(&path)?
888 };
889 if !plaintext_path.is_file() {
890 anyhow::bail!("plaintext file not found: {plaintext_path}");
891 }
892 let cipher_path = Utf8PathBuf::from(format!("{plaintext_path}.age"));
893 if cipher_path.exists() && !force {
894 anyhow::bail!("{cipher_path} already exists; pass --force to overwrite");
895 }
896
897 let plaintext = std::fs::read(&plaintext_path)?;
898 let recipients = secret::parse_passkey_recipients(&config.secrets.recipients)?;
906 let cipher = secret::encrypt_to_passkeys(&plaintext, &recipients)?;
907 std::fs::write(&cipher_path, &cipher)?;
908 info!("encrypted {plaintext_path} → {cipher_path}");
909
910 if rm_plaintext {
911 if plaintext_path.starts_with(&source) {
914 std::fs::remove_file(&plaintext_path)?;
915 info!("removed plaintext: {plaintext_path}");
916 } else {
917 warn!(
918 "plaintext lives outside source ({plaintext_path}); \
919 skipping --rm-plaintext as a safety check"
920 );
921 }
922 }
923 Ok(())
924}
925
926pub fn secret_store(source: Option<Utf8PathBuf>, force: bool) -> Result<()> {
936 let source = resolve_source(source)?;
937 let yui = YuiVars::detect(&source);
938 let config = config::load(&source, &yui)?;
939
940 let vault_cfg = config.secrets.vault.as_ref().ok_or_else(|| {
941 anyhow::anyhow!(
942 "[secrets.vault] is not configured — set provider \
943 (\"bitwarden\" or \"1password\") and item before \
944 calling store"
945 )
946 })?;
947
948 let identity_path = paths::expand_tilde(&config.secrets.identity);
949 if !identity_path.is_file() {
950 anyhow::bail!(
951 "no X25519 identity at {identity_path}; run `yui secret init` first \
952 (store needs that file's content to push to the vault)"
953 );
954 }
955 let plaintext = std::fs::read(&identity_path)?;
956 secret::validate_x25519_identity_bytes(&plaintext)?;
961
962 let vault = vault::driver(vault_cfg);
963 vault.precheck()?;
968 info!(
969 "pushing X25519 identity to {} item {:?}",
970 vault.provider_name(),
971 config::VAULT_ITEM_NAME
972 );
973 vault.store(config::VAULT_ITEM_NAME, &plaintext, force)?;
974
975 println!();
976 println!(
977 " X25519 identity pushed to {} item {:?}",
978 vault.provider_name(),
979 config::VAULT_ITEM_NAME
980 );
981 println!(" On a new machine, run `yui secret unlock`.");
982 Ok(())
983}
984
985pub fn secret_unlock(source: Option<Utf8PathBuf>) -> Result<()> {
991 let source = resolve_source(source)?;
992 let yui = YuiVars::detect(&source);
993 let config = config::load(&source, &yui)?;
994
995 let vault_cfg = config.secrets.vault.as_ref().ok_or_else(|| {
996 anyhow::anyhow!(
997 "[secrets.vault] is not configured — nothing to unlock. \
998 Run `yui secret init` + `yui secret store` on an existing \
999 machine first, then commit + push the config."
1000 )
1001 })?;
1002 let identity_path = paths::expand_tilde(&config.secrets.identity);
1003 if identity_path.exists() {
1004 anyhow::bail!(
1005 "{identity_path} already exists — refusing to clobber a live \
1006 X25519 identity. Delete it first if you really mean to \
1007 re-unlock from scratch."
1008 );
1009 }
1010
1011 let vault = vault::driver(vault_cfg);
1012 vault.precheck()?;
1013 info!(
1014 "fetching X25519 identity from {} item {:?}",
1015 vault.provider_name(),
1016 config::VAULT_ITEM_NAME
1017 );
1018 let plaintext = vault.fetch(config::VAULT_ITEM_NAME)?;
1019
1020 secret::validate_x25519_identity_bytes(&plaintext)?;
1026
1027 secret::write_private_file(&identity_path, &plaintext)?;
1029 info!("wrote X25519 identity: {identity_path}");
1030 println!();
1031 println!(" X25519 identity restored at {identity_path}");
1032 println!(" Run `yui apply` next.");
1033 Ok(())
1034}
1035
1036pub fn update(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
1047 let source = resolve_source(source)?;
1048 if !crate::git::is_clean(&source)? {
1049 anyhow::bail!(
1050 "source repo {source} has uncommitted changes — \
1051 commit or stash before `yui update` (or run \
1052 `git pull` + `yui apply` manually if you know what \
1053 you're doing)"
1054 );
1055 }
1056 info!("git pull --ff-only at {source}");
1057 let status = std::process::Command::new("git")
1058 .arg("-C")
1059 .arg(source.as_str())
1060 .arg("pull")
1061 .arg("--ff-only")
1062 .status()
1063 .map_err(|e| anyhow::anyhow!("invoking git: {e}"))?;
1064 if !status.success() {
1065 anyhow::bail!("git pull --ff-only failed at {source}");
1066 }
1067 apply(Some(source), dry_run)
1068}
1069
1070pub fn unmanaged(
1081 source: Option<Utf8PathBuf>,
1082 icons_override: Option<IconsMode>,
1083 no_color: bool,
1084) -> Result<()> {
1085 let source = resolve_source(source)?;
1086 let yui = YuiVars::detect(&source);
1087 let config = config::load(&source, &yui)?;
1088
1089 let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
1090 let color = !no_color && supports_color_stdout();
1091
1092 let mut engine = template::Engine::new();
1107 let tera_ctx = template::template_context(&yui, &config.vars);
1108 let mount_srcs: Vec<Utf8PathBuf> = config
1109 .mount
1110 .entry
1111 .iter()
1112 .map(|e| -> Result<Utf8PathBuf> {
1113 let rendered = engine.render(e.src.as_str(), &tera_ctx)?;
1114 Ok(paths::resolve_mount_src(&source, rendered.trim()))
1115 })
1116 .collect::<Result<_>>()?;
1117
1118 let mut items: Vec<Utf8PathBuf> = Vec::new();
1119 let walker = paths::source_walker(&source).build();
1120 for entry in walker {
1121 let entry = match entry {
1122 Ok(e) => e,
1123 Err(_) => continue,
1124 };
1125 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
1126 continue;
1127 }
1128 let std_path = entry.path();
1129 let path = match Utf8PathBuf::from_path_buf(std_path.to_path_buf()) {
1130 Ok(p) => p,
1131 Err(_) => continue,
1132 };
1133 if is_repo_meta(&path, &source, &config.mount.marker_filename) {
1137 continue;
1138 }
1139 if mount_srcs.iter().any(|m| path.starts_with(m)) {
1140 continue;
1141 }
1142 items.push(path);
1143 }
1144 items.sort();
1145
1146 if items.is_empty() {
1147 println!(" no unmanaged files under {source}");
1148 return Ok(());
1149 }
1150
1151 print_unmanaged_table(&items, &source, color);
1152 println!();
1153 println!(" {} unmanaged file(s)", items.len());
1154 Ok(())
1155}
1156
1157fn is_repo_meta(path: &Utf8Path, source: &Utf8Path, marker_filename: &str) -> bool {
1173 let Some(name) = path.file_name() else {
1174 return false;
1175 };
1176 if name.ends_with(".tera") {
1177 return true;
1178 }
1179 if name == marker_filename || name == ".yuiignore" {
1180 return true;
1181 }
1182 let parent = path.parent().unwrap_or(Utf8Path::new(""));
1183 let at_root = parent == source;
1184 if at_root && name == ".gitignore" {
1185 return true;
1186 }
1187 if at_root && (name == "config.toml" || name == "config.local.toml") {
1188 return true;
1189 }
1190 if at_root
1191 && name.starts_with("config.")
1192 && (name.ends_with(".toml") || name.ends_with(".example.toml"))
1193 {
1194 return true;
1195 }
1196 false
1197}
1198
1199fn print_unmanaged_table(items: &[Utf8PathBuf], source: &Utf8Path, color: bool) {
1200 use owo_colors::OwoColorize as _;
1201 if color {
1202 println!(" {}", "PATH (relative to source)".dimmed());
1203 } else {
1204 println!(" PATH (relative to source)");
1205 }
1206 for p in items {
1207 let rel = p
1208 .strip_prefix(source)
1209 .map(Utf8PathBuf::from)
1210 .unwrap_or_else(|_| p.clone());
1211 if color {
1212 println!(" {}", rel.cyan());
1213 } else {
1214 println!(" {rel}");
1215 }
1216 }
1217}
1218
1219pub fn diff(
1227 source: Option<Utf8PathBuf>,
1228 icons_override: Option<IconsMode>,
1229 no_color: bool,
1230) -> Result<()> {
1231 let source = resolve_source(source)?;
1232 let yui = YuiVars::detect(&source);
1233 let config = config::load(&source, &yui)?;
1234 let mut engine = template::Engine::new();
1235 let tera_ctx = template::template_context(&yui, &config.vars);
1236 let mounts = mount::resolve(
1237 &source,
1238 &config.mount.entry,
1239 config.mount.default_strategy,
1240 &mut engine,
1241 &tera_ctx,
1242 )?;
1243
1244 let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
1245 let color = !no_color && supports_color_stdout();
1246
1247 let mut report: Vec<StatusItem> = Vec::new();
1249 let mut yuiignore = paths::YuiIgnoreStack::new();
1250 yuiignore.push_dir(&source)?;
1251 let walk_result = (|| -> Result<()> {
1252 for m in &mounts {
1253 let src_root = m.src.clone();
1254 if !src_root.is_dir() {
1255 continue;
1256 }
1257 classify_walk(
1258 &src_root,
1259 &m.dst,
1260 &config,
1261 m.strategy,
1262 &mut engine,
1263 &tera_ctx,
1264 &source,
1265 &mut yuiignore,
1266 &mut report,
1267 )?;
1268 }
1269 Ok(())
1270 })();
1271 yuiignore.pop_dir(&source);
1272 walk_result?;
1273
1274 let render_report = render::render_all(&source, &config, &yui, true)?;
1276 for rendered in &render_report.diverged {
1277 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
1278 report.push(StatusItem {
1279 src: tera_path,
1280 dst: rendered.clone(),
1281 state: StatusState::RenderDrift,
1282 });
1283 }
1284
1285 let mut printed = 0usize;
1286 for item in &report {
1287 if !diff_worth_printing(&item.state) {
1288 continue;
1289 }
1290 let src_abs = resolve_diff_src(item, &source);
1291 print_unified_diff(
1292 &src_abs,
1293 &item.dst,
1294 &item.state,
1295 &source,
1296 &config,
1297 &yui,
1298 color,
1299 );
1300 printed += 1;
1301 }
1302
1303 if printed == 0 {
1304 println!(" no diff — every entry is in sync (or only needs a relink)");
1305 } else {
1306 println!();
1307 println!(
1308 " {printed} entr{} with content drift",
1309 if printed == 1 { "y" } else { "ies" }
1310 );
1311 }
1312 Ok(())
1313}
1314
1315fn resolve_diff_src(item: &StatusItem, source: &Utf8Path) -> Utf8PathBuf {
1327 match item.state {
1328 StatusState::RenderDrift => item.src.clone(),
1329 StatusState::Link(_) => source.join(&item.src),
1330 }
1331}
1332
1333fn diff_worth_printing(state: &StatusState) -> bool {
1334 use absorb::AbsorbDecision::*;
1335 match state {
1336 StatusState::Link(InSync) => false,
1337 StatusState::Link(Restore) => false, StatusState::Link(RelinkOnly) => false, StatusState::Link(_) => true,
1340 StatusState::RenderDrift => true,
1341 }
1342}
1343
1344fn print_unified_diff(
1352 src: &Utf8Path,
1353 dst: &Utf8Path,
1354 state: &StatusState,
1355 source_root: &Utf8Path,
1356 config: &Config,
1357 yui: &YuiVars,
1358 color: bool,
1359) {
1360 use owo_colors::OwoColorize as _;
1361
1362 let header = match state {
1363 StatusState::RenderDrift => format!("--- render drift: {src} (template) vs {dst}"),
1364 _ => format!("--- {src} → {dst}"),
1365 };
1366 if color {
1367 println!("{}", header.bold());
1368 } else {
1369 println!("{header}");
1370 }
1371
1372 if src.is_dir() || dst.is_dir() {
1373 println!("(directory entry — content listing skipped)");
1374 println!();
1375 return;
1376 }
1377
1378 let src_content = match state {
1383 StatusState::RenderDrift => match render::render_to_string(src, source_root, config, yui) {
1384 Ok(Some(s)) => s,
1385 Ok(None) => {
1386 println!(
1387 "(template would be skipped on this host — drift will resolve on next render)"
1388 );
1389 println!();
1390 return;
1391 }
1392 Err(e) => {
1393 println!("(error rendering template: {e})");
1394 println!();
1395 return;
1396 }
1397 },
1398 _ => match read_text_for_diff(src) {
1399 DiffSide::Text(s) => s,
1400 DiffSide::Binary => {
1401 println!("(binary file or non-UTF-8 content — diff skipped)");
1402 println!();
1403 return;
1404 }
1405 },
1406 };
1407 let dst_content = match read_text_for_diff(dst) {
1408 DiffSide::Text(s) => s,
1409 DiffSide::Binary => {
1410 println!("(binary file or non-UTF-8 content — diff skipped)");
1411 println!();
1412 return;
1413 }
1414 };
1415 print_unified_text_diff(
1416 &src_content,
1417 &dst_content,
1418 src.as_str(),
1419 dst.as_str(),
1420 color,
1421 );
1422 println!();
1423}
1424
1425fn print_unified_text_diff(src: &str, dst: &str, src_label: &str, dst_label: &str, color: bool) {
1434 use owo_colors::OwoColorize as _;
1435 let diff = similar::TextDiff::from_lines(src, dst);
1436 let formatted = diff.unified_diff().header(src_label, dst_label).to_string();
1437 for line in formatted.lines() {
1438 if !color {
1439 println!("{line}");
1440 } else if line.starts_with("+++") || line.starts_with("---") {
1441 println!("{}", line.dimmed());
1442 } else if line.starts_with("@@") {
1443 println!("{}", line.cyan());
1444 } else if line.starts_with('+') {
1445 println!("{}", line.green());
1446 } else if line.starts_with('-') {
1447 println!("{}", line.red());
1448 } else {
1449 println!("{line}");
1450 }
1451 }
1452}
1453
1454enum DiffSide {
1460 Text(String),
1461 Binary,
1462}
1463
1464fn read_text_for_diff(p: &Utf8Path) -> DiffSide {
1465 match std::fs::read_to_string(p) {
1466 Ok(s) => DiffSide::Text(s),
1467 Err(e) if e.kind() == std::io::ErrorKind::InvalidData => DiffSide::Binary,
1468 Err(_) => DiffSide::Text(String::new()),
1469 }
1470}
1471
1472pub fn status(
1485 source: Option<Utf8PathBuf>,
1486 icons_override: Option<IconsMode>,
1487 no_color: bool,
1488) -> Result<()> {
1489 let source = resolve_source(source)?;
1490 let yui = YuiVars::detect(&source);
1491 let config = config::load(&source, &yui)?;
1492
1493 let mut engine = template::Engine::new();
1494 let tera_ctx = template::template_context(&yui, &config.vars);
1495 let mounts = mount::resolve(
1496 &source,
1497 &config.mount.entry,
1498 config.mount.default_strategy,
1499 &mut engine,
1500 &tera_ctx,
1501 )?;
1502
1503 let icons_mode = icons_override.unwrap_or(config.ui.icons);
1504 let icons = Icons::for_mode(icons_mode);
1505 let color = !no_color && supports_color_stdout();
1506
1507 let mut report: Vec<StatusItem> = Vec::new();
1508
1509 let render_report = render::render_all(&source, &config, &yui, true)?;
1512 for rendered in &render_report.diverged {
1513 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
1517 report.push(StatusItem {
1518 src: relative_for_display(&source, &tera_path),
1519 dst: rendered.clone(),
1520 state: StatusState::RenderDrift,
1521 });
1522 }
1523
1524 let mut yuiignore = paths::YuiIgnoreStack::new();
1528 yuiignore.push_dir(&source)?;
1529 let walk_result = (|| -> Result<()> {
1530 for m in &mounts {
1531 let src_root = m.src.clone();
1532 if !src_root.is_dir() {
1533 warn!("mount src missing: {src_root}");
1534 continue;
1535 }
1536 classify_walk(
1537 &src_root,
1538 &m.dst,
1539 &config,
1540 m.strategy,
1541 &mut engine,
1542 &tera_ctx,
1543 &source,
1544 &mut yuiignore,
1545 &mut report,
1546 )?;
1547 }
1548 Ok(())
1549 })();
1550 yuiignore.pop_dir(&source);
1551 walk_result?;
1552
1553 report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
1554
1555 print_status_table(&report, icons, color);
1556
1557 let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
1558
1559 println!();
1560 let total = report.len();
1561 let in_sync = total - drift;
1562 if drift == 0 {
1563 println!(" {total} entries · all in sync");
1564 Ok(())
1565 } else {
1566 println!(" {total} entries · {in_sync} in sync · {drift} diverged");
1567 anyhow::bail!("status: {drift} entries diverged from source")
1568 }
1569}
1570
1571#[derive(Debug)]
1572struct StatusItem {
1573 src: Utf8PathBuf,
1575 dst: Utf8PathBuf,
1577 state: StatusState,
1578}
1579
1580#[derive(Debug, Clone, Copy)]
1581enum StatusState {
1582 Link(absorb::AbsorbDecision),
1583 RenderDrift,
1586}
1587
1588impl StatusState {
1589 fn is_in_sync(self) -> bool {
1590 matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
1591 }
1592}
1593
1594#[allow(clippy::too_many_arguments)]
1595fn classify_walk(
1596 src_dir: &Utf8Path,
1597 dst_dir: &Utf8Path,
1598 config: &Config,
1599 strategy: MountStrategy,
1600 engine: &mut template::Engine,
1601 tera_ctx: &TeraContext,
1602 source_root: &Utf8Path,
1603 yuiignore: &mut paths::YuiIgnoreStack,
1604 report: &mut Vec<StatusItem>,
1605) -> Result<()> {
1606 classify_walk_inner(
1607 src_dir,
1608 dst_dir,
1609 config,
1610 strategy,
1611 engine,
1612 tera_ctx,
1613 source_root,
1614 yuiignore,
1615 report,
1616 false,
1617 )
1618}
1619
1620#[allow(clippy::too_many_arguments)]
1621fn classify_walk_inner(
1622 src_dir: &Utf8Path,
1623 dst_dir: &Utf8Path,
1624 config: &Config,
1625 strategy: MountStrategy,
1626 engine: &mut template::Engine,
1627 tera_ctx: &TeraContext,
1628 source_root: &Utf8Path,
1629 yuiignore: &mut paths::YuiIgnoreStack,
1630 report: &mut Vec<StatusItem>,
1631 parent_covered: bool,
1632) -> Result<()> {
1633 if yuiignore.is_ignored(src_dir, true) {
1634 return Ok(());
1635 }
1636 yuiignore.push_dir(src_dir)?;
1639 let result = classify_walk_inner_body(
1640 src_dir,
1641 dst_dir,
1642 config,
1643 strategy,
1644 engine,
1645 tera_ctx,
1646 source_root,
1647 yuiignore,
1648 report,
1649 parent_covered,
1650 );
1651 yuiignore.pop_dir(src_dir);
1652 result
1653}
1654
1655#[allow(clippy::too_many_arguments)]
1656fn classify_walk_inner_body(
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 let marker_filename = &config.mount.marker_filename;
1669 let mut covered = parent_covered;
1670
1671 if strategy == MountStrategy::Marker {
1672 match marker::read_spec(src_dir, marker_filename)? {
1673 None => {}
1674 Some(MarkerSpec::PassThrough) => {
1675 let decision = absorb::classify(src_dir, dst_dir)?;
1676 report.push(StatusItem {
1677 src: relative_for_display(source_root, src_dir),
1678 dst: dst_dir.to_path_buf(),
1679 state: StatusState::Link(decision),
1680 });
1681 covered = true;
1682 }
1683 Some(MarkerSpec::Explicit { links }) => {
1684 let mut emitted_dir_link = false;
1685 for link in &links {
1686 if let Some(when) = &link.when {
1687 if !template::eval_truthy(when, engine, tera_ctx)? {
1688 continue;
1689 }
1690 }
1691 let dst_str = engine.render(&link.dst, tera_ctx)?;
1692 let dst = paths::expand_tilde(dst_str.trim());
1693 if let Some(filename) = &link.src {
1694 let file_src = src_dir.join(filename);
1695 if !file_src.is_file() {
1696 anyhow::bail!(
1697 "marker at {src_dir}: [[link]] src={filename:?} \
1698 not found"
1699 );
1700 }
1701 let decision = absorb::classify(&file_src, &dst)?;
1702 report.push(StatusItem {
1703 src: relative_for_display(source_root, &file_src),
1704 dst,
1705 state: StatusState::Link(decision),
1706 });
1707 } else {
1708 let decision = absorb::classify(src_dir, &dst)?;
1709 report.push(StatusItem {
1710 src: relative_for_display(source_root, src_dir),
1711 dst,
1712 state: StatusState::Link(decision),
1713 });
1714 emitted_dir_link = true;
1715 }
1716 }
1717 if emitted_dir_link {
1718 covered = true;
1719 }
1720 }
1721 }
1722 }
1723
1724 for entry in std::fs::read_dir(src_dir)? {
1725 let entry = entry?;
1726 let name_os = entry.file_name();
1727 let Some(name) = name_os.to_str() else {
1728 continue;
1729 };
1730 if name == marker_filename || name.ends_with(".tera") {
1731 continue;
1732 }
1733 let src_path = src_dir.join(name);
1734 let dst_path = dst_dir.join(name);
1735 let ft = entry.file_type()?;
1736 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
1737 continue;
1738 }
1739 if ft.is_dir() {
1740 classify_walk_inner(
1741 &src_path,
1742 &dst_path,
1743 config,
1744 strategy,
1745 engine,
1746 tera_ctx,
1747 source_root,
1748 yuiignore,
1749 report,
1750 covered,
1751 )?;
1752 } else if ft.is_file() && !covered {
1753 let decision = absorb::classify(&src_path, &dst_path)?;
1754 report.push(StatusItem {
1755 src: relative_for_display(source_root, &src_path),
1756 dst: dst_path,
1757 state: StatusState::Link(decision),
1758 });
1759 }
1760 }
1761 Ok(())
1762}
1763
1764fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
1765 p.strip_prefix(source_root)
1766 .map(Utf8PathBuf::from)
1767 .unwrap_or_else(|_| p.to_path_buf())
1768}
1769
1770fn print_status_table(items: &[StatusItem], icons: Icons, color: bool) {
1771 let src_w = items
1772 .iter()
1773 .map(|i| i.src.as_str().chars().count())
1774 .max()
1775 .unwrap_or(0)
1776 .max("SRC".len());
1777 let dst_w = items
1778 .iter()
1779 .map(|i| i.dst.as_str().chars().count())
1780 .max()
1781 .unwrap_or(0)
1782 .max("DST".len());
1783 let state_label_w = items
1785 .iter()
1786 .map(|i| state_label(i.state).len())
1787 .max()
1788 .unwrap_or(0)
1789 .max("STATE".len() - 2); let state_w = state_label_w + 2; print_status_header(state_w, src_w, dst_w, color);
1793 let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
1794 if color {
1795 use owo_colors::OwoColorize as _;
1796 println!("{}", sep.dimmed());
1797 } else {
1798 println!("{sep}");
1799 }
1800 for item in items {
1801 print_status_row(item, icons, state_w, src_w, dst_w, color);
1802 }
1803}
1804
1805fn state_label(s: StatusState) -> &'static str {
1806 use absorb::AbsorbDecision::*;
1807 match s {
1808 StatusState::Link(InSync) => "in-sync",
1809 StatusState::Link(RelinkOnly) => "relink",
1810 StatusState::Link(AutoAbsorb) => "drift (auto)",
1811 StatusState::Link(NeedsConfirm) => "drift (anomaly)",
1812 StatusState::Link(Restore) => "missing",
1813 StatusState::RenderDrift => "render drift",
1814 }
1815}
1816
1817fn state_icon(s: StatusState, icons: Icons) -> &'static str {
1818 use absorb::AbsorbDecision::*;
1819 match s {
1820 StatusState::Link(InSync) => icons.ok,
1821 StatusState::Link(RelinkOnly) => icons.warn,
1822 StatusState::Link(AutoAbsorb) => icons.warn,
1823 StatusState::Link(NeedsConfirm) => icons.error,
1824 StatusState::Link(Restore) => icons.info,
1825 StatusState::RenderDrift => icons.error,
1826 }
1827}
1828
1829fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
1830 use owo_colors::OwoColorize as _;
1831 let line = format!(
1834 " {:<state_w$} {:<src_w$} {:<dst_w$}",
1835 "STATE", "SRC", "DST"
1836 );
1837 if color {
1838 println!("{}", line.bold());
1839 } else {
1840 println!("{line}");
1841 }
1842}
1843
1844fn render_status_separator(
1845 sep_ch: char,
1846 state_w: usize,
1847 src_w: usize,
1848 dst_w: usize,
1849 arrow: &str,
1850) -> String {
1851 let bar = |n: usize| sep_ch.to_string().repeat(n);
1852 format!(
1853 " {} {} {} {}",
1854 bar(state_w),
1855 bar(src_w),
1856 bar(arrow.chars().count()),
1857 bar(dst_w)
1858 )
1859}
1860
1861fn print_status_row(
1862 item: &StatusItem,
1863 icons: Icons,
1864 state_w: usize,
1865 src_w: usize,
1866 dst_w: usize,
1867 color: bool,
1868) {
1869 use owo_colors::OwoColorize as _;
1870 let icon = state_icon(item.state, icons);
1871 let label = state_label(item.state);
1872 let state_text = format!("{icon} {label}");
1873 let src_display = item.src.as_str().replace('\\', "/");
1874 let dst_display = item.dst.as_str().replace('\\', "/");
1875 let arrow = icons.arrow;
1876
1877 let cell_state = format!("{:<state_w$}", state_text);
1878 let cell_src = format!("{:<src_w$}", src_display);
1879 let cell_dst = format!("{:<dst_w$}", dst_display);
1880
1881 if !color {
1882 println!(" {cell_state} {cell_src} {arrow} {cell_dst}");
1883 return;
1884 }
1885
1886 use absorb::AbsorbDecision::*;
1887 let state_colored = match item.state {
1888 StatusState::Link(InSync) => cell_state.green().to_string(),
1889 StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
1890 cell_state.yellow().to_string()
1891 }
1892 StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
1893 StatusState::Link(Restore) => cell_state.cyan().to_string(),
1894 StatusState::RenderDrift => cell_state.red().to_string(),
1895 };
1896 let src_colored = cell_src.cyan().to_string();
1897 let arrow_colored = arrow.dimmed().to_string();
1898 let dst_colored = cell_dst.dimmed().to_string();
1899 println!(" {state_colored} {src_colored} {arrow_colored} {dst_colored}");
1900}
1901
1902pub fn absorb(
1916 source: Option<Utf8PathBuf>,
1917 target: Utf8PathBuf,
1918 dry_run: bool,
1919 yes: bool,
1920) -> Result<()> {
1921 let source = resolve_source(source)?;
1922 let target = absolutize(&target)?;
1923 let yui = YuiVars::detect(&source);
1924 let config = config::load(&source, &yui)?;
1925
1926 let mut engine = template::Engine::new();
1927 let tera_ctx = template::template_context(&yui, &config.vars);
1928
1929 let src_path = match find_source_for_target(&source, &config, &target, &mut engine, &tera_ctx)?
1930 {
1931 Some(s) => s,
1932 None => anyhow::bail!(
1933 "no mount entry / .yuilink override claims target {target}; \
1934 pass a path inside a known dst"
1935 ),
1936 };
1937
1938 info!("source for {target}: {src_path}");
1939
1940 print_absorb_diff(&src_path, &target);
1945
1946 if dry_run {
1947 info!("[dry-run] would absorb {target} → {src_path}");
1948 return Ok(());
1949 }
1950
1951 if !yes {
1952 use std::io::IsTerminal;
1953 if !std::io::stdin().is_terminal() {
1954 anyhow::bail!(
1955 "manual absorb refuses to run off-TTY without --yes \
1956 (would silently overwrite {src_path})"
1957 );
1958 }
1959 if !prompt_yes_no("absorb target into source?")? {
1960 warn!("manual absorb cancelled by user: {target}");
1961 return Ok(());
1962 }
1963 }
1964
1965 let backup_root = source.join(&config.backup.dir);
1966 let ctx = ApplyCtx {
1967 config: &config,
1968 source: &source,
1969 file_mode: resolve_file_mode(config.link.file_mode),
1970 dir_mode: resolve_dir_mode(config.link.dir_mode),
1971 backup_root: &backup_root,
1972 dry_run: false,
1973 sticky_anomaly: Cell::new(None),
1974 quit_requested: Cell::new(false),
1975 };
1976
1977 absorb_target_into_source(&src_path, &target, &ctx)
1980}
1981
1982fn print_absorb_diff(src: &Utf8Path, dst: &Utf8Path) {
1987 use owo_colors::OwoColorize as _;
1988 use std::io::IsTerminal;
1989
1990 let color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none();
1993
1994 eprintln!();
1995 if color {
1996 eprintln!(
1997 "{} {} {}",
1998 "── unified diff ──".bold(),
1999 "[-] src".red().bold(),
2000 "[+] dst".green().bold()
2001 );
2002 eprintln!(" {} {}", "[-] src:".red(), src);
2003 eprintln!(" {} {}", "[+] dst:".green(), dst);
2004 } else {
2005 eprintln!("── unified diff ── [-] src [+] dst");
2006 eprintln!(" [-] src: {src}");
2007 eprintln!(" [+] dst: {dst}");
2008 }
2009 eprintln!();
2010
2011 if src.is_dir() || dst.is_dir() {
2012 eprintln!("(directory absorb — content listing skipped)");
2013 eprintln!();
2014 return;
2015 }
2016 let src_content = match read_text_for_diff(src) {
2017 DiffSide::Text(s) => s,
2018 DiffSide::Binary => {
2019 eprintln!("(binary file or non-UTF-8 content — diff skipped)");
2020 eprintln!();
2021 return;
2022 }
2023 };
2024 let dst_content = match read_text_for_diff(dst) {
2025 DiffSide::Text(s) => s,
2026 DiffSide::Binary => {
2027 eprintln!("(binary file or non-UTF-8 content — diff skipped)");
2028 eprintln!();
2029 return;
2030 }
2031 };
2032
2033 let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
2034 for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
2038 let header = hunk.header().to_string();
2039 if color {
2040 eprintln!("{}", header.cyan());
2041 } else {
2042 eprintln!("{header}");
2043 }
2044 for change in hunk.iter_changes() {
2045 let line = change.value();
2046 let line = line.strip_suffix('\n').unwrap_or(line);
2047 match change.tag() {
2048 similar::ChangeTag::Delete => {
2049 if color {
2050 eprintln!("{} {}", "-".red().bold(), line.red());
2051 } else {
2052 eprintln!("- {line}");
2053 }
2054 }
2055 similar::ChangeTag::Insert => {
2056 if color {
2057 eprintln!("{} {}", "+".green().bold(), line.green());
2058 } else {
2059 eprintln!("+ {line}");
2060 }
2061 }
2062 similar::ChangeTag::Equal => {
2063 if color {
2064 eprintln!(" {}", line.dimmed());
2065 } else {
2066 eprintln!(" {line}");
2067 }
2068 }
2069 }
2070 }
2071 }
2072 eprintln!();
2073}
2074
2075fn prompt_yes_no(question: &str) -> Result<bool> {
2076 use std::io::Write as _;
2077 eprint!("{question} [y/N]: ");
2078 std::io::stderr().flush().ok();
2079 let mut input = String::new();
2080 std::io::stdin().read_line(&mut input)?;
2081 let answer = input.trim();
2082 Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
2083}
2084
2085fn find_source_for_target(
2089 source: &Utf8Path,
2090 config: &Config,
2091 target: &Utf8Path,
2092 engine: &mut template::Engine,
2093 tera_ctx: &TeraContext,
2094) -> Result<Option<Utf8PathBuf>> {
2095 for entry in &config.mount.entry {
2097 if let Some(when) = &entry.when {
2098 if !template::eval_truthy(when, engine, tera_ctx)? {
2099 continue;
2100 }
2101 }
2102 let dst_str = engine.render(&entry.dst, tera_ctx)?;
2103 let dst_root = paths::expand_tilde(dst_str.trim());
2104 if let Ok(rel) = target.strip_prefix(&dst_root) {
2105 let src_str = engine.render(entry.src.as_str(), tera_ctx)?;
2106 let candidate = paths::resolve_mount_src(source, src_str.trim()).join(rel);
2107 if paths::is_ignored_at(source, &candidate, candidate.is_dir())? {
2112 continue;
2113 }
2114 return Ok(Some(candidate));
2115 }
2116 }
2117
2118 let walker = paths::source_walker(source).build();
2124 let marker_filename = &config.mount.marker_filename;
2125 for ent in walker {
2126 let ent = match ent {
2127 Ok(e) => e,
2128 Err(_) => continue,
2129 };
2130 if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
2131 continue;
2132 }
2133 if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
2134 continue;
2135 }
2136 let dir = match ent.path().parent() {
2137 Some(d) => d,
2138 None => continue,
2139 };
2140 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
2141 Ok(p) => p,
2142 Err(_) => continue,
2143 };
2144 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
2145 Some(s) => s,
2146 None => continue,
2147 };
2148 let MarkerSpec::Explicit { links } = spec else {
2149 continue;
2150 };
2151 for link in &links {
2152 if let Some(when) = &link.when {
2153 if !template::eval_truthy(when, engine, tera_ctx)? {
2154 continue;
2155 }
2156 }
2157 let dst_str = engine.render(&link.dst, tera_ctx)?;
2158 let dst = paths::expand_tilde(dst_str.trim());
2159 if let Some(filename) = &link.src {
2166 let file_src = dir_utf8.join(filename);
2167 if !file_src.is_file() {
2168 anyhow::bail!(
2169 "marker at {dir_utf8}: [[link]] src={filename:?} \
2170 not found"
2171 );
2172 }
2173 if target == dst {
2174 return Ok(Some(file_src));
2175 }
2176 continue;
2177 }
2178 if target == dst {
2179 return Ok(Some(dir_utf8));
2180 }
2181 if let Ok(rel) = target.strip_prefix(&dst) {
2182 return Ok(Some(dir_utf8.join(rel)));
2183 }
2184 }
2185 }
2186
2187 Ok(None)
2188}
2189
2190pub fn doctor(
2191 source: Option<Utf8PathBuf>,
2192 icons_override: Option<IconsMode>,
2193 no_color: bool,
2194) -> Result<()> {
2195 use owo_colors::OwoColorize as _;
2196
2197 let resolved_source = resolve_source(source);
2202
2203 let yui = match &resolved_source {
2208 Ok(s) => YuiVars::detect(s),
2209 Err(_) => YuiVars::detect(Utf8Path::new(".")),
2210 };
2211
2212 let cfg_res = match &resolved_source {
2217 Ok(s) => Some(config::load(s, &yui)),
2218 Err(_) => None,
2219 };
2220 let cfg = cfg_res.as_ref().and_then(|r| r.as_ref().ok());
2221 let icons_mode = icons_override
2222 .or_else(|| cfg.map(|c| c.ui.icons))
2223 .unwrap_or_default();
2224 let icons = Icons::for_mode(icons_mode);
2225 let color = !no_color && supports_color_stdout();
2226
2227 let mut probes: Vec<Probe> = Vec::new();
2228
2229 probes.push(Probe::group("identity"));
2231 probes.push(Probe::ok("os/arch", format!("{} / {}", yui.os, yui.arch)));
2232 probes.push(Probe::ok("user@host", format!("{}@{}", yui.user, yui.host)));
2233
2234 probes.push(Probe::group("repo"));
2236 let mut have_source = false;
2237 match &resolved_source {
2238 Ok(s) => {
2239 have_source = true;
2240 probes.push(Probe::ok("source", s.to_string()));
2241 match cfg_res.as_ref().expect("cfg_res set when source is Ok") {
2242 Ok(c) => {
2243 probes.push(Probe::ok(
2244 "config",
2245 format!(
2246 "{} mount{} · {} hook{} · {} render rule{}",
2247 c.mount.entry.len(),
2248 plural(c.mount.entry.len()),
2249 c.hook.len(),
2250 plural(c.hook.len()),
2251 c.render.rule.len(),
2252 plural(c.render.rule.len()),
2253 ),
2254 ));
2255 }
2256 Err(e) => probes.push(Probe::error("config", format!("{e}"))),
2257 }
2258 match crate::git::is_clean(s) {
2262 Ok(true) => probes.push(Probe::ok("git", "clean")),
2263 Ok(false) => probes.push(Probe::warn(
2264 "git",
2265 "uncommitted changes — `[absorb] require_clean_git` will defer auto-absorb",
2266 )),
2267 Err(_) => probes.push(Probe::warn(
2268 "git",
2269 "no git repo (auto-absorb still works; commit history won't track drift)",
2270 )),
2271 }
2272 }
2273 Err(e) => {
2274 probes.push(Probe::error("source", format!("not found — {e}")));
2275 }
2276 }
2277
2278 probes.push(Probe::group("links"));
2280 if cfg!(windows) {
2281 probes.push(Probe::ok(
2282 "default mode",
2283 "files=hardlink, dirs=junction (no admin needed)",
2284 ));
2285 } else {
2286 probes.push(Probe::ok("default mode", "files=symlink, dirs=symlink"));
2287 }
2288
2289 if have_source {
2291 if let (Ok(s), Some(c)) = (&resolved_source, cfg) {
2292 probes.push(Probe::group("hooks"));
2293 if c.hook.is_empty() {
2294 probes.push(Probe::ok("hooks", "(none configured)"));
2295 } else {
2296 let mut missing = 0usize;
2297 for h in &c.hook {
2298 if !s.join(&h.script).is_file() {
2299 missing += 1;
2300 probes.push(Probe::error(
2301 format!("hook[{}]", h.name),
2302 format!("script not found at {}", h.script),
2303 ));
2304 }
2305 }
2306 if missing == 0 {
2307 probes.push(Probe::ok(
2308 "scripts",
2309 format!(
2310 "{} hook{} configured, all scripts present",
2311 c.hook.len(),
2312 plural(c.hook.len())
2313 ),
2314 ));
2315 }
2316 }
2317 }
2318 }
2319
2320 if let Some(home) = paths::home_dir() {
2322 let chezmoi_src = home.join(".local/share/chezmoi");
2323 if chezmoi_src.is_dir() {
2324 probes.push(Probe::group("chezmoi"));
2325 probes.push(Probe::warn(
2326 "legacy source",
2327 format!(
2328 "{chezmoi_src} still exists — yui doesn't use it, safe to archive once your migration has settled"
2329 ),
2330 ));
2331 }
2332 }
2333
2334 println!();
2336 if color {
2337 println!(" {}", "yui doctor".bold().underline());
2338 } else {
2339 println!(" yui doctor");
2340 }
2341 println!();
2342 for probe in &probes {
2343 probe.print(&icons, color);
2344 }
2345
2346 let errors = probes.iter().filter(|p| p.is_error()).count();
2347 let warns = probes.iter().filter(|p| p.is_warn()).count();
2348 let oks = probes.iter().filter(|p| p.is_ok()).count();
2349 println!();
2350 let summary = format!("{oks} ok · {warns} warn · {errors} error");
2351 if color {
2352 if errors > 0 {
2353 println!(" {}", summary.red().bold());
2354 } else if warns > 0 {
2355 println!(" {}", summary.yellow());
2356 } else {
2357 println!(" {}", summary.green());
2358 }
2359 } else {
2360 println!(" {summary}");
2361 }
2362
2363 if errors > 0 {
2364 anyhow::bail!("doctor: {errors} probe(s) failed");
2365 }
2366 Ok(())
2367}
2368
2369#[derive(Debug)]
2370enum Probe {
2371 Group(&'static str),
2373 Ok {
2374 label: String,
2375 detail: String,
2376 },
2377 Warn {
2378 label: String,
2379 detail: String,
2380 },
2381 Error {
2382 label: String,
2383 detail: String,
2384 },
2385}
2386
2387impl Probe {
2388 fn group(label: &'static str) -> Self {
2389 Self::Group(label)
2390 }
2391 fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
2392 Self::Ok {
2393 label: label.into(),
2394 detail: detail.into(),
2395 }
2396 }
2397 fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
2398 Self::Warn {
2399 label: label.into(),
2400 detail: detail.into(),
2401 }
2402 }
2403 fn error(label: impl Into<String>, detail: impl Into<String>) -> Self {
2404 Self::Error {
2405 label: label.into(),
2406 detail: detail.into(),
2407 }
2408 }
2409 fn is_ok(&self) -> bool {
2410 matches!(self, Self::Ok { .. })
2411 }
2412 fn is_warn(&self) -> bool {
2413 matches!(self, Self::Warn { .. })
2414 }
2415 fn is_error(&self) -> bool {
2416 matches!(self, Self::Error { .. })
2417 }
2418 fn print(&self, icons: &Icons, color: bool) {
2419 use owo_colors::OwoColorize as _;
2420 match self {
2421 Self::Group(name) => {
2422 println!();
2423 if color {
2424 println!(" {}", name.cyan().bold());
2425 } else {
2426 println!(" {name}");
2427 }
2428 }
2429 Self::Ok { label, detail } => {
2430 let icon = icons.ok;
2431 let padded = format!("{label:<14}");
2435 if color {
2436 println!(
2437 " {} {} {}",
2438 icon.green(),
2439 padded.bold(),
2440 detail.dimmed()
2441 );
2442 } else {
2443 println!(" {icon} {padded} {detail}");
2444 }
2445 }
2446 Self::Warn { label, detail } => {
2447 let icon = icons.warn;
2448 let padded = format!("{label:<14}");
2449 if color {
2450 println!(
2451 " {} {} {}",
2452 icon.yellow(),
2453 padded.bold().yellow(),
2454 detail
2455 );
2456 } else {
2457 println!(" {icon} {padded} {detail}");
2458 }
2459 }
2460 Self::Error { label, detail } => {
2461 let icon = icons.error;
2462 let padded = format!("{label:<14}");
2463 if color {
2464 println!(
2465 " {} {} {}",
2466 icon.red().bold(),
2467 padded.bold().red(),
2468 detail.red()
2469 );
2470 } else {
2471 println!(" {icon} {padded} {detail}");
2472 }
2473 }
2474 }
2475 }
2476}
2477
2478fn plural(n: usize) -> &'static str {
2479 if n == 1 { "" } else { "s" }
2480}
2481
2482pub fn gc_backup(
2502 source: Option<Utf8PathBuf>,
2503 older_than: Option<String>,
2504 dry_run: bool,
2505 icons_override: Option<IconsMode>,
2506 no_color: bool,
2507) -> Result<()> {
2508 let source = resolve_source(source)?;
2509 let yui = YuiVars::detect(&source);
2510 let config = config::load(&source, &yui)?;
2511 let backup_root = source.join(&config.backup.dir);
2512 let icons_mode = icons_override.unwrap_or(config.ui.icons);
2513 let icons = Icons::for_mode(icons_mode);
2514 let color = !no_color && supports_color_stdout();
2515
2516 if !backup_root.is_dir() {
2517 println!(" no backup tree at {backup_root}");
2518 return Ok(());
2519 }
2520
2521 let mut entries = walk_gc_backups(&backup_root)?;
2522 if entries.is_empty() {
2523 println!(" no yui-stamped backups under {backup_root}");
2524 return Ok(());
2525 }
2526 entries.sort_by_key(|e| e.ts);
2528 let now = jiff::Zoned::now();
2529
2530 match older_than {
2531 None => {
2532 let refs: Vec<&BackupEntry> = entries.iter().collect();
2533 print_gc_table(&refs, &backup_root, &now, icons, color);
2534 println!();
2535 println!(
2536 " {} entries · {} total — pass --older-than DUR (e.g. 30d) to delete",
2537 entries.len(),
2538 format_bytes(entries.iter().map(|e| e.size_bytes).sum())
2539 );
2540 Ok(())
2541 }
2542 Some(dur_str) => {
2543 let span = parse_human_duration(&dur_str)?;
2544 let cutoff = now
2545 .checked_sub(span)
2546 .map_err(|e| anyhow::anyhow!("invalid duration {dur_str:?}: {e}"))?;
2547 let cutoff_dt = cutoff.datetime();
2548
2549 let total_before: u64 = entries.iter().map(|e| e.size_bytes).sum();
2550 let to_delete: Vec<&BackupEntry> =
2551 entries.iter().filter(|e| e.ts < cutoff_dt).collect();
2552
2553 if to_delete.is_empty() {
2554 println!(
2555 " no backups older than {dur_str} (oldest: {})",
2556 format_age(entries[0].ts, &now)
2557 );
2558 return Ok(());
2559 }
2560
2561 print_gc_table(&to_delete, &backup_root, &now, icons, color);
2562 println!();
2563 let total_freed: u64 = to_delete.iter().map(|e| e.size_bytes).sum();
2564
2565 if dry_run {
2566 println!(
2567 " [dry-run] would remove {} of {} entries · would free {} of {}",
2568 to_delete.len(),
2569 entries.len(),
2570 format_bytes(total_freed),
2571 format_bytes(total_before),
2572 );
2573 return Ok(());
2574 }
2575
2576 for entry in &to_delete {
2577 match entry.kind {
2578 BackupKind::File => std::fs::remove_file(&entry.path)?,
2579 BackupKind::Dir => std::fs::remove_dir_all(&entry.path)?,
2580 }
2581 if let Some(parent) = entry.path.parent() {
2582 cleanup_empty_parents(parent, &backup_root);
2583 }
2584 }
2585 println!(
2586 " removed {} of {} entries · freed {} (was {}, now {})",
2587 to_delete.len(),
2588 entries.len(),
2589 format_bytes(total_freed),
2590 format_bytes(total_before),
2591 format_bytes(total_before - total_freed),
2592 );
2593 Ok(())
2594 }
2595 }
2596}
2597
2598#[derive(Debug)]
2599struct BackupEntry {
2600 path: Utf8PathBuf,
2601 ts: jiff::civil::DateTime,
2602 kind: BackupKind,
2603 size_bytes: u64,
2604}
2605
2606#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2607enum BackupKind {
2608 File,
2609 Dir,
2610}
2611
2612fn walk_gc_backups(root: &Utf8Path) -> Result<Vec<BackupEntry>> {
2617 let mut out = Vec::new();
2618 walk_gc_backups_rec(root, &mut out)?;
2619 Ok(out)
2620}
2621
2622fn walk_gc_backups_rec(dir: &Utf8Path, out: &mut Vec<BackupEntry>) -> Result<()> {
2623 for entry in std::fs::read_dir(dir)? {
2624 let entry = entry?;
2625 let name_os = entry.file_name();
2626 let Some(name) = name_os.to_str() else {
2627 continue;
2628 };
2629 let path = dir.join(name);
2630 let ft = entry.file_type()?;
2631 if ft.is_dir() {
2632 if let Some(ts) = parse_backup_suffix(name) {
2633 let size = dir_size(&path)?;
2634 out.push(BackupEntry {
2635 path,
2636 ts,
2637 kind: BackupKind::Dir,
2638 size_bytes: size,
2639 });
2640 } else {
2641 walk_gc_backups_rec(&path, out)?;
2642 }
2643 } else if ft.is_file() {
2644 if let Some(ts) = parse_backup_suffix(name) {
2647 let size = entry.metadata()?.len();
2648 out.push(BackupEntry {
2649 path,
2650 ts,
2651 kind: BackupKind::File,
2652 size_bytes: size,
2653 });
2654 }
2655 }
2656 }
2657 Ok(())
2658}
2659
2660fn dir_size(dir: &Utf8Path) -> Result<u64> {
2661 let mut total: u64 = 0;
2662 for entry in std::fs::read_dir(dir)? {
2663 let entry = entry?;
2664 let ft = entry.file_type()?;
2665 if ft.is_dir() {
2666 let p = match Utf8PathBuf::from_path_buf(entry.path()) {
2667 Ok(p) => p,
2668 Err(_) => continue,
2669 };
2670 total = total.saturating_add(dir_size(&p)?);
2671 } else if ft.is_file() {
2672 total = total.saturating_add(entry.metadata()?.len());
2673 }
2674 }
2675 Ok(total)
2676}
2677
2678fn cleanup_empty_parents(start: &Utf8Path, root: &Utf8Path) {
2682 let mut cur = start.to_path_buf();
2683 loop {
2684 if cur == *root {
2685 return;
2686 }
2687 if std::fs::remove_dir(&cur).is_err() {
2689 return;
2690 }
2691 match cur.parent() {
2692 Some(p) => cur = p.to_path_buf(),
2693 None => return,
2694 }
2695 }
2696}
2697
2698fn parse_backup_suffix(name: &str) -> Option<jiff::civil::DateTime> {
2704 if let Some(ts) = parse_ts_at_end(name) {
2705 return Some(ts);
2706 }
2707 if let Some((before, _ext)) = name.rsplit_once('.') {
2710 if let Some(ts) = parse_ts_at_end(before) {
2711 return Some(ts);
2712 }
2713 }
2714 None
2715}
2716
2717fn parse_ts_at_end(s: &str) -> Option<jiff::civil::DateTime> {
2718 if s.len() < 20 {
2720 return None;
2721 }
2722 let split_at = s.len() - 19;
2723 if s.as_bytes()[split_at] != b'_' {
2724 return None;
2725 }
2726 parse_ts(&s[split_at + 1..])
2727}
2728
2729fn parse_ts(s: &str) -> Option<jiff::civil::DateTime> {
2731 if s.len() != 18 || s.as_bytes()[8] != b'_' {
2732 return None;
2733 }
2734 for (i, &b) in s.as_bytes().iter().enumerate() {
2735 if i == 8 {
2736 continue;
2737 }
2738 if !b.is_ascii_digit() {
2739 return None;
2740 }
2741 }
2742 let year: i16 = s[0..4].parse().ok()?;
2743 let month: i8 = s[4..6].parse().ok()?;
2744 let day: i8 = s[6..8].parse().ok()?;
2745 let hour: i8 = s[9..11].parse().ok()?;
2746 let minute: i8 = s[11..13].parse().ok()?;
2747 let second: i8 = s[13..15].parse().ok()?;
2748 let ms: i32 = s[15..18].parse().ok()?;
2749 jiff::civil::DateTime::new(year, month, day, hour, minute, second, ms * 1_000_000).ok()
2750}
2751
2752fn parse_human_duration(s: &str) -> Result<jiff::Span> {
2761 let s = s.trim();
2762 let split = s
2763 .bytes()
2764 .position(|b| b.is_ascii_alphabetic())
2765 .ok_or_else(|| anyhow::anyhow!("invalid duration {s:?}: missing unit (e.g. 30d, 2w)"))?;
2766 let n: i64 = s[..split]
2767 .trim()
2768 .parse()
2769 .map_err(|_| anyhow::anyhow!("invalid duration {s:?}: bad leading number"))?;
2770 if n < 0 {
2771 anyhow::bail!("invalid duration {s:?}: negative durations don't make sense");
2772 }
2773 let unit = s[split..].to_ascii_lowercase();
2774 let span = match unit.as_str() {
2775 "y" | "yr" | "year" | "years" => jiff::Span::new().years(n),
2776 "mo" | "month" | "months" => jiff::Span::new().months(n),
2777 "w" | "wk" | "week" | "weeks" => jiff::Span::new().weeks(n),
2778 "d" | "day" | "days" => jiff::Span::new().days(n),
2779 "h" | "hr" | "hour" | "hours" => jiff::Span::new().hours(n),
2780 "m" | "min" | "minute" | "minutes" => jiff::Span::new().minutes(n),
2781 other => {
2782 anyhow::bail!(
2783 "invalid duration {s:?}: unknown unit {other:?} \
2784 (use y / mo / w / d / h / m)"
2785 )
2786 }
2787 };
2788 Ok(span)
2789}
2790
2791fn format_bytes(n: u64) -> String {
2792 const KIB: u64 = 1024;
2793 const MIB: u64 = KIB * 1024;
2794 const GIB: u64 = MIB * 1024;
2795 if n >= GIB {
2796 format!("{:.1} GiB", n as f64 / GIB as f64)
2797 } else if n >= MIB {
2798 format!("{:.1} MiB", n as f64 / MIB as f64)
2799 } else if n >= KIB {
2800 format!("{:.1} KiB", n as f64 / KIB as f64)
2801 } else {
2802 format!("{n} B")
2803 }
2804}
2805
2806fn format_age(ts: jiff::civil::DateTime, now: &jiff::Zoned) -> String {
2807 let Ok(ts_zoned) = ts.to_zoned(now.time_zone().clone()) else {
2808 return "?".into();
2809 };
2810 let secs = match (now - &ts_zoned).total(jiff::Unit::Second) {
2811 Ok(s) => s as i64,
2812 Err(_) => return "?".into(),
2813 };
2814 if secs < 0 {
2815 return "future".into();
2816 }
2817 if secs < 60 {
2818 format!("{secs}s")
2819 } else if secs < 3600 {
2820 format!("{}m", secs / 60)
2821 } else if secs < 86_400 {
2822 format!("{}h", secs / 3600)
2823 } else if secs < 86_400 * 30 {
2824 format!("{}d", secs / 86_400)
2825 } else if secs < 86_400 * 365 {
2826 format!("{}mo", secs / (86_400 * 30))
2827 } else {
2828 format!("{}y", secs / (86_400 * 365))
2829 }
2830}
2831
2832fn print_gc_table(
2839 entries: &[&BackupEntry],
2840 backup_root: &Utf8Path,
2841 now: &jiff::Zoned,
2842 _icons: Icons,
2843 color: bool,
2844) {
2845 use owo_colors::OwoColorize as _;
2846
2847 let rows: Vec<(String, String, String)> = entries
2848 .iter()
2849 .map(|e| {
2850 let rel = e
2851 .path
2852 .strip_prefix(backup_root)
2853 .map(Utf8PathBuf::from)
2854 .unwrap_or_else(|_| e.path.clone());
2855 let path_disp = match e.kind {
2856 BackupKind::Dir => format!("{rel}/"),
2857 BackupKind::File => rel.to_string(),
2858 };
2859 (format_age(e.ts, now), format_bytes(e.size_bytes), path_disp)
2860 })
2861 .collect();
2862
2863 let age_w = rows.iter().map(|r| r.0.len()).max().unwrap_or(3);
2864 let size_w = rows.iter().map(|r| r.1.len()).max().unwrap_or(4);
2865
2866 if color {
2867 println!(
2868 " {:<age_w$} {:>size_w$} {}",
2869 "AGE".dimmed(),
2870 "SIZE".dimmed(),
2871 "PATH".dimmed(),
2872 );
2873 } else {
2874 println!(" {:<age_w$} {:>size_w$} PATH", "AGE", "SIZE");
2875 }
2876 for (age, size, path) in &rows {
2877 if color {
2878 println!(
2879 " {:<age_w$} {:>size_w$} {}",
2880 age.yellow(),
2881 size,
2882 path.cyan(),
2883 );
2884 } else {
2885 println!(" {:<age_w$} {:>size_w$} {}", age, size, path);
2886 }
2887 }
2888}
2889
2890pub fn hooks_list(
2892 source: Option<Utf8PathBuf>,
2893 icons_override: Option<IconsMode>,
2894 no_color: bool,
2895) -> Result<()> {
2896 let source = resolve_source(source)?;
2897 let yui = YuiVars::detect(&source);
2898 let config = config::load(&source, &yui)?;
2899 let state = hook::State::load(&source)?;
2900
2901 let icons_mode = icons_override.unwrap_or(config.ui.icons);
2902 let icons = Icons::for_mode(icons_mode);
2903 let color = !no_color && supports_color_stdout();
2904
2905 if config.hook.is_empty() {
2906 println!("(no [[hook]] entries in config)");
2907 return Ok(());
2908 }
2909
2910 let mut engine = template::Engine::new();
2914 let tera_ctx = template::template_context(&yui, &config.vars);
2915 let rows: Vec<HookRow> = config
2916 .hook
2917 .iter()
2918 .map(|h| -> Result<HookRow> {
2919 let active = match &h.when {
2923 None => true,
2924 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
2925 };
2926 let last_run_at = state.hooks.get(&h.name).and_then(|s| s.last_run_at.clone());
2927 Ok(HookRow {
2928 name: h.name.clone(),
2929 phase: match h.phase {
2930 HookPhase::Pre => "pre",
2931 HookPhase::Post => "post",
2932 },
2933 when_run: match h.when_run {
2934 config::WhenRun::Once => "once",
2935 config::WhenRun::Onchange => "onchange",
2936 config::WhenRun::Every => "every",
2937 },
2938 last_run_at,
2939 when: h.when.clone(),
2940 active,
2941 })
2942 })
2943 .collect::<Result<Vec<_>>>()?;
2944
2945 print_hooks_table(&rows, icons, color);
2946
2947 let total = rows.len();
2948 let active = rows.iter().filter(|r| r.active).count();
2949 let inactive = total - active;
2950 let ran = rows.iter().filter(|r| r.last_run_at.is_some()).count();
2951 let never = total - ran;
2952 println!();
2953 println!(
2954 " {total} hooks · {active} active · {inactive} inactive · {ran} ran · {never} never run"
2955 );
2956
2957 Ok(())
2958}
2959
2960#[derive(Debug)]
2961struct HookRow {
2962 name: String,
2963 phase: &'static str,
2964 when_run: &'static str,
2965 last_run_at: Option<String>,
2966 when: Option<String>,
2967 active: bool,
2968}
2969
2970fn print_hooks_table(rows: &[HookRow], icons: Icons, color: bool) {
2971 use owo_colors::OwoColorize as _;
2972 use std::fmt::Write as _;
2973
2974 let name_w = rows
2975 .iter()
2976 .map(|r| r.name.chars().count())
2977 .max()
2978 .unwrap_or(0)
2979 .max("NAME".len());
2980 let phase_w = rows
2981 .iter()
2982 .map(|r| r.phase.len())
2983 .max()
2984 .unwrap_or(0)
2985 .max("PHASE".len());
2986 let when_run_w = rows
2987 .iter()
2988 .map(|r| r.when_run.len())
2989 .max()
2990 .unwrap_or(0)
2991 .max("WHEN_RUN".len());
2992 let last_w = rows
2993 .iter()
2994 .map(|r| {
2995 r.last_run_at
2996 .as_deref()
2997 .map(|s| s.chars().count())
2998 .unwrap_or("(never)".len())
2999 })
3000 .max()
3001 .unwrap_or(0)
3002 .max("LAST_RUN".len());
3003 let status_w = "STATUS".len();
3004
3005 let mut header = String::new();
3007 let _ = write!(
3008 &mut header,
3009 " {:<status_w$} {:<name_w$} {:<phase_w$} {:<when_run_w$} {:<last_w$} WHEN",
3010 "STATUS", "NAME", "PHASE", "WHEN_RUN", "LAST_RUN"
3011 );
3012 if color {
3013 println!("{}", header.bold());
3014 } else {
3015 println!("{header}");
3016 }
3017
3018 let bar = |n: usize| icons.sep.to_string().repeat(n);
3020 let sep = format!(
3021 " {} {} {} {} {} {}",
3022 bar(status_w),
3023 bar(name_w),
3024 bar(phase_w),
3025 bar(when_run_w),
3026 bar(last_w),
3027 bar("WHEN".len())
3028 );
3029 if color {
3030 println!("{}", sep.dimmed());
3031 } else {
3032 println!("{sep}");
3033 }
3034
3035 for r in rows {
3037 let (icon, ran) = match (r.active, r.last_run_at.is_some()) {
3042 (false, _) => (icons.inactive, false),
3043 (true, true) => (icons.active, true),
3044 (true, false) => (icons.info, false),
3045 };
3046 let last = r.last_run_at.as_deref().unwrap_or("(never)");
3047 let when_str = r
3048 .when
3049 .as_deref()
3050 .map(strip_braces)
3051 .unwrap_or_else(|| "(always)".to_string());
3052
3053 let cell_status = format!("{icon:<status_w$}");
3054 let cell_name = format!("{:<name_w$}", r.name);
3055 let cell_phase = format!("{:<phase_w$}", r.phase);
3056 let cell_when_run = format!("{:<when_run_w$}", r.when_run);
3057 let cell_last = format!("{last:<last_w$}");
3058
3059 if !color {
3060 println!(
3061 " {cell_status} {cell_name} {cell_phase} {cell_when_run} {cell_last} {when_str}"
3062 );
3063 continue;
3064 }
3065
3066 if !r.active {
3070 println!(
3071 " {} {} {} {} {} {}",
3072 cell_status.dimmed(),
3073 cell_name.dimmed(),
3074 cell_phase.dimmed(),
3075 cell_when_run.dimmed(),
3076 cell_last.dimmed(),
3077 when_str.dimmed()
3078 );
3079 } else if ran {
3080 println!(
3081 " {} {} {} {} {} {}",
3082 cell_status.green(),
3083 cell_name.cyan().bold(),
3084 cell_phase.dimmed(),
3085 cell_when_run.dimmed(),
3086 cell_last.green(),
3087 when_str.dimmed()
3088 );
3089 } else {
3090 println!(
3091 " {} {} {} {} {} {}",
3092 cell_status.yellow(),
3093 cell_name.cyan().bold(),
3094 cell_phase.dimmed(),
3095 cell_when_run.dimmed(),
3096 cell_last.yellow(),
3097 when_str.dimmed()
3098 );
3099 }
3100 }
3101}
3102
3103pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
3107 let source = resolve_source(source)?;
3108 let yui = YuiVars::detect(&source);
3109 let config = config::load(&source, &yui)?;
3110 let mut engine = template::Engine::new();
3111 let tera_ctx = template::template_context(&yui, &config.vars);
3112
3113 let targets: Vec<&config::HookConfig> = match &name {
3114 Some(want) => {
3115 let m = config
3116 .hook
3117 .iter()
3118 .find(|h| &h.name == want)
3119 .ok_or_else(|| {
3120 anyhow::anyhow!(
3121 "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
3122 )
3123 })?;
3124 vec![m]
3125 }
3126 None => config.hook.iter().collect(),
3127 };
3128
3129 let mut state = hook::State::load(&source)?;
3130 for h in targets {
3131 let outcome = hook::run_hook(
3132 h,
3133 &source,
3134 &yui,
3135 &config.vars,
3136 &mut engine,
3137 &tera_ctx,
3138 &mut state,
3139 false,
3140 force,
3141 )?;
3142 let label = match outcome {
3143 HookOutcome::Ran => "ran",
3144 HookOutcome::SkippedOnce => "skipped (once: already ran)",
3145 HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
3146 HookOutcome::SkippedWhenFalse => "skipped (when=false)",
3147 HookOutcome::DryRun => "would run (dry-run)",
3148 };
3149 info!("hook[{}]: {label}", h.name);
3150 if outcome == HookOutcome::Ran {
3151 state.save(&source)?;
3152 }
3153 }
3154 Ok(())
3155}
3156
3157#[allow(clippy::too_many_arguments)]
3162fn process_mount(
3163 m: &ResolvedMount,
3164 ctx: &ApplyCtx<'_>,
3165 engine: &mut template::Engine,
3166 tera_ctx: &TeraContext,
3167 yuiignore: &mut paths::YuiIgnoreStack,
3168) -> Result<()> {
3169 let src_root = m.src.clone();
3172 if !src_root.is_dir() {
3173 warn!("mount src missing: {src_root}");
3174 return Ok(());
3175 }
3176 walk_and_link(
3177 &src_root, &m.dst, ctx, m.strategy, engine, tera_ctx, yuiignore, false,
3178 )
3179}
3180
3181#[allow(clippy::too_many_arguments)]
3182fn walk_and_link(
3183 src_dir: &Utf8Path,
3184 dst_dir: &Utf8Path,
3185 ctx: &ApplyCtx<'_>,
3186 strategy: MountStrategy,
3187 engine: &mut template::Engine,
3188 tera_ctx: &TeraContext,
3189 yuiignore: &mut paths::YuiIgnoreStack,
3190 parent_covered: bool,
3191) -> Result<()> {
3192 if yuiignore.is_ignored(src_dir, true) {
3195 return Ok(());
3196 }
3197 yuiignore.push_dir(src_dir)?;
3200 let result = walk_and_link_body(
3201 src_dir,
3202 dst_dir,
3203 ctx,
3204 strategy,
3205 engine,
3206 tera_ctx,
3207 yuiignore,
3208 parent_covered,
3209 );
3210 yuiignore.pop_dir(src_dir);
3211 result
3212}
3213
3214#[allow(clippy::too_many_arguments)]
3215fn walk_and_link_body(
3216 src_dir: &Utf8Path,
3217 dst_dir: &Utf8Path,
3218 ctx: &ApplyCtx<'_>,
3219 strategy: MountStrategy,
3220 engine: &mut template::Engine,
3221 tera_ctx: &TeraContext,
3222 yuiignore: &mut paths::YuiIgnoreStack,
3223 parent_covered: bool,
3224) -> Result<()> {
3225 let marker_filename = &ctx.config.mount.marker_filename;
3226 let mut covered = parent_covered;
3227
3228 if strategy == MountStrategy::Marker {
3229 match marker::read_spec(src_dir, marker_filename)? {
3230 None => {} Some(MarkerSpec::PassThrough) => {
3232 link_dir_with_backup(src_dir, dst_dir, ctx)?;
3236 covered = true;
3237 }
3238 Some(MarkerSpec::Explicit { links }) => {
3239 let mut emitted_dir_link = false;
3240 let mut emitted_any = false;
3241 for link in &links {
3242 if let Some(when) = &link.when {
3245 if !template::eval_truthy(when, engine, tera_ctx)? {
3246 continue;
3247 }
3248 }
3249 let dst_str = engine.render(&link.dst, tera_ctx)?;
3250 let dst = paths::expand_tilde(dst_str.trim());
3251 if let Some(filename) = &link.src {
3252 let file_src = src_dir.join(filename);
3253 if !file_src.is_file() {
3254 anyhow::bail!(
3255 "marker at {src_dir}: [[link]] src={filename:?} \
3256 not found"
3257 );
3258 }
3259 link_file_with_backup(&file_src, &dst, ctx)?;
3260 } else {
3261 link_dir_with_backup(src_dir, &dst, ctx)?;
3262 emitted_dir_link = true;
3263 }
3264 emitted_any = true;
3265 }
3266 if !emitted_any {
3267 info!(
3272 "marker at {src_dir} had no active links \
3273 — falling back to defaults"
3274 );
3275 }
3276 if emitted_dir_link {
3277 covered = true;
3278 }
3279 }
3280 }
3281 }
3282
3283 for entry in std::fs::read_dir(src_dir)? {
3284 let entry = entry?;
3285 let name_os = entry.file_name();
3286 let Some(name) = name_os.to_str() else {
3287 continue;
3288 };
3289 if name == marker_filename {
3290 continue;
3291 }
3292 if name.ends_with(".tera") {
3293 continue;
3295 }
3296 let src_path = src_dir.join(name);
3297 let dst_path = dst_dir.join(name);
3298 let ft = entry.file_type()?;
3299
3300 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
3301 continue;
3302 }
3303
3304 if ft.is_dir() {
3305 walk_and_link(
3306 &src_path, &dst_path, ctx, strategy, engine, tera_ctx, yuiignore, covered,
3307 )?;
3308 } else if ft.is_file() {
3309 if !covered {
3315 link_file_with_backup(&src_path, &dst_path, ctx)?;
3316 }
3317 }
3318 }
3319 Ok(())
3320}
3321
3322fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3323 use absorb::AbsorbDecision::*;
3324
3325 if ctx.quit_requested.get() {
3326 return Ok(());
3327 }
3328
3329 let decision = absorb::classify(src, dst)?;
3330
3331 if ctx.dry_run {
3332 info!("[dry-run] {decision:?}: {src} → {dst}");
3333 return Ok(());
3334 }
3335
3336 match decision {
3337 InSync => {
3338 Ok(())
3340 }
3341 Restore => {
3342 info!("link: {src} → {dst}");
3343 link::link_file(src, dst, ctx.file_mode)?;
3344 Ok(())
3345 }
3346 RelinkOnly => {
3347 info!("relink: {src} → {dst}");
3350 link::unlink(dst)?;
3351 link::link_file(src, dst, ctx.file_mode)?;
3352 Ok(())
3353 }
3354 AutoAbsorb => {
3355 if !ctx.config.absorb.auto {
3358 return handle_anomaly(
3359 src,
3360 dst,
3361 ctx,
3362 "absorb.auto = false; treating divergence as anomaly",
3363 );
3364 }
3365 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
3366 return handle_anomaly(
3367 src,
3368 dst,
3369 ctx,
3370 "source repo is dirty; deferring auto-absorb",
3371 );
3372 }
3373 absorb_target_into_source(src, dst, ctx)
3374 }
3375 NeedsConfirm => handle_anomaly(
3376 src,
3377 dst,
3378 ctx,
3379 "anomaly: source equals/newer than target but content differs",
3380 ),
3381 }
3382}
3383
3384fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3388 info!("absorb: {dst} → {src}");
3389 backup_existing(src, ctx.backup_root, false)?;
3390 std::fs::copy(dst, src)?;
3391 link::unlink(dst)?;
3392 link::link_file(src, dst, ctx.file_mode)?;
3393 Ok(())
3394}
3395
3396fn overwrite_source_into_target(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3402 info!("overwrite: {src} → {dst}");
3403 backup_existing(dst, ctx.backup_root, false)?;
3404 link::unlink(dst)?;
3405 link::link_file(src, dst, ctx.file_mode)?;
3406 Ok(())
3407}
3408
3409fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
3415 use crate::config::AnomalyAction::*;
3416 match ctx.config.absorb.on_anomaly {
3417 Skip => {
3418 warn!("anomaly skip: {dst} ({reason})");
3419 Ok(())
3420 }
3421 Force => {
3422 warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
3423 absorb_target_into_source(src, dst, ctx)
3424 }
3425 Ask => match prompt_anomaly(ctx, src, dst, reason)? {
3426 AnomalyChoice::Absorb => absorb_target_into_source(src, dst, ctx),
3427 AnomalyChoice::Overwrite => overwrite_source_into_target(src, dst, ctx),
3428 AnomalyChoice::Skip => {
3429 warn!("anomaly skipped by user: {dst}");
3430 Ok(())
3431 }
3432 AnomalyChoice::Quit => {
3433 warn!("anomaly: user requested quit; stopping apply at {dst}");
3434 ctx.quit_requested.set(true);
3435 Ok(())
3436 }
3437 },
3438 }
3439}
3440
3441fn prompt_anomaly(
3457 ctx: &ApplyCtx<'_>,
3458 src: &Utf8Path,
3459 dst: &Utf8Path,
3460 reason: &str,
3461) -> Result<AnomalyChoice> {
3462 if ctx.quit_requested.get() {
3467 return Ok(AnomalyChoice::Quit);
3468 }
3469 if let Some(c) = ctx.sticky_anomaly.get() {
3470 return Ok(c);
3471 }
3472
3473 use std::io::IsTerminal;
3474 use std::io::Write as _;
3475 if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
3476 return Ok(AnomalyChoice::Skip);
3477 }
3478
3479 eprintln!();
3480 eprintln!("anomaly: {reason}");
3481 eprintln!(" src: {src}");
3482 eprintln!(" dst: {dst}");
3483 print_absorb_diff(src, dst);
3484
3485 loop {
3486 eprintln!(" [a/A] absorb target → source (this / all remaining)");
3487 eprintln!(" [o/O] overwrite source → target (this / all remaining)");
3488 eprintln!(" [s/S] skip leave as-is (this / all remaining)");
3489 eprintln!(" [d] diff re-show the diff");
3490 eprintln!(" [q] quit skip this and stop apply");
3491 eprint!("choice [s]: ");
3492 std::io::stderr().flush().ok();
3493
3494 let mut input = String::new();
3495 std::io::stdin().read_line(&mut input)?;
3496 let trimmed = input.trim();
3497 let choice = match trimmed {
3501 "" | "s" | "n" => AnomalyChoice::Skip,
3502 "a" | "y" => AnomalyChoice::Absorb,
3503 "o" => AnomalyChoice::Overwrite,
3504 "q" => AnomalyChoice::Quit,
3505 "A" => {
3506 ctx.sticky_anomaly.set(Some(AnomalyChoice::Absorb));
3507 AnomalyChoice::Absorb
3508 }
3509 "O" => {
3510 ctx.sticky_anomaly.set(Some(AnomalyChoice::Overwrite));
3511 AnomalyChoice::Overwrite
3512 }
3513 "S" => {
3514 ctx.sticky_anomaly.set(Some(AnomalyChoice::Skip));
3515 AnomalyChoice::Skip
3516 }
3517 "d" => {
3518 print_absorb_diff(src, dst);
3519 continue;
3520 }
3521 other => {
3522 eprintln!("unknown choice: {other:?}");
3523 continue;
3524 }
3525 };
3526 return Ok(choice);
3527 }
3528}
3529
3530fn source_repo_is_clean(source: &Utf8Path) -> bool {
3535 match crate::git::is_clean(source) {
3536 Ok(b) => b,
3537 Err(e) => {
3538 warn!("git clean check failed at {source}: {e} — treating as clean");
3539 true
3540 }
3541 }
3542}
3543
3544fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3545 use absorb::AbsorbDecision::*;
3546
3547 if ctx.quit_requested.get() {
3548 return Ok(());
3549 }
3550
3551 let decision = absorb::classify(src, dst)?;
3552
3553 if ctx.dry_run {
3554 info!("[dry-run] dir {decision:?}: {src} → {dst}");
3555 return Ok(());
3556 }
3557
3558 match decision {
3559 InSync => Ok(()),
3560 Restore => {
3561 info!("link dir: {src} → {dst}");
3562 link::link_dir(src, dst, ctx.dir_mode)?;
3563 Ok(())
3564 }
3565 RelinkOnly => {
3566 info!("relink dir: {src} → {dst}");
3571 remove_dir_link_or_real(dst)?;
3572 link::link_dir(src, dst, ctx.dir_mode)?;
3573 Ok(())
3574 }
3575 AutoAbsorb | NeedsConfirm => {
3576 if !ctx.config.absorb.auto {
3597 return handle_anomaly_dir(
3598 src,
3599 dst,
3600 ctx,
3601 "absorb.auto = false; treating divergence as anomaly",
3602 );
3603 }
3604 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
3605 return handle_anomaly_dir(
3606 src,
3607 dst,
3608 ctx,
3609 "source repo is dirty; deferring auto-absorb",
3610 );
3611 }
3612 absorb_target_dir_into_source(src, dst, ctx)
3613 }
3614 }
3615}
3616
3617fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
3627 if let Err(unlink_err) = link::unlink(dst) {
3628 let meta = std::fs::symlink_metadata(dst)
3629 .with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
3630 let ft = meta.file_type();
3631 if ft.is_dir() && !ft.is_symlink() {
3632 std::fs::remove_dir_all(dst).with_context(|| {
3633 format!(
3634 "remove_dir_all({dst}) after link::unlink failed: \
3635 {unlink_err}"
3636 )
3637 })?;
3638 } else {
3639 return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
3640 }
3641 }
3642 Ok(())
3643}
3644
3645fn merge_dir_target_into_source(
3655 target: &Utf8Path,
3656 source: &Utf8Path,
3657 ctx: &ApplyCtx<'_>,
3658) -> Result<()> {
3659 for entry in std::fs::read_dir(target)? {
3660 if ctx.quit_requested.get() {
3667 return Ok(());
3668 }
3669 let entry = entry?;
3670 let name_os = entry.file_name();
3671 let Some(name) = name_os.to_str() else {
3672 continue;
3673 };
3674 let target_path = target.join(name);
3675 let source_path = source.join(name);
3676 let ft = entry.file_type()?;
3677
3678 if ft.is_dir() && !ft.is_symlink() {
3679 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
3685 let sft = src_meta.file_type();
3686 if !sft.is_dir() || sft.is_symlink() {
3687 link::unlink(&source_path).with_context(|| {
3688 format!("remove conflicting source entry before dir merge: {source_path}")
3689 })?;
3690 }
3691 }
3692 if !source_path.exists() {
3693 std::fs::create_dir_all(&source_path).with_context(|| {
3694 format!("create_dir_all({source_path}) during target→source merge")
3695 })?;
3696 }
3697 merge_dir_target_into_source(&target_path, &source_path, ctx)?;
3698 } else if ft.is_file() {
3699 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
3703 let sft = src_meta.file_type();
3704 if sft.is_dir() && !sft.is_symlink() {
3705 remove_dir_link_or_real(&source_path).with_context(|| {
3706 format!("remove conflicting source dir before file merge: {source_path}")
3707 })?;
3708 } else if sft.is_symlink() {
3709 link::unlink(&source_path).with_context(|| {
3710 format!(
3711 "remove conflicting source symlink before file merge: {source_path}"
3712 )
3713 })?;
3714 }
3715 }
3716 if let Some(parent) = source_path.parent() {
3717 if !parent.exists() {
3718 std::fs::create_dir_all(parent)?;
3719 }
3720 }
3721 if source_path.is_file() {
3735 merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
3736 } else {
3737 std::fs::copy(&target_path, &source_path)
3738 .with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
3739 }
3740 } else {
3741 warn!(
3742 "merge: skipping non-regular entry {target_path} \
3743 (symlink / junction / special — content not copied)"
3744 );
3745 }
3746 }
3747 Ok(())
3748}
3749
3750fn merge_resolve_file_conflict(
3764 target_path: &Utf8Path,
3765 source_path: &Utf8Path,
3766 ctx: &ApplyCtx<'_>,
3767) -> Result<()> {
3768 use absorb::AbsorbDecision::*;
3769 let decision = absorb::classify(source_path, target_path)?;
3770 match decision {
3771 InSync | RelinkOnly => Ok(()),
3772 AutoAbsorb => {
3773 std::fs::copy(target_path, source_path).with_context(|| {
3774 format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
3775 })?;
3776 Ok(())
3777 }
3778 Restore => {
3779 unreachable!(
3786 "merge_resolve_file_conflict reached with both files present, \
3787 but classify returned Restore (target {target_path} / source {source_path})"
3788 )
3789 }
3790 NeedsConfirm => {
3791 use crate::config::AnomalyAction::*;
3792 match ctx.config.absorb.on_anomaly {
3793 Skip => {
3794 warn!(
3795 "merge anomaly skip: {target_path} (source-newer / content drift) \
3796 — keeping source version, target version dropped"
3797 );
3798 Ok(())
3799 }
3800 Force => {
3801 warn!(
3802 "merge anomaly force: {target_path} \
3803 (source-newer / content drift) — overwriting source"
3804 );
3805 std::fs::copy(target_path, source_path)?;
3806 Ok(())
3807 }
3808 Ask => {
3809 let choice = prompt_anomaly(
3810 ctx,
3811 source_path,
3812 target_path,
3813 "merge: file content differs and source is newer",
3814 )?;
3815 match choice {
3816 AnomalyChoice::Absorb => {
3817 std::fs::copy(target_path, source_path)?;
3818 Ok(())
3819 }
3820 AnomalyChoice::Overwrite => {
3821 backup_existing(target_path, ctx.backup_root, false)?;
3828 std::fs::copy(source_path, target_path)?;
3829 Ok(())
3830 }
3831 AnomalyChoice::Skip => {
3832 warn!("merge: kept source version by user choice: {source_path}");
3833 Ok(())
3834 }
3835 AnomalyChoice::Quit => {
3836 warn!("merge: user requested quit; stopping at {target_path}");
3837 ctx.quit_requested.set(true);
3838 Ok(())
3839 }
3840 }
3841 }
3842 }
3843 }
3844 }
3845}
3846
3847fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3854 info!("absorb dir: {dst} → {src}");
3855 backup_existing(src, ctx.backup_root, true)?;
3856 merge_dir_target_into_source(dst, src, ctx)?;
3857 if ctx.quit_requested.get() {
3864 warn!(
3865 "absorb dir interrupted by user quit: {dst} \
3866 — leaving target tree intact; source backup at {}",
3867 ctx.backup_root
3868 );
3869 return Ok(());
3870 }
3871 remove_dir_link_or_real(dst)?;
3874 link::link_dir(src, dst, ctx.dir_mode)?;
3875 Ok(())
3876}
3877
3878fn overwrite_source_dir_into_target(
3883 src: &Utf8Path,
3884 dst: &Utf8Path,
3885 ctx: &ApplyCtx<'_>,
3886) -> Result<()> {
3887 info!("overwrite dir: {src} → {dst}");
3888 backup_existing(dst, ctx.backup_root, true)?;
3889 remove_dir_link_or_real(dst)?;
3890 link::link_dir(src, dst, ctx.dir_mode)?;
3891 Ok(())
3892}
3893
3894fn handle_anomaly_dir(
3898 src: &Utf8Path,
3899 dst: &Utf8Path,
3900 ctx: &ApplyCtx<'_>,
3901 reason: &str,
3902) -> Result<()> {
3903 use crate::config::AnomalyAction::*;
3904 match ctx.config.absorb.on_anomaly {
3905 Skip => {
3906 warn!("anomaly skip dir: {dst} ({reason})");
3907 Ok(())
3908 }
3909 Force => {
3910 warn!(
3911 "anomaly force dir: {dst} ({reason}) \
3912 — absorbing target into source"
3913 );
3914 absorb_target_dir_into_source(src, dst, ctx)
3915 }
3916 Ask => match prompt_anomaly(ctx, src, dst, reason)? {
3917 AnomalyChoice::Absorb => absorb_target_dir_into_source(src, dst, ctx),
3918 AnomalyChoice::Overwrite => overwrite_source_dir_into_target(src, dst, ctx),
3919 AnomalyChoice::Skip => {
3920 warn!("anomaly skipped by user: {dst}");
3921 Ok(())
3922 }
3923 AnomalyChoice::Quit => {
3924 warn!("anomaly dir: user requested quit; stopping apply at {dst}");
3925 ctx.quit_requested.set(true);
3926 Ok(())
3927 }
3928 },
3929 }
3930}
3931
3932fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
3933 let abs_target = absolutize(target)?;
3934 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
3935 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
3936 info!("backup → {bp}");
3937 if is_dir {
3938 backup::backup_dir(target, &bp)?;
3939 } else {
3940 backup::backup_file(target, &bp)?;
3941 }
3942 Ok(())
3943}
3944
3945fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
3946 if let Some(s) = source {
3947 return absolutize(&s);
3948 }
3949 if let Ok(s) = std::env::var("YUI_SOURCE") {
3950 return absolutize(Utf8Path::new(&s));
3951 }
3952 let cwd = current_dir_utf8()?;
3953 for ancestor in cwd.ancestors() {
3954 if ancestor.join("config.toml").is_file() {
3955 return Ok(ancestor.to_path_buf());
3956 }
3957 }
3958 if let Some(home) = paths::home_dir() {
3959 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
3960 let p = home.join(c);
3961 if p.join("config.toml").is_file() {
3962 return Ok(p);
3963 }
3964 }
3965 }
3966 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
3967}
3968
3969fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
3970 let expanded = paths::expand_tilde(p.as_str());
3972 if expanded.is_absolute() {
3973 return Ok(expanded);
3974 }
3975 let cwd = current_dir_utf8()?;
3976 Ok(cwd.join(expanded))
3977}
3978
3979fn current_dir_utf8() -> Result<Utf8PathBuf> {
3980 let cwd = std::env::current_dir().context("getting cwd")?;
3981 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
3982}
3983
3984const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
3988
3989[vars]
3990# user-defined values; templates can reference these as {{ vars.foo }}
3991
3992# [link]
3993# file_mode = "auto" # auto | symlink | hardlink
3994# dir_mode = "auto" # auto | symlink | junction
3995
3996[mount]
3997default_strategy = "marker"
3998
3999[[mount.entry]]
4000src = "home"
4001# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
4002dst = "~"
4003
4004# [[mount.entry]]
4005# src = "appdata"
4006# dst = "{{ env(name='APPDATA') }}"
4007# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
4008# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
4009# when = "yui.os == 'windows'"
4010"#;
4011
4012const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
4013# .yui/bin/ is intentionally tracked — it holds your hook scripts.
4014/.yui/state.json
4015/.yui/state.json.tmp
4016/.yui/backup/
4017
4018# >>> yui rendered (auto-managed, do not edit) >>>
4019# <<< yui rendered (auto-managed) <<<
4020
4021# config.local.toml is per-machine; commit a config.local.example.toml instead.
4022config.local.toml
4023"#;
4024
4025#[cfg(test)]
4026mod tests {
4027 use super::*;
4028 use tempfile::TempDir;
4029
4030 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
4031 Utf8PathBuf::from_path_buf(p).unwrap()
4032 }
4033
4034 fn toml_path(p: &Utf8Path) -> String {
4036 p.as_str().replace('\\', "/")
4037 }
4038
4039 #[test]
4040 fn apply_links_a_raw_file() {
4041 let tmp = TempDir::new().unwrap();
4042 let source = utf8(tmp.path().join("dotfiles"));
4043 let target = utf8(tmp.path().join("target"));
4044 std::fs::create_dir_all(source.join("home")).unwrap();
4045 std::fs::create_dir_all(&target).unwrap();
4046 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4047
4048 let cfg = format!(
4049 r#"
4050[[mount.entry]]
4051src = "home"
4052dst = "{}"
4053"#,
4054 toml_path(&target)
4055 );
4056 std::fs::write(source.join("config.toml"), cfg).unwrap();
4057
4058 apply(Some(source), false).unwrap();
4059
4060 let linked = target.join(".bashrc");
4061 assert!(linked.exists(), "expected {linked} to exist");
4062 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
4063 }
4064
4065 #[test]
4066 fn apply_with_marker_links_whole_directory() {
4067 let tmp = TempDir::new().unwrap();
4068 let source = utf8(tmp.path().join("dotfiles"));
4069 let target = utf8(tmp.path().join("target"));
4070 let nvim_src = source.join("home/nvim");
4071 std::fs::create_dir_all(&nvim_src).unwrap();
4072 std::fs::create_dir_all(&target).unwrap();
4073 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
4074 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
4075 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
4076
4077 let cfg = format!(
4078 r#"
4079[[mount.entry]]
4080src = "home"
4081dst = "{}"
4082"#,
4083 toml_path(&target)
4084 );
4085 std::fs::write(source.join("config.toml"), cfg).unwrap();
4086
4087 apply(Some(source.clone()), false).unwrap();
4088
4089 let nvim_dst = target.join("nvim");
4090 assert!(nvim_dst.exists());
4091 assert_eq!(
4092 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
4093 "-- hi\n"
4094 );
4095 }
4099
4100 #[test]
4101 fn apply_dry_run_does_not_write() {
4102 let tmp = TempDir::new().unwrap();
4103 let source = utf8(tmp.path().join("dotfiles"));
4104 let target = utf8(tmp.path().join("target"));
4105 std::fs::create_dir_all(source.join("home")).unwrap();
4106 std::fs::create_dir_all(&target).unwrap();
4107 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
4108
4109 let cfg = format!(
4110 r#"
4111[[mount.entry]]
4112src = "home"
4113dst = "{}"
4114"#,
4115 toml_path(&target)
4116 );
4117 std::fs::write(source.join("config.toml"), cfg).unwrap();
4118
4119 apply(Some(source), true).unwrap();
4120
4121 assert!(!target.join(".bashrc").exists());
4122 }
4123
4124 #[test]
4125 fn apply_renders_templates_then_links_rendered_outputs() {
4126 let tmp = TempDir::new().unwrap();
4127 let source = utf8(tmp.path().join("dotfiles"));
4128 let target = utf8(tmp.path().join("target"));
4129 std::fs::create_dir_all(source.join("home")).unwrap();
4130 std::fs::create_dir_all(&target).unwrap();
4131 std::fs::write(
4132 source.join("home/.gitconfig.tera"),
4133 "[user]\n os = {{ yui.os }}\n",
4134 )
4135 .unwrap();
4136 std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
4137
4138 let cfg = format!(
4139 r#"
4140[[mount.entry]]
4141src = "home"
4142dst = "{}"
4143"#,
4144 toml_path(&target)
4145 );
4146 std::fs::write(source.join("config.toml"), cfg).unwrap();
4147
4148 apply(Some(source.clone()), false).unwrap();
4149
4150 assert!(target.join(".bashrc").exists());
4152 assert!(source.join("home/.gitconfig").exists());
4154 assert!(target.join(".gitconfig").exists());
4155 assert!(!target.join(".gitconfig.tera").exists());
4157 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
4159 assert!(linked.contains("os = "));
4160 }
4161
4162 #[test]
4163 fn apply_marker_override_links_to_custom_dst() {
4164 let tmp = TempDir::new().unwrap();
4165 let source = utf8(tmp.path().join("dotfiles"));
4166 let target_a = utf8(tmp.path().join("target_a"));
4167 let target_b = utf8(tmp.path().join("target_b"));
4168 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
4169 std::fs::create_dir_all(&target_a).unwrap();
4170 std::fs::create_dir_all(&target_b).unwrap();
4171 std::fs::write(
4172 source.join("home/.config/nvim/init.lua"),
4173 "-- nvim config\n",
4174 )
4175 .unwrap();
4176
4177 std::fs::write(
4180 source.join("home/.config/nvim/.yuilink"),
4181 format!(
4182 r#"
4183[[link]]
4184dst = "{}/nvim"
4185
4186[[link]]
4187dst = "{}/nvim"
4188when = "{{{{ yui.os == '{}' }}}}"
4189"#,
4190 toml_path(&target_a),
4191 toml_path(&target_b),
4192 std::env::consts::OS
4193 ),
4194 )
4195 .unwrap();
4196
4197 let parent_target = utf8(tmp.path().join("parent_target"));
4198 std::fs::create_dir_all(&parent_target).unwrap();
4199 let cfg = format!(
4200 r#"
4201[[mount.entry]]
4202src = "home"
4203dst = "{}"
4204"#,
4205 toml_path(&parent_target)
4206 );
4207 std::fs::write(source.join("config.toml"), cfg).unwrap();
4208
4209 apply(Some(source.clone()), false).unwrap();
4210
4211 assert!(
4213 target_a.join("nvim/init.lua").exists(),
4214 "target_a/nvim/init.lua should be reachable through the link"
4215 );
4216 assert!(
4217 target_b.join("nvim/init.lua").exists(),
4218 "target_b/nvim/init.lua should be reachable through the link"
4219 );
4220 assert!(
4223 !parent_target.join(".config/nvim").exists(),
4224 "parent mount should have skipped the marker-claimed sub-dir"
4225 );
4226 }
4227
4228 #[test]
4229 fn apply_marker_inactive_link_falls_through_to_default() {
4230 let tmp = TempDir::new().unwrap();
4235 let source = utf8(tmp.path().join("dotfiles"));
4236 let target_inactive = utf8(tmp.path().join("inactive"));
4237 let parent_target = utf8(tmp.path().join("parent"));
4238 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
4239 std::fs::create_dir_all(&parent_target).unwrap();
4240 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
4241
4242 std::fs::write(
4244 source.join("home/.config/nvim/.yuilink"),
4245 format!(
4246 r#"
4247[[link]]
4248dst = "{}/nvim"
4249when = "{{{{ yui.os == 'no-such-os' }}}}"
4250"#,
4251 toml_path(&target_inactive)
4252 ),
4253 )
4254 .unwrap();
4255
4256 let cfg = format!(
4257 r#"
4258[[mount.entry]]
4259src = "home"
4260dst = "{}"
4261"#,
4262 toml_path(&parent_target)
4263 );
4264 std::fs::write(source.join("config.toml"), cfg).unwrap();
4265
4266 apply(Some(source.clone()), false).unwrap();
4267
4268 assert!(!target_inactive.join("nvim").exists());
4270 assert!(parent_target.join(".config/nvim/init.lua").exists());
4273 }
4274
4275 #[test]
4276 fn list_shows_mount_entries_and_marker_overrides() {
4277 let tmp = TempDir::new().unwrap();
4278 let source = utf8(tmp.path().join("dotfiles"));
4279 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
4280 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
4281 std::fs::write(
4282 source.join("home/.config/nvim/.yuilink"),
4283 r#"
4284[[link]]
4285dst = "/custom/nvim"
4286"#,
4287 )
4288 .unwrap();
4289 std::fs::write(
4290 source.join("config.toml"),
4291 r#"
4292[[mount.entry]]
4293src = "home"
4294dst = "/h"
4295"#,
4296 )
4297 .unwrap();
4298
4299 list(Some(source), false, None, true).unwrap();
4302 }
4303
4304 #[test]
4305 fn status_reports_in_sync_after_apply() {
4306 let tmp = TempDir::new().unwrap();
4307 let source = utf8(tmp.path().join("dotfiles"));
4308 let target = utf8(tmp.path().join("target"));
4309 std::fs::create_dir_all(source.join("home")).unwrap();
4310 std::fs::create_dir_all(&target).unwrap();
4311 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4312 let cfg = format!(
4313 r#"
4314[[mount.entry]]
4315src = "home"
4316dst = "{}"
4317"#,
4318 toml_path(&target)
4319 );
4320 std::fs::write(source.join("config.toml"), cfg).unwrap();
4321 apply(Some(source.clone()), false).unwrap();
4323 status(Some(source), None, true).unwrap();
4325 }
4326
4327 #[test]
4328 fn status_reports_template_drift() {
4329 let tmp = TempDir::new().unwrap();
4330 let source = utf8(tmp.path().join("dotfiles"));
4331 let target = utf8(tmp.path().join("target"));
4332 std::fs::create_dir_all(source.join("home")).unwrap();
4333 std::fs::create_dir_all(&target).unwrap();
4334 std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
4337 std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
4338
4339 let cfg = format!(
4340 r#"
4341[[mount.entry]]
4342src = "home"
4343dst = "{}"
4344"#,
4345 toml_path(&target)
4346 );
4347 std::fs::write(source.join("config.toml"), cfg).unwrap();
4348
4349 let err = status(Some(source), None, true).unwrap_err();
4350 assert!(format!("{err}").contains("diverged"));
4351 }
4352
4353 #[test]
4354 fn status_fails_when_target_missing() {
4355 let tmp = TempDir::new().unwrap();
4356 let source = utf8(tmp.path().join("dotfiles"));
4357 let target = utf8(tmp.path().join("target"));
4358 std::fs::create_dir_all(source.join("home")).unwrap();
4359 std::fs::create_dir_all(&target).unwrap();
4360 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4361 let cfg = format!(
4362 r#"
4363[[mount.entry]]
4364src = "home"
4365dst = "{}"
4366"#,
4367 toml_path(&target)
4368 );
4369 std::fs::write(source.join("config.toml"), cfg).unwrap();
4370 let err = status(Some(source), None, true).unwrap_err();
4372 assert!(format!("{err}").contains("diverged"));
4373 }
4374
4375 #[test]
4376 fn strip_braces_removes_outer_template_braces() {
4377 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
4378 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
4379 assert_eq!(strip_braces(" {{x}} "), "x");
4380 }
4381
4382 #[test]
4383 fn apply_aborts_on_render_drift() {
4384 let tmp = TempDir::new().unwrap();
4385 let source = utf8(tmp.path().join("dotfiles"));
4386 let target = utf8(tmp.path().join("target"));
4387 std::fs::create_dir_all(source.join("home")).unwrap();
4388 std::fs::create_dir_all(&target).unwrap();
4389 std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
4390 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
4391
4392 let cfg = format!(
4393 r#"
4394[[mount.entry]]
4395src = "home"
4396dst = "{}"
4397"#,
4398 toml_path(&target)
4399 );
4400 std::fs::write(source.join("config.toml"), cfg).unwrap();
4401
4402 let err = apply(Some(source.clone()), false).unwrap_err();
4403 assert!(format!("{err}").contains("drift"));
4404 assert_eq!(
4406 std::fs::read_to_string(source.join("home/foo")).unwrap(),
4407 "manually edited"
4408 );
4409 assert!(!target.join("foo").exists());
4411 }
4412
4413 #[test]
4414 fn init_creates_skeleton_when_dir_empty() {
4415 let tmp = TempDir::new().unwrap();
4416 let dir = utf8(tmp.path().join("new_dotfiles"));
4417 init(Some(dir.clone()), false).unwrap();
4418 assert!(dir.join("config.toml").is_file());
4419 assert!(dir.join(".gitignore").is_file());
4420 }
4421
4422 #[test]
4423 fn init_refuses_to_overwrite_existing_config() {
4424 let tmp = TempDir::new().unwrap();
4425 let dir = utf8(tmp.path().join("dotfiles"));
4426 std::fs::create_dir_all(&dir).unwrap();
4427 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
4428 let err = init(Some(dir), false).unwrap_err();
4429 assert!(format!("{err}").contains("already exists"));
4430 }
4431
4432 #[test]
4438 fn init_appends_missing_gitignore_entries_into_existing_file() {
4439 let tmp = TempDir::new().unwrap();
4440 let dir = utf8(tmp.path().join("dotfiles"));
4441 std::fs::create_dir_all(&dir).unwrap();
4442 let user_gitignore = "# user entries\n*.swp\nnode_modules/\n";
4444 std::fs::write(dir.join(".gitignore"), user_gitignore).unwrap();
4445
4446 init(Some(dir.clone()), false).unwrap();
4447
4448 let body = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
4449 assert!(body.contains("*.swp"));
4451 assert!(body.contains("node_modules/"));
4452 assert!(body.contains("/.yui/state.json"));
4454 assert!(body.contains("/.yui/backup/"));
4455 assert!(body.contains("config.local.toml"));
4456 let before_rerun = body.clone();
4458 std::fs::remove_file(dir.join("config.toml")).unwrap();
4461 init(Some(dir.clone()), false).unwrap();
4462 let after_rerun = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
4463 assert_eq!(
4464 before_rerun, after_rerun,
4465 "init must be idempotent when the gitignore already has every yui entry"
4466 );
4467 }
4468
4469 #[test]
4475 fn init_with_git_hooks_installs_into_existing_repo() {
4476 let tmp = TempDir::new().unwrap();
4477 let dir = utf8(tmp.path().join("dotfiles"));
4478 std::fs::create_dir_all(&dir).unwrap();
4479 let st = std::process::Command::new("git")
4480 .args(["init", "-q"])
4481 .current_dir(dir.as_std_path())
4482 .status()
4483 .expect("git init");
4484 if !st.success() {
4485 return;
4486 }
4487 let user_config = "# user already wrote this\n";
4489 std::fs::write(dir.join("config.toml"), user_config).unwrap();
4490
4491 init(Some(dir.clone()), true).unwrap();
4493
4494 assert_eq!(
4495 std::fs::read_to_string(dir.join("config.toml")).unwrap(),
4496 user_config
4497 );
4498 assert!(dir.join(".git/hooks/pre-commit").is_file());
4499 assert!(dir.join(".git/hooks/pre-push").is_file());
4500 }
4501
4502 #[test]
4507 fn init_with_git_hooks_writes_pre_commit_and_pre_push() {
4508 let tmp = TempDir::new().unwrap();
4509 let dir = utf8(tmp.path().join("dotfiles"));
4510 std::fs::create_dir_all(&dir).unwrap();
4511 let st = std::process::Command::new("git")
4513 .args(["init", "-q"])
4514 .current_dir(dir.as_std_path())
4515 .status()
4516 .expect("git init");
4517 if !st.success() {
4518 eprintln!("skipping: git not available");
4520 return;
4521 }
4522 init(Some(dir.clone()), true).unwrap();
4523
4524 let pre_commit = dir.join(".git/hooks/pre-commit");
4525 let pre_push = dir.join(".git/hooks/pre-push");
4526 assert!(pre_commit.is_file(), "pre-commit hook should be written");
4527 assert!(pre_push.is_file(), "pre-push hook should be written");
4528
4529 let body = std::fs::read_to_string(&pre_commit).unwrap();
4530 assert!(
4531 body.contains("yui render --check"),
4532 "pre-commit hook should call `yui render --check`, got: {body}"
4533 );
4534 }
4535
4536 #[test]
4540 fn init_with_git_hooks_errors_outside_a_git_repo() {
4541 let tmp = TempDir::new().unwrap();
4542 let dir = utf8(tmp.path().join("not-a-repo"));
4543 std::fs::create_dir_all(&dir).unwrap();
4544 let err = init(Some(dir), true).unwrap_err();
4545 let msg = format!("{err:#}");
4546 assert!(
4547 msg.contains("git repo") || msg.contains("git rev-parse"),
4548 "expected error to mention the git issue, got: {msg}"
4549 );
4550 }
4551
4552 #[test]
4555 fn init_with_git_hooks_does_not_clobber_existing_hooks() {
4556 let tmp = TempDir::new().unwrap();
4557 let dir = utf8(tmp.path().join("dotfiles"));
4558 std::fs::create_dir_all(&dir).unwrap();
4559 let st = std::process::Command::new("git")
4560 .args(["init", "-q"])
4561 .current_dir(dir.as_std_path())
4562 .status()
4563 .expect("git init");
4564 if !st.success() {
4565 return;
4566 }
4567 let hooks = dir.join(".git/hooks");
4568 std::fs::create_dir_all(&hooks).unwrap();
4569 std::fs::write(hooks.join("pre-commit"), "#! /bin/sh\nexit 0\n").unwrap();
4570
4571 init(Some(dir.clone()), true).unwrap();
4572
4573 let pc = std::fs::read_to_string(hooks.join("pre-commit")).unwrap();
4575 assert!(
4576 !pc.contains("yui render --check"),
4577 "existing pre-commit must not be overwritten"
4578 );
4579 let pp = std::fs::read_to_string(hooks.join("pre-push")).unwrap();
4580 assert!(
4581 pp.contains("yui render --check"),
4582 "missing pre-push should be written: {pp}"
4583 );
4584 }
4585
4586 fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
4589 let source = utf8(tmp.path().join("dotfiles"));
4590 let target = utf8(tmp.path().join("target"));
4591 std::fs::create_dir_all(source.join("home")).unwrap();
4592 std::fs::create_dir_all(&target).unwrap();
4593 let cfg = format!(
4594 r#"
4595[[mount.entry]]
4596src = "home"
4597dst = "{}"
4598"#,
4599 toml_path(&target)
4600 );
4601 std::fs::write(source.join("config.toml"), cfg).unwrap();
4602 (source, target)
4603 }
4604
4605 fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
4606 std::fs::write(path, body).unwrap();
4607 let f = std::fs::OpenOptions::new()
4608 .write(true)
4609 .open(path)
4610 .expect("open writable");
4611 f.set_modified(when).expect("set_modified");
4612 }
4613
4614 #[test]
4615 fn apply_target_newer_absorbs_target_into_source() {
4616 let tmp = TempDir::new().unwrap();
4620 let (source, target) = setup_minimal_dotfiles(&tmp);
4621
4622 let now = std::time::SystemTime::now();
4623 let past = now - std::time::Duration::from_secs(120);
4624 write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
4625 write_with_mtime(&target.join(".bashrc"), "user's edit", now);
4627
4628 apply(Some(source.clone()), false).unwrap();
4629
4630 assert_eq!(
4632 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4633 "user's edit"
4634 );
4635 assert_eq!(
4637 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4638 "user's edit"
4639 );
4640 let backup_root = source.join(".yui/backup");
4642 let mut found_old = false;
4643 for entry in walkdir(&backup_root) {
4644 if let Ok(s) = std::fs::read_to_string(&entry) {
4645 if s == "default from repo" {
4646 found_old = true;
4647 break;
4648 }
4649 }
4650 }
4651 assert!(found_old, "expected backup containing 'default from repo'");
4652 }
4653
4654 #[test]
4655 fn apply_in_sync_target_is_a_no_op() {
4656 let tmp = TempDir::new().unwrap();
4659 let (source, target) = setup_minimal_dotfiles(&tmp);
4660 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4661 apply(Some(source.clone()), false).unwrap();
4662 let backup_root = source.join(".yui/backup");
4663 let backup_count_after_first = walkdir(&backup_root).len();
4664
4665 apply(Some(source.clone()), false).unwrap();
4667 assert_eq!(
4668 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4669 "echo hi\n"
4670 );
4671 let backup_count_after_second = walkdir(&backup_root).len();
4672 assert_eq!(
4673 backup_count_after_first, backup_count_after_second,
4674 "second apply on an in-sync tree should not produce backups"
4675 );
4676 }
4677
4678 #[test]
4679 fn apply_skip_policy_leaves_anomaly_alone() {
4680 let tmp = TempDir::new().unwrap();
4683 let source = utf8(tmp.path().join("dotfiles"));
4684 let target = utf8(tmp.path().join("target"));
4685 std::fs::create_dir_all(source.join("home")).unwrap();
4686 std::fs::create_dir_all(&target).unwrap();
4687 let cfg = format!(
4688 r#"
4689[absorb]
4690on_anomaly = "skip"
4691
4692[[mount.entry]]
4693src = "home"
4694dst = "{}"
4695"#,
4696 toml_path(&target)
4697 );
4698 std::fs::write(source.join("config.toml"), cfg).unwrap();
4699
4700 let now = std::time::SystemTime::now();
4701 let past = now - std::time::Duration::from_secs(120);
4702 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4703 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4704
4705 apply(Some(source.clone()), false).unwrap();
4706
4707 assert_eq!(
4709 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4710 "user's edit (older)"
4711 );
4712 assert_eq!(
4714 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4715 "fresh from upstream"
4716 );
4717 }
4718
4719 #[test]
4720 fn apply_force_policy_absorbs_anomaly_anyway() {
4721 let tmp = TempDir::new().unwrap();
4723 let source = utf8(tmp.path().join("dotfiles"));
4724 let target = utf8(tmp.path().join("target"));
4725 std::fs::create_dir_all(source.join("home")).unwrap();
4726 std::fs::create_dir_all(&target).unwrap();
4727 let cfg = format!(
4728 r#"
4729[absorb]
4730on_anomaly = "force"
4731
4732[[mount.entry]]
4733src = "home"
4734dst = "{}"
4735"#,
4736 toml_path(&target)
4737 );
4738 std::fs::write(source.join("config.toml"), cfg).unwrap();
4739
4740 let now = std::time::SystemTime::now();
4741 let past = now - std::time::Duration::from_secs(120);
4742 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4743 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4744
4745 apply(Some(source.clone()), false).unwrap();
4746
4747 assert_eq!(
4749 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4750 "user's edit (older)"
4751 );
4752 assert_eq!(
4753 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4754 "user's edit (older)"
4755 );
4756 }
4757
4758 #[test]
4770 fn apply_absorbs_non_empty_target_dir_target_wins() {
4771 let tmp = TempDir::new().unwrap();
4772 let source = utf8(tmp.path().join("dotfiles"));
4773 let target = utf8(tmp.path().join("target"));
4774 std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
4775 std::fs::create_dir_all(target.join(".config/app")).unwrap();
4776 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4779 std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
4780 std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
4782 std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
4785 std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
4786
4787 let cfg = format!(
4788 r#"
4789[absorb]
4790on_anomaly = "force"
4791
4792[[mount.entry]]
4793src = "home"
4794dst = "{}"
4795"#,
4796 toml_path(&target)
4797 );
4798 std::fs::write(source.join("config.toml"), cfg).unwrap();
4799
4800 apply(Some(source.clone()), false).unwrap();
4802
4803 assert_eq!(
4805 std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
4806 "target side"
4807 );
4808 assert_eq!(
4810 std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
4811 "{}"
4812 );
4813 let backup_root = source.join(".yui/backup");
4816 let mut backup_files: Vec<String> = Vec::new();
4817 for entry in walkdir(&backup_root) {
4818 if let Some(n) = entry.file_name() {
4819 backup_files.push(n.to_string());
4820 }
4821 }
4822 assert!(
4823 backup_files.iter().any(|f| f == "config.toml"),
4824 "expected source's config.toml to land in the backup tree, got {backup_files:?}"
4825 );
4826 assert!(
4828 source.join("home/.config/app/source-only.toml").exists(),
4829 "source-only file should survive a target-wins merge"
4830 );
4831 assert!(
4833 source.join("home/.config/app/state.json").exists(),
4834 "target-only state.json should be merged into source"
4835 );
4836 }
4837
4838 #[test]
4844 fn marker_dir_absorbs_with_default_ask_policy() {
4845 let tmp = TempDir::new().unwrap();
4846 let source = utf8(tmp.path().join("dotfiles"));
4847 let target = utf8(tmp.path().join("target"));
4848 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4849 std::fs::create_dir_all(target.join(".config/gh")).unwrap();
4850 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4852 std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\n").unwrap();
4854
4855 let cfg = format!(
4859 r#"
4860[[mount.entry]]
4861src = "home"
4862dst = "{}"
4863"#,
4864 toml_path(&target)
4865 );
4866 std::fs::write(source.join("config.toml"), cfg).unwrap();
4867
4868 apply(Some(source.clone()), false).unwrap();
4872
4873 assert!(target.join(".config/gh/hosts.yml").exists());
4876 assert!(source.join("home/.config/gh/hosts.yml").exists());
4877 }
4878
4879 #[test]
4885 fn merge_handles_file_vs_dir_collisions_target_wins() {
4886 let tmp = TempDir::new().unwrap();
4887 let source = utf8(tmp.path().join("dotfiles"));
4888 let target = utf8(tmp.path().join("target"));
4889 std::fs::create_dir_all(source.join("home/.config/foo")).unwrap();
4890 std::fs::create_dir_all(target.join(".config")).unwrap();
4891 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4892
4893 std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
4895 std::fs::write(target.join(".config/foo"), "target file body").unwrap();
4896 std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
4898 std::fs::create_dir_all(target.join(".config/bar")).unwrap();
4899 std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
4900
4901 let cfg = format!(
4902 r#"
4903[absorb]
4904on_anomaly = "force"
4905
4906[[mount.entry]]
4907src = "home"
4908dst = "{}"
4909"#,
4910 toml_path(&target)
4911 );
4912 std::fs::write(source.join("config.toml"), cfg).unwrap();
4913 apply(Some(source.clone()), false).unwrap();
4914
4915 let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
4919 assert!(foo_meta.file_type().is_file(), "foo should be a file");
4920 assert_eq!(
4921 std::fs::read_to_string(target.join(".config/foo")).unwrap(),
4922 "target file body"
4923 );
4924 let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
4926 assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
4927 assert_eq!(
4928 std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
4929 "target nested"
4930 );
4931 }
4932
4933 #[test]
4937 fn merge_per_file_target_newer_auto_absorbs() {
4938 let tmp = TempDir::new().unwrap();
4939 let source = utf8(tmp.path().join("dotfiles"));
4940 let target = utf8(tmp.path().join("target"));
4941 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4942 std::fs::create_dir_all(target.join(".config")).unwrap();
4943 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4944
4945 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4947 write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
4948 std::fs::write(target.join(".config/app.toml"), "user's live edit").unwrap();
4949
4950 let cfg = format!(
4954 r#"
4955[[mount.entry]]
4956src = "home"
4957dst = "{}"
4958"#,
4959 toml_path(&target)
4960 );
4961 std::fs::write(source.join("config.toml"), cfg).unwrap();
4962 apply(Some(source.clone()), false).unwrap();
4963
4964 assert_eq!(
4966 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4967 "user's live edit"
4968 );
4969 }
4970
4971 #[test]
4977 fn merge_per_file_source_newer_skip_keeps_source() {
4978 let tmp = TempDir::new().unwrap();
4979 let source = utf8(tmp.path().join("dotfiles"));
4980 let target = utf8(tmp.path().join("target"));
4981 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4982 std::fs::create_dir_all(target.join(".config")).unwrap();
4983 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4984
4985 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4987 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
4988 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
4989
4990 let cfg = format!(
4991 r#"
4992[absorb]
4993on_anomaly = "skip"
4994
4995[[mount.entry]]
4996src = "home"
4997dst = "{}"
4998"#,
4999 toml_path(&target)
5000 );
5001 std::fs::write(source.join("config.toml"), cfg).unwrap();
5002 apply(Some(source.clone()), false).unwrap();
5003
5004 assert_eq!(
5007 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
5008 "fresh source"
5009 );
5010 }
5011
5012 #[test]
5015 fn merge_per_file_source_newer_force_overwrites_source() {
5016 let tmp = TempDir::new().unwrap();
5017 let source = utf8(tmp.path().join("dotfiles"));
5018 let target = utf8(tmp.path().join("target"));
5019 std::fs::create_dir_all(source.join("home/.config")).unwrap();
5020 std::fs::create_dir_all(target.join(".config")).unwrap();
5021 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
5022
5023 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
5024 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
5025 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
5026
5027 let cfg = format!(
5028 r#"
5029[absorb]
5030on_anomaly = "force"
5031
5032[[mount.entry]]
5033src = "home"
5034dst = "{}"
5035"#,
5036 toml_path(&target)
5037 );
5038 std::fs::write(source.join("config.toml"), cfg).unwrap();
5039 apply(Some(source.clone()), false).unwrap();
5040
5041 assert_eq!(
5043 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
5044 "old target"
5045 );
5046 }
5047
5048 #[test]
5053 fn merge_per_file_identical_content_is_noop() {
5054 let tmp = TempDir::new().unwrap();
5055 let source = utf8(tmp.path().join("dotfiles"));
5056 let target = utf8(tmp.path().join("target"));
5057 std::fs::create_dir_all(source.join("home/.config")).unwrap();
5058 std::fs::create_dir_all(target.join(".config")).unwrap();
5059 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
5060 std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
5061 std::fs::write(target.join(".config/app.toml"), "same").unwrap();
5062
5063 let cfg = format!(
5066 r#"
5067[[mount.entry]]
5068src = "home"
5069dst = "{}"
5070"#,
5071 toml_path(&target)
5072 );
5073 std::fs::write(source.join("config.toml"), cfg).unwrap();
5074 apply(Some(source.clone()), false).unwrap();
5075
5076 assert_eq!(
5077 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
5078 "same"
5079 );
5080 }
5081
5082 #[test]
5083 fn manual_absorb_command_pulls_target_into_source() {
5084 let tmp = TempDir::new().unwrap();
5086 let source = utf8(tmp.path().join("dotfiles"));
5087 let target = utf8(tmp.path().join("target"));
5088 std::fs::create_dir_all(source.join("home")).unwrap();
5089 std::fs::create_dir_all(&target).unwrap();
5090 let cfg = format!(
5092 r#"
5093[absorb]
5094on_anomaly = "skip"
5095
5096[[mount.entry]]
5097src = "home"
5098dst = "{}"
5099"#,
5100 toml_path(&target)
5101 );
5102 std::fs::write(source.join("config.toml"), cfg).unwrap();
5103 std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
5104 std::fs::write(source.join("home/.bashrc"), "default").unwrap();
5105
5106 absorb(
5109 Some(source.clone()),
5110 target.join(".bashrc"),
5111 false,
5112 true,
5113 )
5114 .unwrap();
5115
5116 assert_eq!(
5118 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
5119 "user picked this"
5120 );
5121 }
5122
5123 #[test]
5124 fn manual_absorb_errors_when_target_outside_known_mounts() {
5125 let tmp = TempDir::new().unwrap();
5126 let (source, _target) = setup_minimal_dotfiles(&tmp);
5127 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5128 let stranger = utf8(tmp.path().join("not-managed/foo"));
5129 std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
5130 std::fs::write(&stranger, "not yui's").unwrap();
5131 let err = absorb(Some(source), stranger, false, true).unwrap_err();
5132 assert!(format!("{err}").contains("no mount entry"));
5133 }
5134
5135 #[test]
5136 fn yuiignore_excludes_file_from_linking() {
5137 let tmp = TempDir::new().unwrap();
5138 let (source, target) = setup_minimal_dotfiles(&tmp);
5139 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
5140 std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
5141 std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
5143 apply(Some(source.clone()), false).unwrap();
5144 assert!(target.join(".bashrc").exists());
5145 assert!(
5146 !target.join("lock.json").exists(),
5147 "yuiignore should keep lock.json out of target"
5148 );
5149 }
5150
5151 #[test]
5152 fn yuiignore_excludes_directory_subtree() {
5153 let tmp = TempDir::new().unwrap();
5154 let (source, target) = setup_minimal_dotfiles(&tmp);
5155 std::fs::create_dir_all(source.join("home/cache")).unwrap();
5156 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
5157 std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
5158 std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
5159 std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
5161 apply(Some(source.clone()), false).unwrap();
5162 assert!(target.join(".bashrc").exists());
5163 assert!(
5164 !target.join("cache").exists(),
5165 "yuiignore'd subtree should not appear in target"
5166 );
5167 }
5168
5169 #[test]
5170 fn yuiignore_negation_re_includes_file() {
5171 let tmp = TempDir::new().unwrap();
5172 let (source, target) = setup_minimal_dotfiles(&tmp);
5173 std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
5174 std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
5175 std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
5177 apply(Some(source.clone()), false).unwrap();
5178 assert!(target.join("keep.cache").exists());
5179 assert!(!target.join("drop.cache").exists());
5180 }
5181
5182 #[test]
5187 fn nested_yuiignore_only_affects_its_subtree() {
5188 let tmp = TempDir::new().unwrap();
5189 let (source, target) = setup_minimal_dotfiles(&tmp);
5190 std::fs::create_dir_all(source.join("home/inner")).unwrap();
5191 std::fs::write(source.join("home/secret.txt"), "outer keep").unwrap();
5192 std::fs::write(source.join("home/inner/secret.txt"), "inner drop").unwrap();
5193 std::fs::write(source.join("home/inner/keep.txt"), "inner keep").unwrap();
5194 std::fs::write(source.join("home/inner/.yuiignore"), "secret*\n").unwrap();
5196 apply(Some(source.clone()), false).unwrap();
5197 assert!(
5198 target.join("secret.txt").exists(),
5199 "outer secret.txt is outside the nested .yuiignore scope"
5200 );
5201 assert!(target.join("inner/keep.txt").exists());
5202 assert!(
5203 !target.join("inner/secret.txt").exists(),
5204 "inner secret.txt should be excluded by the nested .yuiignore"
5205 );
5206 }
5207
5208 #[test]
5212 fn nested_yuiignore_negation_overrides_root_rule() {
5213 let tmp = TempDir::new().unwrap();
5214 let (source, target) = setup_minimal_dotfiles(&tmp);
5215 std::fs::create_dir_all(source.join("home/keepers")).unwrap();
5216 std::fs::write(source.join("home/drop.lock"), "outer drop").unwrap();
5217 std::fs::write(source.join("home/keepers/wanted.lock"), "inner keep").unwrap();
5218 std::fs::write(source.join(".yuiignore"), "*.lock\n").unwrap();
5219 std::fs::write(source.join("home/keepers/.yuiignore"), "!*.lock\n").unwrap();
5221 apply(Some(source.clone()), false).unwrap();
5222 assert!(
5223 !target.join("drop.lock").exists(),
5224 "root rule still drops outer .lock file"
5225 );
5226 assert!(
5227 target.join("keepers/wanted.lock").exists(),
5228 "nested negation re-includes .lock under keepers/"
5229 );
5230 }
5231
5232 #[test]
5236 fn nested_yuiignore_status_walk_scoped() {
5237 let tmp = TempDir::new().unwrap();
5238 let (source, _target) = setup_minimal_dotfiles(&tmp);
5239 std::fs::create_dir_all(source.join("home/a")).unwrap();
5240 std::fs::create_dir_all(source.join("home/b")).unwrap();
5241 std::fs::write(source.join("home/a/foo.txt"), "a-foo").unwrap();
5242 std::fs::write(source.join("home/b/foo.txt"), "b-foo").unwrap();
5243 std::fs::write(source.join("home/a/.yuiignore"), "foo.txt\n").unwrap();
5245 apply(Some(source.clone()), false).unwrap();
5246 let res = status(Some(source), None, true);
5248 assert!(res.is_ok() || matches!(&res, Err(e) if format!("{e}").contains("diverged")));
5249 }
5250
5251 #[test]
5252 fn yuiignore_skips_template_in_render() {
5253 let tmp = TempDir::new().unwrap();
5254 let source = utf8(tmp.path().join("dotfiles"));
5255 let target = utf8(tmp.path().join("target"));
5256 std::fs::create_dir_all(source.join("home")).unwrap();
5257 std::fs::create_dir_all(&target).unwrap();
5258 std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
5259 std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
5260 let cfg = format!(
5261 r#"
5262[[mount.entry]]
5263src = "home"
5264dst = "{}"
5265"#,
5266 toml_path(&target)
5267 );
5268 std::fs::write(source.join("config.toml"), cfg).unwrap();
5269 apply(Some(source.clone()), false).unwrap();
5270 assert!(!source.join("home/note").exists());
5272 assert!(!target.join("note").exists());
5273 assert!(!target.join("note.tera").exists());
5274 }
5275
5276 #[test]
5285 fn apply_decrypts_age_files_to_sibling_and_links() {
5286 let tmp = TempDir::new().unwrap();
5287 let source = utf8(tmp.path().join("dotfiles"));
5288 let target = utf8(tmp.path().join("target"));
5289 std::fs::create_dir_all(source.join("home/.ssh")).unwrap();
5290 std::fs::create_dir_all(&target).unwrap();
5291
5292 let identity_path = utf8(tmp.path().join("age.txt"));
5295 let (secret, public) = secret::generate_x25519_keypair();
5296 std::fs::write(&identity_path, format!("{secret}\n")).unwrap();
5297
5298 let recipient = secret::parse_x25519_recipient(&public).unwrap();
5300 let cipher = secret::encrypt_x25519(b"-- super secret key --\n", &[recipient]).unwrap();
5301 std::fs::write(source.join("home/.ssh/id_ed25519.age"), &cipher).unwrap();
5302
5303 let cfg = format!(
5305 r#"
5306[[mount.entry]]
5307src = "home"
5308dst = "{}"
5309
5310[secrets]
5311identity = "{}"
5312recipients = ["{}"]
5313"#,
5314 toml_path(&target),
5315 toml_path(&identity_path),
5316 public
5317 );
5318 std::fs::write(source.join("config.toml"), cfg).unwrap();
5319
5320 apply(Some(source.clone()), false).unwrap();
5321
5322 assert!(source.join("home/.ssh/id_ed25519").exists());
5324 let target_bytes = std::fs::read(target.join(".ssh/id_ed25519")).unwrap();
5326 assert_eq!(target_bytes, b"-- super secret key --\n");
5327 let gi = std::fs::read_to_string(source.join(".gitignore")).unwrap();
5329 assert!(
5330 gi.contains("home/.ssh/id_ed25519"),
5331 ".gitignore should list the decrypted plaintext sibling: {gi}"
5332 );
5333 }
5336
5337 #[test]
5342 fn apply_bails_on_secret_drift() {
5343 let tmp = TempDir::new().unwrap();
5344 let source = utf8(tmp.path().join("dotfiles"));
5345 let target = utf8(tmp.path().join("target"));
5346 std::fs::create_dir_all(source.join("home")).unwrap();
5347 std::fs::create_dir_all(&target).unwrap();
5348
5349 let identity_path = utf8(tmp.path().join("age.txt"));
5350 let (secret_key, public) = secret::generate_x25519_keypair();
5351 std::fs::write(&identity_path, format!("{secret_key}\n")).unwrap();
5352
5353 let recipient = secret::parse_x25519_recipient(&public).unwrap();
5354 let cipher = secret::encrypt_x25519(b"v1 content\n", &[recipient]).unwrap();
5355 std::fs::write(source.join("home/secret.age"), &cipher).unwrap();
5356 std::fs::write(source.join("home/secret"), "edited locally\n").unwrap();
5358
5359 let cfg = format!(
5360 r#"
5361[[mount.entry]]
5362src = "home"
5363dst = "{}"
5364
5365[secrets]
5366identity = "{}"
5367recipients = ["{}"]
5368"#,
5369 toml_path(&target),
5370 toml_path(&identity_path),
5371 public
5372 );
5373 std::fs::write(source.join("config.toml"), cfg).unwrap();
5374
5375 let err = apply(Some(source.clone()), false).unwrap_err();
5376 assert!(
5377 format!("{err:#}").contains("secret drift"),
5378 "expected secret drift error, got: {err:#}"
5379 );
5380 }
5381
5382 #[test]
5385 fn append_recipient_creates_secrets_table_when_missing() {
5386 let result =
5387 append_recipient_to_config("", "host alice", "age1abcrecipientpublickey").unwrap();
5388 let parsed: toml::Table = toml::from_str(&result).unwrap();
5390 let secrets = parsed.get("secrets").and_then(|v| v.as_table()).unwrap();
5391 let recipients = secrets
5392 .get("recipients")
5393 .and_then(|v| v.as_array())
5394 .unwrap();
5395 assert_eq!(recipients.len(), 1);
5396 assert_eq!(recipients[0].as_str(), Some("age1abcrecipientpublickey"));
5397 }
5398
5399 #[test]
5400 fn append_recipient_preserves_existing_other_tables() {
5401 let existing = r#"
5405[vars]
5406greet = "hi"
5407
5408[secrets]
5409recipients = ["age1machine_a"]
5410
5411[ui]
5412icons = "ascii"
5413"#;
5414 let result = append_recipient_to_config(existing, "host b", "age1machine_b").unwrap();
5415 let parsed: toml::Table = toml::from_str(&result).unwrap();
5416 assert!(parsed.get("vars").is_some());
5418 assert!(parsed.get("secrets").is_some());
5419 assert!(parsed.get("ui").is_some());
5420 let recipients = parsed["secrets"]["recipients"].as_array().unwrap();
5422 assert_eq!(recipients.len(), 2);
5423 let pubs: Vec<&str> = recipients.iter().filter_map(|v| v.as_str()).collect();
5424 assert!(pubs.contains(&"age1machine_a"));
5425 assert!(pubs.contains(&"age1machine_b"));
5426 }
5427
5428 #[test]
5429 fn append_recipient_is_idempotent_on_duplicate() {
5430 let existing = r#"[secrets]
5431recipients = ["age1same"]
5432"#;
5433 let result = append_recipient_to_config(existing, "anyone", "age1same").unwrap();
5434 let parsed: toml::Table = toml::from_str(&result).unwrap();
5435 let recipients = parsed["secrets"]["recipients"].as_array().unwrap();
5436 assert_eq!(recipients.len(), 1, "duplicate must not be appended twice");
5437 }
5438
5439 #[test]
5440 fn append_recipient_creates_recipients_array_when_secrets_table_empty() {
5441 let existing = r#"[secrets]
5444identity = "~/.config/yui/age.txt"
5445"#;
5446 let result = append_recipient_to_config(existing, "h", "age1new").unwrap();
5447 let parsed: toml::Table = toml::from_str(&result).unwrap();
5448 let secrets = parsed["secrets"].as_table().unwrap();
5449 assert_eq!(
5450 secrets["identity"].as_str(),
5451 Some("~/.config/yui/age.txt"),
5452 "existing identity field must survive"
5453 );
5454 let recipients = secrets["recipients"].as_array().unwrap();
5455 assert_eq!(recipients.len(), 1);
5456 assert_eq!(recipients[0].as_str(), Some("age1new"));
5457 }
5458
5459 #[test]
5463 fn apply_without_recipients_skips_secret_walker() {
5464 let tmp = TempDir::new().unwrap();
5465 let (source, _target) = setup_minimal_dotfiles(&tmp);
5466 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5468 std::fs::write(source.join("home/some.junk.age"), b"not actually a cipher").unwrap();
5472 apply(Some(source.clone()), false).unwrap();
5473 }
5474
5475 #[test]
5479 fn nested_marker_accumulates_extra_dst() {
5480 let tmp = TempDir::new().unwrap();
5481 let source = utf8(tmp.path().join("dotfiles"));
5482 let parent_target = utf8(tmp.path().join("home"));
5483 let extra_target = utf8(tmp.path().join("extra"));
5484 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
5485 std::fs::create_dir_all(&parent_target).unwrap();
5486 std::fs::create_dir_all(&extra_target).unwrap();
5487 std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
5488
5489 std::fs::write(
5491 source.join("home/.config/.yuilink"),
5492 format!(
5493 r#"
5494[[link]]
5495dst = "{}/.config"
5496"#,
5497 toml_path(&parent_target)
5498 ),
5499 )
5500 .unwrap();
5501 std::fs::write(
5504 source.join("home/.config/nvim/.yuilink"),
5505 format!(
5506 r#"
5507[[link]]
5508dst = "{}/nvim"
5509when = "{{{{ yui.os == '{}' }}}}"
5510"#,
5511 toml_path(&extra_target),
5512 std::env::consts::OS
5513 ),
5514 )
5515 .unwrap();
5516
5517 let cfg = format!(
5518 r#"
5519[[mount.entry]]
5520src = "home"
5521dst = "{}"
5522"#,
5523 toml_path(&parent_target)
5524 );
5525 std::fs::write(source.join("config.toml"), cfg).unwrap();
5526
5527 apply(Some(source.clone()), false).unwrap();
5528
5529 assert!(parent_target.join(".config/nvim/init.lua").exists());
5532 assert!(extra_target.join("nvim/init.lua").exists());
5533 }
5534
5535 #[test]
5540 fn marker_file_link_targets_specific_file() {
5541 let tmp = TempDir::new().unwrap();
5542 let source = utf8(tmp.path().join("dotfiles"));
5543 let parent_target = utf8(tmp.path().join("home"));
5544 let docs_target = utf8(tmp.path().join("docs"));
5545 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
5546 std::fs::create_dir_all(&parent_target).unwrap();
5547 std::fs::create_dir_all(&docs_target).unwrap();
5548 std::fs::write(
5549 source.join("home/.config/powershell/profile.ps1"),
5550 "# profile\n",
5551 )
5552 .unwrap();
5553 std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
5554
5555 std::fs::write(
5558 source.join("home/.config/powershell/.yuilink"),
5559 format!(
5560 r#"
5561[[link]]
5562src = "profile.ps1"
5563dst = "{}/Microsoft.PowerShell_profile.ps1"
5564"#,
5565 toml_path(&docs_target)
5566 ),
5567 )
5568 .unwrap();
5569
5570 let cfg = format!(
5571 r#"
5572[[mount.entry]]
5573src = "home"
5574dst = "{}"
5575"#,
5576 toml_path(&parent_target)
5577 );
5578 std::fs::write(source.join("config.toml"), cfg).unwrap();
5579
5580 apply(Some(source.clone()), false).unwrap();
5581
5582 assert!(
5584 docs_target
5585 .join("Microsoft.PowerShell_profile.ps1")
5586 .exists()
5587 );
5588 assert!(
5591 parent_target
5592 .join(".config/powershell/profile.ps1")
5593 .exists()
5594 );
5595 assert!(parent_target.join(".config/powershell/extra.txt").exists());
5596 }
5597
5598 #[test]
5601 fn marker_file_link_missing_src_errors() {
5602 let tmp = TempDir::new().unwrap();
5603 let source = utf8(tmp.path().join("dotfiles"));
5604 let parent_target = utf8(tmp.path().join("home"));
5605 let docs_target = utf8(tmp.path().join("docs"));
5606 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
5607 std::fs::create_dir_all(&parent_target).unwrap();
5608 std::fs::create_dir_all(&docs_target).unwrap();
5609
5610 std::fs::write(
5611 source.join("home/.config/powershell/.yuilink"),
5612 format!(
5613 r#"
5614[[link]]
5615src = "missing.ps1"
5616dst = "{}/profile.ps1"
5617"#,
5618 toml_path(&docs_target)
5619 ),
5620 )
5621 .unwrap();
5622
5623 let cfg = format!(
5624 r#"
5625[[mount.entry]]
5626src = "home"
5627dst = "{}"
5628"#,
5629 toml_path(&parent_target)
5630 );
5631 std::fs::write(source.join("config.toml"), cfg).unwrap();
5632
5633 let err = apply(Some(source.clone()), false).unwrap_err();
5634 assert!(format!("{err:#}").contains("missing.ps1"));
5635 }
5636
5637 #[test]
5646 fn unmanaged_finds_files_outside_any_mount() {
5647 let tmp = TempDir::new().unwrap();
5648 let (source, _target) = setup_minimal_dotfiles(&tmp);
5649 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5651 std::fs::write(source.join("orphan.txt"), "y").unwrap();
5653 std::fs::create_dir_all(source.join("notes")).unwrap();
5654 std::fs::write(source.join("notes/scratch.md"), "z").unwrap();
5655
5656 unmanaged(Some(source.clone()), None, true).unwrap();
5658
5659 let yui = YuiVars::detect(&source);
5661 let cfg = config::load(&source, &yui).unwrap();
5662 let mount_srcs: Vec<Utf8PathBuf> = cfg
5663 .mount
5664 .entry
5665 .iter()
5666 .map(|m| source.join(&m.src))
5667 .collect();
5668 let walker = paths::source_walker(&source).build();
5669 let mut unmanaged_paths = Vec::new();
5670 for entry in walker.flatten() {
5671 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
5672 continue;
5673 }
5674 let p = match Utf8PathBuf::from_path_buf(entry.path().to_path_buf()) {
5675 Ok(p) => p,
5676 Err(_) => continue,
5677 };
5678 if is_repo_meta(&p, &source, &cfg.mount.marker_filename) {
5679 continue;
5680 }
5681 if mount_srcs.iter().any(|m| p.starts_with(m)) {
5682 continue;
5683 }
5684 unmanaged_paths.push(p);
5685 }
5686 let names: Vec<String> = unmanaged_paths
5687 .iter()
5688 .filter_map(|p| p.file_name().map(String::from))
5689 .collect();
5690 assert!(names.contains(&"orphan.txt".into()));
5691 assert!(names.contains(&"scratch.md".into()));
5692 assert!(!names.contains(&".bashrc".into()), "mount-claimed file");
5693 assert!(!names.contains(&"config.toml".into()), "repo meta");
5694 }
5695
5696 #[test]
5697 fn is_repo_meta_recognises_yui_scaffold() {
5698 let source = Utf8Path::new("/dot");
5699 assert!(is_repo_meta(
5701 Utf8Path::new("/dot/config.toml"),
5702 source,
5703 ".yuilink",
5704 ));
5705 assert!(is_repo_meta(
5706 Utf8Path::new("/dot/config.local.toml"),
5707 source,
5708 ".yuilink",
5709 ));
5710 assert!(is_repo_meta(
5711 Utf8Path::new("/dot/config.linux.toml"),
5712 source,
5713 ".yuilink",
5714 ));
5715 assert!(is_repo_meta(
5716 Utf8Path::new("/dot/config.local.example.toml"),
5717 source,
5718 ".yuilink",
5719 ));
5720 assert!(is_repo_meta(
5722 Utf8Path::new("/dot/.gitignore"),
5723 source,
5724 ".yuilink",
5725 ));
5726 assert!(is_repo_meta(
5728 Utf8Path::new("/dot/home/.config/foo/.yuilink"),
5729 source,
5730 ".yuilink",
5731 ));
5732 assert!(is_repo_meta(
5733 Utf8Path::new("/dot/home/.gitconfig.tera"),
5734 source,
5735 ".yuilink",
5736 ));
5737 assert!(!is_repo_meta(
5739 Utf8Path::new("/dot/home/.config/myapp/config.toml"),
5740 source,
5741 ".yuilink",
5742 ));
5743 assert!(!is_repo_meta(
5747 Utf8Path::new("/dot/home/.config/git/.gitignore"),
5748 source,
5749 ".yuilink",
5750 ));
5751 }
5752
5753 #[test]
5760 fn unmanaged_respects_inactive_mount_entries() {
5761 let tmp = TempDir::new().unwrap();
5762 let source = utf8(tmp.path().join("dotfiles"));
5763 let target = utf8(tmp.path().join("target"));
5764 std::fs::create_dir_all(source.join("home_active")).unwrap();
5765 std::fs::create_dir_all(source.join("home_other_os")).unwrap();
5766 std::fs::create_dir_all(&target).unwrap();
5767 std::fs::write(source.join("home_active/.bashrc"), "active").unwrap();
5768 std::fs::write(source.join("home_other_os/.bashrc"), "inactive").unwrap();
5769 let cfg = format!(
5771 r#"
5772[[mount.entry]]
5773src = "home_active"
5774dst = "{target}"
5775
5776[[mount.entry]]
5777src = "home_other_os"
5778dst = "{target}"
5779when = "yui.os == 'definitely_not_a_real_os'"
5780"#,
5781 target = toml_path(&target)
5782 );
5783 std::fs::write(source.join("config.toml"), cfg).unwrap();
5784
5785 let yui = YuiVars::detect(&source);
5789 let cfg = config::load(&source, &yui).unwrap();
5790 let mount_srcs: Vec<Utf8PathBuf> = cfg
5791 .mount
5792 .entry
5793 .iter()
5794 .map(|m| source.join(&m.src))
5795 .collect();
5796 let inactive_file = source.join("home_other_os/.bashrc");
5797 let claimed = mount_srcs.iter().any(|m| inactive_file.starts_with(m));
5798 assert!(
5799 claimed,
5800 "raw config.mount.entry should claim files even under inactive mounts"
5801 );
5802 }
5803
5804 #[test]
5809 fn diff_shows_drift_skips_in_sync() {
5810 let tmp = TempDir::new().unwrap();
5811 let (source, target) = setup_minimal_dotfiles(&tmp);
5812 std::fs::write(source.join("home/.bashrc"), "first\nsecond\n").unwrap();
5813 apply(Some(source.clone()), false).unwrap();
5815 std::fs::remove_file(target.join(".bashrc")).unwrap();
5817 std::fs::write(target.join(".bashrc"), "first\nEDITED\n").unwrap();
5818
5819 diff(Some(source.clone()), None, true).unwrap();
5822 }
5823
5824 #[test]
5829 fn read_text_for_diff_classifies_correctly() {
5830 let tmp = TempDir::new().unwrap();
5831 let root = utf8(tmp.path().to_path_buf());
5832 let txt = root.join("a.txt");
5834 std::fs::write(&txt, "hello\n").unwrap();
5835 match read_text_for_diff(&txt) {
5836 DiffSide::Text(s) => assert_eq!(s, "hello\n"),
5837 DiffSide::Binary => panic!("text file misclassified as binary"),
5838 }
5839 let bin = root.join("b.bin");
5841 std::fs::write(&bin, [0xff, 0xfe, 0x00, 0xff]).unwrap();
5842 assert!(matches!(read_text_for_diff(&bin), DiffSide::Binary));
5843 let missing = root.join("missing.txt");
5845 match read_text_for_diff(&missing) {
5846 DiffSide::Text(s) => assert!(s.is_empty()),
5847 DiffSide::Binary => panic!("missing file misclassified as binary"),
5848 }
5849 }
5850
5851 #[test]
5858 fn diff_render_drift_uses_rendered_output_not_raw_template() {
5859 let tmp = TempDir::new().unwrap();
5860 let (source, _target) = setup_minimal_dotfiles(&tmp);
5861 std::fs::write(source.join("home/note.tera"), "os = {{ yui.os }}\n").unwrap();
5864 std::fs::write(source.join("home/note"), "os = ancient\n").unwrap();
5865 let yui = YuiVars::detect(&source);
5867 let cfg = config::load(&source, &yui).unwrap();
5868 let rendered =
5869 render::render_to_string(&source.join("home/note.tera"), &source, &cfg, &yui)
5870 .unwrap()
5871 .expect("template should render on this host");
5872 assert!(rendered.starts_with("os = "));
5873 assert!(
5874 !rendered.contains("{{"),
5875 "rendered output must not contain raw Tera tags"
5876 );
5877 }
5878
5879 #[test]
5887 fn resolve_diff_src_absolutizes_link_rows() {
5888 let source = Utf8Path::new("/dot");
5889 let link_item = StatusItem {
5890 src: Utf8PathBuf::from("home/.bashrc"),
5891 dst: Utf8PathBuf::from("/h/u/.bashrc"),
5892 state: StatusState::Link(absorb::AbsorbDecision::AutoAbsorb),
5893 };
5894 assert_eq!(
5895 resolve_diff_src(&link_item, source),
5896 Utf8PathBuf::from("/dot/home/.bashrc"),
5897 );
5898 let render_item = StatusItem {
5899 src: Utf8PathBuf::from("/dot/home/foo.tera"),
5900 dst: Utf8PathBuf::from("/dot/home/foo"),
5901 state: StatusState::RenderDrift,
5902 };
5903 assert_eq!(
5904 resolve_diff_src(&render_item, source),
5905 Utf8PathBuf::from("/dot/home/foo.tera"),
5906 );
5907 }
5908
5909 #[test]
5910 fn diff_classifier_skips_uninteresting_states() {
5911 use absorb::AbsorbDecision::*;
5912 assert!(!diff_worth_printing(&StatusState::Link(InSync)));
5914 assert!(!diff_worth_printing(&StatusState::Link(Restore)));
5915 assert!(!diff_worth_printing(&StatusState::Link(RelinkOnly)));
5916 assert!(diff_worth_printing(&StatusState::Link(AutoAbsorb)));
5918 assert!(diff_worth_printing(&StatusState::Link(NeedsConfirm)));
5919 assert!(diff_worth_printing(&StatusState::RenderDrift));
5920 }
5921
5922 #[test]
5933 fn update_errors_when_source_is_not_a_git_repo() {
5934 let tmp = TempDir::new().unwrap();
5935 let source = utf8(tmp.path().join("dotfiles"));
5936 std::fs::create_dir_all(&source).unwrap();
5937 std::fs::write(source.join("config.toml"), "").unwrap();
5938 let err = update(Some(source), false).unwrap_err();
5940 let msg = format!("{err:#}");
5941 assert!(
5942 msg.contains("not a git repository")
5943 || msg.contains("uncommitted")
5944 || msg.contains("git"),
5945 "unexpected error: {msg}",
5946 );
5947 }
5948
5949 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
5950 let mut out = Vec::new();
5951 let mut stack = vec![root.to_path_buf()];
5952 while let Some(dir) = stack.pop() {
5953 let Ok(entries) = std::fs::read_dir(&dir) else {
5954 continue;
5955 };
5956 for e in entries.flatten() {
5957 let p = utf8(e.path());
5958 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
5959 stack.push(p);
5960 } else {
5961 out.push(p);
5962 }
5963 }
5964 }
5965 out
5966 }
5967
5968 #[test]
5973 fn parse_backup_suffix_recognises_file_with_extension() {
5974 let dt = parse_backup_suffix("foo_20260429_143022123.yml").unwrap();
5975 assert_eq!(dt.year(), 2026);
5976 assert_eq!(dt.month(), 4);
5977 assert_eq!(dt.day(), 29);
5978 assert_eq!(dt.hour(), 14);
5979 assert_eq!(dt.minute(), 30);
5980 assert_eq!(dt.second(), 22);
5981 }
5982
5983 #[test]
5984 fn parse_backup_suffix_recognises_dotfile_no_extension() {
5985 let dt = parse_backup_suffix(".gitconfig_20260429_143022123").unwrap();
5986 assert_eq!(dt.year(), 2026);
5987 }
5988
5989 #[test]
5990 fn parse_backup_suffix_recognises_directory_form() {
5991 let dt = parse_backup_suffix("nvim_20260429_143022123").unwrap();
5992 assert_eq!(dt.day(), 29);
5993 }
5994
5995 #[test]
5996 fn parse_backup_suffix_recognises_multi_dot_filename() {
5997 let dt = parse_backup_suffix("archive.tar.gz_20260429_143022123.gz").unwrap();
5999 assert_eq!(dt.month(), 4);
6000 }
6001
6002 #[test]
6003 fn parse_backup_suffix_rejects_non_yui_names() {
6004 assert!(parse_backup_suffix("README.md").is_none());
6005 assert!(parse_backup_suffix("notes_2026.txt").is_none());
6006 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());
6010 }
6011
6012 #[test]
6013 fn parse_human_duration_basic_units() {
6014 let s = parse_human_duration("30d").unwrap();
6015 assert_eq!(s.get_days(), 30);
6016 let s = parse_human_duration("2w").unwrap();
6017 assert_eq!(s.get_weeks(), 2);
6018 let s = parse_human_duration("12h").unwrap();
6019 assert_eq!(s.get_hours(), 12);
6020 let s = parse_human_duration("5m").unwrap();
6022 assert_eq!(s.get_minutes(), 5);
6023 let s = parse_human_duration("6mo").unwrap();
6024 assert_eq!(s.get_months(), 6);
6025 let s = parse_human_duration("1y").unwrap();
6026 assert_eq!(s.get_years(), 1);
6027 }
6028
6029 #[test]
6030 fn parse_human_duration_case_insensitive_and_whitespace() {
6031 let s = parse_human_duration(" 90D ").unwrap();
6032 assert_eq!(s.get_days(), 90);
6033 let s = parse_human_duration("3WEEKS").unwrap();
6034 assert_eq!(s.get_weeks(), 3);
6035 }
6036
6037 #[test]
6038 fn parse_human_duration_rejects_garbage() {
6039 assert!(parse_human_duration("").is_err());
6040 assert!(parse_human_duration("d30").is_err());
6041 assert!(parse_human_duration("30").is_err()); assert!(parse_human_duration("30x").is_err()); assert!(parse_human_duration("-1d").is_err()); }
6045
6046 #[test]
6050 fn walk_gc_backups_collects_files_and_dir_snapshots() {
6051 let tmp = TempDir::new().unwrap();
6052 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
6053 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
6054 std::fs::write(
6056 root.join("C/Users/u/.config/foo_20260429_143022123.yml"),
6057 "old yml",
6058 )
6059 .unwrap();
6060 std::fs::create_dir_all(root.join("C/Users/u/nvim_20260101_000000000/lua")).unwrap();
6062 std::fs::write(
6063 root.join("C/Users/u/nvim_20260101_000000000/init.lua"),
6064 "ok",
6065 )
6066 .unwrap();
6067 std::fs::write(
6068 root.join("C/Users/u/nvim_20260101_000000000/lua/x.lua"),
6069 "kk",
6070 )
6071 .unwrap();
6072 std::fs::write(root.join("C/Users/u/.config/README.md"), "user note").unwrap();
6074
6075 let entries = walk_gc_backups(&root).unwrap();
6076 assert_eq!(entries.len(), 2, "two backup roots, not three");
6077 let kinds: Vec<_> = entries.iter().map(|e| e.kind).collect();
6078 assert!(kinds.contains(&BackupKind::File));
6079 assert!(kinds.contains(&BackupKind::Dir));
6080 let dir_entry = entries.iter().find(|e| e.kind == BackupKind::Dir).unwrap();
6082 assert!(dir_entry.size_bytes >= 4); }
6084
6085 #[test]
6086 fn cleanup_empty_parents_stops_at_root_and_at_non_empty() {
6087 let tmp = TempDir::new().unwrap();
6088 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
6089 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
6090 std::fs::write(root.join("C/Users/u/sibling_keep"), "x").unwrap();
6091
6092 cleanup_empty_parents(&root.join("C/Users/u/.config"), &root);
6096
6097 assert!(!root.join("C/Users/u/.config").exists(), "empty leaf gone");
6098 assert!(root.join("C/Users/u").exists(), "stops at non-empty parent");
6099 assert!(root.exists(), "backup root preserved");
6100 }
6101
6102 #[test]
6104 fn gc_backup_survey_keeps_all_entries() {
6105 let tmp = TempDir::new().unwrap();
6106 let source = utf8(tmp.path().join("dotfiles"));
6107 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
6108 std::fs::write(source.join("config.toml"), "").unwrap();
6109 let backup = source.join(".yui/backup");
6110 std::fs::write(backup.join("a_20260101_000000000.txt"), "old").unwrap();
6111 std::fs::write(backup.join("b_20260415_120000000.txt"), "fresh").unwrap();
6112
6113 gc_backup(Some(source.clone()), None, false, None, true).unwrap();
6114
6115 assert!(backup.join("a_20260101_000000000.txt").exists());
6117 assert!(backup.join("b_20260415_120000000.txt").exists());
6118 }
6119
6120 #[test]
6123 fn gc_backup_prune_removes_old_files_only() {
6124 let tmp = TempDir::new().unwrap();
6125 let source = utf8(tmp.path().join("dotfiles"));
6126 std::fs::create_dir_all(source.join(".yui/backup/sub")).unwrap();
6127 std::fs::write(source.join("config.toml"), "").unwrap();
6128 let backup = source.join(".yui/backup");
6129
6130 std::fs::write(backup.join("sub/old_20200101_000000000.txt"), "old").unwrap();
6132 let tomorrow = jiff::Zoned::now()
6134 .checked_add(jiff::Span::new().days(1))
6135 .unwrap();
6136 let bdt = jiff::fmt::strtime::BrokenDownTime::from(&tomorrow);
6137 let future_ts = bdt.to_string("%Y%m%d_%H%M%S%3f").unwrap();
6138 std::fs::write(backup.join(format!("fresh_{future_ts}.txt")), "fresh").unwrap();
6139 std::fs::write(backup.join("notes.md"), "mine").unwrap();
6141
6142 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
6143
6144 assert!(!backup.join("sub/old_20200101_000000000.txt").exists());
6145 assert!(!backup.join("sub").exists(), "empty parent removed");
6147 assert!(backup.exists());
6149 assert!(backup.join(format!("fresh_{future_ts}.txt")).exists());
6150 assert!(backup.join("notes.md").exists(), "user file untouched");
6151 }
6152
6153 #[test]
6155 fn gc_backup_dry_run_does_not_delete() {
6156 let tmp = TempDir::new().unwrap();
6157 let source = utf8(tmp.path().join("dotfiles"));
6158 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
6159 std::fs::write(source.join("config.toml"), "").unwrap();
6160 let backup = source.join(".yui/backup");
6161 std::fs::write(backup.join("old_20200101_000000000.txt"), "old").unwrap();
6162
6163 gc_backup(Some(source.clone()), Some("30d".into()), true, None, true).unwrap();
6164
6165 assert!(
6166 backup.join("old_20200101_000000000.txt").exists(),
6167 "dry-run keeps everything in place"
6168 );
6169 }
6170
6171 #[test]
6175 fn gc_backup_prune_handles_directory_snapshot() {
6176 let tmp = TempDir::new().unwrap();
6177 let source = utf8(tmp.path().join("dotfiles"));
6178 std::fs::create_dir_all(source.join(".yui/backup/mirror/u")).unwrap();
6179 std::fs::write(source.join("config.toml"), "").unwrap();
6180 let backup = source.join(".yui/backup");
6181 let snap = backup.join("mirror/u/nvim_20200101_000000000");
6182 std::fs::create_dir_all(snap.join("lua")).unwrap();
6183 std::fs::write(snap.join("init.lua"), "x").unwrap();
6184 std::fs::write(snap.join("lua/y.lua"), "y").unwrap();
6185
6186 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
6187
6188 assert!(!snap.exists(), "dir snapshot removed wholesale");
6189 assert!(!backup.join("mirror").exists(), "empty mirror chain pruned");
6190 assert!(backup.exists(), "backup root preserved");
6191 }
6192
6193 fn ctx_for_test(tmp: &TempDir) -> (Config, Utf8PathBuf, Utf8PathBuf) {
6198 let source = utf8(tmp.path().join("src"));
6199 let backup_root = source.join(".yui/backup");
6200 std::fs::create_dir_all(&source).unwrap();
6201 let cfg = Config::default();
6202 (cfg, source, backup_root)
6203 }
6204
6205 #[test]
6206 fn prompt_anomaly_short_circuits_on_quit_requested() {
6207 let tmp = TempDir::new().unwrap();
6212 let (cfg, source, backup_root) = ctx_for_test(&tmp);
6213 let src_file = source.join("a");
6214 let dst_file = utf8(tmp.path().join("dst"));
6215 std::fs::write(&src_file, "X").unwrap();
6216 std::fs::write(&dst_file, "Y").unwrap();
6217
6218 let ctx = ApplyCtx {
6219 config: &cfg,
6220 source: &source,
6221 file_mode: resolve_file_mode(cfg.link.file_mode),
6222 dir_mode: resolve_dir_mode(cfg.link.dir_mode),
6223 backup_root: &backup_root,
6224 dry_run: false,
6225 sticky_anomaly: Cell::new(None),
6226 quit_requested: Cell::new(true),
6227 };
6228
6229 let got = prompt_anomaly(&ctx, &src_file, &dst_file, "test").unwrap();
6230 assert_eq!(got, AnomalyChoice::Quit);
6231 }
6232
6233 #[test]
6234 fn prompt_anomaly_short_circuits_on_sticky_choice() {
6235 let tmp = TempDir::new().unwrap();
6240 let (cfg, source, backup_root) = ctx_for_test(&tmp);
6241 let src_file = source.join("a");
6242 let dst_file = utf8(tmp.path().join("dst"));
6243 std::fs::write(&src_file, "X").unwrap();
6244 std::fs::write(&dst_file, "Y").unwrap();
6245
6246 let ctx = ApplyCtx {
6247 config: &cfg,
6248 source: &source,
6249 file_mode: resolve_file_mode(cfg.link.file_mode),
6250 dir_mode: resolve_dir_mode(cfg.link.dir_mode),
6251 backup_root: &backup_root,
6252 dry_run: false,
6253 sticky_anomaly: Cell::new(Some(AnomalyChoice::Overwrite)),
6254 quit_requested: Cell::new(false),
6255 };
6256
6257 let got = prompt_anomaly(&ctx, &src_file, &dst_file, "test").unwrap();
6258 assert_eq!(got, AnomalyChoice::Overwrite);
6259 }
6260
6261 #[test]
6262 fn overwrite_source_into_target_replaces_target_and_backs_up() {
6263 let tmp = TempDir::new().unwrap();
6268 let (cfg, source, backup_root) = ctx_for_test(&tmp);
6269 let src_file = source.join("a");
6270 let dst_file = utf8(tmp.path().join("dst"));
6271 std::fs::write(&src_file, "from source").unwrap();
6272 std::fs::write(&dst_file, "diverged target content").unwrap();
6273
6274 let ctx = ApplyCtx {
6275 config: &cfg,
6276 source: &source,
6277 file_mode: resolve_file_mode(cfg.link.file_mode),
6278 dir_mode: resolve_dir_mode(cfg.link.dir_mode),
6279 backup_root: &backup_root,
6280 dry_run: false,
6281 sticky_anomaly: Cell::new(None),
6282 quit_requested: Cell::new(false),
6283 };
6284
6285 overwrite_source_into_target(&src_file, &dst_file, &ctx).unwrap();
6286
6287 assert_eq!(std::fs::read_to_string(&dst_file).unwrap(), "from source");
6289 assert_eq!(std::fs::read_to_string(&src_file).unwrap(), "from source");
6291 let mut found_old = false;
6293 for entry in walkdir(&backup_root) {
6294 if let Ok(s) = std::fs::read_to_string(&entry) {
6295 if s == "diverged target content" {
6296 found_old = true;
6297 break;
6298 }
6299 }
6300 }
6301 assert!(
6302 found_old,
6303 "expected backup containing target's diverged content"
6304 );
6305 }
6306
6307 #[test]
6308 fn link_file_with_backup_short_circuits_when_quit_requested() {
6309 let tmp = TempDir::new().unwrap();
6315 let (mut cfg, source, backup_root) = ctx_for_test(&tmp);
6316 cfg.absorb.on_anomaly = crate::config::AnomalyAction::Force;
6317
6318 let src_file = source.join("a");
6319 let dst_file = utf8(tmp.path().join("dst"));
6320 let now = std::time::SystemTime::now();
6321 let past = now - std::time::Duration::from_secs(120);
6322 write_with_mtime(&dst_file, "target old", past);
6323 write_with_mtime(&src_file, "source new", now);
6324 let dst_before = std::fs::read_to_string(&dst_file).unwrap();
6325 let src_before = std::fs::read_to_string(&src_file).unwrap();
6326
6327 let ctx = ApplyCtx {
6328 config: &cfg,
6329 source: &source,
6330 file_mode: resolve_file_mode(cfg.link.file_mode),
6331 dir_mode: resolve_dir_mode(cfg.link.dir_mode),
6332 backup_root: &backup_root,
6333 dry_run: false,
6334 sticky_anomaly: Cell::new(None),
6335 quit_requested: Cell::new(true),
6336 };
6337
6338 link_file_with_backup(&src_file, &dst_file, &ctx).unwrap();
6339
6340 assert_eq!(std::fs::read_to_string(&dst_file).unwrap(), dst_before);
6341 assert_eq!(std::fs::read_to_string(&src_file).unwrap(), src_before);
6342 assert!(
6343 !backup_root.exists() || walkdir(&backup_root).is_empty(),
6344 "no backup should be produced when quit is requested"
6345 );
6346 }
6347}