1use std::fmt::Write as _;
6
7use anyhow::{Context as _, Result};
8use camino::{Utf8Path, Utf8PathBuf};
9use tera::Context as TeraContext;
10use tracing::{info, warn};
11
12use crate::config::{self, Config, HookPhase, IconsMode, MountStrategy};
13use crate::hook::{self, HookOutcome};
14use crate::icons::Icons;
15use crate::link::{self, EffectiveDirMode, EffectiveFileMode, resolve_dir_mode, resolve_file_mode};
16use crate::marker::{self, MarkerSpec};
17use crate::mount::{self, ResolvedMount};
18use crate::render::{self, RenderReport};
19use crate::secret;
20use crate::template;
21use crate::vars::YuiVars;
22use crate::{absorb, backup, paths};
23
24pub fn init(source: Option<Utf8PathBuf>, git_hooks: bool) -> Result<()> {
31 let dir = match source {
32 Some(s) => absolutize(&s)?,
33 None => current_dir_utf8()?,
34 };
35 std::fs::create_dir_all(&dir)?;
36 let config_path = dir.join("config.toml");
37 let scaffolded = if !config_path.exists() {
38 std::fs::write(&config_path, SKELETON_CONFIG)?;
39 info!("initialized yui source repo at {dir}");
40 info!("created: {config_path}");
41 true
42 } else if git_hooks {
43 info!(
48 "config.toml already exists at {config_path} \
49 — skipping scaffold, installing git hooks only"
50 );
51 false
52 } else {
53 anyhow::bail!("config.toml already exists at {config_path}");
54 };
55
56 ensure_gitignore_yui_entries(&dir)?;
63
64 if git_hooks {
65 install_git_hooks(&dir)?;
66 }
67 if scaffolded {
68 info!("next: edit config.toml, then run `yui apply`");
69 }
70 Ok(())
71}
72
73const YUI_REQUIRED_GITIGNORE: &[&str] = &[
78 "/.yui/state.json",
79 "/.yui/state.json.tmp",
80 "/.yui/backup/",
81 "config.local.toml",
82];
83
84fn ensure_gitignore_yui_entries(dir: &Utf8Path) -> Result<()> {
90 let path = dir.join(".gitignore");
91 if !path.exists() {
92 std::fs::write(&path, SKELETON_GITIGNORE)?;
93 info!("created: {path}");
94 return Ok(());
95 }
96 let existing = std::fs::read_to_string(&path)?;
97 let missing: Vec<&str> = YUI_REQUIRED_GITIGNORE
98 .iter()
99 .copied()
100 .filter(|entry| !existing.lines().any(|line| line.trim() == *entry))
101 .collect();
102 if missing.is_empty() {
103 return Ok(());
104 }
105 let mut next = existing;
106 if !next.is_empty() && !next.ends_with('\n') {
107 next.push('\n');
108 }
109 if !next.is_empty() {
110 next.push('\n');
111 }
112 next.push_str("# yui per-machine state and backups (added by `yui init`).\n");
113 for entry in &missing {
114 next.push_str(entry);
115 next.push('\n');
116 }
117 std::fs::write(&path, next)?;
118 info!(
119 "updated .gitignore: appended {} yui entr{} ({})",
120 missing.len(),
121 if missing.len() == 1 { "y" } else { "ies" },
122 missing.join(", ")
123 );
124 Ok(())
125}
126
127fn install_git_hooks(source: &Utf8Path) -> Result<()> {
141 let out = std::process::Command::new("git")
142 .args(["rev-parse", "--git-path", "hooks"])
143 .current_dir(source.as_std_path())
144 .output()
145 .with_context(|| format!("git rev-parse --git-path hooks in {source}"))?;
146 if !out.status.success() {
147 let stderr = String::from_utf8_lossy(&out.stderr);
148 anyhow::bail!(
149 "--git-hooks: {source} doesn't look like a git repo \
150 (run `git init` first). git: {}",
151 stderr.trim()
152 );
153 }
154 let raw = String::from_utf8(out.stdout)?;
155 let hooks_dir = {
156 let p = Utf8PathBuf::from(raw.trim());
157 if p.is_absolute() { p } else { source.join(p) }
158 };
159 std::fs::create_dir_all(&hooks_dir).with_context(|| format!("mkdir -p {hooks_dir}"))?;
160
161 for (name, body) in [("pre-commit", PRE_COMMIT_HOOK), ("pre-push", PRE_PUSH_HOOK)] {
162 let path = hooks_dir.join(name);
163 if path.exists() {
164 warn!("--git-hooks: {path} already exists — leaving it alone");
165 continue;
166 }
167 std::fs::write(&path, body).with_context(|| format!("write hook {path}"))?;
168 #[cfg(unix)]
169 {
170 use std::os::unix::fs::PermissionsExt;
171 let mut perms = std::fs::metadata(&path)?.permissions();
172 perms.set_mode(0o755);
173 std::fs::set_permissions(&path, perms)?;
174 }
175 info!("installed: {path}");
176 }
177 Ok(())
178}
179
180const PRE_COMMIT_HOOK: &str = r#"#!/bin/sh
181# Installed by `yui init --git-hooks`.
182# Reject the commit if any `*.tera` template would render to something
183# that diverges from the rendered output staged alongside it. Run
184# `yui apply` (or `yui render`) to refresh and re-commit.
185exec yui render --check
186"#;
187
188const PRE_PUSH_HOOK: &str = r#"#!/bin/sh
189# Installed by `yui init --git-hooks`.
190# Same render-drift check as pre-commit, mirrored on push so a
191# `--no-verify` commit doesn't sneak diverged state to the remote.
192exec yui render --check
193"#;
194
195pub fn apply(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
196 let source = resolve_source(source)?;
197 let yui = YuiVars::detect(&source);
198 let config = config::load(&source, &yui)?;
199
200 let mut engine = template::Engine::new();
201 let tera_ctx = template::template_context(&yui, &config.vars);
202
203 hook::run_phase(
206 &config,
207 &source,
208 &yui,
209 &mut engine,
210 &tera_ctx,
211 HookPhase::Pre,
212 dry_run,
213 )?;
214
215 let secret_report = secret::decrypt_all(&source, &config, dry_run)?;
221 log_secret_report(&secret_report);
222 if secret_report.has_drift() {
223 anyhow::bail!(
224 "secret drift detected ({} file(s)); the plaintext sibling diverged \
225 from the canonical .age — run `yui secret encrypt <path>` to roll \
226 the edit back into ciphertext before re-running apply",
227 secret_report.diverged.len()
228 );
229 }
230
231 let render_report = render::render_all(&source, &config, &yui, dry_run)?;
233 log_render_report(&render_report);
234 if render_report.has_drift() {
235 anyhow::bail!(
236 "render drift detected ({} file(s)); reflect target edits back into the .tera before re-running apply",
237 render_report.diverged.len()
238 );
239 }
240
241 if !dry_run && config.render.manage_gitignore {
248 let mut managed: Vec<Utf8PathBuf> = render::report_managed_paths(&render_report)
249 .into_iter()
250 .chain(secret_report.managed_paths().cloned())
251 .collect();
252 managed.sort();
253 managed.dedup();
254 render::write_managed_section(&source, &managed)?;
255 }
256
257 let mounts = mount::resolve(
259 &source,
260 &config.mount.entry,
261 config.mount.default_strategy,
262 &mut engine,
263 &tera_ctx,
264 )?;
265
266 let backup_root = source.join(&config.backup.dir);
267 let ctx = ApplyCtx {
268 config: &config,
269 source: &source,
270 file_mode: resolve_file_mode(config.link.file_mode),
271 dir_mode: resolve_dir_mode(config.link.dir_mode),
272 backup_root: &backup_root,
273 dry_run,
274 };
275
276 info!("source: {source}");
277 info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
278 if dry_run {
279 info!("dry-run: nothing will be written");
280 }
281
282 let mut yuiignore = paths::YuiIgnoreStack::new();
286 yuiignore.push_dir(&source)?;
287 let walk_result = (|| -> Result<()> {
288 for m in &mounts {
289 info!("mount: {} → {}", m.src, m.dst);
290 process_mount(m, &ctx, &mut engine, &tera_ctx, &mut yuiignore)?;
291 }
292 Ok(())
293 })();
294 yuiignore.pop_dir(&source);
295 walk_result?;
296
297 hook::run_phase(
299 &config,
300 &source,
301 &yui,
302 &mut engine,
303 &tera_ctx,
304 HookPhase::Post,
305 dry_run,
306 )?;
307 Ok(())
308}
309
310fn log_render_report(r: &RenderReport) {
311 if !r.written.is_empty() {
312 info!("rendered {} new file(s)", r.written.len());
313 }
314 if !r.unchanged.is_empty() {
315 info!("rendered {} file(s) unchanged", r.unchanged.len());
316 }
317 if !r.skipped_when_false.is_empty() {
318 info!(
319 "skipped {} template(s) (when=false)",
320 r.skipped_when_false.len()
321 );
322 }
323 for d in &r.diverged {
324 warn!("rendered file diverged from template: {d}");
325 }
326}
327
328fn log_secret_report(r: &secret::SecretReport) {
329 if !r.written.is_empty() {
330 info!("decrypted {} secret file(s)", r.written.len());
331 }
332 if !r.unchanged.is_empty() {
333 info!("decrypted {} secret(s) unchanged", r.unchanged.len());
334 }
335 for d in &r.diverged {
336 warn!("plaintext sibling diverged from .age: {d}");
337 }
338}
339
340struct ApplyCtx<'a> {
347 config: &'a Config,
348 source: &'a Utf8Path,
350 file_mode: EffectiveFileMode,
351 dir_mode: EffectiveDirMode,
352 backup_root: &'a Utf8Path,
353 dry_run: bool,
354}
355
356pub fn list(
362 source: Option<Utf8PathBuf>,
363 all: bool,
364 icons_override: Option<IconsMode>,
365 no_color: bool,
366) -> Result<()> {
367 let source = resolve_source(source)?;
368 let yui = YuiVars::detect(&source);
369 let config = config::load(&source, &yui)?;
370
371 let icons_mode = icons_override.unwrap_or(config.ui.icons);
372 let icons = Icons::for_mode(icons_mode);
373 let color = !no_color && supports_color_stdout();
374
375 let items = collect_list_items(&source, &config, &yui)?;
376 let displayed: Vec<&ListItem> = if all {
377 items.iter().collect()
378 } else {
379 items.iter().filter(|i| i.active).collect()
380 };
381
382 print_list_table(&displayed, icons, color);
383
384 let total = items.len();
385 let active = items.iter().filter(|i| i.active).count();
386 let inactive = total - active;
387 println!();
388 if all {
389 println!(" {total} entries · {active} active · {inactive} inactive");
390 } else {
391 println!(
392 " {} of {} entries shown ({} inactive hidden — use --all)",
393 active, total, inactive
394 );
395 }
396 Ok(())
397}
398
399#[derive(Debug)]
400struct ListItem {
401 src: Utf8PathBuf,
402 dst: String,
403 when: Option<String>,
404 active: bool,
405}
406
407fn collect_list_items(source: &Utf8Path, config: &Config, yui: &YuiVars) -> Result<Vec<ListItem>> {
408 let mut engine = template::Engine::new();
409 let tera_ctx = template::template_context(yui, &config.vars);
410 let mut items = Vec::new();
411
412 for entry in &config.mount.entry {
414 let active = match &entry.when {
415 None => true,
416 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
417 };
418 let dst = engine
419 .render(&entry.dst, &tera_ctx)
420 .map(|s| paths::expand_tilde(s.trim()).to_string())
421 .unwrap_or_else(|_| entry.dst.clone());
422 items.push(ListItem {
423 src: entry.src.clone(),
424 dst,
425 when: entry.when.clone(),
426 active,
427 });
428 }
429
430 let walker = paths::source_walker(source).build();
432 let marker_filename = &config.mount.marker_filename;
433 for entry in walker {
434 let entry = match entry {
435 Ok(e) => e,
436 Err(_) => continue,
437 };
438 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
439 continue;
440 }
441 if entry.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
442 continue;
443 }
444 let dir = match entry.path().parent() {
445 Some(d) => d,
446 None => continue,
447 };
448 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
449 Ok(p) => p,
450 Err(_) => continue,
451 };
452 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
456 Some(s) => s,
457 None => continue,
458 };
459 let MarkerSpec::Explicit { links } = spec else {
460 continue; };
462 let rel = dir_utf8
463 .strip_prefix(source)
464 .map(Utf8PathBuf::from)
465 .unwrap_or(dir_utf8);
466 for link in &links {
467 let active = match &link.when {
468 None => true,
469 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
470 };
471 let dst = engine
472 .render(&link.dst, &tera_ctx)
473 .map(|s| paths::expand_tilde(s.trim()).to_string())
474 .unwrap_or_else(|_| link.dst.clone());
475 let src_display = match &link.src {
480 Some(filename) => rel.join(filename),
481 None => rel.clone(),
482 };
483 items.push(ListItem {
484 src: src_display,
485 dst,
486 when: link.when.clone(),
487 active,
488 });
489 }
490 }
491
492 items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
493 Ok(items)
494}
495
496fn supports_color_stdout() -> bool {
497 use std::io::IsTerminal;
498 std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
499}
500
501fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
502 let src_w = items
503 .iter()
504 .map(|i| i.src.as_str().chars().count())
505 .max()
506 .unwrap_or(0)
507 .max("SRC".len());
508 let dst_w = items
509 .iter()
510 .map(|i| i.dst.chars().count())
511 .max()
512 .unwrap_or(0)
513 .max("DST".len());
514
515 let status_w = "STATUS".len();
516 let arrow_w = icons.arrow.chars().count();
517
518 print_header(status_w, src_w, arrow_w, dst_w, color);
520
521 let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
523 if color {
524 use owo_colors::OwoColorize as _;
525 println!("{}", sep.dimmed());
526 } else {
527 println!("{sep}");
528 }
529
530 for item in items {
532 print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
533 }
534}
535
536fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
537 use owo_colors::OwoColorize as _;
538 let mut line = String::new();
539 let _ = write!(
540 &mut line,
541 " {:<status_w$} {:<src_w$} {:<arrow_w$} {:<dst_w$} WHEN",
542 "STATUS", "SRC", "", "DST"
543 );
544 if color {
545 println!("{}", line.bold());
546 } else {
547 println!("{line}");
548 }
549}
550
551fn render_separator(
552 sep_ch: char,
553 status_w: usize,
554 src_w: usize,
555 arrow_w: usize,
556 dst_w: usize,
557) -> String {
558 let bar = |n: usize| sep_ch.to_string().repeat(n);
559 format!(
560 " {} {} {} {} {}",
561 bar(status_w),
562 bar(src_w),
563 bar(arrow_w),
564 bar(dst_w),
565 bar("WHEN".len())
566 )
567}
568
569fn print_row(
570 item: &ListItem,
571 icons: Icons,
572 status_w: usize,
573 src_w: usize,
574 arrow_w: usize,
575 dst_w: usize,
576 color: bool,
577) {
578 use owo_colors::OwoColorize as _;
579 let status = if item.active {
580 icons.active
581 } else {
582 icons.inactive
583 };
584 let when_str = item
585 .when
586 .as_deref()
587 .map(strip_braces)
588 .unwrap_or_else(|| "(always)".to_string());
589
590 let src_display = item.src.as_str().replace('\\', "/");
592 let src = src_display.as_str();
593 let dst = &item.dst;
594 let arrow = icons.arrow;
595
596 let cell_status = format!("{:<status_w$}", status);
601 let cell_src = format!("{:<src_w$}", src);
602 let cell_arrow = format!("{:<arrow_w$}", arrow);
603 let cell_dst = format!("{:<dst_w$}", dst);
604
605 if !color {
606 println!(" {cell_status} {cell_src} {cell_arrow} {cell_dst} {when_str}");
607 return;
608 }
609
610 if item.active {
611 println!(
612 " {} {} {} {} {}",
613 cell_status.green(),
614 cell_src.cyan(),
615 cell_arrow.dimmed(),
616 cell_dst.green(),
617 when_str.dimmed()
618 );
619 } else {
620 println!(
621 " {} {} {} {} {}",
622 cell_status.red().dimmed(),
623 cell_src.dimmed(),
624 cell_arrow.dimmed(),
625 cell_dst.dimmed(),
626 when_str.dimmed()
627 );
628 }
629}
630
631fn strip_braces(expr: &str) -> String {
634 let trimmed = expr.trim();
635 if let Some(inner) = trimmed
636 .strip_prefix("{{")
637 .and_then(|s| s.strip_suffix("}}"))
638 {
639 inner.trim().to_string()
640 } else {
641 trimmed.to_string()
642 }
643}
644
645pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
646 let source = resolve_source(source)?;
647 let yui = YuiVars::detect(&source);
648 let config = config::load(&source, &yui)?;
649 let effective_dry_run = dry_run || check;
651 let report = render::render_all(&source, &config, &yui, effective_dry_run)?;
652 log_render_report(&report);
653 if !effective_dry_run && config.render.manage_gitignore {
658 let managed = render::report_managed_paths(&report);
659 render::write_managed_section(&source, &managed)?;
660 }
661 if check && report.has_drift() {
662 anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
663 }
664 Ok(())
665}
666
667pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
668 apply(source, dry_run)
670}
671
672pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
673 let _source = resolve_source(source)?;
674 if paths_arg.is_empty() {
675 anyhow::bail!("yui unlink: provide at least one target path");
676 }
677 for p in paths_arg {
678 let abs = absolutize(&p)?;
679 info!("unlink: {abs}");
680 link::unlink(&abs)?;
681 }
682 Ok(())
683}
684
685pub fn secret_init(source: Option<Utf8PathBuf>, comment: Option<String>) -> Result<()> {
696 let source = resolve_source(source)?;
697 let yui = YuiVars::detect(&source);
698 let config = config::load(&source, &yui)?;
699
700 let identity_path = paths::expand_tilde(&config.secrets.identity);
702 if identity_path.exists() {
703 anyhow::bail!(
704 "identity file already exists at {identity_path}; \
705 refusing to overwrite. Delete it first if you really \
706 mean to start fresh (you'll lose access to existing \
707 .age files encrypted to its public key)."
708 );
709 }
710
711 let (secret, public) = secret::generate_x25519_keypair();
715 if let Some(parent) = identity_path.parent() {
716 std::fs::create_dir_all(parent)?;
717 }
718 let now = jiff::Zoned::now().to_string();
719 let body = format!(
720 "# created: {now}\n\
721 # public key: {public}\n\
722 {secret}\n"
723 );
724 std::fs::write(&identity_path, body)?;
725 info!("wrote identity file: {identity_path}");
726
727 let local_path = source.join("config.local.toml");
731 let comment = comment.unwrap_or_else(|| format!("{} {}", yui.host, yui.user));
732 let entry_comment = format!("{comment} — added by `yui secret init` on {now}");
733 let local_existing = match std::fs::read_to_string(&local_path) {
734 Ok(s) => s,
735 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
736 Err(e) => anyhow::bail!("read {local_path}: {e}"),
737 };
738 let updated_local = append_recipient_to_local(&local_existing, &entry_comment, &public)?;
739 std::fs::write(&local_path, updated_local)?;
740 info!("appended public key to {local_path}");
741 println!();
742 println!(" age identity: {identity_path}");
743 println!(" public key: {public}");
744 println!();
745 println!(
746 " Next: encrypt a file with `yui secret encrypt <path>`. \
747 The plaintext sibling will be auto-decrypted on every `yui apply`."
748 );
749 Ok(())
750}
751
752fn append_recipient_to_local(existing: &str, comment: &str, public: &str) -> Result<String> {
766 use toml_edit::{Array, DocumentMut, Item, Table, Value};
767
768 let mut doc: DocumentMut = if existing.trim().is_empty() {
769 DocumentMut::new()
770 } else {
771 existing
772 .parse()
773 .map_err(|e| anyhow::anyhow!("config.local.toml is not valid TOML: {e}"))?
774 };
775
776 if !doc.contains_key("secrets") {
778 let mut t = Table::new();
779 t.set_implicit(false);
780 doc.insert("secrets", Item::Table(t));
781 }
782 let secrets = doc["secrets"].as_table_mut().ok_or_else(|| {
783 anyhow::anyhow!("[secrets] in config.local.toml is not a table — refusing to clobber")
784 })?;
785
786 if !secrets.contains_key("recipients") {
788 secrets.insert("recipients", Item::Value(Value::Array(Array::new())));
789 }
790 let recipients = secrets["recipients"]
791 .as_array_mut()
792 .ok_or_else(|| anyhow::anyhow!("[secrets].recipients is not an array"))?;
793
794 let already_present = recipients.iter().any(|v| v.as_str() == Some(public));
796 if already_present {
797 return Ok(doc.to_string());
798 }
799
800 let mut value = Value::from(public);
804 let prefix = format!("\n # {comment}\n ");
805 *value.decor_mut() = toml_edit::Decor::new(prefix, "");
806 recipients.push_formatted(value);
807 recipients.set_trailing("\n");
811 recipients.set_trailing_comma(true);
812
813 Ok(doc.to_string())
814}
815
816pub fn secret_encrypt(
820 source: Option<Utf8PathBuf>,
821 path: Utf8PathBuf,
822 force: bool,
823 rm_plaintext: bool,
824) -> Result<()> {
825 let source = resolve_source(source)?;
826 let yui = YuiVars::detect(&source);
827 let config = config::load(&source, &yui)?;
828
829 if !config.secrets.enabled() {
830 anyhow::bail!(
831 "no recipients configured — run `yui secret init` to generate \
832 a keypair, or add at least one entry to `[secrets] recipients`."
833 );
834 }
835
836 let plaintext_path = if path.is_absolute() {
840 path.clone()
841 } else {
842 absolutize(&path)?
843 };
844 if !plaintext_path.is_file() {
845 anyhow::bail!("plaintext file not found: {plaintext_path}");
846 }
847 let cipher_path = Utf8PathBuf::from(format!("{plaintext_path}.age"));
848 if cipher_path.exists() && !force {
849 anyhow::bail!("{cipher_path} already exists; pass --force to overwrite");
850 }
851
852 let plaintext = std::fs::read(&plaintext_path)?;
853 let recipients: Vec<age::x25519::Recipient> = config
854 .secrets
855 .recipients
856 .iter()
857 .map(|s| secret::parse_recipient(s))
858 .collect::<crate::Result<_>>()?;
859 let cipher = secret::encrypt(&plaintext, &recipients)?;
860 std::fs::write(&cipher_path, &cipher)?;
861 info!("encrypted {plaintext_path} → {cipher_path}");
862
863 if rm_plaintext {
864 if plaintext_path.starts_with(&source) {
867 std::fs::remove_file(&plaintext_path)?;
868 info!("removed plaintext: {plaintext_path}");
869 } else {
870 warn!(
871 "plaintext lives outside source ({plaintext_path}); \
872 skipping --rm-plaintext as a safety check"
873 );
874 }
875 }
876 Ok(())
877}
878
879pub fn update(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
890 let source = resolve_source(source)?;
891 if !crate::git::is_clean(&source)? {
892 anyhow::bail!(
893 "source repo {source} has uncommitted changes — \
894 commit or stash before `yui update` (or run \
895 `git pull` + `yui apply` manually if you know what \
896 you're doing)"
897 );
898 }
899 info!("git pull --ff-only at {source}");
900 let status = std::process::Command::new("git")
901 .arg("-C")
902 .arg(source.as_str())
903 .arg("pull")
904 .arg("--ff-only")
905 .status()
906 .map_err(|e| anyhow::anyhow!("invoking git: {e}"))?;
907 if !status.success() {
908 anyhow::bail!("git pull --ff-only failed at {source}");
909 }
910 apply(Some(source), dry_run)
911}
912
913pub fn unmanaged(
924 source: Option<Utf8PathBuf>,
925 icons_override: Option<IconsMode>,
926 no_color: bool,
927) -> Result<()> {
928 let source = resolve_source(source)?;
929 let yui = YuiVars::detect(&source);
930 let config = config::load(&source, &yui)?;
931
932 let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
933 let color = !no_color && supports_color_stdout();
934
935 let mut engine = template::Engine::new();
950 let tera_ctx = template::template_context(&yui, &config.vars);
951 let mount_srcs: Vec<Utf8PathBuf> = config
952 .mount
953 .entry
954 .iter()
955 .map(|e| -> Result<Utf8PathBuf> {
956 let rendered = engine.render(e.src.as_str(), &tera_ctx)?;
957 Ok(paths::resolve_mount_src(&source, rendered.trim()))
958 })
959 .collect::<Result<_>>()?;
960
961 let mut items: Vec<Utf8PathBuf> = Vec::new();
962 let walker = paths::source_walker(&source).build();
963 for entry in walker {
964 let entry = match entry {
965 Ok(e) => e,
966 Err(_) => continue,
967 };
968 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
969 continue;
970 }
971 let std_path = entry.path();
972 let path = match Utf8PathBuf::from_path_buf(std_path.to_path_buf()) {
973 Ok(p) => p,
974 Err(_) => continue,
975 };
976 if is_repo_meta(&path, &source, &config.mount.marker_filename) {
980 continue;
981 }
982 if mount_srcs.iter().any(|m| path.starts_with(m)) {
983 continue;
984 }
985 items.push(path);
986 }
987 items.sort();
988
989 if items.is_empty() {
990 println!(" no unmanaged files under {source}");
991 return Ok(());
992 }
993
994 print_unmanaged_table(&items, &source, color);
995 println!();
996 println!(" {} unmanaged file(s)", items.len());
997 Ok(())
998}
999
1000fn is_repo_meta(path: &Utf8Path, source: &Utf8Path, marker_filename: &str) -> bool {
1016 let Some(name) = path.file_name() else {
1017 return false;
1018 };
1019 if name.ends_with(".tera") {
1020 return true;
1021 }
1022 if name == marker_filename || name == ".yuiignore" {
1023 return true;
1024 }
1025 let parent = path.parent().unwrap_or(Utf8Path::new(""));
1026 let at_root = parent == source;
1027 if at_root && name == ".gitignore" {
1028 return true;
1029 }
1030 if at_root && (name == "config.toml" || name == "config.local.toml") {
1031 return true;
1032 }
1033 if at_root
1034 && name.starts_with("config.")
1035 && (name.ends_with(".toml") || name.ends_with(".example.toml"))
1036 {
1037 return true;
1038 }
1039 false
1040}
1041
1042fn print_unmanaged_table(items: &[Utf8PathBuf], source: &Utf8Path, color: bool) {
1043 use owo_colors::OwoColorize as _;
1044 if color {
1045 println!(" {}", "PATH (relative to source)".dimmed());
1046 } else {
1047 println!(" PATH (relative to source)");
1048 }
1049 for p in items {
1050 let rel = p
1051 .strip_prefix(source)
1052 .map(Utf8PathBuf::from)
1053 .unwrap_or_else(|_| p.clone());
1054 if color {
1055 println!(" {}", rel.cyan());
1056 } else {
1057 println!(" {rel}");
1058 }
1059 }
1060}
1061
1062pub fn diff(
1070 source: Option<Utf8PathBuf>,
1071 icons_override: Option<IconsMode>,
1072 no_color: bool,
1073) -> Result<()> {
1074 let source = resolve_source(source)?;
1075 let yui = YuiVars::detect(&source);
1076 let config = config::load(&source, &yui)?;
1077 let mut engine = template::Engine::new();
1078 let tera_ctx = template::template_context(&yui, &config.vars);
1079 let mounts = mount::resolve(
1080 &source,
1081 &config.mount.entry,
1082 config.mount.default_strategy,
1083 &mut engine,
1084 &tera_ctx,
1085 )?;
1086
1087 let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
1088 let color = !no_color && supports_color_stdout();
1089
1090 let mut report: Vec<StatusItem> = Vec::new();
1092 let mut yuiignore = paths::YuiIgnoreStack::new();
1093 yuiignore.push_dir(&source)?;
1094 let walk_result = (|| -> Result<()> {
1095 for m in &mounts {
1096 let src_root = m.src.clone();
1097 if !src_root.is_dir() {
1098 continue;
1099 }
1100 classify_walk(
1101 &src_root,
1102 &m.dst,
1103 &config,
1104 m.strategy,
1105 &mut engine,
1106 &tera_ctx,
1107 &source,
1108 &mut yuiignore,
1109 &mut report,
1110 )?;
1111 }
1112 Ok(())
1113 })();
1114 yuiignore.pop_dir(&source);
1115 walk_result?;
1116
1117 let render_report = render::render_all(&source, &config, &yui, true)?;
1119 for rendered in &render_report.diverged {
1120 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
1121 report.push(StatusItem {
1122 src: tera_path,
1123 dst: rendered.clone(),
1124 state: StatusState::RenderDrift,
1125 });
1126 }
1127
1128 let mut printed = 0usize;
1129 for item in &report {
1130 if !diff_worth_printing(&item.state) {
1131 continue;
1132 }
1133 let src_abs = resolve_diff_src(item, &source);
1134 print_unified_diff(
1135 &src_abs,
1136 &item.dst,
1137 &item.state,
1138 &source,
1139 &config,
1140 &yui,
1141 color,
1142 );
1143 printed += 1;
1144 }
1145
1146 if printed == 0 {
1147 println!(" no diff — every entry is in sync (or only needs a relink)");
1148 } else {
1149 println!();
1150 println!(
1151 " {printed} entr{} with content drift",
1152 if printed == 1 { "y" } else { "ies" }
1153 );
1154 }
1155 Ok(())
1156}
1157
1158fn resolve_diff_src(item: &StatusItem, source: &Utf8Path) -> Utf8PathBuf {
1170 match item.state {
1171 StatusState::RenderDrift => item.src.clone(),
1172 StatusState::Link(_) => source.join(&item.src),
1173 }
1174}
1175
1176fn diff_worth_printing(state: &StatusState) -> bool {
1177 use absorb::AbsorbDecision::*;
1178 match state {
1179 StatusState::Link(InSync) => false,
1180 StatusState::Link(Restore) => false, StatusState::Link(RelinkOnly) => false, StatusState::Link(_) => true,
1183 StatusState::RenderDrift => true,
1184 }
1185}
1186
1187fn print_unified_diff(
1195 src: &Utf8Path,
1196 dst: &Utf8Path,
1197 state: &StatusState,
1198 source_root: &Utf8Path,
1199 config: &Config,
1200 yui: &YuiVars,
1201 color: bool,
1202) {
1203 use owo_colors::OwoColorize as _;
1204
1205 let header = match state {
1206 StatusState::RenderDrift => format!("--- render drift: {src} (template) vs {dst}"),
1207 _ => format!("--- {src} → {dst}"),
1208 };
1209 if color {
1210 println!("{}", header.bold());
1211 } else {
1212 println!("{header}");
1213 }
1214
1215 if src.is_dir() || dst.is_dir() {
1216 println!("(directory entry — content listing skipped)");
1217 println!();
1218 return;
1219 }
1220
1221 let src_content = match state {
1226 StatusState::RenderDrift => match render::render_to_string(src, source_root, config, yui) {
1227 Ok(Some(s)) => s,
1228 Ok(None) => {
1229 println!(
1230 "(template would be skipped on this host — drift will resolve on next render)"
1231 );
1232 println!();
1233 return;
1234 }
1235 Err(e) => {
1236 println!("(error rendering template: {e})");
1237 println!();
1238 return;
1239 }
1240 },
1241 _ => match read_text_for_diff(src) {
1242 DiffSide::Text(s) => s,
1243 DiffSide::Binary => {
1244 println!("(binary file or non-UTF-8 content — diff skipped)");
1245 println!();
1246 return;
1247 }
1248 },
1249 };
1250 let dst_content = match read_text_for_diff(dst) {
1251 DiffSide::Text(s) => s,
1252 DiffSide::Binary => {
1253 println!("(binary file or non-UTF-8 content — diff skipped)");
1254 println!();
1255 return;
1256 }
1257 };
1258 print_unified_text_diff(
1259 &src_content,
1260 &dst_content,
1261 src.as_str(),
1262 dst.as_str(),
1263 color,
1264 );
1265 println!();
1266}
1267
1268fn print_unified_text_diff(src: &str, dst: &str, src_label: &str, dst_label: &str, color: bool) {
1277 use owo_colors::OwoColorize as _;
1278 let diff = similar::TextDiff::from_lines(src, dst);
1279 let formatted = diff.unified_diff().header(src_label, dst_label).to_string();
1280 for line in formatted.lines() {
1281 if !color {
1282 println!("{line}");
1283 } else if line.starts_with("+++") || line.starts_with("---") {
1284 println!("{}", line.dimmed());
1285 } else if line.starts_with("@@") {
1286 println!("{}", line.cyan());
1287 } else if line.starts_with('+') {
1288 println!("{}", line.green());
1289 } else if line.starts_with('-') {
1290 println!("{}", line.red());
1291 } else {
1292 println!("{line}");
1293 }
1294 }
1295}
1296
1297enum DiffSide {
1303 Text(String),
1304 Binary,
1305}
1306
1307fn read_text_for_diff(p: &Utf8Path) -> DiffSide {
1308 match std::fs::read_to_string(p) {
1309 Ok(s) => DiffSide::Text(s),
1310 Err(e) if e.kind() == std::io::ErrorKind::InvalidData => DiffSide::Binary,
1311 Err(_) => DiffSide::Text(String::new()),
1312 }
1313}
1314
1315pub fn status(
1328 source: Option<Utf8PathBuf>,
1329 icons_override: Option<IconsMode>,
1330 no_color: bool,
1331) -> Result<()> {
1332 let source = resolve_source(source)?;
1333 let yui = YuiVars::detect(&source);
1334 let config = config::load(&source, &yui)?;
1335
1336 let mut engine = template::Engine::new();
1337 let tera_ctx = template::template_context(&yui, &config.vars);
1338 let mounts = mount::resolve(
1339 &source,
1340 &config.mount.entry,
1341 config.mount.default_strategy,
1342 &mut engine,
1343 &tera_ctx,
1344 )?;
1345
1346 let icons_mode = icons_override.unwrap_or(config.ui.icons);
1347 let icons = Icons::for_mode(icons_mode);
1348 let color = !no_color && supports_color_stdout();
1349
1350 let mut report: Vec<StatusItem> = Vec::new();
1351
1352 let render_report = render::render_all(&source, &config, &yui, true)?;
1355 for rendered in &render_report.diverged {
1356 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
1360 report.push(StatusItem {
1361 src: relative_for_display(&source, &tera_path),
1362 dst: rendered.clone(),
1363 state: StatusState::RenderDrift,
1364 });
1365 }
1366
1367 let mut yuiignore = paths::YuiIgnoreStack::new();
1371 yuiignore.push_dir(&source)?;
1372 let walk_result = (|| -> Result<()> {
1373 for m in &mounts {
1374 let src_root = m.src.clone();
1375 if !src_root.is_dir() {
1376 warn!("mount src missing: {src_root}");
1377 continue;
1378 }
1379 classify_walk(
1380 &src_root,
1381 &m.dst,
1382 &config,
1383 m.strategy,
1384 &mut engine,
1385 &tera_ctx,
1386 &source,
1387 &mut yuiignore,
1388 &mut report,
1389 )?;
1390 }
1391 Ok(())
1392 })();
1393 yuiignore.pop_dir(&source);
1394 walk_result?;
1395
1396 report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
1397
1398 print_status_table(&report, icons, color);
1399
1400 let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
1401
1402 println!();
1403 let total = report.len();
1404 let in_sync = total - drift;
1405 if drift == 0 {
1406 println!(" {total} entries · all in sync");
1407 Ok(())
1408 } else {
1409 println!(" {total} entries · {in_sync} in sync · {drift} diverged");
1410 anyhow::bail!("status: {drift} entries diverged from source")
1411 }
1412}
1413
1414#[derive(Debug)]
1415struct StatusItem {
1416 src: Utf8PathBuf,
1418 dst: Utf8PathBuf,
1420 state: StatusState,
1421}
1422
1423#[derive(Debug, Clone, Copy)]
1424enum StatusState {
1425 Link(absorb::AbsorbDecision),
1426 RenderDrift,
1429}
1430
1431impl StatusState {
1432 fn is_in_sync(self) -> bool {
1433 matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
1434 }
1435}
1436
1437#[allow(clippy::too_many_arguments)]
1438fn classify_walk(
1439 src_dir: &Utf8Path,
1440 dst_dir: &Utf8Path,
1441 config: &Config,
1442 strategy: MountStrategy,
1443 engine: &mut template::Engine,
1444 tera_ctx: &TeraContext,
1445 source_root: &Utf8Path,
1446 yuiignore: &mut paths::YuiIgnoreStack,
1447 report: &mut Vec<StatusItem>,
1448) -> Result<()> {
1449 classify_walk_inner(
1450 src_dir,
1451 dst_dir,
1452 config,
1453 strategy,
1454 engine,
1455 tera_ctx,
1456 source_root,
1457 yuiignore,
1458 report,
1459 false,
1460 )
1461}
1462
1463#[allow(clippy::too_many_arguments)]
1464fn classify_walk_inner(
1465 src_dir: &Utf8Path,
1466 dst_dir: &Utf8Path,
1467 config: &Config,
1468 strategy: MountStrategy,
1469 engine: &mut template::Engine,
1470 tera_ctx: &TeraContext,
1471 source_root: &Utf8Path,
1472 yuiignore: &mut paths::YuiIgnoreStack,
1473 report: &mut Vec<StatusItem>,
1474 parent_covered: bool,
1475) -> Result<()> {
1476 if yuiignore.is_ignored(src_dir, true) {
1477 return Ok(());
1478 }
1479 yuiignore.push_dir(src_dir)?;
1482 let result = classify_walk_inner_body(
1483 src_dir,
1484 dst_dir,
1485 config,
1486 strategy,
1487 engine,
1488 tera_ctx,
1489 source_root,
1490 yuiignore,
1491 report,
1492 parent_covered,
1493 );
1494 yuiignore.pop_dir(src_dir);
1495 result
1496}
1497
1498#[allow(clippy::too_many_arguments)]
1499fn classify_walk_inner_body(
1500 src_dir: &Utf8Path,
1501 dst_dir: &Utf8Path,
1502 config: &Config,
1503 strategy: MountStrategy,
1504 engine: &mut template::Engine,
1505 tera_ctx: &TeraContext,
1506 source_root: &Utf8Path,
1507 yuiignore: &mut paths::YuiIgnoreStack,
1508 report: &mut Vec<StatusItem>,
1509 parent_covered: bool,
1510) -> Result<()> {
1511 let marker_filename = &config.mount.marker_filename;
1512 let mut covered = parent_covered;
1513
1514 if strategy == MountStrategy::Marker {
1515 match marker::read_spec(src_dir, marker_filename)? {
1516 None => {}
1517 Some(MarkerSpec::PassThrough) => {
1518 let decision = absorb::classify(src_dir, dst_dir)?;
1519 report.push(StatusItem {
1520 src: relative_for_display(source_root, src_dir),
1521 dst: dst_dir.to_path_buf(),
1522 state: StatusState::Link(decision),
1523 });
1524 covered = true;
1525 }
1526 Some(MarkerSpec::Explicit { links }) => {
1527 let mut emitted_dir_link = false;
1528 for link in &links {
1529 if let Some(when) = &link.when {
1530 if !template::eval_truthy(when, engine, tera_ctx)? {
1531 continue;
1532 }
1533 }
1534 let dst_str = engine.render(&link.dst, tera_ctx)?;
1535 let dst = paths::expand_tilde(dst_str.trim());
1536 if let Some(filename) = &link.src {
1537 let file_src = src_dir.join(filename);
1538 if !file_src.is_file() {
1539 anyhow::bail!(
1540 "marker at {src_dir}: [[link]] src={filename:?} \
1541 not found"
1542 );
1543 }
1544 let decision = absorb::classify(&file_src, &dst)?;
1545 report.push(StatusItem {
1546 src: relative_for_display(source_root, &file_src),
1547 dst,
1548 state: StatusState::Link(decision),
1549 });
1550 } else {
1551 let decision = absorb::classify(src_dir, &dst)?;
1552 report.push(StatusItem {
1553 src: relative_for_display(source_root, src_dir),
1554 dst,
1555 state: StatusState::Link(decision),
1556 });
1557 emitted_dir_link = true;
1558 }
1559 }
1560 if emitted_dir_link {
1561 covered = true;
1562 }
1563 }
1564 }
1565 }
1566
1567 for entry in std::fs::read_dir(src_dir)? {
1568 let entry = entry?;
1569 let name_os = entry.file_name();
1570 let Some(name) = name_os.to_str() else {
1571 continue;
1572 };
1573 if name == marker_filename || name.ends_with(".tera") {
1574 continue;
1575 }
1576 let src_path = src_dir.join(name);
1577 let dst_path = dst_dir.join(name);
1578 let ft = entry.file_type()?;
1579 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
1580 continue;
1581 }
1582 if ft.is_dir() {
1583 classify_walk_inner(
1584 &src_path,
1585 &dst_path,
1586 config,
1587 strategy,
1588 engine,
1589 tera_ctx,
1590 source_root,
1591 yuiignore,
1592 report,
1593 covered,
1594 )?;
1595 } else if ft.is_file() && !covered {
1596 let decision = absorb::classify(&src_path, &dst_path)?;
1597 report.push(StatusItem {
1598 src: relative_for_display(source_root, &src_path),
1599 dst: dst_path,
1600 state: StatusState::Link(decision),
1601 });
1602 }
1603 }
1604 Ok(())
1605}
1606
1607fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
1608 p.strip_prefix(source_root)
1609 .map(Utf8PathBuf::from)
1610 .unwrap_or_else(|_| p.to_path_buf())
1611}
1612
1613fn print_status_table(items: &[StatusItem], icons: Icons, color: bool) {
1614 let src_w = items
1615 .iter()
1616 .map(|i| i.src.as_str().chars().count())
1617 .max()
1618 .unwrap_or(0)
1619 .max("SRC".len());
1620 let dst_w = items
1621 .iter()
1622 .map(|i| i.dst.as_str().chars().count())
1623 .max()
1624 .unwrap_or(0)
1625 .max("DST".len());
1626 let state_label_w = items
1628 .iter()
1629 .map(|i| state_label(i.state).len())
1630 .max()
1631 .unwrap_or(0)
1632 .max("STATE".len() - 2); let state_w = state_label_w + 2; print_status_header(state_w, src_w, dst_w, color);
1636 let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
1637 if color {
1638 use owo_colors::OwoColorize as _;
1639 println!("{}", sep.dimmed());
1640 } else {
1641 println!("{sep}");
1642 }
1643 for item in items {
1644 print_status_row(item, icons, state_w, src_w, dst_w, color);
1645 }
1646}
1647
1648fn state_label(s: StatusState) -> &'static str {
1649 use absorb::AbsorbDecision::*;
1650 match s {
1651 StatusState::Link(InSync) => "in-sync",
1652 StatusState::Link(RelinkOnly) => "relink",
1653 StatusState::Link(AutoAbsorb) => "drift (auto)",
1654 StatusState::Link(NeedsConfirm) => "drift (anomaly)",
1655 StatusState::Link(Restore) => "missing",
1656 StatusState::RenderDrift => "render drift",
1657 }
1658}
1659
1660fn state_icon(s: StatusState, icons: Icons) -> &'static str {
1661 use absorb::AbsorbDecision::*;
1662 match s {
1663 StatusState::Link(InSync) => icons.ok,
1664 StatusState::Link(RelinkOnly) => icons.warn,
1665 StatusState::Link(AutoAbsorb) => icons.warn,
1666 StatusState::Link(NeedsConfirm) => icons.error,
1667 StatusState::Link(Restore) => icons.info,
1668 StatusState::RenderDrift => icons.error,
1669 }
1670}
1671
1672fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
1673 use owo_colors::OwoColorize as _;
1674 let line = format!(
1677 " {:<state_w$} {:<src_w$} {:<dst_w$}",
1678 "STATE", "SRC", "DST"
1679 );
1680 if color {
1681 println!("{}", line.bold());
1682 } else {
1683 println!("{line}");
1684 }
1685}
1686
1687fn render_status_separator(
1688 sep_ch: char,
1689 state_w: usize,
1690 src_w: usize,
1691 dst_w: usize,
1692 arrow: &str,
1693) -> String {
1694 let bar = |n: usize| sep_ch.to_string().repeat(n);
1695 format!(
1696 " {} {} {} {}",
1697 bar(state_w),
1698 bar(src_w),
1699 bar(arrow.chars().count()),
1700 bar(dst_w)
1701 )
1702}
1703
1704fn print_status_row(
1705 item: &StatusItem,
1706 icons: Icons,
1707 state_w: usize,
1708 src_w: usize,
1709 dst_w: usize,
1710 color: bool,
1711) {
1712 use owo_colors::OwoColorize as _;
1713 let icon = state_icon(item.state, icons);
1714 let label = state_label(item.state);
1715 let state_text = format!("{icon} {label}");
1716 let src_display = item.src.as_str().replace('\\', "/");
1717 let dst_display = item.dst.as_str().replace('\\', "/");
1718 let arrow = icons.arrow;
1719
1720 let cell_state = format!("{:<state_w$}", state_text);
1721 let cell_src = format!("{:<src_w$}", src_display);
1722 let cell_dst = format!("{:<dst_w$}", dst_display);
1723
1724 if !color {
1725 println!(" {cell_state} {cell_src} {arrow} {cell_dst}");
1726 return;
1727 }
1728
1729 use absorb::AbsorbDecision::*;
1730 let state_colored = match item.state {
1731 StatusState::Link(InSync) => cell_state.green().to_string(),
1732 StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
1733 cell_state.yellow().to_string()
1734 }
1735 StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
1736 StatusState::Link(Restore) => cell_state.cyan().to_string(),
1737 StatusState::RenderDrift => cell_state.red().to_string(),
1738 };
1739 let src_colored = cell_src.cyan().to_string();
1740 let arrow_colored = arrow.dimmed().to_string();
1741 let dst_colored = cell_dst.dimmed().to_string();
1742 println!(" {state_colored} {src_colored} {arrow_colored} {dst_colored}");
1743}
1744
1745pub fn absorb(
1759 source: Option<Utf8PathBuf>,
1760 target: Utf8PathBuf,
1761 dry_run: bool,
1762 yes: bool,
1763) -> Result<()> {
1764 let source = resolve_source(source)?;
1765 let target = absolutize(&target)?;
1766 let yui = YuiVars::detect(&source);
1767 let config = config::load(&source, &yui)?;
1768
1769 let mut engine = template::Engine::new();
1770 let tera_ctx = template::template_context(&yui, &config.vars);
1771
1772 let src_path = match find_source_for_target(&source, &config, &target, &mut engine, &tera_ctx)?
1773 {
1774 Some(s) => s,
1775 None => anyhow::bail!(
1776 "no mount entry / .yuilink override claims target {target}; \
1777 pass a path inside a known dst"
1778 ),
1779 };
1780
1781 info!("source for {target}: {src_path}");
1782
1783 print_absorb_diff(&src_path, &target);
1788
1789 if dry_run {
1790 info!("[dry-run] would absorb {target} → {src_path}");
1791 return Ok(());
1792 }
1793
1794 if !yes {
1795 use std::io::IsTerminal;
1796 if !std::io::stdin().is_terminal() {
1797 anyhow::bail!(
1798 "manual absorb refuses to run off-TTY without --yes \
1799 (would silently overwrite {src_path})"
1800 );
1801 }
1802 if !prompt_yes_no("absorb target into source?")? {
1803 warn!("manual absorb cancelled by user: {target}");
1804 return Ok(());
1805 }
1806 }
1807
1808 let backup_root = source.join(&config.backup.dir);
1809 let ctx = ApplyCtx {
1810 config: &config,
1811 source: &source,
1812 file_mode: resolve_file_mode(config.link.file_mode),
1813 dir_mode: resolve_dir_mode(config.link.dir_mode),
1814 backup_root: &backup_root,
1815 dry_run: false,
1816 };
1817
1818 absorb_target_into_source(&src_path, &target, &ctx)
1821}
1822
1823fn print_absorb_diff(src: &Utf8Path, dst: &Utf8Path) {
1828 eprintln!();
1829 eprintln!("--- diff (- source, + target) ---");
1830 eprintln!(" src: {src}");
1831 eprintln!(" dst: {dst}");
1832 eprintln!();
1833 if src.is_dir() || dst.is_dir() {
1834 eprintln!("(directory absorb — content listing skipped)");
1835 eprintln!();
1836 return;
1837 }
1838 let src_content = match read_text_for_diff(src) {
1839 DiffSide::Text(s) => s,
1840 DiffSide::Binary => {
1841 eprintln!("(binary file or non-UTF-8 content — diff skipped)");
1842 eprintln!();
1843 return;
1844 }
1845 };
1846 let dst_content = match read_text_for_diff(dst) {
1847 DiffSide::Text(s) => s,
1848 DiffSide::Binary => {
1849 eprintln!("(binary file or non-UTF-8 content — diff skipped)");
1850 eprintln!();
1851 return;
1852 }
1853 };
1854 let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
1855 let formatted = diff
1856 .unified_diff()
1857 .header(src.as_str(), dst.as_str())
1858 .to_string();
1859 eprint!("{formatted}");
1860 eprintln!();
1861}
1862
1863fn prompt_yes_no(question: &str) -> Result<bool> {
1864 use std::io::Write as _;
1865 eprint!("{question} [y/N]: ");
1866 std::io::stderr().flush().ok();
1867 let mut input = String::new();
1868 std::io::stdin().read_line(&mut input)?;
1869 let answer = input.trim();
1870 Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
1871}
1872
1873fn find_source_for_target(
1877 source: &Utf8Path,
1878 config: &Config,
1879 target: &Utf8Path,
1880 engine: &mut template::Engine,
1881 tera_ctx: &TeraContext,
1882) -> Result<Option<Utf8PathBuf>> {
1883 for entry in &config.mount.entry {
1885 if let Some(when) = &entry.when {
1886 if !template::eval_truthy(when, engine, tera_ctx)? {
1887 continue;
1888 }
1889 }
1890 let dst_str = engine.render(&entry.dst, tera_ctx)?;
1891 let dst_root = paths::expand_tilde(dst_str.trim());
1892 if let Ok(rel) = target.strip_prefix(&dst_root) {
1893 let src_str = engine.render(entry.src.as_str(), tera_ctx)?;
1894 let candidate = paths::resolve_mount_src(source, src_str.trim()).join(rel);
1895 if paths::is_ignored_at(source, &candidate, candidate.is_dir())? {
1900 continue;
1901 }
1902 return Ok(Some(candidate));
1903 }
1904 }
1905
1906 let walker = paths::source_walker(source).build();
1912 let marker_filename = &config.mount.marker_filename;
1913 for ent in walker {
1914 let ent = match ent {
1915 Ok(e) => e,
1916 Err(_) => continue,
1917 };
1918 if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
1919 continue;
1920 }
1921 if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
1922 continue;
1923 }
1924 let dir = match ent.path().parent() {
1925 Some(d) => d,
1926 None => continue,
1927 };
1928 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
1929 Ok(p) => p,
1930 Err(_) => continue,
1931 };
1932 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
1933 Some(s) => s,
1934 None => continue,
1935 };
1936 let MarkerSpec::Explicit { links } = spec else {
1937 continue;
1938 };
1939 for link in &links {
1940 if let Some(when) = &link.when {
1941 if !template::eval_truthy(when, engine, tera_ctx)? {
1942 continue;
1943 }
1944 }
1945 let dst_str = engine.render(&link.dst, tera_ctx)?;
1946 let dst = paths::expand_tilde(dst_str.trim());
1947 if let Some(filename) = &link.src {
1954 let file_src = dir_utf8.join(filename);
1955 if !file_src.is_file() {
1956 anyhow::bail!(
1957 "marker at {dir_utf8}: [[link]] src={filename:?} \
1958 not found"
1959 );
1960 }
1961 if target == dst {
1962 return Ok(Some(file_src));
1963 }
1964 continue;
1965 }
1966 if target == dst {
1967 return Ok(Some(dir_utf8));
1968 }
1969 if let Ok(rel) = target.strip_prefix(&dst) {
1970 return Ok(Some(dir_utf8.join(rel)));
1971 }
1972 }
1973 }
1974
1975 Ok(None)
1976}
1977
1978pub fn doctor(
1979 source: Option<Utf8PathBuf>,
1980 icons_override: Option<IconsMode>,
1981 no_color: bool,
1982) -> Result<()> {
1983 use owo_colors::OwoColorize as _;
1984
1985 let resolved_source = resolve_source(source);
1990
1991 let yui = match &resolved_source {
1996 Ok(s) => YuiVars::detect(s),
1997 Err(_) => YuiVars::detect(Utf8Path::new(".")),
1998 };
1999
2000 let cfg_res = match &resolved_source {
2005 Ok(s) => Some(config::load(s, &yui)),
2006 Err(_) => None,
2007 };
2008 let cfg = cfg_res.as_ref().and_then(|r| r.as_ref().ok());
2009 let icons_mode = icons_override
2010 .or_else(|| cfg.map(|c| c.ui.icons))
2011 .unwrap_or_default();
2012 let icons = Icons::for_mode(icons_mode);
2013 let color = !no_color && supports_color_stdout();
2014
2015 let mut probes: Vec<Probe> = Vec::new();
2016
2017 probes.push(Probe::group("identity"));
2019 probes.push(Probe::ok("os/arch", format!("{} / {}", yui.os, yui.arch)));
2020 probes.push(Probe::ok("user@host", format!("{}@{}", yui.user, yui.host)));
2021
2022 probes.push(Probe::group("repo"));
2024 let mut have_source = false;
2025 match &resolved_source {
2026 Ok(s) => {
2027 have_source = true;
2028 probes.push(Probe::ok("source", s.to_string()));
2029 match cfg_res.as_ref().expect("cfg_res set when source is Ok") {
2030 Ok(c) => {
2031 probes.push(Probe::ok(
2032 "config",
2033 format!(
2034 "{} mount{} · {} hook{} · {} render rule{}",
2035 c.mount.entry.len(),
2036 plural(c.mount.entry.len()),
2037 c.hook.len(),
2038 plural(c.hook.len()),
2039 c.render.rule.len(),
2040 plural(c.render.rule.len()),
2041 ),
2042 ));
2043 }
2044 Err(e) => probes.push(Probe::error("config", format!("{e}"))),
2045 }
2046 match crate::git::is_clean(s) {
2050 Ok(true) => probes.push(Probe::ok("git", "clean")),
2051 Ok(false) => probes.push(Probe::warn(
2052 "git",
2053 "uncommitted changes — `[absorb] require_clean_git` will defer auto-absorb",
2054 )),
2055 Err(_) => probes.push(Probe::warn(
2056 "git",
2057 "no git repo (auto-absorb still works; commit history won't track drift)",
2058 )),
2059 }
2060 }
2061 Err(e) => {
2062 probes.push(Probe::error("source", format!("not found — {e}")));
2063 }
2064 }
2065
2066 probes.push(Probe::group("links"));
2068 if cfg!(windows) {
2069 probes.push(Probe::ok(
2070 "default mode",
2071 "files=hardlink, dirs=junction (no admin needed)",
2072 ));
2073 } else {
2074 probes.push(Probe::ok("default mode", "files=symlink, dirs=symlink"));
2075 }
2076
2077 if have_source {
2079 if let (Ok(s), Some(c)) = (&resolved_source, cfg) {
2080 probes.push(Probe::group("hooks"));
2081 if c.hook.is_empty() {
2082 probes.push(Probe::ok("hooks", "(none configured)"));
2083 } else {
2084 let mut missing = 0usize;
2085 for h in &c.hook {
2086 if !s.join(&h.script).is_file() {
2087 missing += 1;
2088 probes.push(Probe::error(
2089 format!("hook[{}]", h.name),
2090 format!("script not found at {}", h.script),
2091 ));
2092 }
2093 }
2094 if missing == 0 {
2095 probes.push(Probe::ok(
2096 "scripts",
2097 format!(
2098 "{} hook{} configured, all scripts present",
2099 c.hook.len(),
2100 plural(c.hook.len())
2101 ),
2102 ));
2103 }
2104 }
2105 }
2106 }
2107
2108 if let Some(home) = paths::home_dir() {
2110 let chezmoi_src = home.join(".local/share/chezmoi");
2111 if chezmoi_src.is_dir() {
2112 probes.push(Probe::group("chezmoi"));
2113 probes.push(Probe::warn(
2114 "legacy source",
2115 format!(
2116 "{chezmoi_src} still exists — yui doesn't use it, safe to archive once your migration has settled"
2117 ),
2118 ));
2119 }
2120 }
2121
2122 println!();
2124 if color {
2125 println!(" {}", "yui doctor".bold().underline());
2126 } else {
2127 println!(" yui doctor");
2128 }
2129 println!();
2130 for probe in &probes {
2131 probe.print(&icons, color);
2132 }
2133
2134 let errors = probes.iter().filter(|p| p.is_error()).count();
2135 let warns = probes.iter().filter(|p| p.is_warn()).count();
2136 let oks = probes.iter().filter(|p| p.is_ok()).count();
2137 println!();
2138 let summary = format!("{oks} ok · {warns} warn · {errors} error");
2139 if color {
2140 if errors > 0 {
2141 println!(" {}", summary.red().bold());
2142 } else if warns > 0 {
2143 println!(" {}", summary.yellow());
2144 } else {
2145 println!(" {}", summary.green());
2146 }
2147 } else {
2148 println!(" {summary}");
2149 }
2150
2151 if errors > 0 {
2152 anyhow::bail!("doctor: {errors} probe(s) failed");
2153 }
2154 Ok(())
2155}
2156
2157#[derive(Debug)]
2158enum Probe {
2159 Group(&'static str),
2161 Ok {
2162 label: String,
2163 detail: String,
2164 },
2165 Warn {
2166 label: String,
2167 detail: String,
2168 },
2169 Error {
2170 label: String,
2171 detail: String,
2172 },
2173}
2174
2175impl Probe {
2176 fn group(label: &'static str) -> Self {
2177 Self::Group(label)
2178 }
2179 fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
2180 Self::Ok {
2181 label: label.into(),
2182 detail: detail.into(),
2183 }
2184 }
2185 fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
2186 Self::Warn {
2187 label: label.into(),
2188 detail: detail.into(),
2189 }
2190 }
2191 fn error(label: impl Into<String>, detail: impl Into<String>) -> Self {
2192 Self::Error {
2193 label: label.into(),
2194 detail: detail.into(),
2195 }
2196 }
2197 fn is_ok(&self) -> bool {
2198 matches!(self, Self::Ok { .. })
2199 }
2200 fn is_warn(&self) -> bool {
2201 matches!(self, Self::Warn { .. })
2202 }
2203 fn is_error(&self) -> bool {
2204 matches!(self, Self::Error { .. })
2205 }
2206 fn print(&self, icons: &Icons, color: bool) {
2207 use owo_colors::OwoColorize as _;
2208 match self {
2209 Self::Group(name) => {
2210 println!();
2211 if color {
2212 println!(" {}", name.cyan().bold());
2213 } else {
2214 println!(" {name}");
2215 }
2216 }
2217 Self::Ok { label, detail } => {
2218 let icon = icons.ok;
2219 let padded = format!("{label:<14}");
2223 if color {
2224 println!(
2225 " {} {} {}",
2226 icon.green(),
2227 padded.bold(),
2228 detail.dimmed()
2229 );
2230 } else {
2231 println!(" {icon} {padded} {detail}");
2232 }
2233 }
2234 Self::Warn { label, detail } => {
2235 let icon = icons.warn;
2236 let padded = format!("{label:<14}");
2237 if color {
2238 println!(
2239 " {} {} {}",
2240 icon.yellow(),
2241 padded.bold().yellow(),
2242 detail
2243 );
2244 } else {
2245 println!(" {icon} {padded} {detail}");
2246 }
2247 }
2248 Self::Error { label, detail } => {
2249 let icon = icons.error;
2250 let padded = format!("{label:<14}");
2251 if color {
2252 println!(
2253 " {} {} {}",
2254 icon.red().bold(),
2255 padded.bold().red(),
2256 detail.red()
2257 );
2258 } else {
2259 println!(" {icon} {padded} {detail}");
2260 }
2261 }
2262 }
2263 }
2264}
2265
2266fn plural(n: usize) -> &'static str {
2267 if n == 1 { "" } else { "s" }
2268}
2269
2270pub fn gc_backup(
2290 source: Option<Utf8PathBuf>,
2291 older_than: Option<String>,
2292 dry_run: bool,
2293 icons_override: Option<IconsMode>,
2294 no_color: bool,
2295) -> Result<()> {
2296 let source = resolve_source(source)?;
2297 let yui = YuiVars::detect(&source);
2298 let config = config::load(&source, &yui)?;
2299 let backup_root = source.join(&config.backup.dir);
2300 let icons_mode = icons_override.unwrap_or(config.ui.icons);
2301 let icons = Icons::for_mode(icons_mode);
2302 let color = !no_color && supports_color_stdout();
2303
2304 if !backup_root.is_dir() {
2305 println!(" no backup tree at {backup_root}");
2306 return Ok(());
2307 }
2308
2309 let mut entries = walk_gc_backups(&backup_root)?;
2310 if entries.is_empty() {
2311 println!(" no yui-stamped backups under {backup_root}");
2312 return Ok(());
2313 }
2314 entries.sort_by_key(|e| e.ts);
2316 let now = jiff::Zoned::now();
2317
2318 match older_than {
2319 None => {
2320 let refs: Vec<&BackupEntry> = entries.iter().collect();
2321 print_gc_table(&refs, &backup_root, &now, icons, color);
2322 println!();
2323 println!(
2324 " {} entries · {} total — pass --older-than DUR (e.g. 30d) to delete",
2325 entries.len(),
2326 format_bytes(entries.iter().map(|e| e.size_bytes).sum())
2327 );
2328 Ok(())
2329 }
2330 Some(dur_str) => {
2331 let span = parse_human_duration(&dur_str)?;
2332 let cutoff = now
2333 .checked_sub(span)
2334 .map_err(|e| anyhow::anyhow!("invalid duration {dur_str:?}: {e}"))?;
2335 let cutoff_dt = cutoff.datetime();
2336
2337 let total_before: u64 = entries.iter().map(|e| e.size_bytes).sum();
2338 let to_delete: Vec<&BackupEntry> =
2339 entries.iter().filter(|e| e.ts < cutoff_dt).collect();
2340
2341 if to_delete.is_empty() {
2342 println!(
2343 " no backups older than {dur_str} (oldest: {})",
2344 format_age(entries[0].ts, &now)
2345 );
2346 return Ok(());
2347 }
2348
2349 print_gc_table(&to_delete, &backup_root, &now, icons, color);
2350 println!();
2351 let total_freed: u64 = to_delete.iter().map(|e| e.size_bytes).sum();
2352
2353 if dry_run {
2354 println!(
2355 " [dry-run] would remove {} of {} entries · would free {} of {}",
2356 to_delete.len(),
2357 entries.len(),
2358 format_bytes(total_freed),
2359 format_bytes(total_before),
2360 );
2361 return Ok(());
2362 }
2363
2364 for entry in &to_delete {
2365 match entry.kind {
2366 BackupKind::File => std::fs::remove_file(&entry.path)?,
2367 BackupKind::Dir => std::fs::remove_dir_all(&entry.path)?,
2368 }
2369 if let Some(parent) = entry.path.parent() {
2370 cleanup_empty_parents(parent, &backup_root);
2371 }
2372 }
2373 println!(
2374 " removed {} of {} entries · freed {} (was {}, now {})",
2375 to_delete.len(),
2376 entries.len(),
2377 format_bytes(total_freed),
2378 format_bytes(total_before),
2379 format_bytes(total_before - total_freed),
2380 );
2381 Ok(())
2382 }
2383 }
2384}
2385
2386#[derive(Debug)]
2387struct BackupEntry {
2388 path: Utf8PathBuf,
2389 ts: jiff::civil::DateTime,
2390 kind: BackupKind,
2391 size_bytes: u64,
2392}
2393
2394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2395enum BackupKind {
2396 File,
2397 Dir,
2398}
2399
2400fn walk_gc_backups(root: &Utf8Path) -> Result<Vec<BackupEntry>> {
2405 let mut out = Vec::new();
2406 walk_gc_backups_rec(root, &mut out)?;
2407 Ok(out)
2408}
2409
2410fn walk_gc_backups_rec(dir: &Utf8Path, out: &mut Vec<BackupEntry>) -> Result<()> {
2411 for entry in std::fs::read_dir(dir)? {
2412 let entry = entry?;
2413 let name_os = entry.file_name();
2414 let Some(name) = name_os.to_str() else {
2415 continue;
2416 };
2417 let path = dir.join(name);
2418 let ft = entry.file_type()?;
2419 if ft.is_dir() {
2420 if let Some(ts) = parse_backup_suffix(name) {
2421 let size = dir_size(&path)?;
2422 out.push(BackupEntry {
2423 path,
2424 ts,
2425 kind: BackupKind::Dir,
2426 size_bytes: size,
2427 });
2428 } else {
2429 walk_gc_backups_rec(&path, out)?;
2430 }
2431 } else if ft.is_file() {
2432 if let Some(ts) = parse_backup_suffix(name) {
2435 let size = entry.metadata()?.len();
2436 out.push(BackupEntry {
2437 path,
2438 ts,
2439 kind: BackupKind::File,
2440 size_bytes: size,
2441 });
2442 }
2443 }
2444 }
2445 Ok(())
2446}
2447
2448fn dir_size(dir: &Utf8Path) -> Result<u64> {
2449 let mut total: u64 = 0;
2450 for entry in std::fs::read_dir(dir)? {
2451 let entry = entry?;
2452 let ft = entry.file_type()?;
2453 if ft.is_dir() {
2454 let p = match Utf8PathBuf::from_path_buf(entry.path()) {
2455 Ok(p) => p,
2456 Err(_) => continue,
2457 };
2458 total = total.saturating_add(dir_size(&p)?);
2459 } else if ft.is_file() {
2460 total = total.saturating_add(entry.metadata()?.len());
2461 }
2462 }
2463 Ok(total)
2464}
2465
2466fn cleanup_empty_parents(start: &Utf8Path, root: &Utf8Path) {
2470 let mut cur = start.to_path_buf();
2471 loop {
2472 if cur == *root {
2473 return;
2474 }
2475 if std::fs::remove_dir(&cur).is_err() {
2477 return;
2478 }
2479 match cur.parent() {
2480 Some(p) => cur = p.to_path_buf(),
2481 None => return,
2482 }
2483 }
2484}
2485
2486fn parse_backup_suffix(name: &str) -> Option<jiff::civil::DateTime> {
2492 if let Some(ts) = parse_ts_at_end(name) {
2493 return Some(ts);
2494 }
2495 if let Some((before, _ext)) = name.rsplit_once('.') {
2498 if let Some(ts) = parse_ts_at_end(before) {
2499 return Some(ts);
2500 }
2501 }
2502 None
2503}
2504
2505fn parse_ts_at_end(s: &str) -> Option<jiff::civil::DateTime> {
2506 if s.len() < 20 {
2508 return None;
2509 }
2510 let split_at = s.len() - 19;
2511 if s.as_bytes()[split_at] != b'_' {
2512 return None;
2513 }
2514 parse_ts(&s[split_at + 1..])
2515}
2516
2517fn parse_ts(s: &str) -> Option<jiff::civil::DateTime> {
2519 if s.len() != 18 || s.as_bytes()[8] != b'_' {
2520 return None;
2521 }
2522 for (i, &b) in s.as_bytes().iter().enumerate() {
2523 if i == 8 {
2524 continue;
2525 }
2526 if !b.is_ascii_digit() {
2527 return None;
2528 }
2529 }
2530 let year: i16 = s[0..4].parse().ok()?;
2531 let month: i8 = s[4..6].parse().ok()?;
2532 let day: i8 = s[6..8].parse().ok()?;
2533 let hour: i8 = s[9..11].parse().ok()?;
2534 let minute: i8 = s[11..13].parse().ok()?;
2535 let second: i8 = s[13..15].parse().ok()?;
2536 let ms: i32 = s[15..18].parse().ok()?;
2537 jiff::civil::DateTime::new(year, month, day, hour, minute, second, ms * 1_000_000).ok()
2538}
2539
2540fn parse_human_duration(s: &str) -> Result<jiff::Span> {
2549 let s = s.trim();
2550 let split = s
2551 .bytes()
2552 .position(|b| b.is_ascii_alphabetic())
2553 .ok_or_else(|| anyhow::anyhow!("invalid duration {s:?}: missing unit (e.g. 30d, 2w)"))?;
2554 let n: i64 = s[..split]
2555 .trim()
2556 .parse()
2557 .map_err(|_| anyhow::anyhow!("invalid duration {s:?}: bad leading number"))?;
2558 if n < 0 {
2559 anyhow::bail!("invalid duration {s:?}: negative durations don't make sense");
2560 }
2561 let unit = s[split..].to_ascii_lowercase();
2562 let span = match unit.as_str() {
2563 "y" | "yr" | "year" | "years" => jiff::Span::new().years(n),
2564 "mo" | "month" | "months" => jiff::Span::new().months(n),
2565 "w" | "wk" | "week" | "weeks" => jiff::Span::new().weeks(n),
2566 "d" | "day" | "days" => jiff::Span::new().days(n),
2567 "h" | "hr" | "hour" | "hours" => jiff::Span::new().hours(n),
2568 "m" | "min" | "minute" | "minutes" => jiff::Span::new().minutes(n),
2569 other => {
2570 anyhow::bail!(
2571 "invalid duration {s:?}: unknown unit {other:?} \
2572 (use y / mo / w / d / h / m)"
2573 )
2574 }
2575 };
2576 Ok(span)
2577}
2578
2579fn format_bytes(n: u64) -> String {
2580 const KIB: u64 = 1024;
2581 const MIB: u64 = KIB * 1024;
2582 const GIB: u64 = MIB * 1024;
2583 if n >= GIB {
2584 format!("{:.1} GiB", n as f64 / GIB as f64)
2585 } else if n >= MIB {
2586 format!("{:.1} MiB", n as f64 / MIB as f64)
2587 } else if n >= KIB {
2588 format!("{:.1} KiB", n as f64 / KIB as f64)
2589 } else {
2590 format!("{n} B")
2591 }
2592}
2593
2594fn format_age(ts: jiff::civil::DateTime, now: &jiff::Zoned) -> String {
2595 let Ok(ts_zoned) = ts.to_zoned(now.time_zone().clone()) else {
2596 return "?".into();
2597 };
2598 let secs = match (now - &ts_zoned).total(jiff::Unit::Second) {
2599 Ok(s) => s as i64,
2600 Err(_) => return "?".into(),
2601 };
2602 if secs < 0 {
2603 return "future".into();
2604 }
2605 if secs < 60 {
2606 format!("{secs}s")
2607 } else if secs < 3600 {
2608 format!("{}m", secs / 60)
2609 } else if secs < 86_400 {
2610 format!("{}h", secs / 3600)
2611 } else if secs < 86_400 * 30 {
2612 format!("{}d", secs / 86_400)
2613 } else if secs < 86_400 * 365 {
2614 format!("{}mo", secs / (86_400 * 30))
2615 } else {
2616 format!("{}y", secs / (86_400 * 365))
2617 }
2618}
2619
2620fn print_gc_table(
2627 entries: &[&BackupEntry],
2628 backup_root: &Utf8Path,
2629 now: &jiff::Zoned,
2630 _icons: Icons,
2631 color: bool,
2632) {
2633 use owo_colors::OwoColorize as _;
2634
2635 let rows: Vec<(String, String, String)> = entries
2636 .iter()
2637 .map(|e| {
2638 let rel = e
2639 .path
2640 .strip_prefix(backup_root)
2641 .map(Utf8PathBuf::from)
2642 .unwrap_or_else(|_| e.path.clone());
2643 let path_disp = match e.kind {
2644 BackupKind::Dir => format!("{rel}/"),
2645 BackupKind::File => rel.to_string(),
2646 };
2647 (format_age(e.ts, now), format_bytes(e.size_bytes), path_disp)
2648 })
2649 .collect();
2650
2651 let age_w = rows.iter().map(|r| r.0.len()).max().unwrap_or(3);
2652 let size_w = rows.iter().map(|r| r.1.len()).max().unwrap_or(4);
2653
2654 if color {
2655 println!(
2656 " {:<age_w$} {:>size_w$} {}",
2657 "AGE".dimmed(),
2658 "SIZE".dimmed(),
2659 "PATH".dimmed(),
2660 );
2661 } else {
2662 println!(" {:<age_w$} {:>size_w$} PATH", "AGE", "SIZE");
2663 }
2664 for (age, size, path) in &rows {
2665 if color {
2666 println!(
2667 " {:<age_w$} {:>size_w$} {}",
2668 age.yellow(),
2669 size,
2670 path.cyan(),
2671 );
2672 } else {
2673 println!(" {:<age_w$} {:>size_w$} {}", age, size, path);
2674 }
2675 }
2676}
2677
2678pub fn hooks_list(
2680 source: Option<Utf8PathBuf>,
2681 icons_override: Option<IconsMode>,
2682 no_color: bool,
2683) -> Result<()> {
2684 let source = resolve_source(source)?;
2685 let yui = YuiVars::detect(&source);
2686 let config = config::load(&source, &yui)?;
2687 let state = hook::State::load(&source)?;
2688
2689 let icons_mode = icons_override.unwrap_or(config.ui.icons);
2690 let icons = Icons::for_mode(icons_mode);
2691 let color = !no_color && supports_color_stdout();
2692
2693 if config.hook.is_empty() {
2694 println!("(no [[hook]] entries in config)");
2695 return Ok(());
2696 }
2697
2698 let mut engine = template::Engine::new();
2702 let tera_ctx = template::template_context(&yui, &config.vars);
2703 let rows: Vec<HookRow> = config
2704 .hook
2705 .iter()
2706 .map(|h| -> Result<HookRow> {
2707 let active = match &h.when {
2711 None => true,
2712 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
2713 };
2714 let last_run_at = state.hooks.get(&h.name).and_then(|s| s.last_run_at.clone());
2715 Ok(HookRow {
2716 name: h.name.clone(),
2717 phase: match h.phase {
2718 HookPhase::Pre => "pre",
2719 HookPhase::Post => "post",
2720 },
2721 when_run: match h.when_run {
2722 config::WhenRun::Once => "once",
2723 config::WhenRun::Onchange => "onchange",
2724 config::WhenRun::Every => "every",
2725 },
2726 last_run_at,
2727 when: h.when.clone(),
2728 active,
2729 })
2730 })
2731 .collect::<Result<Vec<_>>>()?;
2732
2733 print_hooks_table(&rows, icons, color);
2734
2735 let total = rows.len();
2736 let active = rows.iter().filter(|r| r.active).count();
2737 let inactive = total - active;
2738 let ran = rows.iter().filter(|r| r.last_run_at.is_some()).count();
2739 let never = total - ran;
2740 println!();
2741 println!(
2742 " {total} hooks · {active} active · {inactive} inactive · {ran} ran · {never} never run"
2743 );
2744
2745 Ok(())
2746}
2747
2748#[derive(Debug)]
2749struct HookRow {
2750 name: String,
2751 phase: &'static str,
2752 when_run: &'static str,
2753 last_run_at: Option<String>,
2754 when: Option<String>,
2755 active: bool,
2756}
2757
2758fn print_hooks_table(rows: &[HookRow], icons: Icons, color: bool) {
2759 use owo_colors::OwoColorize as _;
2760 use std::fmt::Write as _;
2761
2762 let name_w = rows
2763 .iter()
2764 .map(|r| r.name.chars().count())
2765 .max()
2766 .unwrap_or(0)
2767 .max("NAME".len());
2768 let phase_w = rows
2769 .iter()
2770 .map(|r| r.phase.len())
2771 .max()
2772 .unwrap_or(0)
2773 .max("PHASE".len());
2774 let when_run_w = rows
2775 .iter()
2776 .map(|r| r.when_run.len())
2777 .max()
2778 .unwrap_or(0)
2779 .max("WHEN_RUN".len());
2780 let last_w = rows
2781 .iter()
2782 .map(|r| {
2783 r.last_run_at
2784 .as_deref()
2785 .map(|s| s.chars().count())
2786 .unwrap_or("(never)".len())
2787 })
2788 .max()
2789 .unwrap_or(0)
2790 .max("LAST_RUN".len());
2791 let status_w = "STATUS".len();
2792
2793 let mut header = String::new();
2795 let _ = write!(
2796 &mut header,
2797 " {:<status_w$} {:<name_w$} {:<phase_w$} {:<when_run_w$} {:<last_w$} WHEN",
2798 "STATUS", "NAME", "PHASE", "WHEN_RUN", "LAST_RUN"
2799 );
2800 if color {
2801 println!("{}", header.bold());
2802 } else {
2803 println!("{header}");
2804 }
2805
2806 let bar = |n: usize| icons.sep.to_string().repeat(n);
2808 let sep = format!(
2809 " {} {} {} {} {} {}",
2810 bar(status_w),
2811 bar(name_w),
2812 bar(phase_w),
2813 bar(when_run_w),
2814 bar(last_w),
2815 bar("WHEN".len())
2816 );
2817 if color {
2818 println!("{}", sep.dimmed());
2819 } else {
2820 println!("{sep}");
2821 }
2822
2823 for r in rows {
2825 let (icon, ran) = match (r.active, r.last_run_at.is_some()) {
2830 (false, _) => (icons.inactive, false),
2831 (true, true) => (icons.active, true),
2832 (true, false) => (icons.info, false),
2833 };
2834 let last = r.last_run_at.as_deref().unwrap_or("(never)");
2835 let when_str = r
2836 .when
2837 .as_deref()
2838 .map(strip_braces)
2839 .unwrap_or_else(|| "(always)".to_string());
2840
2841 let cell_status = format!("{icon:<status_w$}");
2842 let cell_name = format!("{:<name_w$}", r.name);
2843 let cell_phase = format!("{:<phase_w$}", r.phase);
2844 let cell_when_run = format!("{:<when_run_w$}", r.when_run);
2845 let cell_last = format!("{last:<last_w$}");
2846
2847 if !color {
2848 println!(
2849 " {cell_status} {cell_name} {cell_phase} {cell_when_run} {cell_last} {when_str}"
2850 );
2851 continue;
2852 }
2853
2854 if !r.active {
2858 println!(
2859 " {} {} {} {} {} {}",
2860 cell_status.dimmed(),
2861 cell_name.dimmed(),
2862 cell_phase.dimmed(),
2863 cell_when_run.dimmed(),
2864 cell_last.dimmed(),
2865 when_str.dimmed()
2866 );
2867 } else if ran {
2868 println!(
2869 " {} {} {} {} {} {}",
2870 cell_status.green(),
2871 cell_name.cyan().bold(),
2872 cell_phase.dimmed(),
2873 cell_when_run.dimmed(),
2874 cell_last.green(),
2875 when_str.dimmed()
2876 );
2877 } else {
2878 println!(
2879 " {} {} {} {} {} {}",
2880 cell_status.yellow(),
2881 cell_name.cyan().bold(),
2882 cell_phase.dimmed(),
2883 cell_when_run.dimmed(),
2884 cell_last.yellow(),
2885 when_str.dimmed()
2886 );
2887 }
2888 }
2889}
2890
2891pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
2895 let source = resolve_source(source)?;
2896 let yui = YuiVars::detect(&source);
2897 let config = config::load(&source, &yui)?;
2898 let mut engine = template::Engine::new();
2899 let tera_ctx = template::template_context(&yui, &config.vars);
2900
2901 let targets: Vec<&config::HookConfig> = match &name {
2902 Some(want) => {
2903 let m = config
2904 .hook
2905 .iter()
2906 .find(|h| &h.name == want)
2907 .ok_or_else(|| {
2908 anyhow::anyhow!(
2909 "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
2910 )
2911 })?;
2912 vec![m]
2913 }
2914 None => config.hook.iter().collect(),
2915 };
2916
2917 let mut state = hook::State::load(&source)?;
2918 for h in targets {
2919 let outcome = hook::run_hook(
2920 h,
2921 &source,
2922 &yui,
2923 &config.vars,
2924 &mut engine,
2925 &tera_ctx,
2926 &mut state,
2927 false,
2928 force,
2929 )?;
2930 let label = match outcome {
2931 HookOutcome::Ran => "ran",
2932 HookOutcome::SkippedOnce => "skipped (once: already ran)",
2933 HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
2934 HookOutcome::SkippedWhenFalse => "skipped (when=false)",
2935 HookOutcome::DryRun => "would run (dry-run)",
2936 };
2937 info!("hook[{}]: {label}", h.name);
2938 if outcome == HookOutcome::Ran {
2939 state.save(&source)?;
2940 }
2941 }
2942 Ok(())
2943}
2944
2945#[allow(clippy::too_many_arguments)]
2950fn process_mount(
2951 m: &ResolvedMount,
2952 ctx: &ApplyCtx<'_>,
2953 engine: &mut template::Engine,
2954 tera_ctx: &TeraContext,
2955 yuiignore: &mut paths::YuiIgnoreStack,
2956) -> Result<()> {
2957 let src_root = m.src.clone();
2960 if !src_root.is_dir() {
2961 warn!("mount src missing: {src_root}");
2962 return Ok(());
2963 }
2964 walk_and_link(
2965 &src_root, &m.dst, ctx, m.strategy, engine, tera_ctx, yuiignore, false,
2966 )
2967}
2968
2969#[allow(clippy::too_many_arguments)]
2970fn walk_and_link(
2971 src_dir: &Utf8Path,
2972 dst_dir: &Utf8Path,
2973 ctx: &ApplyCtx<'_>,
2974 strategy: MountStrategy,
2975 engine: &mut template::Engine,
2976 tera_ctx: &TeraContext,
2977 yuiignore: &mut paths::YuiIgnoreStack,
2978 parent_covered: bool,
2979) -> Result<()> {
2980 if yuiignore.is_ignored(src_dir, true) {
2983 return Ok(());
2984 }
2985 yuiignore.push_dir(src_dir)?;
2988 let result = walk_and_link_body(
2989 src_dir,
2990 dst_dir,
2991 ctx,
2992 strategy,
2993 engine,
2994 tera_ctx,
2995 yuiignore,
2996 parent_covered,
2997 );
2998 yuiignore.pop_dir(src_dir);
2999 result
3000}
3001
3002#[allow(clippy::too_many_arguments)]
3003fn walk_and_link_body(
3004 src_dir: &Utf8Path,
3005 dst_dir: &Utf8Path,
3006 ctx: &ApplyCtx<'_>,
3007 strategy: MountStrategy,
3008 engine: &mut template::Engine,
3009 tera_ctx: &TeraContext,
3010 yuiignore: &mut paths::YuiIgnoreStack,
3011 parent_covered: bool,
3012) -> Result<()> {
3013 let marker_filename = &ctx.config.mount.marker_filename;
3014 let mut covered = parent_covered;
3015
3016 if strategy == MountStrategy::Marker {
3017 match marker::read_spec(src_dir, marker_filename)? {
3018 None => {} Some(MarkerSpec::PassThrough) => {
3020 link_dir_with_backup(src_dir, dst_dir, ctx)?;
3024 covered = true;
3025 }
3026 Some(MarkerSpec::Explicit { links }) => {
3027 let mut emitted_dir_link = false;
3028 let mut emitted_any = false;
3029 for link in &links {
3030 if let Some(when) = &link.when {
3033 if !template::eval_truthy(when, engine, tera_ctx)? {
3034 continue;
3035 }
3036 }
3037 let dst_str = engine.render(&link.dst, tera_ctx)?;
3038 let dst = paths::expand_tilde(dst_str.trim());
3039 if let Some(filename) = &link.src {
3040 let file_src = src_dir.join(filename);
3041 if !file_src.is_file() {
3042 anyhow::bail!(
3043 "marker at {src_dir}: [[link]] src={filename:?} \
3044 not found"
3045 );
3046 }
3047 link_file_with_backup(&file_src, &dst, ctx)?;
3048 } else {
3049 link_dir_with_backup(src_dir, &dst, ctx)?;
3050 emitted_dir_link = true;
3051 }
3052 emitted_any = true;
3053 }
3054 if !emitted_any {
3055 info!(
3060 "marker at {src_dir} had no active links \
3061 — falling back to defaults"
3062 );
3063 }
3064 if emitted_dir_link {
3065 covered = true;
3066 }
3067 }
3068 }
3069 }
3070
3071 for entry in std::fs::read_dir(src_dir)? {
3072 let entry = entry?;
3073 let name_os = entry.file_name();
3074 let Some(name) = name_os.to_str() else {
3075 continue;
3076 };
3077 if name == marker_filename {
3078 continue;
3079 }
3080 if name.ends_with(".tera") {
3081 continue;
3083 }
3084 let src_path = src_dir.join(name);
3085 let dst_path = dst_dir.join(name);
3086 let ft = entry.file_type()?;
3087
3088 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
3089 continue;
3090 }
3091
3092 if ft.is_dir() {
3093 walk_and_link(
3094 &src_path, &dst_path, ctx, strategy, engine, tera_ctx, yuiignore, covered,
3095 )?;
3096 } else if ft.is_file() {
3097 if !covered {
3103 link_file_with_backup(&src_path, &dst_path, ctx)?;
3104 }
3105 }
3106 }
3107 Ok(())
3108}
3109
3110fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3111 use absorb::AbsorbDecision::*;
3112
3113 let decision = absorb::classify(src, dst)?;
3114
3115 if ctx.dry_run {
3116 info!("[dry-run] {decision:?}: {src} → {dst}");
3117 return Ok(());
3118 }
3119
3120 match decision {
3121 InSync => {
3122 Ok(())
3124 }
3125 Restore => {
3126 info!("link: {src} → {dst}");
3127 link::link_file(src, dst, ctx.file_mode)?;
3128 Ok(())
3129 }
3130 RelinkOnly => {
3131 info!("relink: {src} → {dst}");
3134 link::unlink(dst)?;
3135 link::link_file(src, dst, ctx.file_mode)?;
3136 Ok(())
3137 }
3138 AutoAbsorb => {
3139 if !ctx.config.absorb.auto {
3142 return handle_anomaly(
3143 src,
3144 dst,
3145 ctx,
3146 "absorb.auto = false; treating divergence as anomaly",
3147 );
3148 }
3149 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
3150 return handle_anomaly(
3151 src,
3152 dst,
3153 ctx,
3154 "source repo is dirty; deferring auto-absorb",
3155 );
3156 }
3157 absorb_target_into_source(src, dst, ctx)
3158 }
3159 NeedsConfirm => handle_anomaly(
3160 src,
3161 dst,
3162 ctx,
3163 "anomaly: source equals/newer than target but content differs",
3164 ),
3165 }
3166}
3167
3168fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3172 info!("absorb: {dst} → {src}");
3173 backup_existing(src, ctx.backup_root, false)?;
3174 std::fs::copy(dst, src)?;
3175 link::unlink(dst)?;
3176 link::link_file(src, dst, ctx.file_mode)?;
3177 Ok(())
3178}
3179
3180fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
3186 use crate::config::AnomalyAction::*;
3187 match ctx.config.absorb.on_anomaly {
3188 Skip => {
3189 warn!("anomaly skip: {dst} ({reason})");
3190 Ok(())
3191 }
3192 Force => {
3193 warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
3194 absorb_target_into_source(src, dst, ctx)
3195 }
3196 Ask => {
3197 use std::io::IsTerminal;
3198 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
3199 if prompt_absorb_with_diff(src, dst, reason)? {
3200 absorb_target_into_source(src, dst, ctx)
3201 } else {
3202 warn!("anomaly skipped by user: {dst}");
3203 Ok(())
3204 }
3205 } else {
3206 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
3207 Ok(())
3208 }
3209 }
3210 }
3211}
3212
3213fn prompt_absorb_with_diff(src: &Utf8Path, dst: &Utf8Path, reason: &str) -> Result<bool> {
3214 eprintln!();
3215 eprintln!("anomaly: {reason}");
3216 print_absorb_diff(src, dst);
3217 prompt_yes_no("absorb target into source?")
3218}
3219
3220fn source_repo_is_clean(source: &Utf8Path) -> bool {
3225 match crate::git::is_clean(source) {
3226 Ok(b) => b,
3227 Err(e) => {
3228 warn!("git clean check failed at {source}: {e} — treating as clean");
3229 true
3230 }
3231 }
3232}
3233
3234fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3235 use absorb::AbsorbDecision::*;
3236 let decision = absorb::classify(src, dst)?;
3237
3238 if ctx.dry_run {
3239 info!("[dry-run] dir {decision:?}: {src} → {dst}");
3240 return Ok(());
3241 }
3242
3243 match decision {
3244 InSync => Ok(()),
3245 Restore => {
3246 info!("link dir: {src} → {dst}");
3247 link::link_dir(src, dst, ctx.dir_mode)?;
3248 Ok(())
3249 }
3250 RelinkOnly => {
3251 info!("relink dir: {src} → {dst}");
3256 remove_dir_link_or_real(dst)?;
3257 link::link_dir(src, dst, ctx.dir_mode)?;
3258 Ok(())
3259 }
3260 AutoAbsorb | NeedsConfirm => {
3261 if !ctx.config.absorb.auto {
3282 return handle_anomaly_dir(
3283 src,
3284 dst,
3285 ctx,
3286 "absorb.auto = false; treating divergence as anomaly",
3287 );
3288 }
3289 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
3290 return handle_anomaly_dir(
3291 src,
3292 dst,
3293 ctx,
3294 "source repo is dirty; deferring auto-absorb",
3295 );
3296 }
3297 absorb_target_dir_into_source(src, dst, ctx)
3298 }
3299 }
3300}
3301
3302fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
3312 if let Err(unlink_err) = link::unlink(dst) {
3313 let meta = std::fs::symlink_metadata(dst)
3314 .with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
3315 let ft = meta.file_type();
3316 if ft.is_dir() && !ft.is_symlink() {
3317 std::fs::remove_dir_all(dst).with_context(|| {
3318 format!(
3319 "remove_dir_all({dst}) after link::unlink failed: \
3320 {unlink_err}"
3321 )
3322 })?;
3323 } else {
3324 return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
3325 }
3326 }
3327 Ok(())
3328}
3329
3330fn merge_dir_target_into_source(
3340 target: &Utf8Path,
3341 source: &Utf8Path,
3342 ctx: &ApplyCtx<'_>,
3343) -> Result<()> {
3344 for entry in std::fs::read_dir(target)? {
3345 let entry = entry?;
3346 let name_os = entry.file_name();
3347 let Some(name) = name_os.to_str() else {
3348 continue;
3349 };
3350 let target_path = target.join(name);
3351 let source_path = source.join(name);
3352 let ft = entry.file_type()?;
3353
3354 if ft.is_dir() && !ft.is_symlink() {
3355 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
3361 let sft = src_meta.file_type();
3362 if !sft.is_dir() || sft.is_symlink() {
3363 link::unlink(&source_path).with_context(|| {
3364 format!("remove conflicting source entry before dir merge: {source_path}")
3365 })?;
3366 }
3367 }
3368 if !source_path.exists() {
3369 std::fs::create_dir_all(&source_path).with_context(|| {
3370 format!("create_dir_all({source_path}) during target→source merge")
3371 })?;
3372 }
3373 merge_dir_target_into_source(&target_path, &source_path, ctx)?;
3374 } else if ft.is_file() {
3375 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
3379 let sft = src_meta.file_type();
3380 if sft.is_dir() && !sft.is_symlink() {
3381 remove_dir_link_or_real(&source_path).with_context(|| {
3382 format!("remove conflicting source dir before file merge: {source_path}")
3383 })?;
3384 } else if sft.is_symlink() {
3385 link::unlink(&source_path).with_context(|| {
3386 format!(
3387 "remove conflicting source symlink before file merge: {source_path}"
3388 )
3389 })?;
3390 }
3391 }
3392 if let Some(parent) = source_path.parent() {
3393 if !parent.exists() {
3394 std::fs::create_dir_all(parent)?;
3395 }
3396 }
3397 if source_path.is_file() {
3411 merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
3412 } else {
3413 std::fs::copy(&target_path, &source_path)
3414 .with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
3415 }
3416 } else {
3417 warn!(
3418 "merge: skipping non-regular entry {target_path} \
3419 (symlink / junction / special — content not copied)"
3420 );
3421 }
3422 }
3423 Ok(())
3424}
3425
3426fn merge_resolve_file_conflict(
3440 target_path: &Utf8Path,
3441 source_path: &Utf8Path,
3442 ctx: &ApplyCtx<'_>,
3443) -> Result<()> {
3444 use absorb::AbsorbDecision::*;
3445 let decision = absorb::classify(source_path, target_path)?;
3446 match decision {
3447 InSync | RelinkOnly => Ok(()),
3448 AutoAbsorb => {
3449 std::fs::copy(target_path, source_path).with_context(|| {
3450 format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
3451 })?;
3452 Ok(())
3453 }
3454 Restore => {
3455 unreachable!(
3462 "merge_resolve_file_conflict reached with both files present, \
3463 but classify returned Restore (target {target_path} / source {source_path})"
3464 )
3465 }
3466 NeedsConfirm => {
3467 use crate::config::AnomalyAction::*;
3468 match ctx.config.absorb.on_anomaly {
3469 Skip => {
3470 warn!(
3471 "merge anomaly skip: {target_path} (source-newer / content drift) \
3472 — keeping source version, target version dropped"
3473 );
3474 Ok(())
3475 }
3476 Force => {
3477 warn!(
3478 "merge anomaly force: {target_path} \
3479 (source-newer / content drift) — overwriting source"
3480 );
3481 std::fs::copy(target_path, source_path)?;
3482 Ok(())
3483 }
3484 Ask => {
3485 use std::io::IsTerminal;
3486 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
3487 if prompt_absorb_with_diff(
3488 source_path,
3489 target_path,
3490 "merge: file content differs and source is newer",
3491 )? {
3492 std::fs::copy(target_path, source_path)?;
3493 } else {
3494 warn!("merge: kept source version by user choice: {source_path}");
3495 }
3496 Ok(())
3497 } else {
3498 warn!(
3499 "merge anomaly skip (non-TTY ask mode): {target_path} \
3500 — keeping source version"
3501 );
3502 Ok(())
3503 }
3504 }
3505 }
3506 }
3507 }
3508}
3509
3510fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3517 info!("absorb dir: {dst} → {src}");
3518 backup_existing(src, ctx.backup_root, true)?;
3519 merge_dir_target_into_source(dst, src, ctx)?;
3520 remove_dir_link_or_real(dst)?;
3523 link::link_dir(src, dst, ctx.dir_mode)?;
3524 Ok(())
3525}
3526
3527fn handle_anomaly_dir(
3531 src: &Utf8Path,
3532 dst: &Utf8Path,
3533 ctx: &ApplyCtx<'_>,
3534 reason: &str,
3535) -> Result<()> {
3536 use crate::config::AnomalyAction::*;
3537 match ctx.config.absorb.on_anomaly {
3538 Skip => {
3539 warn!("anomaly skip dir: {dst} ({reason})");
3540 Ok(())
3541 }
3542 Force => {
3543 warn!(
3544 "anomaly force dir: {dst} ({reason}) \
3545 — absorbing target into source"
3546 );
3547 absorb_target_dir_into_source(src, dst, ctx)
3548 }
3549 Ask => {
3550 use std::io::IsTerminal;
3551 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
3552 eprintln!();
3553 eprintln!("anomaly: {dst}");
3554 eprintln!(" {reason}");
3555 eprintln!(" source: {src}");
3556 eprint!(" absorb target dir into source? (y/N) ");
3557 use std::io::{BufRead as _, Write as _};
3558 std::io::stderr().flush().ok();
3559 let mut buf = String::new();
3560 std::io::stdin().lock().read_line(&mut buf)?;
3561 let answer = buf.trim();
3562 if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
3563 absorb_target_dir_into_source(src, dst, ctx)
3564 } else {
3565 warn!("anomaly skipped by user: {dst}");
3566 Ok(())
3567 }
3568 } else {
3569 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
3570 Ok(())
3571 }
3572 }
3573 }
3574}
3575
3576fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
3577 let abs_target = absolutize(target)?;
3578 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
3579 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
3580 info!("backup → {bp}");
3581 if is_dir {
3582 backup::backup_dir(target, &bp)?;
3583 } else {
3584 backup::backup_file(target, &bp)?;
3585 }
3586 Ok(())
3587}
3588
3589fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
3590 if let Some(s) = source {
3591 return absolutize(&s);
3592 }
3593 if let Ok(s) = std::env::var("YUI_SOURCE") {
3594 return absolutize(Utf8Path::new(&s));
3595 }
3596 let cwd = current_dir_utf8()?;
3597 for ancestor in cwd.ancestors() {
3598 if ancestor.join("config.toml").is_file() {
3599 return Ok(ancestor.to_path_buf());
3600 }
3601 }
3602 if let Some(home) = paths::home_dir() {
3603 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
3604 let p = home.join(c);
3605 if p.join("config.toml").is_file() {
3606 return Ok(p);
3607 }
3608 }
3609 }
3610 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
3611}
3612
3613fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
3614 let expanded = paths::expand_tilde(p.as_str());
3616 if expanded.is_absolute() {
3617 return Ok(expanded);
3618 }
3619 let cwd = current_dir_utf8()?;
3620 Ok(cwd.join(expanded))
3621}
3622
3623fn current_dir_utf8() -> Result<Utf8PathBuf> {
3624 let cwd = std::env::current_dir().context("getting cwd")?;
3625 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
3626}
3627
3628const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
3632
3633[vars]
3634# user-defined values; templates can reference these as {{ vars.foo }}
3635
3636# [link]
3637# file_mode = "auto" # auto | symlink | hardlink
3638# dir_mode = "auto" # auto | symlink | junction
3639
3640[mount]
3641default_strategy = "marker"
3642
3643[[mount.entry]]
3644src = "home"
3645# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
3646dst = "~"
3647
3648# [[mount.entry]]
3649# src = "appdata"
3650# dst = "{{ env(name='APPDATA') }}"
3651# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
3652# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
3653# when = "yui.os == 'windows'"
3654"#;
3655
3656const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
3657# .yui/bin/ is intentionally tracked — it holds your hook scripts.
3658/.yui/state.json
3659/.yui/state.json.tmp
3660/.yui/backup/
3661
3662# >>> yui rendered (auto-managed, do not edit) >>>
3663# <<< yui rendered (auto-managed) <<<
3664
3665# config.local.toml is per-machine; commit a config.local.example.toml instead.
3666config.local.toml
3667"#;
3668
3669#[cfg(test)]
3670mod tests {
3671 use super::*;
3672 use tempfile::TempDir;
3673
3674 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
3675 Utf8PathBuf::from_path_buf(p).unwrap()
3676 }
3677
3678 fn toml_path(p: &Utf8Path) -> String {
3680 p.as_str().replace('\\', "/")
3681 }
3682
3683 #[test]
3684 fn apply_links_a_raw_file() {
3685 let tmp = TempDir::new().unwrap();
3686 let source = utf8(tmp.path().join("dotfiles"));
3687 let target = utf8(tmp.path().join("target"));
3688 std::fs::create_dir_all(source.join("home")).unwrap();
3689 std::fs::create_dir_all(&target).unwrap();
3690 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3691
3692 let cfg = format!(
3693 r#"
3694[[mount.entry]]
3695src = "home"
3696dst = "{}"
3697"#,
3698 toml_path(&target)
3699 );
3700 std::fs::write(source.join("config.toml"), cfg).unwrap();
3701
3702 apply(Some(source), false).unwrap();
3703
3704 let linked = target.join(".bashrc");
3705 assert!(linked.exists(), "expected {linked} to exist");
3706 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
3707 }
3708
3709 #[test]
3710 fn apply_with_marker_links_whole_directory() {
3711 let tmp = TempDir::new().unwrap();
3712 let source = utf8(tmp.path().join("dotfiles"));
3713 let target = utf8(tmp.path().join("target"));
3714 let nvim_src = source.join("home/nvim");
3715 std::fs::create_dir_all(&nvim_src).unwrap();
3716 std::fs::create_dir_all(&target).unwrap();
3717 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
3718 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
3719 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
3720
3721 let cfg = format!(
3722 r#"
3723[[mount.entry]]
3724src = "home"
3725dst = "{}"
3726"#,
3727 toml_path(&target)
3728 );
3729 std::fs::write(source.join("config.toml"), cfg).unwrap();
3730
3731 apply(Some(source.clone()), false).unwrap();
3732
3733 let nvim_dst = target.join("nvim");
3734 assert!(nvim_dst.exists());
3735 assert_eq!(
3736 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
3737 "-- hi\n"
3738 );
3739 }
3743
3744 #[test]
3745 fn apply_dry_run_does_not_write() {
3746 let tmp = TempDir::new().unwrap();
3747 let source = utf8(tmp.path().join("dotfiles"));
3748 let target = utf8(tmp.path().join("target"));
3749 std::fs::create_dir_all(source.join("home")).unwrap();
3750 std::fs::create_dir_all(&target).unwrap();
3751 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
3752
3753 let cfg = format!(
3754 r#"
3755[[mount.entry]]
3756src = "home"
3757dst = "{}"
3758"#,
3759 toml_path(&target)
3760 );
3761 std::fs::write(source.join("config.toml"), cfg).unwrap();
3762
3763 apply(Some(source), true).unwrap();
3764
3765 assert!(!target.join(".bashrc").exists());
3766 }
3767
3768 #[test]
3769 fn apply_renders_templates_then_links_rendered_outputs() {
3770 let tmp = TempDir::new().unwrap();
3771 let source = utf8(tmp.path().join("dotfiles"));
3772 let target = utf8(tmp.path().join("target"));
3773 std::fs::create_dir_all(source.join("home")).unwrap();
3774 std::fs::create_dir_all(&target).unwrap();
3775 std::fs::write(
3776 source.join("home/.gitconfig.tera"),
3777 "[user]\n os = {{ yui.os }}\n",
3778 )
3779 .unwrap();
3780 std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
3781
3782 let cfg = format!(
3783 r#"
3784[[mount.entry]]
3785src = "home"
3786dst = "{}"
3787"#,
3788 toml_path(&target)
3789 );
3790 std::fs::write(source.join("config.toml"), cfg).unwrap();
3791
3792 apply(Some(source.clone()), false).unwrap();
3793
3794 assert!(target.join(".bashrc").exists());
3796 assert!(source.join("home/.gitconfig").exists());
3798 assert!(target.join(".gitconfig").exists());
3799 assert!(!target.join(".gitconfig.tera").exists());
3801 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
3803 assert!(linked.contains("os = "));
3804 }
3805
3806 #[test]
3807 fn apply_marker_override_links_to_custom_dst() {
3808 let tmp = TempDir::new().unwrap();
3809 let source = utf8(tmp.path().join("dotfiles"));
3810 let target_a = utf8(tmp.path().join("target_a"));
3811 let target_b = utf8(tmp.path().join("target_b"));
3812 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3813 std::fs::create_dir_all(&target_a).unwrap();
3814 std::fs::create_dir_all(&target_b).unwrap();
3815 std::fs::write(
3816 source.join("home/.config/nvim/init.lua"),
3817 "-- nvim config\n",
3818 )
3819 .unwrap();
3820
3821 std::fs::write(
3824 source.join("home/.config/nvim/.yuilink"),
3825 format!(
3826 r#"
3827[[link]]
3828dst = "{}/nvim"
3829
3830[[link]]
3831dst = "{}/nvim"
3832when = "{{{{ yui.os == '{}' }}}}"
3833"#,
3834 toml_path(&target_a),
3835 toml_path(&target_b),
3836 std::env::consts::OS
3837 ),
3838 )
3839 .unwrap();
3840
3841 let parent_target = utf8(tmp.path().join("parent_target"));
3842 std::fs::create_dir_all(&parent_target).unwrap();
3843 let cfg = format!(
3844 r#"
3845[[mount.entry]]
3846src = "home"
3847dst = "{}"
3848"#,
3849 toml_path(&parent_target)
3850 );
3851 std::fs::write(source.join("config.toml"), cfg).unwrap();
3852
3853 apply(Some(source.clone()), false).unwrap();
3854
3855 assert!(
3857 target_a.join("nvim/init.lua").exists(),
3858 "target_a/nvim/init.lua should be reachable through the link"
3859 );
3860 assert!(
3861 target_b.join("nvim/init.lua").exists(),
3862 "target_b/nvim/init.lua should be reachable through the link"
3863 );
3864 assert!(
3867 !parent_target.join(".config/nvim").exists(),
3868 "parent mount should have skipped the marker-claimed sub-dir"
3869 );
3870 }
3871
3872 #[test]
3873 fn apply_marker_inactive_link_falls_through_to_default() {
3874 let tmp = TempDir::new().unwrap();
3879 let source = utf8(tmp.path().join("dotfiles"));
3880 let target_inactive = utf8(tmp.path().join("inactive"));
3881 let parent_target = utf8(tmp.path().join("parent"));
3882 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3883 std::fs::create_dir_all(&parent_target).unwrap();
3884 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
3885
3886 std::fs::write(
3888 source.join("home/.config/nvim/.yuilink"),
3889 format!(
3890 r#"
3891[[link]]
3892dst = "{}/nvim"
3893when = "{{{{ yui.os == 'no-such-os' }}}}"
3894"#,
3895 toml_path(&target_inactive)
3896 ),
3897 )
3898 .unwrap();
3899
3900 let cfg = format!(
3901 r#"
3902[[mount.entry]]
3903src = "home"
3904dst = "{}"
3905"#,
3906 toml_path(&parent_target)
3907 );
3908 std::fs::write(source.join("config.toml"), cfg).unwrap();
3909
3910 apply(Some(source.clone()), false).unwrap();
3911
3912 assert!(!target_inactive.join("nvim").exists());
3914 assert!(parent_target.join(".config/nvim/init.lua").exists());
3917 }
3918
3919 #[test]
3920 fn list_shows_mount_entries_and_marker_overrides() {
3921 let tmp = TempDir::new().unwrap();
3922 let source = utf8(tmp.path().join("dotfiles"));
3923 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3924 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
3925 std::fs::write(
3926 source.join("home/.config/nvim/.yuilink"),
3927 r#"
3928[[link]]
3929dst = "/custom/nvim"
3930"#,
3931 )
3932 .unwrap();
3933 std::fs::write(
3934 source.join("config.toml"),
3935 r#"
3936[[mount.entry]]
3937src = "home"
3938dst = "/h"
3939"#,
3940 )
3941 .unwrap();
3942
3943 list(Some(source), false, None, true).unwrap();
3946 }
3947
3948 #[test]
3949 fn status_reports_in_sync_after_apply() {
3950 let tmp = TempDir::new().unwrap();
3951 let source = utf8(tmp.path().join("dotfiles"));
3952 let target = utf8(tmp.path().join("target"));
3953 std::fs::create_dir_all(source.join("home")).unwrap();
3954 std::fs::create_dir_all(&target).unwrap();
3955 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3956 let cfg = format!(
3957 r#"
3958[[mount.entry]]
3959src = "home"
3960dst = "{}"
3961"#,
3962 toml_path(&target)
3963 );
3964 std::fs::write(source.join("config.toml"), cfg).unwrap();
3965 apply(Some(source.clone()), false).unwrap();
3967 status(Some(source), None, true).unwrap();
3969 }
3970
3971 #[test]
3972 fn status_reports_template_drift() {
3973 let tmp = TempDir::new().unwrap();
3974 let source = utf8(tmp.path().join("dotfiles"));
3975 let target = utf8(tmp.path().join("target"));
3976 std::fs::create_dir_all(source.join("home")).unwrap();
3977 std::fs::create_dir_all(&target).unwrap();
3978 std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
3981 std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
3982
3983 let cfg = format!(
3984 r#"
3985[[mount.entry]]
3986src = "home"
3987dst = "{}"
3988"#,
3989 toml_path(&target)
3990 );
3991 std::fs::write(source.join("config.toml"), cfg).unwrap();
3992
3993 let err = status(Some(source), None, true).unwrap_err();
3994 assert!(format!("{err}").contains("diverged"));
3995 }
3996
3997 #[test]
3998 fn status_fails_when_target_missing() {
3999 let tmp = TempDir::new().unwrap();
4000 let source = utf8(tmp.path().join("dotfiles"));
4001 let target = utf8(tmp.path().join("target"));
4002 std::fs::create_dir_all(source.join("home")).unwrap();
4003 std::fs::create_dir_all(&target).unwrap();
4004 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4005 let cfg = format!(
4006 r#"
4007[[mount.entry]]
4008src = "home"
4009dst = "{}"
4010"#,
4011 toml_path(&target)
4012 );
4013 std::fs::write(source.join("config.toml"), cfg).unwrap();
4014 let err = status(Some(source), None, true).unwrap_err();
4016 assert!(format!("{err}").contains("diverged"));
4017 }
4018
4019 #[test]
4020 fn strip_braces_removes_outer_template_braces() {
4021 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
4022 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
4023 assert_eq!(strip_braces(" {{x}} "), "x");
4024 }
4025
4026 #[test]
4027 fn apply_aborts_on_render_drift() {
4028 let tmp = TempDir::new().unwrap();
4029 let source = utf8(tmp.path().join("dotfiles"));
4030 let target = utf8(tmp.path().join("target"));
4031 std::fs::create_dir_all(source.join("home")).unwrap();
4032 std::fs::create_dir_all(&target).unwrap();
4033 std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
4034 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
4035
4036 let cfg = format!(
4037 r#"
4038[[mount.entry]]
4039src = "home"
4040dst = "{}"
4041"#,
4042 toml_path(&target)
4043 );
4044 std::fs::write(source.join("config.toml"), cfg).unwrap();
4045
4046 let err = apply(Some(source.clone()), false).unwrap_err();
4047 assert!(format!("{err}").contains("drift"));
4048 assert_eq!(
4050 std::fs::read_to_string(source.join("home/foo")).unwrap(),
4051 "manually edited"
4052 );
4053 assert!(!target.join("foo").exists());
4055 }
4056
4057 #[test]
4058 fn init_creates_skeleton_when_dir_empty() {
4059 let tmp = TempDir::new().unwrap();
4060 let dir = utf8(tmp.path().join("new_dotfiles"));
4061 init(Some(dir.clone()), false).unwrap();
4062 assert!(dir.join("config.toml").is_file());
4063 assert!(dir.join(".gitignore").is_file());
4064 }
4065
4066 #[test]
4067 fn init_refuses_to_overwrite_existing_config() {
4068 let tmp = TempDir::new().unwrap();
4069 let dir = utf8(tmp.path().join("dotfiles"));
4070 std::fs::create_dir_all(&dir).unwrap();
4071 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
4072 let err = init(Some(dir), false).unwrap_err();
4073 assert!(format!("{err}").contains("already exists"));
4074 }
4075
4076 #[test]
4082 fn init_appends_missing_gitignore_entries_into_existing_file() {
4083 let tmp = TempDir::new().unwrap();
4084 let dir = utf8(tmp.path().join("dotfiles"));
4085 std::fs::create_dir_all(&dir).unwrap();
4086 let user_gitignore = "# user entries\n*.swp\nnode_modules/\n";
4088 std::fs::write(dir.join(".gitignore"), user_gitignore).unwrap();
4089
4090 init(Some(dir.clone()), false).unwrap();
4091
4092 let body = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
4093 assert!(body.contains("*.swp"));
4095 assert!(body.contains("node_modules/"));
4096 assert!(body.contains("/.yui/state.json"));
4098 assert!(body.contains("/.yui/backup/"));
4099 assert!(body.contains("config.local.toml"));
4100 let before_rerun = body.clone();
4102 std::fs::remove_file(dir.join("config.toml")).unwrap();
4105 init(Some(dir.clone()), false).unwrap();
4106 let after_rerun = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
4107 assert_eq!(
4108 before_rerun, after_rerun,
4109 "init must be idempotent when the gitignore already has every yui entry"
4110 );
4111 }
4112
4113 #[test]
4119 fn init_with_git_hooks_installs_into_existing_repo() {
4120 let tmp = TempDir::new().unwrap();
4121 let dir = utf8(tmp.path().join("dotfiles"));
4122 std::fs::create_dir_all(&dir).unwrap();
4123 let st = std::process::Command::new("git")
4124 .args(["init", "-q"])
4125 .current_dir(dir.as_std_path())
4126 .status()
4127 .expect("git init");
4128 if !st.success() {
4129 return;
4130 }
4131 let user_config = "# user already wrote this\n";
4133 std::fs::write(dir.join("config.toml"), user_config).unwrap();
4134
4135 init(Some(dir.clone()), true).unwrap();
4137
4138 assert_eq!(
4139 std::fs::read_to_string(dir.join("config.toml")).unwrap(),
4140 user_config
4141 );
4142 assert!(dir.join(".git/hooks/pre-commit").is_file());
4143 assert!(dir.join(".git/hooks/pre-push").is_file());
4144 }
4145
4146 #[test]
4151 fn init_with_git_hooks_writes_pre_commit_and_pre_push() {
4152 let tmp = TempDir::new().unwrap();
4153 let dir = utf8(tmp.path().join("dotfiles"));
4154 std::fs::create_dir_all(&dir).unwrap();
4155 let st = std::process::Command::new("git")
4157 .args(["init", "-q"])
4158 .current_dir(dir.as_std_path())
4159 .status()
4160 .expect("git init");
4161 if !st.success() {
4162 eprintln!("skipping: git not available");
4164 return;
4165 }
4166 init(Some(dir.clone()), true).unwrap();
4167
4168 let pre_commit = dir.join(".git/hooks/pre-commit");
4169 let pre_push = dir.join(".git/hooks/pre-push");
4170 assert!(pre_commit.is_file(), "pre-commit hook should be written");
4171 assert!(pre_push.is_file(), "pre-push hook should be written");
4172
4173 let body = std::fs::read_to_string(&pre_commit).unwrap();
4174 assert!(
4175 body.contains("yui render --check"),
4176 "pre-commit hook should call `yui render --check`, got: {body}"
4177 );
4178 }
4179
4180 #[test]
4184 fn init_with_git_hooks_errors_outside_a_git_repo() {
4185 let tmp = TempDir::new().unwrap();
4186 let dir = utf8(tmp.path().join("not-a-repo"));
4187 std::fs::create_dir_all(&dir).unwrap();
4188 let err = init(Some(dir), true).unwrap_err();
4189 let msg = format!("{err:#}");
4190 assert!(
4191 msg.contains("git repo") || msg.contains("git rev-parse"),
4192 "expected error to mention the git issue, got: {msg}"
4193 );
4194 }
4195
4196 #[test]
4199 fn init_with_git_hooks_does_not_clobber_existing_hooks() {
4200 let tmp = TempDir::new().unwrap();
4201 let dir = utf8(tmp.path().join("dotfiles"));
4202 std::fs::create_dir_all(&dir).unwrap();
4203 let st = std::process::Command::new("git")
4204 .args(["init", "-q"])
4205 .current_dir(dir.as_std_path())
4206 .status()
4207 .expect("git init");
4208 if !st.success() {
4209 return;
4210 }
4211 let hooks = dir.join(".git/hooks");
4212 std::fs::create_dir_all(&hooks).unwrap();
4213 std::fs::write(hooks.join("pre-commit"), "#! /bin/sh\nexit 0\n").unwrap();
4214
4215 init(Some(dir.clone()), true).unwrap();
4216
4217 let pc = std::fs::read_to_string(hooks.join("pre-commit")).unwrap();
4219 assert!(
4220 !pc.contains("yui render --check"),
4221 "existing pre-commit must not be overwritten"
4222 );
4223 let pp = std::fs::read_to_string(hooks.join("pre-push")).unwrap();
4224 assert!(
4225 pp.contains("yui render --check"),
4226 "missing pre-push should be written: {pp}"
4227 );
4228 }
4229
4230 fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
4233 let source = utf8(tmp.path().join("dotfiles"));
4234 let target = utf8(tmp.path().join("target"));
4235 std::fs::create_dir_all(source.join("home")).unwrap();
4236 std::fs::create_dir_all(&target).unwrap();
4237 let cfg = format!(
4238 r#"
4239[[mount.entry]]
4240src = "home"
4241dst = "{}"
4242"#,
4243 toml_path(&target)
4244 );
4245 std::fs::write(source.join("config.toml"), cfg).unwrap();
4246 (source, target)
4247 }
4248
4249 fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
4250 std::fs::write(path, body).unwrap();
4251 let f = std::fs::OpenOptions::new()
4252 .write(true)
4253 .open(path)
4254 .expect("open writable");
4255 f.set_modified(when).expect("set_modified");
4256 }
4257
4258 #[test]
4259 fn apply_target_newer_absorbs_target_into_source() {
4260 let tmp = TempDir::new().unwrap();
4264 let (source, target) = setup_minimal_dotfiles(&tmp);
4265
4266 let now = std::time::SystemTime::now();
4267 let past = now - std::time::Duration::from_secs(120);
4268 write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
4269 write_with_mtime(&target.join(".bashrc"), "user's edit", now);
4271
4272 apply(Some(source.clone()), false).unwrap();
4273
4274 assert_eq!(
4276 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4277 "user's edit"
4278 );
4279 assert_eq!(
4281 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4282 "user's edit"
4283 );
4284 let backup_root = source.join(".yui/backup");
4286 let mut found_old = false;
4287 for entry in walkdir(&backup_root) {
4288 if let Ok(s) = std::fs::read_to_string(&entry) {
4289 if s == "default from repo" {
4290 found_old = true;
4291 break;
4292 }
4293 }
4294 }
4295 assert!(found_old, "expected backup containing 'default from repo'");
4296 }
4297
4298 #[test]
4299 fn apply_in_sync_target_is_a_no_op() {
4300 let tmp = TempDir::new().unwrap();
4303 let (source, target) = setup_minimal_dotfiles(&tmp);
4304 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4305 apply(Some(source.clone()), false).unwrap();
4306 let backup_root = source.join(".yui/backup");
4307 let backup_count_after_first = walkdir(&backup_root).len();
4308
4309 apply(Some(source.clone()), false).unwrap();
4311 assert_eq!(
4312 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4313 "echo hi\n"
4314 );
4315 let backup_count_after_second = walkdir(&backup_root).len();
4316 assert_eq!(
4317 backup_count_after_first, backup_count_after_second,
4318 "second apply on an in-sync tree should not produce backups"
4319 );
4320 }
4321
4322 #[test]
4323 fn apply_skip_policy_leaves_anomaly_alone() {
4324 let tmp = TempDir::new().unwrap();
4327 let source = utf8(tmp.path().join("dotfiles"));
4328 let target = utf8(tmp.path().join("target"));
4329 std::fs::create_dir_all(source.join("home")).unwrap();
4330 std::fs::create_dir_all(&target).unwrap();
4331 let cfg = format!(
4332 r#"
4333[absorb]
4334on_anomaly = "skip"
4335
4336[[mount.entry]]
4337src = "home"
4338dst = "{}"
4339"#,
4340 toml_path(&target)
4341 );
4342 std::fs::write(source.join("config.toml"), cfg).unwrap();
4343
4344 let now = std::time::SystemTime::now();
4345 let past = now - std::time::Duration::from_secs(120);
4346 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4347 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4348
4349 apply(Some(source.clone()), false).unwrap();
4350
4351 assert_eq!(
4353 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4354 "user's edit (older)"
4355 );
4356 assert_eq!(
4358 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4359 "fresh from upstream"
4360 );
4361 }
4362
4363 #[test]
4364 fn apply_force_policy_absorbs_anomaly_anyway() {
4365 let tmp = TempDir::new().unwrap();
4367 let source = utf8(tmp.path().join("dotfiles"));
4368 let target = utf8(tmp.path().join("target"));
4369 std::fs::create_dir_all(source.join("home")).unwrap();
4370 std::fs::create_dir_all(&target).unwrap();
4371 let cfg = format!(
4372 r#"
4373[absorb]
4374on_anomaly = "force"
4375
4376[[mount.entry]]
4377src = "home"
4378dst = "{}"
4379"#,
4380 toml_path(&target)
4381 );
4382 std::fs::write(source.join("config.toml"), cfg).unwrap();
4383
4384 let now = std::time::SystemTime::now();
4385 let past = now - std::time::Duration::from_secs(120);
4386 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4387 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4388
4389 apply(Some(source.clone()), false).unwrap();
4390
4391 assert_eq!(
4393 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4394 "user's edit (older)"
4395 );
4396 assert_eq!(
4397 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4398 "user's edit (older)"
4399 );
4400 }
4401
4402 #[test]
4414 fn apply_absorbs_non_empty_target_dir_target_wins() {
4415 let tmp = TempDir::new().unwrap();
4416 let source = utf8(tmp.path().join("dotfiles"));
4417 let target = utf8(tmp.path().join("target"));
4418 std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
4419 std::fs::create_dir_all(target.join(".config/app")).unwrap();
4420 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4423 std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
4424 std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
4426 std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
4429 std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
4430
4431 let cfg = format!(
4432 r#"
4433[absorb]
4434on_anomaly = "force"
4435
4436[[mount.entry]]
4437src = "home"
4438dst = "{}"
4439"#,
4440 toml_path(&target)
4441 );
4442 std::fs::write(source.join("config.toml"), cfg).unwrap();
4443
4444 apply(Some(source.clone()), false).unwrap();
4446
4447 assert_eq!(
4449 std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
4450 "target side"
4451 );
4452 assert_eq!(
4454 std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
4455 "{}"
4456 );
4457 let backup_root = source.join(".yui/backup");
4460 let mut backup_files: Vec<String> = Vec::new();
4461 for entry in walkdir(&backup_root) {
4462 if let Some(n) = entry.file_name() {
4463 backup_files.push(n.to_string());
4464 }
4465 }
4466 assert!(
4467 backup_files.iter().any(|f| f == "config.toml"),
4468 "expected source's config.toml to land in the backup tree, got {backup_files:?}"
4469 );
4470 assert!(
4472 source.join("home/.config/app/source-only.toml").exists(),
4473 "source-only file should survive a target-wins merge"
4474 );
4475 assert!(
4477 source.join("home/.config/app/state.json").exists(),
4478 "target-only state.json should be merged into source"
4479 );
4480 }
4481
4482 #[test]
4488 fn marker_dir_absorbs_with_default_ask_policy() {
4489 let tmp = TempDir::new().unwrap();
4490 let source = utf8(tmp.path().join("dotfiles"));
4491 let target = utf8(tmp.path().join("target"));
4492 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4493 std::fs::create_dir_all(target.join(".config/gh")).unwrap();
4494 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4496 std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\n").unwrap();
4498
4499 let cfg = format!(
4503 r#"
4504[[mount.entry]]
4505src = "home"
4506dst = "{}"
4507"#,
4508 toml_path(&target)
4509 );
4510 std::fs::write(source.join("config.toml"), cfg).unwrap();
4511
4512 apply(Some(source.clone()), false).unwrap();
4516
4517 assert!(target.join(".config/gh/hosts.yml").exists());
4520 assert!(source.join("home/.config/gh/hosts.yml").exists());
4521 }
4522
4523 #[test]
4529 fn merge_handles_file_vs_dir_collisions_target_wins() {
4530 let tmp = TempDir::new().unwrap();
4531 let source = utf8(tmp.path().join("dotfiles"));
4532 let target = utf8(tmp.path().join("target"));
4533 std::fs::create_dir_all(source.join("home/.config/foo")).unwrap();
4534 std::fs::create_dir_all(target.join(".config")).unwrap();
4535 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4536
4537 std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
4539 std::fs::write(target.join(".config/foo"), "target file body").unwrap();
4540 std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
4542 std::fs::create_dir_all(target.join(".config/bar")).unwrap();
4543 std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
4544
4545 let cfg = format!(
4546 r#"
4547[absorb]
4548on_anomaly = "force"
4549
4550[[mount.entry]]
4551src = "home"
4552dst = "{}"
4553"#,
4554 toml_path(&target)
4555 );
4556 std::fs::write(source.join("config.toml"), cfg).unwrap();
4557 apply(Some(source.clone()), false).unwrap();
4558
4559 let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
4563 assert!(foo_meta.file_type().is_file(), "foo should be a file");
4564 assert_eq!(
4565 std::fs::read_to_string(target.join(".config/foo")).unwrap(),
4566 "target file body"
4567 );
4568 let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
4570 assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
4571 assert_eq!(
4572 std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
4573 "target nested"
4574 );
4575 }
4576
4577 #[test]
4581 fn merge_per_file_target_newer_auto_absorbs() {
4582 let tmp = TempDir::new().unwrap();
4583 let source = utf8(tmp.path().join("dotfiles"));
4584 let target = utf8(tmp.path().join("target"));
4585 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4586 std::fs::create_dir_all(target.join(".config")).unwrap();
4587 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4588
4589 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4591 write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
4592 std::fs::write(target.join(".config/app.toml"), "user's live edit").unwrap();
4593
4594 let cfg = format!(
4598 r#"
4599[[mount.entry]]
4600src = "home"
4601dst = "{}"
4602"#,
4603 toml_path(&target)
4604 );
4605 std::fs::write(source.join("config.toml"), cfg).unwrap();
4606 apply(Some(source.clone()), false).unwrap();
4607
4608 assert_eq!(
4610 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4611 "user's live edit"
4612 );
4613 }
4614
4615 #[test]
4621 fn merge_per_file_source_newer_skip_keeps_source() {
4622 let tmp = TempDir::new().unwrap();
4623 let source = utf8(tmp.path().join("dotfiles"));
4624 let target = utf8(tmp.path().join("target"));
4625 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4626 std::fs::create_dir_all(target.join(".config")).unwrap();
4627 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4628
4629 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4631 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
4632 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
4633
4634 let cfg = format!(
4635 r#"
4636[absorb]
4637on_anomaly = "skip"
4638
4639[[mount.entry]]
4640src = "home"
4641dst = "{}"
4642"#,
4643 toml_path(&target)
4644 );
4645 std::fs::write(source.join("config.toml"), cfg).unwrap();
4646 apply(Some(source.clone()), false).unwrap();
4647
4648 assert_eq!(
4651 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4652 "fresh source"
4653 );
4654 }
4655
4656 #[test]
4659 fn merge_per_file_source_newer_force_overwrites_source() {
4660 let tmp = TempDir::new().unwrap();
4661 let source = utf8(tmp.path().join("dotfiles"));
4662 let target = utf8(tmp.path().join("target"));
4663 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4664 std::fs::create_dir_all(target.join(".config")).unwrap();
4665 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4666
4667 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4668 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
4669 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
4670
4671 let cfg = format!(
4672 r#"
4673[absorb]
4674on_anomaly = "force"
4675
4676[[mount.entry]]
4677src = "home"
4678dst = "{}"
4679"#,
4680 toml_path(&target)
4681 );
4682 std::fs::write(source.join("config.toml"), cfg).unwrap();
4683 apply(Some(source.clone()), false).unwrap();
4684
4685 assert_eq!(
4687 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4688 "old target"
4689 );
4690 }
4691
4692 #[test]
4697 fn merge_per_file_identical_content_is_noop() {
4698 let tmp = TempDir::new().unwrap();
4699 let source = utf8(tmp.path().join("dotfiles"));
4700 let target = utf8(tmp.path().join("target"));
4701 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4702 std::fs::create_dir_all(target.join(".config")).unwrap();
4703 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4704 std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
4705 std::fs::write(target.join(".config/app.toml"), "same").unwrap();
4706
4707 let cfg = format!(
4710 r#"
4711[[mount.entry]]
4712src = "home"
4713dst = "{}"
4714"#,
4715 toml_path(&target)
4716 );
4717 std::fs::write(source.join("config.toml"), cfg).unwrap();
4718 apply(Some(source.clone()), false).unwrap();
4719
4720 assert_eq!(
4721 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4722 "same"
4723 );
4724 }
4725
4726 #[test]
4727 fn manual_absorb_command_pulls_target_into_source() {
4728 let tmp = TempDir::new().unwrap();
4730 let source = utf8(tmp.path().join("dotfiles"));
4731 let target = utf8(tmp.path().join("target"));
4732 std::fs::create_dir_all(source.join("home")).unwrap();
4733 std::fs::create_dir_all(&target).unwrap();
4734 let cfg = format!(
4736 r#"
4737[absorb]
4738on_anomaly = "skip"
4739
4740[[mount.entry]]
4741src = "home"
4742dst = "{}"
4743"#,
4744 toml_path(&target)
4745 );
4746 std::fs::write(source.join("config.toml"), cfg).unwrap();
4747 std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
4748 std::fs::write(source.join("home/.bashrc"), "default").unwrap();
4749
4750 absorb(
4753 Some(source.clone()),
4754 target.join(".bashrc"),
4755 false,
4756 true,
4757 )
4758 .unwrap();
4759
4760 assert_eq!(
4762 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4763 "user picked this"
4764 );
4765 }
4766
4767 #[test]
4768 fn manual_absorb_errors_when_target_outside_known_mounts() {
4769 let tmp = TempDir::new().unwrap();
4770 let (source, _target) = setup_minimal_dotfiles(&tmp);
4771 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
4772 let stranger = utf8(tmp.path().join("not-managed/foo"));
4773 std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
4774 std::fs::write(&stranger, "not yui's").unwrap();
4775 let err = absorb(Some(source), stranger, false, true).unwrap_err();
4776 assert!(format!("{err}").contains("no mount entry"));
4777 }
4778
4779 #[test]
4780 fn yuiignore_excludes_file_from_linking() {
4781 let tmp = TempDir::new().unwrap();
4782 let (source, target) = setup_minimal_dotfiles(&tmp);
4783 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
4784 std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
4785 std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
4787 apply(Some(source.clone()), false).unwrap();
4788 assert!(target.join(".bashrc").exists());
4789 assert!(
4790 !target.join("lock.json").exists(),
4791 "yuiignore should keep lock.json out of target"
4792 );
4793 }
4794
4795 #[test]
4796 fn yuiignore_excludes_directory_subtree() {
4797 let tmp = TempDir::new().unwrap();
4798 let (source, target) = setup_minimal_dotfiles(&tmp);
4799 std::fs::create_dir_all(source.join("home/cache")).unwrap();
4800 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
4801 std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
4802 std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
4803 std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
4805 apply(Some(source.clone()), false).unwrap();
4806 assert!(target.join(".bashrc").exists());
4807 assert!(
4808 !target.join("cache").exists(),
4809 "yuiignore'd subtree should not appear in target"
4810 );
4811 }
4812
4813 #[test]
4814 fn yuiignore_negation_re_includes_file() {
4815 let tmp = TempDir::new().unwrap();
4816 let (source, target) = setup_minimal_dotfiles(&tmp);
4817 std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
4818 std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
4819 std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
4821 apply(Some(source.clone()), false).unwrap();
4822 assert!(target.join("keep.cache").exists());
4823 assert!(!target.join("drop.cache").exists());
4824 }
4825
4826 #[test]
4831 fn nested_yuiignore_only_affects_its_subtree() {
4832 let tmp = TempDir::new().unwrap();
4833 let (source, target) = setup_minimal_dotfiles(&tmp);
4834 std::fs::create_dir_all(source.join("home/inner")).unwrap();
4835 std::fs::write(source.join("home/secret.txt"), "outer keep").unwrap();
4836 std::fs::write(source.join("home/inner/secret.txt"), "inner drop").unwrap();
4837 std::fs::write(source.join("home/inner/keep.txt"), "inner keep").unwrap();
4838 std::fs::write(source.join("home/inner/.yuiignore"), "secret*\n").unwrap();
4840 apply(Some(source.clone()), false).unwrap();
4841 assert!(
4842 target.join("secret.txt").exists(),
4843 "outer secret.txt is outside the nested .yuiignore scope"
4844 );
4845 assert!(target.join("inner/keep.txt").exists());
4846 assert!(
4847 !target.join("inner/secret.txt").exists(),
4848 "inner secret.txt should be excluded by the nested .yuiignore"
4849 );
4850 }
4851
4852 #[test]
4856 fn nested_yuiignore_negation_overrides_root_rule() {
4857 let tmp = TempDir::new().unwrap();
4858 let (source, target) = setup_minimal_dotfiles(&tmp);
4859 std::fs::create_dir_all(source.join("home/keepers")).unwrap();
4860 std::fs::write(source.join("home/drop.lock"), "outer drop").unwrap();
4861 std::fs::write(source.join("home/keepers/wanted.lock"), "inner keep").unwrap();
4862 std::fs::write(source.join(".yuiignore"), "*.lock\n").unwrap();
4863 std::fs::write(source.join("home/keepers/.yuiignore"), "!*.lock\n").unwrap();
4865 apply(Some(source.clone()), false).unwrap();
4866 assert!(
4867 !target.join("drop.lock").exists(),
4868 "root rule still drops outer .lock file"
4869 );
4870 assert!(
4871 target.join("keepers/wanted.lock").exists(),
4872 "nested negation re-includes .lock under keepers/"
4873 );
4874 }
4875
4876 #[test]
4880 fn nested_yuiignore_status_walk_scoped() {
4881 let tmp = TempDir::new().unwrap();
4882 let (source, _target) = setup_minimal_dotfiles(&tmp);
4883 std::fs::create_dir_all(source.join("home/a")).unwrap();
4884 std::fs::create_dir_all(source.join("home/b")).unwrap();
4885 std::fs::write(source.join("home/a/foo.txt"), "a-foo").unwrap();
4886 std::fs::write(source.join("home/b/foo.txt"), "b-foo").unwrap();
4887 std::fs::write(source.join("home/a/.yuiignore"), "foo.txt\n").unwrap();
4889 apply(Some(source.clone()), false).unwrap();
4890 let res = status(Some(source), None, true);
4892 assert!(res.is_ok() || matches!(&res, Err(e) if format!("{e}").contains("diverged")));
4893 }
4894
4895 #[test]
4896 fn yuiignore_skips_template_in_render() {
4897 let tmp = TempDir::new().unwrap();
4898 let source = utf8(tmp.path().join("dotfiles"));
4899 let target = utf8(tmp.path().join("target"));
4900 std::fs::create_dir_all(source.join("home")).unwrap();
4901 std::fs::create_dir_all(&target).unwrap();
4902 std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
4903 std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
4904 let cfg = format!(
4905 r#"
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 assert!(!source.join("home/note").exists());
4916 assert!(!target.join("note").exists());
4917 assert!(!target.join("note.tera").exists());
4918 }
4919
4920 #[test]
4929 fn apply_decrypts_age_files_to_sibling_and_links() {
4930 let tmp = TempDir::new().unwrap();
4931 let source = utf8(tmp.path().join("dotfiles"));
4932 let target = utf8(tmp.path().join("target"));
4933 std::fs::create_dir_all(source.join("home/.ssh")).unwrap();
4934 std::fs::create_dir_all(&target).unwrap();
4935
4936 let identity_path = utf8(tmp.path().join("age.txt"));
4939 let (secret, public) = secret::generate_x25519_keypair();
4940 std::fs::write(&identity_path, format!("{secret}\n")).unwrap();
4941
4942 let recipient = secret::parse_recipient(&public).unwrap();
4944 let cipher = secret::encrypt(b"-- super secret key --\n", &[recipient]).unwrap();
4945 std::fs::write(source.join("home/.ssh/id_ed25519.age"), &cipher).unwrap();
4946
4947 let cfg = format!(
4949 r#"
4950[[mount.entry]]
4951src = "home"
4952dst = "{}"
4953
4954[secrets]
4955identity = "{}"
4956recipients = ["{}"]
4957"#,
4958 toml_path(&target),
4959 toml_path(&identity_path),
4960 public
4961 );
4962 std::fs::write(source.join("config.toml"), cfg).unwrap();
4963
4964 apply(Some(source.clone()), false).unwrap();
4965
4966 assert!(source.join("home/.ssh/id_ed25519").exists());
4968 let target_bytes = std::fs::read(target.join(".ssh/id_ed25519")).unwrap();
4970 assert_eq!(target_bytes, b"-- super secret key --\n");
4971 let gi = std::fs::read_to_string(source.join(".gitignore")).unwrap();
4973 assert!(
4974 gi.contains("home/.ssh/id_ed25519"),
4975 ".gitignore should list the decrypted plaintext sibling: {gi}"
4976 );
4977 }
4980
4981 #[test]
4986 fn apply_bails_on_secret_drift() {
4987 let tmp = TempDir::new().unwrap();
4988 let source = utf8(tmp.path().join("dotfiles"));
4989 let target = utf8(tmp.path().join("target"));
4990 std::fs::create_dir_all(source.join("home")).unwrap();
4991 std::fs::create_dir_all(&target).unwrap();
4992
4993 let identity_path = utf8(tmp.path().join("age.txt"));
4994 let (secret_key, public) = secret::generate_x25519_keypair();
4995 std::fs::write(&identity_path, format!("{secret_key}\n")).unwrap();
4996
4997 let recipient = secret::parse_recipient(&public).unwrap();
4998 let cipher = secret::encrypt(b"v1 content\n", &[recipient]).unwrap();
4999 std::fs::write(source.join("home/secret.age"), &cipher).unwrap();
5000 std::fs::write(source.join("home/secret"), "edited locally\n").unwrap();
5002
5003 let cfg = format!(
5004 r#"
5005[[mount.entry]]
5006src = "home"
5007dst = "{}"
5008
5009[secrets]
5010identity = "{}"
5011recipients = ["{}"]
5012"#,
5013 toml_path(&target),
5014 toml_path(&identity_path),
5015 public
5016 );
5017 std::fs::write(source.join("config.toml"), cfg).unwrap();
5018
5019 let err = apply(Some(source.clone()), false).unwrap_err();
5020 assert!(
5021 format!("{err:#}").contains("secret drift"),
5022 "expected secret drift error, got: {err:#}"
5023 );
5024 }
5025
5026 #[test]
5029 fn append_recipient_creates_secrets_table_when_missing() {
5030 let result =
5031 append_recipient_to_local("", "host alice", "age1abcrecipientpublickey").unwrap();
5032 let parsed: toml::Table = toml::from_str(&result).unwrap();
5034 let secrets = parsed.get("secrets").and_then(|v| v.as_table()).unwrap();
5035 let recipients = secrets
5036 .get("recipients")
5037 .and_then(|v| v.as_array())
5038 .unwrap();
5039 assert_eq!(recipients.len(), 1);
5040 assert_eq!(recipients[0].as_str(), Some("age1abcrecipientpublickey"));
5041 }
5042
5043 #[test]
5044 fn append_recipient_preserves_existing_other_tables() {
5045 let existing = r#"
5049[vars]
5050greet = "hi"
5051
5052[secrets]
5053recipients = ["age1machine_a"]
5054
5055[ui]
5056icons = "ascii"
5057"#;
5058 let result = append_recipient_to_local(existing, "host b", "age1machine_b").unwrap();
5059 let parsed: toml::Table = toml::from_str(&result).unwrap();
5060 assert!(parsed.get("vars").is_some());
5062 assert!(parsed.get("secrets").is_some());
5063 assert!(parsed.get("ui").is_some());
5064 let recipients = parsed["secrets"]["recipients"].as_array().unwrap();
5066 assert_eq!(recipients.len(), 2);
5067 let pubs: Vec<&str> = recipients.iter().filter_map(|v| v.as_str()).collect();
5068 assert!(pubs.contains(&"age1machine_a"));
5069 assert!(pubs.contains(&"age1machine_b"));
5070 }
5071
5072 #[test]
5073 fn append_recipient_is_idempotent_on_duplicate() {
5074 let existing = r#"[secrets]
5075recipients = ["age1same"]
5076"#;
5077 let result = append_recipient_to_local(existing, "anyone", "age1same").unwrap();
5078 let parsed: toml::Table = toml::from_str(&result).unwrap();
5079 let recipients = parsed["secrets"]["recipients"].as_array().unwrap();
5080 assert_eq!(recipients.len(), 1, "duplicate must not be appended twice");
5081 }
5082
5083 #[test]
5084 fn append_recipient_creates_recipients_array_when_secrets_table_empty() {
5085 let existing = r#"[secrets]
5088identity = "~/.config/yui/age.txt"
5089"#;
5090 let result = append_recipient_to_local(existing, "h", "age1new").unwrap();
5091 let parsed: toml::Table = toml::from_str(&result).unwrap();
5092 let secrets = parsed["secrets"].as_table().unwrap();
5093 assert_eq!(
5094 secrets["identity"].as_str(),
5095 Some("~/.config/yui/age.txt"),
5096 "existing identity field must survive"
5097 );
5098 let recipients = secrets["recipients"].as_array().unwrap();
5099 assert_eq!(recipients.len(), 1);
5100 assert_eq!(recipients[0].as_str(), Some("age1new"));
5101 }
5102
5103 #[test]
5107 fn apply_without_recipients_skips_secret_walker() {
5108 let tmp = TempDir::new().unwrap();
5109 let (source, _target) = setup_minimal_dotfiles(&tmp);
5110 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5112 std::fs::write(source.join("home/some.junk.age"), b"not actually a cipher").unwrap();
5116 apply(Some(source.clone()), false).unwrap();
5117 }
5118
5119 #[test]
5123 fn nested_marker_accumulates_extra_dst() {
5124 let tmp = TempDir::new().unwrap();
5125 let source = utf8(tmp.path().join("dotfiles"));
5126 let parent_target = utf8(tmp.path().join("home"));
5127 let extra_target = utf8(tmp.path().join("extra"));
5128 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
5129 std::fs::create_dir_all(&parent_target).unwrap();
5130 std::fs::create_dir_all(&extra_target).unwrap();
5131 std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
5132
5133 std::fs::write(
5135 source.join("home/.config/.yuilink"),
5136 format!(
5137 r#"
5138[[link]]
5139dst = "{}/.config"
5140"#,
5141 toml_path(&parent_target)
5142 ),
5143 )
5144 .unwrap();
5145 std::fs::write(
5148 source.join("home/.config/nvim/.yuilink"),
5149 format!(
5150 r#"
5151[[link]]
5152dst = "{}/nvim"
5153when = "{{{{ yui.os == '{}' }}}}"
5154"#,
5155 toml_path(&extra_target),
5156 std::env::consts::OS
5157 ),
5158 )
5159 .unwrap();
5160
5161 let cfg = format!(
5162 r#"
5163[[mount.entry]]
5164src = "home"
5165dst = "{}"
5166"#,
5167 toml_path(&parent_target)
5168 );
5169 std::fs::write(source.join("config.toml"), cfg).unwrap();
5170
5171 apply(Some(source.clone()), false).unwrap();
5172
5173 assert!(parent_target.join(".config/nvim/init.lua").exists());
5176 assert!(extra_target.join("nvim/init.lua").exists());
5177 }
5178
5179 #[test]
5184 fn marker_file_link_targets_specific_file() {
5185 let tmp = TempDir::new().unwrap();
5186 let source = utf8(tmp.path().join("dotfiles"));
5187 let parent_target = utf8(tmp.path().join("home"));
5188 let docs_target = utf8(tmp.path().join("docs"));
5189 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
5190 std::fs::create_dir_all(&parent_target).unwrap();
5191 std::fs::create_dir_all(&docs_target).unwrap();
5192 std::fs::write(
5193 source.join("home/.config/powershell/profile.ps1"),
5194 "# profile\n",
5195 )
5196 .unwrap();
5197 std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
5198
5199 std::fs::write(
5202 source.join("home/.config/powershell/.yuilink"),
5203 format!(
5204 r#"
5205[[link]]
5206src = "profile.ps1"
5207dst = "{}/Microsoft.PowerShell_profile.ps1"
5208"#,
5209 toml_path(&docs_target)
5210 ),
5211 )
5212 .unwrap();
5213
5214 let cfg = format!(
5215 r#"
5216[[mount.entry]]
5217src = "home"
5218dst = "{}"
5219"#,
5220 toml_path(&parent_target)
5221 );
5222 std::fs::write(source.join("config.toml"), cfg).unwrap();
5223
5224 apply(Some(source.clone()), false).unwrap();
5225
5226 assert!(
5228 docs_target
5229 .join("Microsoft.PowerShell_profile.ps1")
5230 .exists()
5231 );
5232 assert!(
5235 parent_target
5236 .join(".config/powershell/profile.ps1")
5237 .exists()
5238 );
5239 assert!(parent_target.join(".config/powershell/extra.txt").exists());
5240 }
5241
5242 #[test]
5245 fn marker_file_link_missing_src_errors() {
5246 let tmp = TempDir::new().unwrap();
5247 let source = utf8(tmp.path().join("dotfiles"));
5248 let parent_target = utf8(tmp.path().join("home"));
5249 let docs_target = utf8(tmp.path().join("docs"));
5250 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
5251 std::fs::create_dir_all(&parent_target).unwrap();
5252 std::fs::create_dir_all(&docs_target).unwrap();
5253
5254 std::fs::write(
5255 source.join("home/.config/powershell/.yuilink"),
5256 format!(
5257 r#"
5258[[link]]
5259src = "missing.ps1"
5260dst = "{}/profile.ps1"
5261"#,
5262 toml_path(&docs_target)
5263 ),
5264 )
5265 .unwrap();
5266
5267 let cfg = format!(
5268 r#"
5269[[mount.entry]]
5270src = "home"
5271dst = "{}"
5272"#,
5273 toml_path(&parent_target)
5274 );
5275 std::fs::write(source.join("config.toml"), cfg).unwrap();
5276
5277 let err = apply(Some(source.clone()), false).unwrap_err();
5278 assert!(format!("{err:#}").contains("missing.ps1"));
5279 }
5280
5281 #[test]
5290 fn unmanaged_finds_files_outside_any_mount() {
5291 let tmp = TempDir::new().unwrap();
5292 let (source, _target) = setup_minimal_dotfiles(&tmp);
5293 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5295 std::fs::write(source.join("orphan.txt"), "y").unwrap();
5297 std::fs::create_dir_all(source.join("notes")).unwrap();
5298 std::fs::write(source.join("notes/scratch.md"), "z").unwrap();
5299
5300 unmanaged(Some(source.clone()), None, true).unwrap();
5302
5303 let yui = YuiVars::detect(&source);
5305 let cfg = config::load(&source, &yui).unwrap();
5306 let mount_srcs: Vec<Utf8PathBuf> = cfg
5307 .mount
5308 .entry
5309 .iter()
5310 .map(|m| source.join(&m.src))
5311 .collect();
5312 let walker = paths::source_walker(&source).build();
5313 let mut unmanaged_paths = Vec::new();
5314 for entry in walker.flatten() {
5315 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
5316 continue;
5317 }
5318 let p = match Utf8PathBuf::from_path_buf(entry.path().to_path_buf()) {
5319 Ok(p) => p,
5320 Err(_) => continue,
5321 };
5322 if is_repo_meta(&p, &source, &cfg.mount.marker_filename) {
5323 continue;
5324 }
5325 if mount_srcs.iter().any(|m| p.starts_with(m)) {
5326 continue;
5327 }
5328 unmanaged_paths.push(p);
5329 }
5330 let names: Vec<String> = unmanaged_paths
5331 .iter()
5332 .filter_map(|p| p.file_name().map(String::from))
5333 .collect();
5334 assert!(names.contains(&"orphan.txt".into()));
5335 assert!(names.contains(&"scratch.md".into()));
5336 assert!(!names.contains(&".bashrc".into()), "mount-claimed file");
5337 assert!(!names.contains(&"config.toml".into()), "repo meta");
5338 }
5339
5340 #[test]
5341 fn is_repo_meta_recognises_yui_scaffold() {
5342 let source = Utf8Path::new("/dot");
5343 assert!(is_repo_meta(
5345 Utf8Path::new("/dot/config.toml"),
5346 source,
5347 ".yuilink",
5348 ));
5349 assert!(is_repo_meta(
5350 Utf8Path::new("/dot/config.local.toml"),
5351 source,
5352 ".yuilink",
5353 ));
5354 assert!(is_repo_meta(
5355 Utf8Path::new("/dot/config.linux.toml"),
5356 source,
5357 ".yuilink",
5358 ));
5359 assert!(is_repo_meta(
5360 Utf8Path::new("/dot/config.local.example.toml"),
5361 source,
5362 ".yuilink",
5363 ));
5364 assert!(is_repo_meta(
5366 Utf8Path::new("/dot/.gitignore"),
5367 source,
5368 ".yuilink",
5369 ));
5370 assert!(is_repo_meta(
5372 Utf8Path::new("/dot/home/.config/foo/.yuilink"),
5373 source,
5374 ".yuilink",
5375 ));
5376 assert!(is_repo_meta(
5377 Utf8Path::new("/dot/home/.gitconfig.tera"),
5378 source,
5379 ".yuilink",
5380 ));
5381 assert!(!is_repo_meta(
5383 Utf8Path::new("/dot/home/.config/myapp/config.toml"),
5384 source,
5385 ".yuilink",
5386 ));
5387 assert!(!is_repo_meta(
5391 Utf8Path::new("/dot/home/.config/git/.gitignore"),
5392 source,
5393 ".yuilink",
5394 ));
5395 }
5396
5397 #[test]
5404 fn unmanaged_respects_inactive_mount_entries() {
5405 let tmp = TempDir::new().unwrap();
5406 let source = utf8(tmp.path().join("dotfiles"));
5407 let target = utf8(tmp.path().join("target"));
5408 std::fs::create_dir_all(source.join("home_active")).unwrap();
5409 std::fs::create_dir_all(source.join("home_other_os")).unwrap();
5410 std::fs::create_dir_all(&target).unwrap();
5411 std::fs::write(source.join("home_active/.bashrc"), "active").unwrap();
5412 std::fs::write(source.join("home_other_os/.bashrc"), "inactive").unwrap();
5413 let cfg = format!(
5415 r#"
5416[[mount.entry]]
5417src = "home_active"
5418dst = "{target}"
5419
5420[[mount.entry]]
5421src = "home_other_os"
5422dst = "{target}"
5423when = "yui.os == 'definitely_not_a_real_os'"
5424"#,
5425 target = toml_path(&target)
5426 );
5427 std::fs::write(source.join("config.toml"), cfg).unwrap();
5428
5429 let yui = YuiVars::detect(&source);
5433 let cfg = config::load(&source, &yui).unwrap();
5434 let mount_srcs: Vec<Utf8PathBuf> = cfg
5435 .mount
5436 .entry
5437 .iter()
5438 .map(|m| source.join(&m.src))
5439 .collect();
5440 let inactive_file = source.join("home_other_os/.bashrc");
5441 let claimed = mount_srcs.iter().any(|m| inactive_file.starts_with(m));
5442 assert!(
5443 claimed,
5444 "raw config.mount.entry should claim files even under inactive mounts"
5445 );
5446 }
5447
5448 #[test]
5453 fn diff_shows_drift_skips_in_sync() {
5454 let tmp = TempDir::new().unwrap();
5455 let (source, target) = setup_minimal_dotfiles(&tmp);
5456 std::fs::write(source.join("home/.bashrc"), "first\nsecond\n").unwrap();
5457 apply(Some(source.clone()), false).unwrap();
5459 std::fs::remove_file(target.join(".bashrc")).unwrap();
5461 std::fs::write(target.join(".bashrc"), "first\nEDITED\n").unwrap();
5462
5463 diff(Some(source.clone()), None, true).unwrap();
5466 }
5467
5468 #[test]
5473 fn read_text_for_diff_classifies_correctly() {
5474 let tmp = TempDir::new().unwrap();
5475 let root = utf8(tmp.path().to_path_buf());
5476 let txt = root.join("a.txt");
5478 std::fs::write(&txt, "hello\n").unwrap();
5479 match read_text_for_diff(&txt) {
5480 DiffSide::Text(s) => assert_eq!(s, "hello\n"),
5481 DiffSide::Binary => panic!("text file misclassified as binary"),
5482 }
5483 let bin = root.join("b.bin");
5485 std::fs::write(&bin, [0xff, 0xfe, 0x00, 0xff]).unwrap();
5486 assert!(matches!(read_text_for_diff(&bin), DiffSide::Binary));
5487 let missing = root.join("missing.txt");
5489 match read_text_for_diff(&missing) {
5490 DiffSide::Text(s) => assert!(s.is_empty()),
5491 DiffSide::Binary => panic!("missing file misclassified as binary"),
5492 }
5493 }
5494
5495 #[test]
5502 fn diff_render_drift_uses_rendered_output_not_raw_template() {
5503 let tmp = TempDir::new().unwrap();
5504 let (source, _target) = setup_minimal_dotfiles(&tmp);
5505 std::fs::write(source.join("home/note.tera"), "os = {{ yui.os }}\n").unwrap();
5508 std::fs::write(source.join("home/note"), "os = ancient\n").unwrap();
5509 let yui = YuiVars::detect(&source);
5511 let cfg = config::load(&source, &yui).unwrap();
5512 let rendered =
5513 render::render_to_string(&source.join("home/note.tera"), &source, &cfg, &yui)
5514 .unwrap()
5515 .expect("template should render on this host");
5516 assert!(rendered.starts_with("os = "));
5517 assert!(
5518 !rendered.contains("{{"),
5519 "rendered output must not contain raw Tera tags"
5520 );
5521 }
5522
5523 #[test]
5531 fn resolve_diff_src_absolutizes_link_rows() {
5532 let source = Utf8Path::new("/dot");
5533 let link_item = StatusItem {
5534 src: Utf8PathBuf::from("home/.bashrc"),
5535 dst: Utf8PathBuf::from("/h/u/.bashrc"),
5536 state: StatusState::Link(absorb::AbsorbDecision::AutoAbsorb),
5537 };
5538 assert_eq!(
5539 resolve_diff_src(&link_item, source),
5540 Utf8PathBuf::from("/dot/home/.bashrc"),
5541 );
5542 let render_item = StatusItem {
5543 src: Utf8PathBuf::from("/dot/home/foo.tera"),
5544 dst: Utf8PathBuf::from("/dot/home/foo"),
5545 state: StatusState::RenderDrift,
5546 };
5547 assert_eq!(
5548 resolve_diff_src(&render_item, source),
5549 Utf8PathBuf::from("/dot/home/foo.tera"),
5550 );
5551 }
5552
5553 #[test]
5554 fn diff_classifier_skips_uninteresting_states() {
5555 use absorb::AbsorbDecision::*;
5556 assert!(!diff_worth_printing(&StatusState::Link(InSync)));
5558 assert!(!diff_worth_printing(&StatusState::Link(Restore)));
5559 assert!(!diff_worth_printing(&StatusState::Link(RelinkOnly)));
5560 assert!(diff_worth_printing(&StatusState::Link(AutoAbsorb)));
5562 assert!(diff_worth_printing(&StatusState::Link(NeedsConfirm)));
5563 assert!(diff_worth_printing(&StatusState::RenderDrift));
5564 }
5565
5566 #[test]
5577 fn update_errors_when_source_is_not_a_git_repo() {
5578 let tmp = TempDir::new().unwrap();
5579 let source = utf8(tmp.path().join("dotfiles"));
5580 std::fs::create_dir_all(&source).unwrap();
5581 std::fs::write(source.join("config.toml"), "").unwrap();
5582 let err = update(Some(source), false).unwrap_err();
5584 let msg = format!("{err:#}");
5585 assert!(
5586 msg.contains("not a git repository")
5587 || msg.contains("uncommitted")
5588 || msg.contains("git"),
5589 "unexpected error: {msg}",
5590 );
5591 }
5592
5593 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
5594 let mut out = Vec::new();
5595 let mut stack = vec![root.to_path_buf()];
5596 while let Some(dir) = stack.pop() {
5597 let Ok(entries) = std::fs::read_dir(&dir) else {
5598 continue;
5599 };
5600 for e in entries.flatten() {
5601 let p = utf8(e.path());
5602 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
5603 stack.push(p);
5604 } else {
5605 out.push(p);
5606 }
5607 }
5608 }
5609 out
5610 }
5611
5612 #[test]
5617 fn parse_backup_suffix_recognises_file_with_extension() {
5618 let dt = parse_backup_suffix("foo_20260429_143022123.yml").unwrap();
5619 assert_eq!(dt.year(), 2026);
5620 assert_eq!(dt.month(), 4);
5621 assert_eq!(dt.day(), 29);
5622 assert_eq!(dt.hour(), 14);
5623 assert_eq!(dt.minute(), 30);
5624 assert_eq!(dt.second(), 22);
5625 }
5626
5627 #[test]
5628 fn parse_backup_suffix_recognises_dotfile_no_extension() {
5629 let dt = parse_backup_suffix(".gitconfig_20260429_143022123").unwrap();
5630 assert_eq!(dt.year(), 2026);
5631 }
5632
5633 #[test]
5634 fn parse_backup_suffix_recognises_directory_form() {
5635 let dt = parse_backup_suffix("nvim_20260429_143022123").unwrap();
5636 assert_eq!(dt.day(), 29);
5637 }
5638
5639 #[test]
5640 fn parse_backup_suffix_recognises_multi_dot_filename() {
5641 let dt = parse_backup_suffix("archive.tar.gz_20260429_143022123.gz").unwrap();
5643 assert_eq!(dt.month(), 4);
5644 }
5645
5646 #[test]
5647 fn parse_backup_suffix_rejects_non_yui_names() {
5648 assert!(parse_backup_suffix("README.md").is_none());
5649 assert!(parse_backup_suffix("notes_2026.txt").is_none());
5650 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());
5654 }
5655
5656 #[test]
5657 fn parse_human_duration_basic_units() {
5658 let s = parse_human_duration("30d").unwrap();
5659 assert_eq!(s.get_days(), 30);
5660 let s = parse_human_duration("2w").unwrap();
5661 assert_eq!(s.get_weeks(), 2);
5662 let s = parse_human_duration("12h").unwrap();
5663 assert_eq!(s.get_hours(), 12);
5664 let s = parse_human_duration("5m").unwrap();
5666 assert_eq!(s.get_minutes(), 5);
5667 let s = parse_human_duration("6mo").unwrap();
5668 assert_eq!(s.get_months(), 6);
5669 let s = parse_human_duration("1y").unwrap();
5670 assert_eq!(s.get_years(), 1);
5671 }
5672
5673 #[test]
5674 fn parse_human_duration_case_insensitive_and_whitespace() {
5675 let s = parse_human_duration(" 90D ").unwrap();
5676 assert_eq!(s.get_days(), 90);
5677 let s = parse_human_duration("3WEEKS").unwrap();
5678 assert_eq!(s.get_weeks(), 3);
5679 }
5680
5681 #[test]
5682 fn parse_human_duration_rejects_garbage() {
5683 assert!(parse_human_duration("").is_err());
5684 assert!(parse_human_duration("d30").is_err());
5685 assert!(parse_human_duration("30").is_err()); assert!(parse_human_duration("30x").is_err()); assert!(parse_human_duration("-1d").is_err()); }
5689
5690 #[test]
5694 fn walk_gc_backups_collects_files_and_dir_snapshots() {
5695 let tmp = TempDir::new().unwrap();
5696 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
5697 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
5698 std::fs::write(
5700 root.join("C/Users/u/.config/foo_20260429_143022123.yml"),
5701 "old yml",
5702 )
5703 .unwrap();
5704 std::fs::create_dir_all(root.join("C/Users/u/nvim_20260101_000000000/lua")).unwrap();
5706 std::fs::write(
5707 root.join("C/Users/u/nvim_20260101_000000000/init.lua"),
5708 "ok",
5709 )
5710 .unwrap();
5711 std::fs::write(
5712 root.join("C/Users/u/nvim_20260101_000000000/lua/x.lua"),
5713 "kk",
5714 )
5715 .unwrap();
5716 std::fs::write(root.join("C/Users/u/.config/README.md"), "user note").unwrap();
5718
5719 let entries = walk_gc_backups(&root).unwrap();
5720 assert_eq!(entries.len(), 2, "two backup roots, not three");
5721 let kinds: Vec<_> = entries.iter().map(|e| e.kind).collect();
5722 assert!(kinds.contains(&BackupKind::File));
5723 assert!(kinds.contains(&BackupKind::Dir));
5724 let dir_entry = entries.iter().find(|e| e.kind == BackupKind::Dir).unwrap();
5726 assert!(dir_entry.size_bytes >= 4); }
5728
5729 #[test]
5730 fn cleanup_empty_parents_stops_at_root_and_at_non_empty() {
5731 let tmp = TempDir::new().unwrap();
5732 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
5733 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
5734 std::fs::write(root.join("C/Users/u/sibling_keep"), "x").unwrap();
5735
5736 cleanup_empty_parents(&root.join("C/Users/u/.config"), &root);
5740
5741 assert!(!root.join("C/Users/u/.config").exists(), "empty leaf gone");
5742 assert!(root.join("C/Users/u").exists(), "stops at non-empty parent");
5743 assert!(root.exists(), "backup root preserved");
5744 }
5745
5746 #[test]
5748 fn gc_backup_survey_keeps_all_entries() {
5749 let tmp = TempDir::new().unwrap();
5750 let source = utf8(tmp.path().join("dotfiles"));
5751 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
5752 std::fs::write(source.join("config.toml"), "").unwrap();
5753 let backup = source.join(".yui/backup");
5754 std::fs::write(backup.join("a_20260101_000000000.txt"), "old").unwrap();
5755 std::fs::write(backup.join("b_20260415_120000000.txt"), "fresh").unwrap();
5756
5757 gc_backup(Some(source.clone()), None, false, None, true).unwrap();
5758
5759 assert!(backup.join("a_20260101_000000000.txt").exists());
5761 assert!(backup.join("b_20260415_120000000.txt").exists());
5762 }
5763
5764 #[test]
5767 fn gc_backup_prune_removes_old_files_only() {
5768 let tmp = TempDir::new().unwrap();
5769 let source = utf8(tmp.path().join("dotfiles"));
5770 std::fs::create_dir_all(source.join(".yui/backup/sub")).unwrap();
5771 std::fs::write(source.join("config.toml"), "").unwrap();
5772 let backup = source.join(".yui/backup");
5773
5774 std::fs::write(backup.join("sub/old_20200101_000000000.txt"), "old").unwrap();
5776 let tomorrow = jiff::Zoned::now()
5778 .checked_add(jiff::Span::new().days(1))
5779 .unwrap();
5780 let bdt = jiff::fmt::strtime::BrokenDownTime::from(&tomorrow);
5781 let future_ts = bdt.to_string("%Y%m%d_%H%M%S%3f").unwrap();
5782 std::fs::write(backup.join(format!("fresh_{future_ts}.txt")), "fresh").unwrap();
5783 std::fs::write(backup.join("notes.md"), "mine").unwrap();
5785
5786 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
5787
5788 assert!(!backup.join("sub/old_20200101_000000000.txt").exists());
5789 assert!(!backup.join("sub").exists(), "empty parent removed");
5791 assert!(backup.exists());
5793 assert!(backup.join(format!("fresh_{future_ts}.txt")).exists());
5794 assert!(backup.join("notes.md").exists(), "user file untouched");
5795 }
5796
5797 #[test]
5799 fn gc_backup_dry_run_does_not_delete() {
5800 let tmp = TempDir::new().unwrap();
5801 let source = utf8(tmp.path().join("dotfiles"));
5802 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
5803 std::fs::write(source.join("config.toml"), "").unwrap();
5804 let backup = source.join(".yui/backup");
5805 std::fs::write(backup.join("old_20200101_000000000.txt"), "old").unwrap();
5806
5807 gc_backup(Some(source.clone()), Some("30d".into()), true, None, true).unwrap();
5808
5809 assert!(
5810 backup.join("old_20200101_000000000.txt").exists(),
5811 "dry-run keeps everything in place"
5812 );
5813 }
5814
5815 #[test]
5819 fn gc_backup_prune_handles_directory_snapshot() {
5820 let tmp = TempDir::new().unwrap();
5821 let source = utf8(tmp.path().join("dotfiles"));
5822 std::fs::create_dir_all(source.join(".yui/backup/mirror/u")).unwrap();
5823 std::fs::write(source.join("config.toml"), "").unwrap();
5824 let backup = source.join(".yui/backup");
5825 let snap = backup.join("mirror/u/nvim_20200101_000000000");
5826 std::fs::create_dir_all(snap.join("lua")).unwrap();
5827 std::fs::write(snap.join("init.lua"), "x").unwrap();
5828 std::fs::write(snap.join("lua/y.lua"), "y").unwrap();
5829
5830 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
5831
5832 assert!(!snap.exists(), "dir snapshot removed wholesale");
5833 assert!(!backup.join("mirror").exists(), "empty mirror chain pruned");
5834 assert!(backup.exists(), "backup root preserved");
5835 }
5836}