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 let now = jiff::Zoned::now().to_string();
716 let body = format!(
717 "# created: {now}\n\
718 # public key: {public}\n\
719 {secret}\n"
720 );
721 secret::write_private_file(&identity_path, body.as_bytes())?;
724 info!("wrote identity file: {identity_path}");
725
726 let local_path = source.join("config.local.toml");
730 let comment = comment.unwrap_or_else(|| format!("{} {}", yui.host, yui.user));
731 let entry_comment = format!("{comment} — added by `yui secret init` on {now}");
732 let local_existing = match std::fs::read_to_string(&local_path) {
733 Ok(s) => s,
734 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
735 Err(e) => anyhow::bail!("read {local_path}: {e}"),
736 };
737 let updated_local = append_recipient_to_local(&local_existing, &entry_comment, &public)?;
738 std::fs::write(&local_path, updated_local)?;
739 info!("appended public key to {local_path}");
740 println!();
741 println!(" age identity: {identity_path}");
742 println!(" public key: {public}");
743 println!();
744 println!(
745 " Next: encrypt a file with `yui secret encrypt <path>`. \
746 The plaintext sibling will be auto-decrypted on every `yui apply`."
747 );
748 Ok(())
749}
750
751fn append_recipient_to_local(existing: &str, comment: &str, public: &str) -> Result<String> {
765 use toml_edit::{Array, DocumentMut, Item, Table, Value};
766
767 let mut doc: DocumentMut = if existing.trim().is_empty() {
768 DocumentMut::new()
769 } else {
770 existing
771 .parse()
772 .map_err(|e| anyhow::anyhow!("config.local.toml is not valid TOML: {e}"))?
773 };
774
775 if !doc.contains_key("secrets") {
777 let mut t = Table::new();
778 t.set_implicit(false);
779 doc.insert("secrets", Item::Table(t));
780 }
781 let secrets = doc["secrets"].as_table_mut().ok_or_else(|| {
782 anyhow::anyhow!("[secrets] in config.local.toml is not a table — refusing to clobber")
783 })?;
784
785 if !secrets.contains_key("recipients") {
787 secrets.insert("recipients", Item::Value(Value::Array(Array::new())));
788 }
789 let recipients = secrets["recipients"]
790 .as_array_mut()
791 .ok_or_else(|| anyhow::anyhow!("[secrets].recipients is not an array"))?;
792
793 let already_present = recipients.iter().any(|v| v.as_str() == Some(public));
795 if already_present {
796 return Ok(doc.to_string());
797 }
798
799 let mut value = Value::from(public);
803 let prefix = format!("\n # {comment}\n ");
804 *value.decor_mut() = toml_edit::Decor::new(prefix, "");
805 recipients.push_formatted(value);
806 recipients.set_trailing("\n");
810 recipients.set_trailing_comma(true);
811
812 Ok(doc.to_string())
813}
814
815pub fn secret_encrypt(
819 source: Option<Utf8PathBuf>,
820 path: Utf8PathBuf,
821 force: bool,
822 rm_plaintext: bool,
823) -> Result<()> {
824 let source = resolve_source(source)?;
825 let yui = YuiVars::detect(&source);
826 let config = config::load(&source, &yui)?;
827
828 if !config.secrets.enabled() {
829 anyhow::bail!(
830 "no recipients configured — run `yui secret init` to generate \
831 a keypair, or add at least one entry to `[secrets] recipients`."
832 );
833 }
834
835 let plaintext_path = if path.is_absolute() {
839 path.clone()
840 } else {
841 absolutize(&path)?
842 };
843 if !plaintext_path.is_file() {
844 anyhow::bail!("plaintext file not found: {plaintext_path}");
845 }
846 let cipher_path = Utf8PathBuf::from(format!("{plaintext_path}.age"));
847 if cipher_path.exists() && !force {
848 anyhow::bail!("{cipher_path} already exists; pass --force to overwrite");
849 }
850
851 let plaintext = std::fs::read(&plaintext_path)?;
852 let recipients: Vec<age::x25519::Recipient> = config
853 .secrets
854 .recipients
855 .iter()
856 .map(|s| secret::parse_x25519_recipient(s))
857 .collect::<crate::Result<_>>()?;
858 let cipher = secret::encrypt_x25519(&plaintext, &recipients)?;
859 std::fs::write(&cipher_path, &cipher)?;
860 info!("encrypted {plaintext_path} → {cipher_path}");
861
862 if rm_plaintext {
863 if plaintext_path.starts_with(&source) {
866 std::fs::remove_file(&plaintext_path)?;
867 info!("removed plaintext: {plaintext_path}");
868 } else {
869 warn!(
870 "plaintext lives outside source ({plaintext_path}); \
871 skipping --rm-plaintext as a safety check"
872 );
873 }
874 }
875 Ok(())
876}
877
878pub fn secret_wrap(source: Option<Utf8PathBuf>) -> Result<()> {
887 let source = resolve_source(source)?;
888 let yui = YuiVars::detect(&source);
889 let config = config::load(&source, &yui)?;
890
891 let wrapped_rel = config.secrets.passkey_wrapped.as_ref().ok_or_else(|| {
892 anyhow::anyhow!(
893 "[secrets].passkey_wrapped is not configured — set it to a \
894 repo-relative path (e.g. \".yui/age.txt.age\") before calling wrap"
895 )
896 })?;
897 if config.secrets.passkey_recipients.is_empty() {
898 anyhow::bail!(
899 "[secrets].passkey_recipients is empty — add at least one \
900 passkey public key (e.g. an `age1fido2-hmac1…` from \
901 `age-plugin-fido2-hmac --generate`) before calling wrap"
902 );
903 }
904 let identity_path = paths::expand_tilde(&config.secrets.identity);
905 if !identity_path.is_file() {
906 anyhow::bail!(
907 "no X25519 identity at {identity_path}; run `yui secret init` first \
908 (wrap encrypts that file's contents to your passkey devices)"
909 );
910 }
911 let wrapped_path = resolve_secrets_path(&source, wrapped_rel);
912
913 let plaintext = std::fs::read(&identity_path)?;
914 let recipients = secret::parse_passkey_recipients(&config.secrets.passkey_recipients)?;
918 let cipher = secret::encrypt_to_passkeys(&plaintext, &recipients)?;
919 if let Some(parent) = wrapped_path.parent() {
920 std::fs::create_dir_all(parent)?;
921 }
922 std::fs::write(&wrapped_path, &cipher)?;
923 info!("wrote passkey-wrapped identity: {wrapped_path}");
924 println!();
925 println!(" Recipients: {}", config.secrets.passkey_recipients.len());
926 println!(" Wrapped at: {wrapped_path}");
927 println!();
928 println!(" Commit this file. On a new machine, run `yui secret unlock`.");
929 Ok(())
930}
931
932pub fn secret_unlock(source: Option<Utf8PathBuf>, passkey: Option<String>) -> Result<()> {
940 let source = resolve_source(source)?;
941 let yui = YuiVars::detect(&source);
942 let config = config::load(&source, &yui)?;
943
944 let wrapped_rel = config.secrets.passkey_wrapped.as_ref().ok_or_else(|| {
945 anyhow::anyhow!(
946 "[secrets].passkey_wrapped is not set — nothing to unlock. \
947 Run `yui secret init` + `yui secret wrap` on an existing \
948 machine first, then commit + push."
949 )
950 })?;
951 let identities_rel = config.secrets.passkey_identities.as_ref().ok_or_else(|| {
952 anyhow::anyhow!(
953 "[secrets].passkey_identities is not set — point it at the \
954 file holding your `AGE-PLUGIN-…` identity descriptors \
955 (e.g. \".yui/passkeys.txt\")"
956 )
957 })?;
958
959 let wrapped_path = resolve_secrets_path(&source, wrapped_rel);
960 let identities_path = resolve_secrets_path(&source, identities_rel);
961 let identity_path = paths::expand_tilde(&config.secrets.identity);
962
963 if !wrapped_path.is_file() {
964 anyhow::bail!("passkey_wrapped not found at {wrapped_path}");
965 }
966 if !identities_path.is_file() {
967 anyhow::bail!("passkey_identities not found at {identities_path}");
968 }
969 if identity_path.exists() {
970 anyhow::bail!(
971 "{identity_path} already exists — refusing to clobber a live \
972 X25519 identity. Delete it first if you really mean to \
973 re-unlock from scratch."
974 );
975 }
976
977 let raw = std::fs::read_to_string(&identities_path)?;
983 let labels = parse_identity_labels(&raw);
984 let identities = secret::load_passkey_identities(&identities_path)?;
985 if identities.is_empty() {
986 anyhow::bail!(
987 "no identities loaded from {identities_path}; expected one \
988 or more `AGE-PLUGIN-…` (or `AGE-SECRET-KEY-1…`) lines"
989 );
990 }
991
992 let picked_idx = pick_passkey_identity(&labels, identities.len(), passkey.as_deref())?;
993 let cipher = std::fs::read(&wrapped_path)?;
994
995 let plaintext = if let Some(idx) = picked_idx {
998 let single: Vec<secret::BoxedIdentity> = vec![identities.into_iter().nth(idx).unwrap()];
999 info!(
1000 "unlocking via {}",
1001 labels.get(idx).map(String::as_str).unwrap_or("(unlabeled)")
1002 );
1003 secret::decrypt_with_passkeys(&cipher, &single)?
1004 } else {
1005 info!(
1006 "unlocking — age will try {} identities in order",
1007 identities.len()
1008 );
1009 secret::decrypt_with_passkeys(&cipher, &identities)?
1010 };
1011
1012 secret::validate_x25519_identity_bytes(&plaintext)?;
1019
1020 secret::write_private_file(&identity_path, &plaintext)?;
1023 info!("wrote unwrapped X25519 identity: {identity_path}");
1024 println!();
1025 println!(" X25519 identity restored at {identity_path}");
1026 println!(" Run `yui apply` next — no more passkey prompts needed.");
1027 Ok(())
1028}
1029
1030fn pick_passkey_identity(
1042 labels: &[String],
1043 identity_count: usize,
1044 override_label: Option<&str>,
1045) -> Result<Option<usize>> {
1046 if let Some(want) = override_label {
1047 let needle = want.to_ascii_lowercase();
1048 let matches: Vec<usize> = labels
1049 .iter()
1050 .enumerate()
1051 .filter(|(_, l)| l.to_ascii_lowercase().contains(&needle))
1052 .map(|(i, _)| i)
1053 .collect();
1054 match matches.len() {
1055 0 => anyhow::bail!(
1056 "--passkey {want:?} matches no identity label \
1057 (available: {labels:?})"
1058 ),
1059 1 => return Ok(Some(matches[0])),
1060 n => {
1061 let matched_labels: Vec<&str> =
1065 matches.iter().map(|&i| labels[i].as_str()).collect();
1066 anyhow::bail!(
1067 "--passkey {want:?} is ambiguous — matches {n} entries \
1068 ({matched_labels:?}); narrow it down"
1069 )
1070 }
1071 }
1072 }
1073
1074 if identity_count <= 1 {
1075 return Ok(None); }
1077
1078 use std::io::IsTerminal;
1079 if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1080 return Ok(None);
1083 }
1084
1085 let labelled: Vec<&str> = labels
1086 .iter()
1087 .map(|l| l.as_str())
1088 .chain(std::iter::repeat("(unlabeled)"))
1089 .take(identity_count)
1090 .collect();
1091 let pick = dialoguer::Select::new()
1092 .with_prompt("Which passkey to unlock with?")
1093 .items(&labelled)
1094 .default(0)
1095 .interact()
1096 .map_err(|e| anyhow::anyhow!("interactive prompt: {e}"))?;
1097 Ok(Some(pick))
1098}
1099
1100fn parse_identity_labels(raw: &str) -> Vec<String> {
1113 let mut labels = Vec::new();
1114 let mut comment = String::new();
1115 for line in raw.lines() {
1116 let l = line.trim();
1117 if l.is_empty() {
1118 continue;
1119 }
1120 if let Some(rest) = l.strip_prefix('#') {
1121 if !comment.is_empty() {
1122 comment.push(' ');
1123 }
1124 comment.push_str(rest.trim());
1125 } else {
1126 labels.push(if comment.is_empty() {
1127 format!("identity #{}", labels.len() + 1)
1128 } else {
1129 comment.clone()
1130 });
1131 comment.clear();
1132 }
1133 }
1134 labels
1135}
1136
1137fn resolve_secrets_path(source: &Utf8Path, p: &str) -> Utf8PathBuf {
1141 let expanded = paths::expand_tilde(p);
1142 if expanded.is_absolute() {
1143 expanded
1144 } else {
1145 source.join(expanded)
1146 }
1147}
1148
1149pub fn update(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
1160 let source = resolve_source(source)?;
1161 if !crate::git::is_clean(&source)? {
1162 anyhow::bail!(
1163 "source repo {source} has uncommitted changes — \
1164 commit or stash before `yui update` (or run \
1165 `git pull` + `yui apply` manually if you know what \
1166 you're doing)"
1167 );
1168 }
1169 info!("git pull --ff-only at {source}");
1170 let status = std::process::Command::new("git")
1171 .arg("-C")
1172 .arg(source.as_str())
1173 .arg("pull")
1174 .arg("--ff-only")
1175 .status()
1176 .map_err(|e| anyhow::anyhow!("invoking git: {e}"))?;
1177 if !status.success() {
1178 anyhow::bail!("git pull --ff-only failed at {source}");
1179 }
1180 apply(Some(source), dry_run)
1181}
1182
1183pub fn unmanaged(
1194 source: Option<Utf8PathBuf>,
1195 icons_override: Option<IconsMode>,
1196 no_color: bool,
1197) -> Result<()> {
1198 let source = resolve_source(source)?;
1199 let yui = YuiVars::detect(&source);
1200 let config = config::load(&source, &yui)?;
1201
1202 let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
1203 let color = !no_color && supports_color_stdout();
1204
1205 let mut engine = template::Engine::new();
1220 let tera_ctx = template::template_context(&yui, &config.vars);
1221 let mount_srcs: Vec<Utf8PathBuf> = config
1222 .mount
1223 .entry
1224 .iter()
1225 .map(|e| -> Result<Utf8PathBuf> {
1226 let rendered = engine.render(e.src.as_str(), &tera_ctx)?;
1227 Ok(paths::resolve_mount_src(&source, rendered.trim()))
1228 })
1229 .collect::<Result<_>>()?;
1230
1231 let mut items: Vec<Utf8PathBuf> = Vec::new();
1232 let walker = paths::source_walker(&source).build();
1233 for entry in walker {
1234 let entry = match entry {
1235 Ok(e) => e,
1236 Err(_) => continue,
1237 };
1238 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
1239 continue;
1240 }
1241 let std_path = entry.path();
1242 let path = match Utf8PathBuf::from_path_buf(std_path.to_path_buf()) {
1243 Ok(p) => p,
1244 Err(_) => continue,
1245 };
1246 if is_repo_meta(&path, &source, &config.mount.marker_filename) {
1250 continue;
1251 }
1252 if mount_srcs.iter().any(|m| path.starts_with(m)) {
1253 continue;
1254 }
1255 items.push(path);
1256 }
1257 items.sort();
1258
1259 if items.is_empty() {
1260 println!(" no unmanaged files under {source}");
1261 return Ok(());
1262 }
1263
1264 print_unmanaged_table(&items, &source, color);
1265 println!();
1266 println!(" {} unmanaged file(s)", items.len());
1267 Ok(())
1268}
1269
1270fn is_repo_meta(path: &Utf8Path, source: &Utf8Path, marker_filename: &str) -> bool {
1286 let Some(name) = path.file_name() else {
1287 return false;
1288 };
1289 if name.ends_with(".tera") {
1290 return true;
1291 }
1292 if name == marker_filename || name == ".yuiignore" {
1293 return true;
1294 }
1295 let parent = path.parent().unwrap_or(Utf8Path::new(""));
1296 let at_root = parent == source;
1297 if at_root && name == ".gitignore" {
1298 return true;
1299 }
1300 if at_root && (name == "config.toml" || name == "config.local.toml") {
1301 return true;
1302 }
1303 if at_root
1304 && name.starts_with("config.")
1305 && (name.ends_with(".toml") || name.ends_with(".example.toml"))
1306 {
1307 return true;
1308 }
1309 false
1310}
1311
1312fn print_unmanaged_table(items: &[Utf8PathBuf], source: &Utf8Path, color: bool) {
1313 use owo_colors::OwoColorize as _;
1314 if color {
1315 println!(" {}", "PATH (relative to source)".dimmed());
1316 } else {
1317 println!(" PATH (relative to source)");
1318 }
1319 for p in items {
1320 let rel = p
1321 .strip_prefix(source)
1322 .map(Utf8PathBuf::from)
1323 .unwrap_or_else(|_| p.clone());
1324 if color {
1325 println!(" {}", rel.cyan());
1326 } else {
1327 println!(" {rel}");
1328 }
1329 }
1330}
1331
1332pub fn diff(
1340 source: Option<Utf8PathBuf>,
1341 icons_override: Option<IconsMode>,
1342 no_color: bool,
1343) -> Result<()> {
1344 let source = resolve_source(source)?;
1345 let yui = YuiVars::detect(&source);
1346 let config = config::load(&source, &yui)?;
1347 let mut engine = template::Engine::new();
1348 let tera_ctx = template::template_context(&yui, &config.vars);
1349 let mounts = mount::resolve(
1350 &source,
1351 &config.mount.entry,
1352 config.mount.default_strategy,
1353 &mut engine,
1354 &tera_ctx,
1355 )?;
1356
1357 let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
1358 let color = !no_color && supports_color_stdout();
1359
1360 let mut report: Vec<StatusItem> = Vec::new();
1362 let mut yuiignore = paths::YuiIgnoreStack::new();
1363 yuiignore.push_dir(&source)?;
1364 let walk_result = (|| -> Result<()> {
1365 for m in &mounts {
1366 let src_root = m.src.clone();
1367 if !src_root.is_dir() {
1368 continue;
1369 }
1370 classify_walk(
1371 &src_root,
1372 &m.dst,
1373 &config,
1374 m.strategy,
1375 &mut engine,
1376 &tera_ctx,
1377 &source,
1378 &mut yuiignore,
1379 &mut report,
1380 )?;
1381 }
1382 Ok(())
1383 })();
1384 yuiignore.pop_dir(&source);
1385 walk_result?;
1386
1387 let render_report = render::render_all(&source, &config, &yui, true)?;
1389 for rendered in &render_report.diverged {
1390 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
1391 report.push(StatusItem {
1392 src: tera_path,
1393 dst: rendered.clone(),
1394 state: StatusState::RenderDrift,
1395 });
1396 }
1397
1398 let mut printed = 0usize;
1399 for item in &report {
1400 if !diff_worth_printing(&item.state) {
1401 continue;
1402 }
1403 let src_abs = resolve_diff_src(item, &source);
1404 print_unified_diff(
1405 &src_abs,
1406 &item.dst,
1407 &item.state,
1408 &source,
1409 &config,
1410 &yui,
1411 color,
1412 );
1413 printed += 1;
1414 }
1415
1416 if printed == 0 {
1417 println!(" no diff — every entry is in sync (or only needs a relink)");
1418 } else {
1419 println!();
1420 println!(
1421 " {printed} entr{} with content drift",
1422 if printed == 1 { "y" } else { "ies" }
1423 );
1424 }
1425 Ok(())
1426}
1427
1428fn resolve_diff_src(item: &StatusItem, source: &Utf8Path) -> Utf8PathBuf {
1440 match item.state {
1441 StatusState::RenderDrift => item.src.clone(),
1442 StatusState::Link(_) => source.join(&item.src),
1443 }
1444}
1445
1446fn diff_worth_printing(state: &StatusState) -> bool {
1447 use absorb::AbsorbDecision::*;
1448 match state {
1449 StatusState::Link(InSync) => false,
1450 StatusState::Link(Restore) => false, StatusState::Link(RelinkOnly) => false, StatusState::Link(_) => true,
1453 StatusState::RenderDrift => true,
1454 }
1455}
1456
1457fn print_unified_diff(
1465 src: &Utf8Path,
1466 dst: &Utf8Path,
1467 state: &StatusState,
1468 source_root: &Utf8Path,
1469 config: &Config,
1470 yui: &YuiVars,
1471 color: bool,
1472) {
1473 use owo_colors::OwoColorize as _;
1474
1475 let header = match state {
1476 StatusState::RenderDrift => format!("--- render drift: {src} (template) vs {dst}"),
1477 _ => format!("--- {src} → {dst}"),
1478 };
1479 if color {
1480 println!("{}", header.bold());
1481 } else {
1482 println!("{header}");
1483 }
1484
1485 if src.is_dir() || dst.is_dir() {
1486 println!("(directory entry — content listing skipped)");
1487 println!();
1488 return;
1489 }
1490
1491 let src_content = match state {
1496 StatusState::RenderDrift => match render::render_to_string(src, source_root, config, yui) {
1497 Ok(Some(s)) => s,
1498 Ok(None) => {
1499 println!(
1500 "(template would be skipped on this host — drift will resolve on next render)"
1501 );
1502 println!();
1503 return;
1504 }
1505 Err(e) => {
1506 println!("(error rendering template: {e})");
1507 println!();
1508 return;
1509 }
1510 },
1511 _ => match read_text_for_diff(src) {
1512 DiffSide::Text(s) => s,
1513 DiffSide::Binary => {
1514 println!("(binary file or non-UTF-8 content — diff skipped)");
1515 println!();
1516 return;
1517 }
1518 },
1519 };
1520 let dst_content = match read_text_for_diff(dst) {
1521 DiffSide::Text(s) => s,
1522 DiffSide::Binary => {
1523 println!("(binary file or non-UTF-8 content — diff skipped)");
1524 println!();
1525 return;
1526 }
1527 };
1528 print_unified_text_diff(
1529 &src_content,
1530 &dst_content,
1531 src.as_str(),
1532 dst.as_str(),
1533 color,
1534 );
1535 println!();
1536}
1537
1538fn print_unified_text_diff(src: &str, dst: &str, src_label: &str, dst_label: &str, color: bool) {
1547 use owo_colors::OwoColorize as _;
1548 let diff = similar::TextDiff::from_lines(src, dst);
1549 let formatted = diff.unified_diff().header(src_label, dst_label).to_string();
1550 for line in formatted.lines() {
1551 if !color {
1552 println!("{line}");
1553 } else if line.starts_with("+++") || line.starts_with("---") {
1554 println!("{}", line.dimmed());
1555 } else if line.starts_with("@@") {
1556 println!("{}", line.cyan());
1557 } else if line.starts_with('+') {
1558 println!("{}", line.green());
1559 } else if line.starts_with('-') {
1560 println!("{}", line.red());
1561 } else {
1562 println!("{line}");
1563 }
1564 }
1565}
1566
1567enum DiffSide {
1573 Text(String),
1574 Binary,
1575}
1576
1577fn read_text_for_diff(p: &Utf8Path) -> DiffSide {
1578 match std::fs::read_to_string(p) {
1579 Ok(s) => DiffSide::Text(s),
1580 Err(e) if e.kind() == std::io::ErrorKind::InvalidData => DiffSide::Binary,
1581 Err(_) => DiffSide::Text(String::new()),
1582 }
1583}
1584
1585pub fn status(
1598 source: Option<Utf8PathBuf>,
1599 icons_override: Option<IconsMode>,
1600 no_color: bool,
1601) -> Result<()> {
1602 let source = resolve_source(source)?;
1603 let yui = YuiVars::detect(&source);
1604 let config = config::load(&source, &yui)?;
1605
1606 let mut engine = template::Engine::new();
1607 let tera_ctx = template::template_context(&yui, &config.vars);
1608 let mounts = mount::resolve(
1609 &source,
1610 &config.mount.entry,
1611 config.mount.default_strategy,
1612 &mut engine,
1613 &tera_ctx,
1614 )?;
1615
1616 let icons_mode = icons_override.unwrap_or(config.ui.icons);
1617 let icons = Icons::for_mode(icons_mode);
1618 let color = !no_color && supports_color_stdout();
1619
1620 let mut report: Vec<StatusItem> = Vec::new();
1621
1622 let render_report = render::render_all(&source, &config, &yui, true)?;
1625 for rendered in &render_report.diverged {
1626 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
1630 report.push(StatusItem {
1631 src: relative_for_display(&source, &tera_path),
1632 dst: rendered.clone(),
1633 state: StatusState::RenderDrift,
1634 });
1635 }
1636
1637 let mut yuiignore = paths::YuiIgnoreStack::new();
1641 yuiignore.push_dir(&source)?;
1642 let walk_result = (|| -> Result<()> {
1643 for m in &mounts {
1644 let src_root = m.src.clone();
1645 if !src_root.is_dir() {
1646 warn!("mount src missing: {src_root}");
1647 continue;
1648 }
1649 classify_walk(
1650 &src_root,
1651 &m.dst,
1652 &config,
1653 m.strategy,
1654 &mut engine,
1655 &tera_ctx,
1656 &source,
1657 &mut yuiignore,
1658 &mut report,
1659 )?;
1660 }
1661 Ok(())
1662 })();
1663 yuiignore.pop_dir(&source);
1664 walk_result?;
1665
1666 report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
1667
1668 print_status_table(&report, icons, color);
1669
1670 let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
1671
1672 println!();
1673 let total = report.len();
1674 let in_sync = total - drift;
1675 if drift == 0 {
1676 println!(" {total} entries · all in sync");
1677 Ok(())
1678 } else {
1679 println!(" {total} entries · {in_sync} in sync · {drift} diverged");
1680 anyhow::bail!("status: {drift} entries diverged from source")
1681 }
1682}
1683
1684#[derive(Debug)]
1685struct StatusItem {
1686 src: Utf8PathBuf,
1688 dst: Utf8PathBuf,
1690 state: StatusState,
1691}
1692
1693#[derive(Debug, Clone, Copy)]
1694enum StatusState {
1695 Link(absorb::AbsorbDecision),
1696 RenderDrift,
1699}
1700
1701impl StatusState {
1702 fn is_in_sync(self) -> bool {
1703 matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
1704 }
1705}
1706
1707#[allow(clippy::too_many_arguments)]
1708fn classify_walk(
1709 src_dir: &Utf8Path,
1710 dst_dir: &Utf8Path,
1711 config: &Config,
1712 strategy: MountStrategy,
1713 engine: &mut template::Engine,
1714 tera_ctx: &TeraContext,
1715 source_root: &Utf8Path,
1716 yuiignore: &mut paths::YuiIgnoreStack,
1717 report: &mut Vec<StatusItem>,
1718) -> Result<()> {
1719 classify_walk_inner(
1720 src_dir,
1721 dst_dir,
1722 config,
1723 strategy,
1724 engine,
1725 tera_ctx,
1726 source_root,
1727 yuiignore,
1728 report,
1729 false,
1730 )
1731}
1732
1733#[allow(clippy::too_many_arguments)]
1734fn classify_walk_inner(
1735 src_dir: &Utf8Path,
1736 dst_dir: &Utf8Path,
1737 config: &Config,
1738 strategy: MountStrategy,
1739 engine: &mut template::Engine,
1740 tera_ctx: &TeraContext,
1741 source_root: &Utf8Path,
1742 yuiignore: &mut paths::YuiIgnoreStack,
1743 report: &mut Vec<StatusItem>,
1744 parent_covered: bool,
1745) -> Result<()> {
1746 if yuiignore.is_ignored(src_dir, true) {
1747 return Ok(());
1748 }
1749 yuiignore.push_dir(src_dir)?;
1752 let result = classify_walk_inner_body(
1753 src_dir,
1754 dst_dir,
1755 config,
1756 strategy,
1757 engine,
1758 tera_ctx,
1759 source_root,
1760 yuiignore,
1761 report,
1762 parent_covered,
1763 );
1764 yuiignore.pop_dir(src_dir);
1765 result
1766}
1767
1768#[allow(clippy::too_many_arguments)]
1769fn classify_walk_inner_body(
1770 src_dir: &Utf8Path,
1771 dst_dir: &Utf8Path,
1772 config: &Config,
1773 strategy: MountStrategy,
1774 engine: &mut template::Engine,
1775 tera_ctx: &TeraContext,
1776 source_root: &Utf8Path,
1777 yuiignore: &mut paths::YuiIgnoreStack,
1778 report: &mut Vec<StatusItem>,
1779 parent_covered: bool,
1780) -> Result<()> {
1781 let marker_filename = &config.mount.marker_filename;
1782 let mut covered = parent_covered;
1783
1784 if strategy == MountStrategy::Marker {
1785 match marker::read_spec(src_dir, marker_filename)? {
1786 None => {}
1787 Some(MarkerSpec::PassThrough) => {
1788 let decision = absorb::classify(src_dir, dst_dir)?;
1789 report.push(StatusItem {
1790 src: relative_for_display(source_root, src_dir),
1791 dst: dst_dir.to_path_buf(),
1792 state: StatusState::Link(decision),
1793 });
1794 covered = true;
1795 }
1796 Some(MarkerSpec::Explicit { links }) => {
1797 let mut emitted_dir_link = false;
1798 for link in &links {
1799 if let Some(when) = &link.when {
1800 if !template::eval_truthy(when, engine, tera_ctx)? {
1801 continue;
1802 }
1803 }
1804 let dst_str = engine.render(&link.dst, tera_ctx)?;
1805 let dst = paths::expand_tilde(dst_str.trim());
1806 if let Some(filename) = &link.src {
1807 let file_src = src_dir.join(filename);
1808 if !file_src.is_file() {
1809 anyhow::bail!(
1810 "marker at {src_dir}: [[link]] src={filename:?} \
1811 not found"
1812 );
1813 }
1814 let decision = absorb::classify(&file_src, &dst)?;
1815 report.push(StatusItem {
1816 src: relative_for_display(source_root, &file_src),
1817 dst,
1818 state: StatusState::Link(decision),
1819 });
1820 } else {
1821 let decision = absorb::classify(src_dir, &dst)?;
1822 report.push(StatusItem {
1823 src: relative_for_display(source_root, src_dir),
1824 dst,
1825 state: StatusState::Link(decision),
1826 });
1827 emitted_dir_link = true;
1828 }
1829 }
1830 if emitted_dir_link {
1831 covered = true;
1832 }
1833 }
1834 }
1835 }
1836
1837 for entry in std::fs::read_dir(src_dir)? {
1838 let entry = entry?;
1839 let name_os = entry.file_name();
1840 let Some(name) = name_os.to_str() else {
1841 continue;
1842 };
1843 if name == marker_filename || name.ends_with(".tera") {
1844 continue;
1845 }
1846 let src_path = src_dir.join(name);
1847 let dst_path = dst_dir.join(name);
1848 let ft = entry.file_type()?;
1849 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
1850 continue;
1851 }
1852 if ft.is_dir() {
1853 classify_walk_inner(
1854 &src_path,
1855 &dst_path,
1856 config,
1857 strategy,
1858 engine,
1859 tera_ctx,
1860 source_root,
1861 yuiignore,
1862 report,
1863 covered,
1864 )?;
1865 } else if ft.is_file() && !covered {
1866 let decision = absorb::classify(&src_path, &dst_path)?;
1867 report.push(StatusItem {
1868 src: relative_for_display(source_root, &src_path),
1869 dst: dst_path,
1870 state: StatusState::Link(decision),
1871 });
1872 }
1873 }
1874 Ok(())
1875}
1876
1877fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
1878 p.strip_prefix(source_root)
1879 .map(Utf8PathBuf::from)
1880 .unwrap_or_else(|_| p.to_path_buf())
1881}
1882
1883fn print_status_table(items: &[StatusItem], icons: Icons, color: bool) {
1884 let src_w = items
1885 .iter()
1886 .map(|i| i.src.as_str().chars().count())
1887 .max()
1888 .unwrap_or(0)
1889 .max("SRC".len());
1890 let dst_w = items
1891 .iter()
1892 .map(|i| i.dst.as_str().chars().count())
1893 .max()
1894 .unwrap_or(0)
1895 .max("DST".len());
1896 let state_label_w = items
1898 .iter()
1899 .map(|i| state_label(i.state).len())
1900 .max()
1901 .unwrap_or(0)
1902 .max("STATE".len() - 2); let state_w = state_label_w + 2; print_status_header(state_w, src_w, dst_w, color);
1906 let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
1907 if color {
1908 use owo_colors::OwoColorize as _;
1909 println!("{}", sep.dimmed());
1910 } else {
1911 println!("{sep}");
1912 }
1913 for item in items {
1914 print_status_row(item, icons, state_w, src_w, dst_w, color);
1915 }
1916}
1917
1918fn state_label(s: StatusState) -> &'static str {
1919 use absorb::AbsorbDecision::*;
1920 match s {
1921 StatusState::Link(InSync) => "in-sync",
1922 StatusState::Link(RelinkOnly) => "relink",
1923 StatusState::Link(AutoAbsorb) => "drift (auto)",
1924 StatusState::Link(NeedsConfirm) => "drift (anomaly)",
1925 StatusState::Link(Restore) => "missing",
1926 StatusState::RenderDrift => "render drift",
1927 }
1928}
1929
1930fn state_icon(s: StatusState, icons: Icons) -> &'static str {
1931 use absorb::AbsorbDecision::*;
1932 match s {
1933 StatusState::Link(InSync) => icons.ok,
1934 StatusState::Link(RelinkOnly) => icons.warn,
1935 StatusState::Link(AutoAbsorb) => icons.warn,
1936 StatusState::Link(NeedsConfirm) => icons.error,
1937 StatusState::Link(Restore) => icons.info,
1938 StatusState::RenderDrift => icons.error,
1939 }
1940}
1941
1942fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
1943 use owo_colors::OwoColorize as _;
1944 let line = format!(
1947 " {:<state_w$} {:<src_w$} {:<dst_w$}",
1948 "STATE", "SRC", "DST"
1949 );
1950 if color {
1951 println!("{}", line.bold());
1952 } else {
1953 println!("{line}");
1954 }
1955}
1956
1957fn render_status_separator(
1958 sep_ch: char,
1959 state_w: usize,
1960 src_w: usize,
1961 dst_w: usize,
1962 arrow: &str,
1963) -> String {
1964 let bar = |n: usize| sep_ch.to_string().repeat(n);
1965 format!(
1966 " {} {} {} {}",
1967 bar(state_w),
1968 bar(src_w),
1969 bar(arrow.chars().count()),
1970 bar(dst_w)
1971 )
1972}
1973
1974fn print_status_row(
1975 item: &StatusItem,
1976 icons: Icons,
1977 state_w: usize,
1978 src_w: usize,
1979 dst_w: usize,
1980 color: bool,
1981) {
1982 use owo_colors::OwoColorize as _;
1983 let icon = state_icon(item.state, icons);
1984 let label = state_label(item.state);
1985 let state_text = format!("{icon} {label}");
1986 let src_display = item.src.as_str().replace('\\', "/");
1987 let dst_display = item.dst.as_str().replace('\\', "/");
1988 let arrow = icons.arrow;
1989
1990 let cell_state = format!("{:<state_w$}", state_text);
1991 let cell_src = format!("{:<src_w$}", src_display);
1992 let cell_dst = format!("{:<dst_w$}", dst_display);
1993
1994 if !color {
1995 println!(" {cell_state} {cell_src} {arrow} {cell_dst}");
1996 return;
1997 }
1998
1999 use absorb::AbsorbDecision::*;
2000 let state_colored = match item.state {
2001 StatusState::Link(InSync) => cell_state.green().to_string(),
2002 StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
2003 cell_state.yellow().to_string()
2004 }
2005 StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
2006 StatusState::Link(Restore) => cell_state.cyan().to_string(),
2007 StatusState::RenderDrift => cell_state.red().to_string(),
2008 };
2009 let src_colored = cell_src.cyan().to_string();
2010 let arrow_colored = arrow.dimmed().to_string();
2011 let dst_colored = cell_dst.dimmed().to_string();
2012 println!(" {state_colored} {src_colored} {arrow_colored} {dst_colored}");
2013}
2014
2015pub fn absorb(
2029 source: Option<Utf8PathBuf>,
2030 target: Utf8PathBuf,
2031 dry_run: bool,
2032 yes: bool,
2033) -> Result<()> {
2034 let source = resolve_source(source)?;
2035 let target = absolutize(&target)?;
2036 let yui = YuiVars::detect(&source);
2037 let config = config::load(&source, &yui)?;
2038
2039 let mut engine = template::Engine::new();
2040 let tera_ctx = template::template_context(&yui, &config.vars);
2041
2042 let src_path = match find_source_for_target(&source, &config, &target, &mut engine, &tera_ctx)?
2043 {
2044 Some(s) => s,
2045 None => anyhow::bail!(
2046 "no mount entry / .yuilink override claims target {target}; \
2047 pass a path inside a known dst"
2048 ),
2049 };
2050
2051 info!("source for {target}: {src_path}");
2052
2053 print_absorb_diff(&src_path, &target);
2058
2059 if dry_run {
2060 info!("[dry-run] would absorb {target} → {src_path}");
2061 return Ok(());
2062 }
2063
2064 if !yes {
2065 use std::io::IsTerminal;
2066 if !std::io::stdin().is_terminal() {
2067 anyhow::bail!(
2068 "manual absorb refuses to run off-TTY without --yes \
2069 (would silently overwrite {src_path})"
2070 );
2071 }
2072 if !prompt_yes_no("absorb target into source?")? {
2073 warn!("manual absorb cancelled by user: {target}");
2074 return Ok(());
2075 }
2076 }
2077
2078 let backup_root = source.join(&config.backup.dir);
2079 let ctx = ApplyCtx {
2080 config: &config,
2081 source: &source,
2082 file_mode: resolve_file_mode(config.link.file_mode),
2083 dir_mode: resolve_dir_mode(config.link.dir_mode),
2084 backup_root: &backup_root,
2085 dry_run: false,
2086 };
2087
2088 absorb_target_into_source(&src_path, &target, &ctx)
2091}
2092
2093fn print_absorb_diff(src: &Utf8Path, dst: &Utf8Path) {
2098 eprintln!();
2099 eprintln!("--- diff (- source, + target) ---");
2100 eprintln!(" src: {src}");
2101 eprintln!(" dst: {dst}");
2102 eprintln!();
2103 if src.is_dir() || dst.is_dir() {
2104 eprintln!("(directory absorb — content listing skipped)");
2105 eprintln!();
2106 return;
2107 }
2108 let src_content = match read_text_for_diff(src) {
2109 DiffSide::Text(s) => s,
2110 DiffSide::Binary => {
2111 eprintln!("(binary file or non-UTF-8 content — diff skipped)");
2112 eprintln!();
2113 return;
2114 }
2115 };
2116 let dst_content = match read_text_for_diff(dst) {
2117 DiffSide::Text(s) => s,
2118 DiffSide::Binary => {
2119 eprintln!("(binary file or non-UTF-8 content — diff skipped)");
2120 eprintln!();
2121 return;
2122 }
2123 };
2124 let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
2125 let formatted = diff
2126 .unified_diff()
2127 .header(src.as_str(), dst.as_str())
2128 .to_string();
2129 eprint!("{formatted}");
2130 eprintln!();
2131}
2132
2133fn prompt_yes_no(question: &str) -> Result<bool> {
2134 use std::io::Write as _;
2135 eprint!("{question} [y/N]: ");
2136 std::io::stderr().flush().ok();
2137 let mut input = String::new();
2138 std::io::stdin().read_line(&mut input)?;
2139 let answer = input.trim();
2140 Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
2141}
2142
2143fn find_source_for_target(
2147 source: &Utf8Path,
2148 config: &Config,
2149 target: &Utf8Path,
2150 engine: &mut template::Engine,
2151 tera_ctx: &TeraContext,
2152) -> Result<Option<Utf8PathBuf>> {
2153 for entry in &config.mount.entry {
2155 if let Some(when) = &entry.when {
2156 if !template::eval_truthy(when, engine, tera_ctx)? {
2157 continue;
2158 }
2159 }
2160 let dst_str = engine.render(&entry.dst, tera_ctx)?;
2161 let dst_root = paths::expand_tilde(dst_str.trim());
2162 if let Ok(rel) = target.strip_prefix(&dst_root) {
2163 let src_str = engine.render(entry.src.as_str(), tera_ctx)?;
2164 let candidate = paths::resolve_mount_src(source, src_str.trim()).join(rel);
2165 if paths::is_ignored_at(source, &candidate, candidate.is_dir())? {
2170 continue;
2171 }
2172 return Ok(Some(candidate));
2173 }
2174 }
2175
2176 let walker = paths::source_walker(source).build();
2182 let marker_filename = &config.mount.marker_filename;
2183 for ent in walker {
2184 let ent = match ent {
2185 Ok(e) => e,
2186 Err(_) => continue,
2187 };
2188 if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
2189 continue;
2190 }
2191 if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
2192 continue;
2193 }
2194 let dir = match ent.path().parent() {
2195 Some(d) => d,
2196 None => continue,
2197 };
2198 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
2199 Ok(p) => p,
2200 Err(_) => continue,
2201 };
2202 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
2203 Some(s) => s,
2204 None => continue,
2205 };
2206 let MarkerSpec::Explicit { links } = spec else {
2207 continue;
2208 };
2209 for link in &links {
2210 if let Some(when) = &link.when {
2211 if !template::eval_truthy(when, engine, tera_ctx)? {
2212 continue;
2213 }
2214 }
2215 let dst_str = engine.render(&link.dst, tera_ctx)?;
2216 let dst = paths::expand_tilde(dst_str.trim());
2217 if let Some(filename) = &link.src {
2224 let file_src = dir_utf8.join(filename);
2225 if !file_src.is_file() {
2226 anyhow::bail!(
2227 "marker at {dir_utf8}: [[link]] src={filename:?} \
2228 not found"
2229 );
2230 }
2231 if target == dst {
2232 return Ok(Some(file_src));
2233 }
2234 continue;
2235 }
2236 if target == dst {
2237 return Ok(Some(dir_utf8));
2238 }
2239 if let Ok(rel) = target.strip_prefix(&dst) {
2240 return Ok(Some(dir_utf8.join(rel)));
2241 }
2242 }
2243 }
2244
2245 Ok(None)
2246}
2247
2248pub fn doctor(
2249 source: Option<Utf8PathBuf>,
2250 icons_override: Option<IconsMode>,
2251 no_color: bool,
2252) -> Result<()> {
2253 use owo_colors::OwoColorize as _;
2254
2255 let resolved_source = resolve_source(source);
2260
2261 let yui = match &resolved_source {
2266 Ok(s) => YuiVars::detect(s),
2267 Err(_) => YuiVars::detect(Utf8Path::new(".")),
2268 };
2269
2270 let cfg_res = match &resolved_source {
2275 Ok(s) => Some(config::load(s, &yui)),
2276 Err(_) => None,
2277 };
2278 let cfg = cfg_res.as_ref().and_then(|r| r.as_ref().ok());
2279 let icons_mode = icons_override
2280 .or_else(|| cfg.map(|c| c.ui.icons))
2281 .unwrap_or_default();
2282 let icons = Icons::for_mode(icons_mode);
2283 let color = !no_color && supports_color_stdout();
2284
2285 let mut probes: Vec<Probe> = Vec::new();
2286
2287 probes.push(Probe::group("identity"));
2289 probes.push(Probe::ok("os/arch", format!("{} / {}", yui.os, yui.arch)));
2290 probes.push(Probe::ok("user@host", format!("{}@{}", yui.user, yui.host)));
2291
2292 probes.push(Probe::group("repo"));
2294 let mut have_source = false;
2295 match &resolved_source {
2296 Ok(s) => {
2297 have_source = true;
2298 probes.push(Probe::ok("source", s.to_string()));
2299 match cfg_res.as_ref().expect("cfg_res set when source is Ok") {
2300 Ok(c) => {
2301 probes.push(Probe::ok(
2302 "config",
2303 format!(
2304 "{} mount{} · {} hook{} · {} render rule{}",
2305 c.mount.entry.len(),
2306 plural(c.mount.entry.len()),
2307 c.hook.len(),
2308 plural(c.hook.len()),
2309 c.render.rule.len(),
2310 plural(c.render.rule.len()),
2311 ),
2312 ));
2313 }
2314 Err(e) => probes.push(Probe::error("config", format!("{e}"))),
2315 }
2316 match crate::git::is_clean(s) {
2320 Ok(true) => probes.push(Probe::ok("git", "clean")),
2321 Ok(false) => probes.push(Probe::warn(
2322 "git",
2323 "uncommitted changes — `[absorb] require_clean_git` will defer auto-absorb",
2324 )),
2325 Err(_) => probes.push(Probe::warn(
2326 "git",
2327 "no git repo (auto-absorb still works; commit history won't track drift)",
2328 )),
2329 }
2330 }
2331 Err(e) => {
2332 probes.push(Probe::error("source", format!("not found — {e}")));
2333 }
2334 }
2335
2336 probes.push(Probe::group("links"));
2338 if cfg!(windows) {
2339 probes.push(Probe::ok(
2340 "default mode",
2341 "files=hardlink, dirs=junction (no admin needed)",
2342 ));
2343 } else {
2344 probes.push(Probe::ok("default mode", "files=symlink, dirs=symlink"));
2345 }
2346
2347 if have_source {
2349 if let (Ok(s), Some(c)) = (&resolved_source, cfg) {
2350 probes.push(Probe::group("hooks"));
2351 if c.hook.is_empty() {
2352 probes.push(Probe::ok("hooks", "(none configured)"));
2353 } else {
2354 let mut missing = 0usize;
2355 for h in &c.hook {
2356 if !s.join(&h.script).is_file() {
2357 missing += 1;
2358 probes.push(Probe::error(
2359 format!("hook[{}]", h.name),
2360 format!("script not found at {}", h.script),
2361 ));
2362 }
2363 }
2364 if missing == 0 {
2365 probes.push(Probe::ok(
2366 "scripts",
2367 format!(
2368 "{} hook{} configured, all scripts present",
2369 c.hook.len(),
2370 plural(c.hook.len())
2371 ),
2372 ));
2373 }
2374 }
2375 }
2376 }
2377
2378 if let Some(home) = paths::home_dir() {
2380 let chezmoi_src = home.join(".local/share/chezmoi");
2381 if chezmoi_src.is_dir() {
2382 probes.push(Probe::group("chezmoi"));
2383 probes.push(Probe::warn(
2384 "legacy source",
2385 format!(
2386 "{chezmoi_src} still exists — yui doesn't use it, safe to archive once your migration has settled"
2387 ),
2388 ));
2389 }
2390 }
2391
2392 println!();
2394 if color {
2395 println!(" {}", "yui doctor".bold().underline());
2396 } else {
2397 println!(" yui doctor");
2398 }
2399 println!();
2400 for probe in &probes {
2401 probe.print(&icons, color);
2402 }
2403
2404 let errors = probes.iter().filter(|p| p.is_error()).count();
2405 let warns = probes.iter().filter(|p| p.is_warn()).count();
2406 let oks = probes.iter().filter(|p| p.is_ok()).count();
2407 println!();
2408 let summary = format!("{oks} ok · {warns} warn · {errors} error");
2409 if color {
2410 if errors > 0 {
2411 println!(" {}", summary.red().bold());
2412 } else if warns > 0 {
2413 println!(" {}", summary.yellow());
2414 } else {
2415 println!(" {}", summary.green());
2416 }
2417 } else {
2418 println!(" {summary}");
2419 }
2420
2421 if errors > 0 {
2422 anyhow::bail!("doctor: {errors} probe(s) failed");
2423 }
2424 Ok(())
2425}
2426
2427#[derive(Debug)]
2428enum Probe {
2429 Group(&'static str),
2431 Ok {
2432 label: String,
2433 detail: String,
2434 },
2435 Warn {
2436 label: String,
2437 detail: String,
2438 },
2439 Error {
2440 label: String,
2441 detail: String,
2442 },
2443}
2444
2445impl Probe {
2446 fn group(label: &'static str) -> Self {
2447 Self::Group(label)
2448 }
2449 fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
2450 Self::Ok {
2451 label: label.into(),
2452 detail: detail.into(),
2453 }
2454 }
2455 fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
2456 Self::Warn {
2457 label: label.into(),
2458 detail: detail.into(),
2459 }
2460 }
2461 fn error(label: impl Into<String>, detail: impl Into<String>) -> Self {
2462 Self::Error {
2463 label: label.into(),
2464 detail: detail.into(),
2465 }
2466 }
2467 fn is_ok(&self) -> bool {
2468 matches!(self, Self::Ok { .. })
2469 }
2470 fn is_warn(&self) -> bool {
2471 matches!(self, Self::Warn { .. })
2472 }
2473 fn is_error(&self) -> bool {
2474 matches!(self, Self::Error { .. })
2475 }
2476 fn print(&self, icons: &Icons, color: bool) {
2477 use owo_colors::OwoColorize as _;
2478 match self {
2479 Self::Group(name) => {
2480 println!();
2481 if color {
2482 println!(" {}", name.cyan().bold());
2483 } else {
2484 println!(" {name}");
2485 }
2486 }
2487 Self::Ok { label, detail } => {
2488 let icon = icons.ok;
2489 let padded = format!("{label:<14}");
2493 if color {
2494 println!(
2495 " {} {} {}",
2496 icon.green(),
2497 padded.bold(),
2498 detail.dimmed()
2499 );
2500 } else {
2501 println!(" {icon} {padded} {detail}");
2502 }
2503 }
2504 Self::Warn { label, detail } => {
2505 let icon = icons.warn;
2506 let padded = format!("{label:<14}");
2507 if color {
2508 println!(
2509 " {} {} {}",
2510 icon.yellow(),
2511 padded.bold().yellow(),
2512 detail
2513 );
2514 } else {
2515 println!(" {icon} {padded} {detail}");
2516 }
2517 }
2518 Self::Error { label, detail } => {
2519 let icon = icons.error;
2520 let padded = format!("{label:<14}");
2521 if color {
2522 println!(
2523 " {} {} {}",
2524 icon.red().bold(),
2525 padded.bold().red(),
2526 detail.red()
2527 );
2528 } else {
2529 println!(" {icon} {padded} {detail}");
2530 }
2531 }
2532 }
2533 }
2534}
2535
2536fn plural(n: usize) -> &'static str {
2537 if n == 1 { "" } else { "s" }
2538}
2539
2540pub fn gc_backup(
2560 source: Option<Utf8PathBuf>,
2561 older_than: Option<String>,
2562 dry_run: bool,
2563 icons_override: Option<IconsMode>,
2564 no_color: bool,
2565) -> Result<()> {
2566 let source = resolve_source(source)?;
2567 let yui = YuiVars::detect(&source);
2568 let config = config::load(&source, &yui)?;
2569 let backup_root = source.join(&config.backup.dir);
2570 let icons_mode = icons_override.unwrap_or(config.ui.icons);
2571 let icons = Icons::for_mode(icons_mode);
2572 let color = !no_color && supports_color_stdout();
2573
2574 if !backup_root.is_dir() {
2575 println!(" no backup tree at {backup_root}");
2576 return Ok(());
2577 }
2578
2579 let mut entries = walk_gc_backups(&backup_root)?;
2580 if entries.is_empty() {
2581 println!(" no yui-stamped backups under {backup_root}");
2582 return Ok(());
2583 }
2584 entries.sort_by_key(|e| e.ts);
2586 let now = jiff::Zoned::now();
2587
2588 match older_than {
2589 None => {
2590 let refs: Vec<&BackupEntry> = entries.iter().collect();
2591 print_gc_table(&refs, &backup_root, &now, icons, color);
2592 println!();
2593 println!(
2594 " {} entries · {} total — pass --older-than DUR (e.g. 30d) to delete",
2595 entries.len(),
2596 format_bytes(entries.iter().map(|e| e.size_bytes).sum())
2597 );
2598 Ok(())
2599 }
2600 Some(dur_str) => {
2601 let span = parse_human_duration(&dur_str)?;
2602 let cutoff = now
2603 .checked_sub(span)
2604 .map_err(|e| anyhow::anyhow!("invalid duration {dur_str:?}: {e}"))?;
2605 let cutoff_dt = cutoff.datetime();
2606
2607 let total_before: u64 = entries.iter().map(|e| e.size_bytes).sum();
2608 let to_delete: Vec<&BackupEntry> =
2609 entries.iter().filter(|e| e.ts < cutoff_dt).collect();
2610
2611 if to_delete.is_empty() {
2612 println!(
2613 " no backups older than {dur_str} (oldest: {})",
2614 format_age(entries[0].ts, &now)
2615 );
2616 return Ok(());
2617 }
2618
2619 print_gc_table(&to_delete, &backup_root, &now, icons, color);
2620 println!();
2621 let total_freed: u64 = to_delete.iter().map(|e| e.size_bytes).sum();
2622
2623 if dry_run {
2624 println!(
2625 " [dry-run] would remove {} of {} entries · would free {} of {}",
2626 to_delete.len(),
2627 entries.len(),
2628 format_bytes(total_freed),
2629 format_bytes(total_before),
2630 );
2631 return Ok(());
2632 }
2633
2634 for entry in &to_delete {
2635 match entry.kind {
2636 BackupKind::File => std::fs::remove_file(&entry.path)?,
2637 BackupKind::Dir => std::fs::remove_dir_all(&entry.path)?,
2638 }
2639 if let Some(parent) = entry.path.parent() {
2640 cleanup_empty_parents(parent, &backup_root);
2641 }
2642 }
2643 println!(
2644 " removed {} of {} entries · freed {} (was {}, now {})",
2645 to_delete.len(),
2646 entries.len(),
2647 format_bytes(total_freed),
2648 format_bytes(total_before),
2649 format_bytes(total_before - total_freed),
2650 );
2651 Ok(())
2652 }
2653 }
2654}
2655
2656#[derive(Debug)]
2657struct BackupEntry {
2658 path: Utf8PathBuf,
2659 ts: jiff::civil::DateTime,
2660 kind: BackupKind,
2661 size_bytes: u64,
2662}
2663
2664#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2665enum BackupKind {
2666 File,
2667 Dir,
2668}
2669
2670fn walk_gc_backups(root: &Utf8Path) -> Result<Vec<BackupEntry>> {
2675 let mut out = Vec::new();
2676 walk_gc_backups_rec(root, &mut out)?;
2677 Ok(out)
2678}
2679
2680fn walk_gc_backups_rec(dir: &Utf8Path, out: &mut Vec<BackupEntry>) -> Result<()> {
2681 for entry in std::fs::read_dir(dir)? {
2682 let entry = entry?;
2683 let name_os = entry.file_name();
2684 let Some(name) = name_os.to_str() else {
2685 continue;
2686 };
2687 let path = dir.join(name);
2688 let ft = entry.file_type()?;
2689 if ft.is_dir() {
2690 if let Some(ts) = parse_backup_suffix(name) {
2691 let size = dir_size(&path)?;
2692 out.push(BackupEntry {
2693 path,
2694 ts,
2695 kind: BackupKind::Dir,
2696 size_bytes: size,
2697 });
2698 } else {
2699 walk_gc_backups_rec(&path, out)?;
2700 }
2701 } else if ft.is_file() {
2702 if let Some(ts) = parse_backup_suffix(name) {
2705 let size = entry.metadata()?.len();
2706 out.push(BackupEntry {
2707 path,
2708 ts,
2709 kind: BackupKind::File,
2710 size_bytes: size,
2711 });
2712 }
2713 }
2714 }
2715 Ok(())
2716}
2717
2718fn dir_size(dir: &Utf8Path) -> Result<u64> {
2719 let mut total: u64 = 0;
2720 for entry in std::fs::read_dir(dir)? {
2721 let entry = entry?;
2722 let ft = entry.file_type()?;
2723 if ft.is_dir() {
2724 let p = match Utf8PathBuf::from_path_buf(entry.path()) {
2725 Ok(p) => p,
2726 Err(_) => continue,
2727 };
2728 total = total.saturating_add(dir_size(&p)?);
2729 } else if ft.is_file() {
2730 total = total.saturating_add(entry.metadata()?.len());
2731 }
2732 }
2733 Ok(total)
2734}
2735
2736fn cleanup_empty_parents(start: &Utf8Path, root: &Utf8Path) {
2740 let mut cur = start.to_path_buf();
2741 loop {
2742 if cur == *root {
2743 return;
2744 }
2745 if std::fs::remove_dir(&cur).is_err() {
2747 return;
2748 }
2749 match cur.parent() {
2750 Some(p) => cur = p.to_path_buf(),
2751 None => return,
2752 }
2753 }
2754}
2755
2756fn parse_backup_suffix(name: &str) -> Option<jiff::civil::DateTime> {
2762 if let Some(ts) = parse_ts_at_end(name) {
2763 return Some(ts);
2764 }
2765 if let Some((before, _ext)) = name.rsplit_once('.') {
2768 if let Some(ts) = parse_ts_at_end(before) {
2769 return Some(ts);
2770 }
2771 }
2772 None
2773}
2774
2775fn parse_ts_at_end(s: &str) -> Option<jiff::civil::DateTime> {
2776 if s.len() < 20 {
2778 return None;
2779 }
2780 let split_at = s.len() - 19;
2781 if s.as_bytes()[split_at] != b'_' {
2782 return None;
2783 }
2784 parse_ts(&s[split_at + 1..])
2785}
2786
2787fn parse_ts(s: &str) -> Option<jiff::civil::DateTime> {
2789 if s.len() != 18 || s.as_bytes()[8] != b'_' {
2790 return None;
2791 }
2792 for (i, &b) in s.as_bytes().iter().enumerate() {
2793 if i == 8 {
2794 continue;
2795 }
2796 if !b.is_ascii_digit() {
2797 return None;
2798 }
2799 }
2800 let year: i16 = s[0..4].parse().ok()?;
2801 let month: i8 = s[4..6].parse().ok()?;
2802 let day: i8 = s[6..8].parse().ok()?;
2803 let hour: i8 = s[9..11].parse().ok()?;
2804 let minute: i8 = s[11..13].parse().ok()?;
2805 let second: i8 = s[13..15].parse().ok()?;
2806 let ms: i32 = s[15..18].parse().ok()?;
2807 jiff::civil::DateTime::new(year, month, day, hour, minute, second, ms * 1_000_000).ok()
2808}
2809
2810fn parse_human_duration(s: &str) -> Result<jiff::Span> {
2819 let s = s.trim();
2820 let split = s
2821 .bytes()
2822 .position(|b| b.is_ascii_alphabetic())
2823 .ok_or_else(|| anyhow::anyhow!("invalid duration {s:?}: missing unit (e.g. 30d, 2w)"))?;
2824 let n: i64 = s[..split]
2825 .trim()
2826 .parse()
2827 .map_err(|_| anyhow::anyhow!("invalid duration {s:?}: bad leading number"))?;
2828 if n < 0 {
2829 anyhow::bail!("invalid duration {s:?}: negative durations don't make sense");
2830 }
2831 let unit = s[split..].to_ascii_lowercase();
2832 let span = match unit.as_str() {
2833 "y" | "yr" | "year" | "years" => jiff::Span::new().years(n),
2834 "mo" | "month" | "months" => jiff::Span::new().months(n),
2835 "w" | "wk" | "week" | "weeks" => jiff::Span::new().weeks(n),
2836 "d" | "day" | "days" => jiff::Span::new().days(n),
2837 "h" | "hr" | "hour" | "hours" => jiff::Span::new().hours(n),
2838 "m" | "min" | "minute" | "minutes" => jiff::Span::new().minutes(n),
2839 other => {
2840 anyhow::bail!(
2841 "invalid duration {s:?}: unknown unit {other:?} \
2842 (use y / mo / w / d / h / m)"
2843 )
2844 }
2845 };
2846 Ok(span)
2847}
2848
2849fn format_bytes(n: u64) -> String {
2850 const KIB: u64 = 1024;
2851 const MIB: u64 = KIB * 1024;
2852 const GIB: u64 = MIB * 1024;
2853 if n >= GIB {
2854 format!("{:.1} GiB", n as f64 / GIB as f64)
2855 } else if n >= MIB {
2856 format!("{:.1} MiB", n as f64 / MIB as f64)
2857 } else if n >= KIB {
2858 format!("{:.1} KiB", n as f64 / KIB as f64)
2859 } else {
2860 format!("{n} B")
2861 }
2862}
2863
2864fn format_age(ts: jiff::civil::DateTime, now: &jiff::Zoned) -> String {
2865 let Ok(ts_zoned) = ts.to_zoned(now.time_zone().clone()) else {
2866 return "?".into();
2867 };
2868 let secs = match (now - &ts_zoned).total(jiff::Unit::Second) {
2869 Ok(s) => s as i64,
2870 Err(_) => return "?".into(),
2871 };
2872 if secs < 0 {
2873 return "future".into();
2874 }
2875 if secs < 60 {
2876 format!("{secs}s")
2877 } else if secs < 3600 {
2878 format!("{}m", secs / 60)
2879 } else if secs < 86_400 {
2880 format!("{}h", secs / 3600)
2881 } else if secs < 86_400 * 30 {
2882 format!("{}d", secs / 86_400)
2883 } else if secs < 86_400 * 365 {
2884 format!("{}mo", secs / (86_400 * 30))
2885 } else {
2886 format!("{}y", secs / (86_400 * 365))
2887 }
2888}
2889
2890fn print_gc_table(
2897 entries: &[&BackupEntry],
2898 backup_root: &Utf8Path,
2899 now: &jiff::Zoned,
2900 _icons: Icons,
2901 color: bool,
2902) {
2903 use owo_colors::OwoColorize as _;
2904
2905 let rows: Vec<(String, String, String)> = entries
2906 .iter()
2907 .map(|e| {
2908 let rel = e
2909 .path
2910 .strip_prefix(backup_root)
2911 .map(Utf8PathBuf::from)
2912 .unwrap_or_else(|_| e.path.clone());
2913 let path_disp = match e.kind {
2914 BackupKind::Dir => format!("{rel}/"),
2915 BackupKind::File => rel.to_string(),
2916 };
2917 (format_age(e.ts, now), format_bytes(e.size_bytes), path_disp)
2918 })
2919 .collect();
2920
2921 let age_w = rows.iter().map(|r| r.0.len()).max().unwrap_or(3);
2922 let size_w = rows.iter().map(|r| r.1.len()).max().unwrap_or(4);
2923
2924 if color {
2925 println!(
2926 " {:<age_w$} {:>size_w$} {}",
2927 "AGE".dimmed(),
2928 "SIZE".dimmed(),
2929 "PATH".dimmed(),
2930 );
2931 } else {
2932 println!(" {:<age_w$} {:>size_w$} PATH", "AGE", "SIZE");
2933 }
2934 for (age, size, path) in &rows {
2935 if color {
2936 println!(
2937 " {:<age_w$} {:>size_w$} {}",
2938 age.yellow(),
2939 size,
2940 path.cyan(),
2941 );
2942 } else {
2943 println!(" {:<age_w$} {:>size_w$} {}", age, size, path);
2944 }
2945 }
2946}
2947
2948pub fn hooks_list(
2950 source: Option<Utf8PathBuf>,
2951 icons_override: Option<IconsMode>,
2952 no_color: bool,
2953) -> Result<()> {
2954 let source = resolve_source(source)?;
2955 let yui = YuiVars::detect(&source);
2956 let config = config::load(&source, &yui)?;
2957 let state = hook::State::load(&source)?;
2958
2959 let icons_mode = icons_override.unwrap_or(config.ui.icons);
2960 let icons = Icons::for_mode(icons_mode);
2961 let color = !no_color && supports_color_stdout();
2962
2963 if config.hook.is_empty() {
2964 println!("(no [[hook]] entries in config)");
2965 return Ok(());
2966 }
2967
2968 let mut engine = template::Engine::new();
2972 let tera_ctx = template::template_context(&yui, &config.vars);
2973 let rows: Vec<HookRow> = config
2974 .hook
2975 .iter()
2976 .map(|h| -> Result<HookRow> {
2977 let active = match &h.when {
2981 None => true,
2982 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
2983 };
2984 let last_run_at = state.hooks.get(&h.name).and_then(|s| s.last_run_at.clone());
2985 Ok(HookRow {
2986 name: h.name.clone(),
2987 phase: match h.phase {
2988 HookPhase::Pre => "pre",
2989 HookPhase::Post => "post",
2990 },
2991 when_run: match h.when_run {
2992 config::WhenRun::Once => "once",
2993 config::WhenRun::Onchange => "onchange",
2994 config::WhenRun::Every => "every",
2995 },
2996 last_run_at,
2997 when: h.when.clone(),
2998 active,
2999 })
3000 })
3001 .collect::<Result<Vec<_>>>()?;
3002
3003 print_hooks_table(&rows, icons, color);
3004
3005 let total = rows.len();
3006 let active = rows.iter().filter(|r| r.active).count();
3007 let inactive = total - active;
3008 let ran = rows.iter().filter(|r| r.last_run_at.is_some()).count();
3009 let never = total - ran;
3010 println!();
3011 println!(
3012 " {total} hooks · {active} active · {inactive} inactive · {ran} ran · {never} never run"
3013 );
3014
3015 Ok(())
3016}
3017
3018#[derive(Debug)]
3019struct HookRow {
3020 name: String,
3021 phase: &'static str,
3022 when_run: &'static str,
3023 last_run_at: Option<String>,
3024 when: Option<String>,
3025 active: bool,
3026}
3027
3028fn print_hooks_table(rows: &[HookRow], icons: Icons, color: bool) {
3029 use owo_colors::OwoColorize as _;
3030 use std::fmt::Write as _;
3031
3032 let name_w = rows
3033 .iter()
3034 .map(|r| r.name.chars().count())
3035 .max()
3036 .unwrap_or(0)
3037 .max("NAME".len());
3038 let phase_w = rows
3039 .iter()
3040 .map(|r| r.phase.len())
3041 .max()
3042 .unwrap_or(0)
3043 .max("PHASE".len());
3044 let when_run_w = rows
3045 .iter()
3046 .map(|r| r.when_run.len())
3047 .max()
3048 .unwrap_or(0)
3049 .max("WHEN_RUN".len());
3050 let last_w = rows
3051 .iter()
3052 .map(|r| {
3053 r.last_run_at
3054 .as_deref()
3055 .map(|s| s.chars().count())
3056 .unwrap_or("(never)".len())
3057 })
3058 .max()
3059 .unwrap_or(0)
3060 .max("LAST_RUN".len());
3061 let status_w = "STATUS".len();
3062
3063 let mut header = String::new();
3065 let _ = write!(
3066 &mut header,
3067 " {:<status_w$} {:<name_w$} {:<phase_w$} {:<when_run_w$} {:<last_w$} WHEN",
3068 "STATUS", "NAME", "PHASE", "WHEN_RUN", "LAST_RUN"
3069 );
3070 if color {
3071 println!("{}", header.bold());
3072 } else {
3073 println!("{header}");
3074 }
3075
3076 let bar = |n: usize| icons.sep.to_string().repeat(n);
3078 let sep = format!(
3079 " {} {} {} {} {} {}",
3080 bar(status_w),
3081 bar(name_w),
3082 bar(phase_w),
3083 bar(when_run_w),
3084 bar(last_w),
3085 bar("WHEN".len())
3086 );
3087 if color {
3088 println!("{}", sep.dimmed());
3089 } else {
3090 println!("{sep}");
3091 }
3092
3093 for r in rows {
3095 let (icon, ran) = match (r.active, r.last_run_at.is_some()) {
3100 (false, _) => (icons.inactive, false),
3101 (true, true) => (icons.active, true),
3102 (true, false) => (icons.info, false),
3103 };
3104 let last = r.last_run_at.as_deref().unwrap_or("(never)");
3105 let when_str = r
3106 .when
3107 .as_deref()
3108 .map(strip_braces)
3109 .unwrap_or_else(|| "(always)".to_string());
3110
3111 let cell_status = format!("{icon:<status_w$}");
3112 let cell_name = format!("{:<name_w$}", r.name);
3113 let cell_phase = format!("{:<phase_w$}", r.phase);
3114 let cell_when_run = format!("{:<when_run_w$}", r.when_run);
3115 let cell_last = format!("{last:<last_w$}");
3116
3117 if !color {
3118 println!(
3119 " {cell_status} {cell_name} {cell_phase} {cell_when_run} {cell_last} {when_str}"
3120 );
3121 continue;
3122 }
3123
3124 if !r.active {
3128 println!(
3129 " {} {} {} {} {} {}",
3130 cell_status.dimmed(),
3131 cell_name.dimmed(),
3132 cell_phase.dimmed(),
3133 cell_when_run.dimmed(),
3134 cell_last.dimmed(),
3135 when_str.dimmed()
3136 );
3137 } else if ran {
3138 println!(
3139 " {} {} {} {} {} {}",
3140 cell_status.green(),
3141 cell_name.cyan().bold(),
3142 cell_phase.dimmed(),
3143 cell_when_run.dimmed(),
3144 cell_last.green(),
3145 when_str.dimmed()
3146 );
3147 } else {
3148 println!(
3149 " {} {} {} {} {} {}",
3150 cell_status.yellow(),
3151 cell_name.cyan().bold(),
3152 cell_phase.dimmed(),
3153 cell_when_run.dimmed(),
3154 cell_last.yellow(),
3155 when_str.dimmed()
3156 );
3157 }
3158 }
3159}
3160
3161pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
3165 let source = resolve_source(source)?;
3166 let yui = YuiVars::detect(&source);
3167 let config = config::load(&source, &yui)?;
3168 let mut engine = template::Engine::new();
3169 let tera_ctx = template::template_context(&yui, &config.vars);
3170
3171 let targets: Vec<&config::HookConfig> = match &name {
3172 Some(want) => {
3173 let m = config
3174 .hook
3175 .iter()
3176 .find(|h| &h.name == want)
3177 .ok_or_else(|| {
3178 anyhow::anyhow!(
3179 "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
3180 )
3181 })?;
3182 vec![m]
3183 }
3184 None => config.hook.iter().collect(),
3185 };
3186
3187 let mut state = hook::State::load(&source)?;
3188 for h in targets {
3189 let outcome = hook::run_hook(
3190 h,
3191 &source,
3192 &yui,
3193 &config.vars,
3194 &mut engine,
3195 &tera_ctx,
3196 &mut state,
3197 false,
3198 force,
3199 )?;
3200 let label = match outcome {
3201 HookOutcome::Ran => "ran",
3202 HookOutcome::SkippedOnce => "skipped (once: already ran)",
3203 HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
3204 HookOutcome::SkippedWhenFalse => "skipped (when=false)",
3205 HookOutcome::DryRun => "would run (dry-run)",
3206 };
3207 info!("hook[{}]: {label}", h.name);
3208 if outcome == HookOutcome::Ran {
3209 state.save(&source)?;
3210 }
3211 }
3212 Ok(())
3213}
3214
3215#[allow(clippy::too_many_arguments)]
3220fn process_mount(
3221 m: &ResolvedMount,
3222 ctx: &ApplyCtx<'_>,
3223 engine: &mut template::Engine,
3224 tera_ctx: &TeraContext,
3225 yuiignore: &mut paths::YuiIgnoreStack,
3226) -> Result<()> {
3227 let src_root = m.src.clone();
3230 if !src_root.is_dir() {
3231 warn!("mount src missing: {src_root}");
3232 return Ok(());
3233 }
3234 walk_and_link(
3235 &src_root, &m.dst, ctx, m.strategy, engine, tera_ctx, yuiignore, false,
3236 )
3237}
3238
3239#[allow(clippy::too_many_arguments)]
3240fn walk_and_link(
3241 src_dir: &Utf8Path,
3242 dst_dir: &Utf8Path,
3243 ctx: &ApplyCtx<'_>,
3244 strategy: MountStrategy,
3245 engine: &mut template::Engine,
3246 tera_ctx: &TeraContext,
3247 yuiignore: &mut paths::YuiIgnoreStack,
3248 parent_covered: bool,
3249) -> Result<()> {
3250 if yuiignore.is_ignored(src_dir, true) {
3253 return Ok(());
3254 }
3255 yuiignore.push_dir(src_dir)?;
3258 let result = walk_and_link_body(
3259 src_dir,
3260 dst_dir,
3261 ctx,
3262 strategy,
3263 engine,
3264 tera_ctx,
3265 yuiignore,
3266 parent_covered,
3267 );
3268 yuiignore.pop_dir(src_dir);
3269 result
3270}
3271
3272#[allow(clippy::too_many_arguments)]
3273fn walk_and_link_body(
3274 src_dir: &Utf8Path,
3275 dst_dir: &Utf8Path,
3276 ctx: &ApplyCtx<'_>,
3277 strategy: MountStrategy,
3278 engine: &mut template::Engine,
3279 tera_ctx: &TeraContext,
3280 yuiignore: &mut paths::YuiIgnoreStack,
3281 parent_covered: bool,
3282) -> Result<()> {
3283 let marker_filename = &ctx.config.mount.marker_filename;
3284 let mut covered = parent_covered;
3285
3286 if strategy == MountStrategy::Marker {
3287 match marker::read_spec(src_dir, marker_filename)? {
3288 None => {} Some(MarkerSpec::PassThrough) => {
3290 link_dir_with_backup(src_dir, dst_dir, ctx)?;
3294 covered = true;
3295 }
3296 Some(MarkerSpec::Explicit { links }) => {
3297 let mut emitted_dir_link = false;
3298 let mut emitted_any = false;
3299 for link in &links {
3300 if let Some(when) = &link.when {
3303 if !template::eval_truthy(when, engine, tera_ctx)? {
3304 continue;
3305 }
3306 }
3307 let dst_str = engine.render(&link.dst, tera_ctx)?;
3308 let dst = paths::expand_tilde(dst_str.trim());
3309 if let Some(filename) = &link.src {
3310 let file_src = src_dir.join(filename);
3311 if !file_src.is_file() {
3312 anyhow::bail!(
3313 "marker at {src_dir}: [[link]] src={filename:?} \
3314 not found"
3315 );
3316 }
3317 link_file_with_backup(&file_src, &dst, ctx)?;
3318 } else {
3319 link_dir_with_backup(src_dir, &dst, ctx)?;
3320 emitted_dir_link = true;
3321 }
3322 emitted_any = true;
3323 }
3324 if !emitted_any {
3325 info!(
3330 "marker at {src_dir} had no active links \
3331 — falling back to defaults"
3332 );
3333 }
3334 if emitted_dir_link {
3335 covered = true;
3336 }
3337 }
3338 }
3339 }
3340
3341 for entry in std::fs::read_dir(src_dir)? {
3342 let entry = entry?;
3343 let name_os = entry.file_name();
3344 let Some(name) = name_os.to_str() else {
3345 continue;
3346 };
3347 if name == marker_filename {
3348 continue;
3349 }
3350 if name.ends_with(".tera") {
3351 continue;
3353 }
3354 let src_path = src_dir.join(name);
3355 let dst_path = dst_dir.join(name);
3356 let ft = entry.file_type()?;
3357
3358 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
3359 continue;
3360 }
3361
3362 if ft.is_dir() {
3363 walk_and_link(
3364 &src_path, &dst_path, ctx, strategy, engine, tera_ctx, yuiignore, covered,
3365 )?;
3366 } else if ft.is_file() {
3367 if !covered {
3373 link_file_with_backup(&src_path, &dst_path, ctx)?;
3374 }
3375 }
3376 }
3377 Ok(())
3378}
3379
3380fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3381 use absorb::AbsorbDecision::*;
3382
3383 let decision = absorb::classify(src, dst)?;
3384
3385 if ctx.dry_run {
3386 info!("[dry-run] {decision:?}: {src} → {dst}");
3387 return Ok(());
3388 }
3389
3390 match decision {
3391 InSync => {
3392 Ok(())
3394 }
3395 Restore => {
3396 info!("link: {src} → {dst}");
3397 link::link_file(src, dst, ctx.file_mode)?;
3398 Ok(())
3399 }
3400 RelinkOnly => {
3401 info!("relink: {src} → {dst}");
3404 link::unlink(dst)?;
3405 link::link_file(src, dst, ctx.file_mode)?;
3406 Ok(())
3407 }
3408 AutoAbsorb => {
3409 if !ctx.config.absorb.auto {
3412 return handle_anomaly(
3413 src,
3414 dst,
3415 ctx,
3416 "absorb.auto = false; treating divergence as anomaly",
3417 );
3418 }
3419 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
3420 return handle_anomaly(
3421 src,
3422 dst,
3423 ctx,
3424 "source repo is dirty; deferring auto-absorb",
3425 );
3426 }
3427 absorb_target_into_source(src, dst, ctx)
3428 }
3429 NeedsConfirm => handle_anomaly(
3430 src,
3431 dst,
3432 ctx,
3433 "anomaly: source equals/newer than target but content differs",
3434 ),
3435 }
3436}
3437
3438fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3442 info!("absorb: {dst} → {src}");
3443 backup_existing(src, ctx.backup_root, false)?;
3444 std::fs::copy(dst, src)?;
3445 link::unlink(dst)?;
3446 link::link_file(src, dst, ctx.file_mode)?;
3447 Ok(())
3448}
3449
3450fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
3456 use crate::config::AnomalyAction::*;
3457 match ctx.config.absorb.on_anomaly {
3458 Skip => {
3459 warn!("anomaly skip: {dst} ({reason})");
3460 Ok(())
3461 }
3462 Force => {
3463 warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
3464 absorb_target_into_source(src, dst, ctx)
3465 }
3466 Ask => {
3467 use std::io::IsTerminal;
3468 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
3469 if prompt_absorb_with_diff(src, dst, reason)? {
3470 absorb_target_into_source(src, dst, ctx)
3471 } else {
3472 warn!("anomaly skipped by user: {dst}");
3473 Ok(())
3474 }
3475 } else {
3476 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
3477 Ok(())
3478 }
3479 }
3480 }
3481}
3482
3483fn prompt_absorb_with_diff(src: &Utf8Path, dst: &Utf8Path, reason: &str) -> Result<bool> {
3484 eprintln!();
3485 eprintln!("anomaly: {reason}");
3486 print_absorb_diff(src, dst);
3487 prompt_yes_no("absorb target into source?")
3488}
3489
3490fn source_repo_is_clean(source: &Utf8Path) -> bool {
3495 match crate::git::is_clean(source) {
3496 Ok(b) => b,
3497 Err(e) => {
3498 warn!("git clean check failed at {source}: {e} — treating as clean");
3499 true
3500 }
3501 }
3502}
3503
3504fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3505 use absorb::AbsorbDecision::*;
3506 let decision = absorb::classify(src, dst)?;
3507
3508 if ctx.dry_run {
3509 info!("[dry-run] dir {decision:?}: {src} → {dst}");
3510 return Ok(());
3511 }
3512
3513 match decision {
3514 InSync => Ok(()),
3515 Restore => {
3516 info!("link dir: {src} → {dst}");
3517 link::link_dir(src, dst, ctx.dir_mode)?;
3518 Ok(())
3519 }
3520 RelinkOnly => {
3521 info!("relink dir: {src} → {dst}");
3526 remove_dir_link_or_real(dst)?;
3527 link::link_dir(src, dst, ctx.dir_mode)?;
3528 Ok(())
3529 }
3530 AutoAbsorb | NeedsConfirm => {
3531 if !ctx.config.absorb.auto {
3552 return handle_anomaly_dir(
3553 src,
3554 dst,
3555 ctx,
3556 "absorb.auto = false; treating divergence as anomaly",
3557 );
3558 }
3559 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
3560 return handle_anomaly_dir(
3561 src,
3562 dst,
3563 ctx,
3564 "source repo is dirty; deferring auto-absorb",
3565 );
3566 }
3567 absorb_target_dir_into_source(src, dst, ctx)
3568 }
3569 }
3570}
3571
3572fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
3582 if let Err(unlink_err) = link::unlink(dst) {
3583 let meta = std::fs::symlink_metadata(dst)
3584 .with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
3585 let ft = meta.file_type();
3586 if ft.is_dir() && !ft.is_symlink() {
3587 std::fs::remove_dir_all(dst).with_context(|| {
3588 format!(
3589 "remove_dir_all({dst}) after link::unlink failed: \
3590 {unlink_err}"
3591 )
3592 })?;
3593 } else {
3594 return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
3595 }
3596 }
3597 Ok(())
3598}
3599
3600fn merge_dir_target_into_source(
3610 target: &Utf8Path,
3611 source: &Utf8Path,
3612 ctx: &ApplyCtx<'_>,
3613) -> Result<()> {
3614 for entry in std::fs::read_dir(target)? {
3615 let entry = entry?;
3616 let name_os = entry.file_name();
3617 let Some(name) = name_os.to_str() else {
3618 continue;
3619 };
3620 let target_path = target.join(name);
3621 let source_path = source.join(name);
3622 let ft = entry.file_type()?;
3623
3624 if ft.is_dir() && !ft.is_symlink() {
3625 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
3631 let sft = src_meta.file_type();
3632 if !sft.is_dir() || sft.is_symlink() {
3633 link::unlink(&source_path).with_context(|| {
3634 format!("remove conflicting source entry before dir merge: {source_path}")
3635 })?;
3636 }
3637 }
3638 if !source_path.exists() {
3639 std::fs::create_dir_all(&source_path).with_context(|| {
3640 format!("create_dir_all({source_path}) during target→source merge")
3641 })?;
3642 }
3643 merge_dir_target_into_source(&target_path, &source_path, ctx)?;
3644 } else if ft.is_file() {
3645 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
3649 let sft = src_meta.file_type();
3650 if sft.is_dir() && !sft.is_symlink() {
3651 remove_dir_link_or_real(&source_path).with_context(|| {
3652 format!("remove conflicting source dir before file merge: {source_path}")
3653 })?;
3654 } else if sft.is_symlink() {
3655 link::unlink(&source_path).with_context(|| {
3656 format!(
3657 "remove conflicting source symlink before file merge: {source_path}"
3658 )
3659 })?;
3660 }
3661 }
3662 if let Some(parent) = source_path.parent() {
3663 if !parent.exists() {
3664 std::fs::create_dir_all(parent)?;
3665 }
3666 }
3667 if source_path.is_file() {
3681 merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
3682 } else {
3683 std::fs::copy(&target_path, &source_path)
3684 .with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
3685 }
3686 } else {
3687 warn!(
3688 "merge: skipping non-regular entry {target_path} \
3689 (symlink / junction / special — content not copied)"
3690 );
3691 }
3692 }
3693 Ok(())
3694}
3695
3696fn merge_resolve_file_conflict(
3710 target_path: &Utf8Path,
3711 source_path: &Utf8Path,
3712 ctx: &ApplyCtx<'_>,
3713) -> Result<()> {
3714 use absorb::AbsorbDecision::*;
3715 let decision = absorb::classify(source_path, target_path)?;
3716 match decision {
3717 InSync | RelinkOnly => Ok(()),
3718 AutoAbsorb => {
3719 std::fs::copy(target_path, source_path).with_context(|| {
3720 format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
3721 })?;
3722 Ok(())
3723 }
3724 Restore => {
3725 unreachable!(
3732 "merge_resolve_file_conflict reached with both files present, \
3733 but classify returned Restore (target {target_path} / source {source_path})"
3734 )
3735 }
3736 NeedsConfirm => {
3737 use crate::config::AnomalyAction::*;
3738 match ctx.config.absorb.on_anomaly {
3739 Skip => {
3740 warn!(
3741 "merge anomaly skip: {target_path} (source-newer / content drift) \
3742 — keeping source version, target version dropped"
3743 );
3744 Ok(())
3745 }
3746 Force => {
3747 warn!(
3748 "merge anomaly force: {target_path} \
3749 (source-newer / content drift) — overwriting source"
3750 );
3751 std::fs::copy(target_path, source_path)?;
3752 Ok(())
3753 }
3754 Ask => {
3755 use std::io::IsTerminal;
3756 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
3757 if prompt_absorb_with_diff(
3758 source_path,
3759 target_path,
3760 "merge: file content differs and source is newer",
3761 )? {
3762 std::fs::copy(target_path, source_path)?;
3763 } else {
3764 warn!("merge: kept source version by user choice: {source_path}");
3765 }
3766 Ok(())
3767 } else {
3768 warn!(
3769 "merge anomaly skip (non-TTY ask mode): {target_path} \
3770 — keeping source version"
3771 );
3772 Ok(())
3773 }
3774 }
3775 }
3776 }
3777 }
3778}
3779
3780fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3787 info!("absorb dir: {dst} → {src}");
3788 backup_existing(src, ctx.backup_root, true)?;
3789 merge_dir_target_into_source(dst, src, ctx)?;
3790 remove_dir_link_or_real(dst)?;
3793 link::link_dir(src, dst, ctx.dir_mode)?;
3794 Ok(())
3795}
3796
3797fn handle_anomaly_dir(
3801 src: &Utf8Path,
3802 dst: &Utf8Path,
3803 ctx: &ApplyCtx<'_>,
3804 reason: &str,
3805) -> Result<()> {
3806 use crate::config::AnomalyAction::*;
3807 match ctx.config.absorb.on_anomaly {
3808 Skip => {
3809 warn!("anomaly skip dir: {dst} ({reason})");
3810 Ok(())
3811 }
3812 Force => {
3813 warn!(
3814 "anomaly force dir: {dst} ({reason}) \
3815 — absorbing target into source"
3816 );
3817 absorb_target_dir_into_source(src, dst, ctx)
3818 }
3819 Ask => {
3820 use std::io::IsTerminal;
3821 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
3822 eprintln!();
3823 eprintln!("anomaly: {dst}");
3824 eprintln!(" {reason}");
3825 eprintln!(" source: {src}");
3826 eprint!(" absorb target dir into source? (y/N) ");
3827 use std::io::{BufRead as _, Write as _};
3828 std::io::stderr().flush().ok();
3829 let mut buf = String::new();
3830 std::io::stdin().lock().read_line(&mut buf)?;
3831 let answer = buf.trim();
3832 if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
3833 absorb_target_dir_into_source(src, dst, ctx)
3834 } else {
3835 warn!("anomaly skipped by user: {dst}");
3836 Ok(())
3837 }
3838 } else {
3839 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
3840 Ok(())
3841 }
3842 }
3843 }
3844}
3845
3846fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
3847 let abs_target = absolutize(target)?;
3848 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
3849 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
3850 info!("backup → {bp}");
3851 if is_dir {
3852 backup::backup_dir(target, &bp)?;
3853 } else {
3854 backup::backup_file(target, &bp)?;
3855 }
3856 Ok(())
3857}
3858
3859fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
3860 if let Some(s) = source {
3861 return absolutize(&s);
3862 }
3863 if let Ok(s) = std::env::var("YUI_SOURCE") {
3864 return absolutize(Utf8Path::new(&s));
3865 }
3866 let cwd = current_dir_utf8()?;
3867 for ancestor in cwd.ancestors() {
3868 if ancestor.join("config.toml").is_file() {
3869 return Ok(ancestor.to_path_buf());
3870 }
3871 }
3872 if let Some(home) = paths::home_dir() {
3873 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
3874 let p = home.join(c);
3875 if p.join("config.toml").is_file() {
3876 return Ok(p);
3877 }
3878 }
3879 }
3880 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
3881}
3882
3883fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
3884 let expanded = paths::expand_tilde(p.as_str());
3886 if expanded.is_absolute() {
3887 return Ok(expanded);
3888 }
3889 let cwd = current_dir_utf8()?;
3890 Ok(cwd.join(expanded))
3891}
3892
3893fn current_dir_utf8() -> Result<Utf8PathBuf> {
3894 let cwd = std::env::current_dir().context("getting cwd")?;
3895 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
3896}
3897
3898const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
3902
3903[vars]
3904# user-defined values; templates can reference these as {{ vars.foo }}
3905
3906# [link]
3907# file_mode = "auto" # auto | symlink | hardlink
3908# dir_mode = "auto" # auto | symlink | junction
3909
3910[mount]
3911default_strategy = "marker"
3912
3913[[mount.entry]]
3914src = "home"
3915# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
3916dst = "~"
3917
3918# [[mount.entry]]
3919# src = "appdata"
3920# dst = "{{ env(name='APPDATA') }}"
3921# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
3922# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
3923# when = "yui.os == 'windows'"
3924"#;
3925
3926const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
3927# .yui/bin/ is intentionally tracked — it holds your hook scripts.
3928/.yui/state.json
3929/.yui/state.json.tmp
3930/.yui/backup/
3931
3932# >>> yui rendered (auto-managed, do not edit) >>>
3933# <<< yui rendered (auto-managed) <<<
3934
3935# config.local.toml is per-machine; commit a config.local.example.toml instead.
3936config.local.toml
3937"#;
3938
3939#[cfg(test)]
3940mod tests {
3941 use super::*;
3942 use tempfile::TempDir;
3943
3944 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
3945 Utf8PathBuf::from_path_buf(p).unwrap()
3946 }
3947
3948 fn toml_path(p: &Utf8Path) -> String {
3950 p.as_str().replace('\\', "/")
3951 }
3952
3953 #[test]
3954 fn apply_links_a_raw_file() {
3955 let tmp = TempDir::new().unwrap();
3956 let source = utf8(tmp.path().join("dotfiles"));
3957 let target = utf8(tmp.path().join("target"));
3958 std::fs::create_dir_all(source.join("home")).unwrap();
3959 std::fs::create_dir_all(&target).unwrap();
3960 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3961
3962 let cfg = format!(
3963 r#"
3964[[mount.entry]]
3965src = "home"
3966dst = "{}"
3967"#,
3968 toml_path(&target)
3969 );
3970 std::fs::write(source.join("config.toml"), cfg).unwrap();
3971
3972 apply(Some(source), false).unwrap();
3973
3974 let linked = target.join(".bashrc");
3975 assert!(linked.exists(), "expected {linked} to exist");
3976 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
3977 }
3978
3979 #[test]
3980 fn apply_with_marker_links_whole_directory() {
3981 let tmp = TempDir::new().unwrap();
3982 let source = utf8(tmp.path().join("dotfiles"));
3983 let target = utf8(tmp.path().join("target"));
3984 let nvim_src = source.join("home/nvim");
3985 std::fs::create_dir_all(&nvim_src).unwrap();
3986 std::fs::create_dir_all(&target).unwrap();
3987 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
3988 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
3989 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
3990
3991 let cfg = format!(
3992 r#"
3993[[mount.entry]]
3994src = "home"
3995dst = "{}"
3996"#,
3997 toml_path(&target)
3998 );
3999 std::fs::write(source.join("config.toml"), cfg).unwrap();
4000
4001 apply(Some(source.clone()), false).unwrap();
4002
4003 let nvim_dst = target.join("nvim");
4004 assert!(nvim_dst.exists());
4005 assert_eq!(
4006 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
4007 "-- hi\n"
4008 );
4009 }
4013
4014 #[test]
4015 fn apply_dry_run_does_not_write() {
4016 let tmp = TempDir::new().unwrap();
4017 let source = utf8(tmp.path().join("dotfiles"));
4018 let target = utf8(tmp.path().join("target"));
4019 std::fs::create_dir_all(source.join("home")).unwrap();
4020 std::fs::create_dir_all(&target).unwrap();
4021 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
4022
4023 let cfg = format!(
4024 r#"
4025[[mount.entry]]
4026src = "home"
4027dst = "{}"
4028"#,
4029 toml_path(&target)
4030 );
4031 std::fs::write(source.join("config.toml"), cfg).unwrap();
4032
4033 apply(Some(source), true).unwrap();
4034
4035 assert!(!target.join(".bashrc").exists());
4036 }
4037
4038 #[test]
4039 fn apply_renders_templates_then_links_rendered_outputs() {
4040 let tmp = TempDir::new().unwrap();
4041 let source = utf8(tmp.path().join("dotfiles"));
4042 let target = utf8(tmp.path().join("target"));
4043 std::fs::create_dir_all(source.join("home")).unwrap();
4044 std::fs::create_dir_all(&target).unwrap();
4045 std::fs::write(
4046 source.join("home/.gitconfig.tera"),
4047 "[user]\n os = {{ yui.os }}\n",
4048 )
4049 .unwrap();
4050 std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
4051
4052 let cfg = format!(
4053 r#"
4054[[mount.entry]]
4055src = "home"
4056dst = "{}"
4057"#,
4058 toml_path(&target)
4059 );
4060 std::fs::write(source.join("config.toml"), cfg).unwrap();
4061
4062 apply(Some(source.clone()), false).unwrap();
4063
4064 assert!(target.join(".bashrc").exists());
4066 assert!(source.join("home/.gitconfig").exists());
4068 assert!(target.join(".gitconfig").exists());
4069 assert!(!target.join(".gitconfig.tera").exists());
4071 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
4073 assert!(linked.contains("os = "));
4074 }
4075
4076 #[test]
4077 fn apply_marker_override_links_to_custom_dst() {
4078 let tmp = TempDir::new().unwrap();
4079 let source = utf8(tmp.path().join("dotfiles"));
4080 let target_a = utf8(tmp.path().join("target_a"));
4081 let target_b = utf8(tmp.path().join("target_b"));
4082 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
4083 std::fs::create_dir_all(&target_a).unwrap();
4084 std::fs::create_dir_all(&target_b).unwrap();
4085 std::fs::write(
4086 source.join("home/.config/nvim/init.lua"),
4087 "-- nvim config\n",
4088 )
4089 .unwrap();
4090
4091 std::fs::write(
4094 source.join("home/.config/nvim/.yuilink"),
4095 format!(
4096 r#"
4097[[link]]
4098dst = "{}/nvim"
4099
4100[[link]]
4101dst = "{}/nvim"
4102when = "{{{{ yui.os == '{}' }}}}"
4103"#,
4104 toml_path(&target_a),
4105 toml_path(&target_b),
4106 std::env::consts::OS
4107 ),
4108 )
4109 .unwrap();
4110
4111 let parent_target = utf8(tmp.path().join("parent_target"));
4112 std::fs::create_dir_all(&parent_target).unwrap();
4113 let cfg = format!(
4114 r#"
4115[[mount.entry]]
4116src = "home"
4117dst = "{}"
4118"#,
4119 toml_path(&parent_target)
4120 );
4121 std::fs::write(source.join("config.toml"), cfg).unwrap();
4122
4123 apply(Some(source.clone()), false).unwrap();
4124
4125 assert!(
4127 target_a.join("nvim/init.lua").exists(),
4128 "target_a/nvim/init.lua should be reachable through the link"
4129 );
4130 assert!(
4131 target_b.join("nvim/init.lua").exists(),
4132 "target_b/nvim/init.lua should be reachable through the link"
4133 );
4134 assert!(
4137 !parent_target.join(".config/nvim").exists(),
4138 "parent mount should have skipped the marker-claimed sub-dir"
4139 );
4140 }
4141
4142 #[test]
4143 fn apply_marker_inactive_link_falls_through_to_default() {
4144 let tmp = TempDir::new().unwrap();
4149 let source = utf8(tmp.path().join("dotfiles"));
4150 let target_inactive = utf8(tmp.path().join("inactive"));
4151 let parent_target = utf8(tmp.path().join("parent"));
4152 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
4153 std::fs::create_dir_all(&parent_target).unwrap();
4154 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
4155
4156 std::fs::write(
4158 source.join("home/.config/nvim/.yuilink"),
4159 format!(
4160 r#"
4161[[link]]
4162dst = "{}/nvim"
4163when = "{{{{ yui.os == 'no-such-os' }}}}"
4164"#,
4165 toml_path(&target_inactive)
4166 ),
4167 )
4168 .unwrap();
4169
4170 let cfg = format!(
4171 r#"
4172[[mount.entry]]
4173src = "home"
4174dst = "{}"
4175"#,
4176 toml_path(&parent_target)
4177 );
4178 std::fs::write(source.join("config.toml"), cfg).unwrap();
4179
4180 apply(Some(source.clone()), false).unwrap();
4181
4182 assert!(!target_inactive.join("nvim").exists());
4184 assert!(parent_target.join(".config/nvim/init.lua").exists());
4187 }
4188
4189 #[test]
4190 fn list_shows_mount_entries_and_marker_overrides() {
4191 let tmp = TempDir::new().unwrap();
4192 let source = utf8(tmp.path().join("dotfiles"));
4193 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
4194 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
4195 std::fs::write(
4196 source.join("home/.config/nvim/.yuilink"),
4197 r#"
4198[[link]]
4199dst = "/custom/nvim"
4200"#,
4201 )
4202 .unwrap();
4203 std::fs::write(
4204 source.join("config.toml"),
4205 r#"
4206[[mount.entry]]
4207src = "home"
4208dst = "/h"
4209"#,
4210 )
4211 .unwrap();
4212
4213 list(Some(source), false, None, true).unwrap();
4216 }
4217
4218 #[test]
4219 fn status_reports_in_sync_after_apply() {
4220 let tmp = TempDir::new().unwrap();
4221 let source = utf8(tmp.path().join("dotfiles"));
4222 let target = utf8(tmp.path().join("target"));
4223 std::fs::create_dir_all(source.join("home")).unwrap();
4224 std::fs::create_dir_all(&target).unwrap();
4225 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4226 let cfg = format!(
4227 r#"
4228[[mount.entry]]
4229src = "home"
4230dst = "{}"
4231"#,
4232 toml_path(&target)
4233 );
4234 std::fs::write(source.join("config.toml"), cfg).unwrap();
4235 apply(Some(source.clone()), false).unwrap();
4237 status(Some(source), None, true).unwrap();
4239 }
4240
4241 #[test]
4242 fn status_reports_template_drift() {
4243 let tmp = TempDir::new().unwrap();
4244 let source = utf8(tmp.path().join("dotfiles"));
4245 let target = utf8(tmp.path().join("target"));
4246 std::fs::create_dir_all(source.join("home")).unwrap();
4247 std::fs::create_dir_all(&target).unwrap();
4248 std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
4251 std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
4252
4253 let cfg = format!(
4254 r#"
4255[[mount.entry]]
4256src = "home"
4257dst = "{}"
4258"#,
4259 toml_path(&target)
4260 );
4261 std::fs::write(source.join("config.toml"), cfg).unwrap();
4262
4263 let err = status(Some(source), None, true).unwrap_err();
4264 assert!(format!("{err}").contains("diverged"));
4265 }
4266
4267 #[test]
4268 fn status_fails_when_target_missing() {
4269 let tmp = TempDir::new().unwrap();
4270 let source = utf8(tmp.path().join("dotfiles"));
4271 let target = utf8(tmp.path().join("target"));
4272 std::fs::create_dir_all(source.join("home")).unwrap();
4273 std::fs::create_dir_all(&target).unwrap();
4274 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4275 let cfg = format!(
4276 r#"
4277[[mount.entry]]
4278src = "home"
4279dst = "{}"
4280"#,
4281 toml_path(&target)
4282 );
4283 std::fs::write(source.join("config.toml"), cfg).unwrap();
4284 let err = status(Some(source), None, true).unwrap_err();
4286 assert!(format!("{err}").contains("diverged"));
4287 }
4288
4289 #[test]
4290 fn strip_braces_removes_outer_template_braces() {
4291 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
4292 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
4293 assert_eq!(strip_braces(" {{x}} "), "x");
4294 }
4295
4296 #[test]
4297 fn apply_aborts_on_render_drift() {
4298 let tmp = TempDir::new().unwrap();
4299 let source = utf8(tmp.path().join("dotfiles"));
4300 let target = utf8(tmp.path().join("target"));
4301 std::fs::create_dir_all(source.join("home")).unwrap();
4302 std::fs::create_dir_all(&target).unwrap();
4303 std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
4304 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
4305
4306 let cfg = format!(
4307 r#"
4308[[mount.entry]]
4309src = "home"
4310dst = "{}"
4311"#,
4312 toml_path(&target)
4313 );
4314 std::fs::write(source.join("config.toml"), cfg).unwrap();
4315
4316 let err = apply(Some(source.clone()), false).unwrap_err();
4317 assert!(format!("{err}").contains("drift"));
4318 assert_eq!(
4320 std::fs::read_to_string(source.join("home/foo")).unwrap(),
4321 "manually edited"
4322 );
4323 assert!(!target.join("foo").exists());
4325 }
4326
4327 #[test]
4328 fn init_creates_skeleton_when_dir_empty() {
4329 let tmp = TempDir::new().unwrap();
4330 let dir = utf8(tmp.path().join("new_dotfiles"));
4331 init(Some(dir.clone()), false).unwrap();
4332 assert!(dir.join("config.toml").is_file());
4333 assert!(dir.join(".gitignore").is_file());
4334 }
4335
4336 #[test]
4337 fn init_refuses_to_overwrite_existing_config() {
4338 let tmp = TempDir::new().unwrap();
4339 let dir = utf8(tmp.path().join("dotfiles"));
4340 std::fs::create_dir_all(&dir).unwrap();
4341 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
4342 let err = init(Some(dir), false).unwrap_err();
4343 assert!(format!("{err}").contains("already exists"));
4344 }
4345
4346 #[test]
4352 fn init_appends_missing_gitignore_entries_into_existing_file() {
4353 let tmp = TempDir::new().unwrap();
4354 let dir = utf8(tmp.path().join("dotfiles"));
4355 std::fs::create_dir_all(&dir).unwrap();
4356 let user_gitignore = "# user entries\n*.swp\nnode_modules/\n";
4358 std::fs::write(dir.join(".gitignore"), user_gitignore).unwrap();
4359
4360 init(Some(dir.clone()), false).unwrap();
4361
4362 let body = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
4363 assert!(body.contains("*.swp"));
4365 assert!(body.contains("node_modules/"));
4366 assert!(body.contains("/.yui/state.json"));
4368 assert!(body.contains("/.yui/backup/"));
4369 assert!(body.contains("config.local.toml"));
4370 let before_rerun = body.clone();
4372 std::fs::remove_file(dir.join("config.toml")).unwrap();
4375 init(Some(dir.clone()), false).unwrap();
4376 let after_rerun = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
4377 assert_eq!(
4378 before_rerun, after_rerun,
4379 "init must be idempotent when the gitignore already has every yui entry"
4380 );
4381 }
4382
4383 #[test]
4389 fn init_with_git_hooks_installs_into_existing_repo() {
4390 let tmp = TempDir::new().unwrap();
4391 let dir = utf8(tmp.path().join("dotfiles"));
4392 std::fs::create_dir_all(&dir).unwrap();
4393 let st = std::process::Command::new("git")
4394 .args(["init", "-q"])
4395 .current_dir(dir.as_std_path())
4396 .status()
4397 .expect("git init");
4398 if !st.success() {
4399 return;
4400 }
4401 let user_config = "# user already wrote this\n";
4403 std::fs::write(dir.join("config.toml"), user_config).unwrap();
4404
4405 init(Some(dir.clone()), true).unwrap();
4407
4408 assert_eq!(
4409 std::fs::read_to_string(dir.join("config.toml")).unwrap(),
4410 user_config
4411 );
4412 assert!(dir.join(".git/hooks/pre-commit").is_file());
4413 assert!(dir.join(".git/hooks/pre-push").is_file());
4414 }
4415
4416 #[test]
4421 fn init_with_git_hooks_writes_pre_commit_and_pre_push() {
4422 let tmp = TempDir::new().unwrap();
4423 let dir = utf8(tmp.path().join("dotfiles"));
4424 std::fs::create_dir_all(&dir).unwrap();
4425 let st = std::process::Command::new("git")
4427 .args(["init", "-q"])
4428 .current_dir(dir.as_std_path())
4429 .status()
4430 .expect("git init");
4431 if !st.success() {
4432 eprintln!("skipping: git not available");
4434 return;
4435 }
4436 init(Some(dir.clone()), true).unwrap();
4437
4438 let pre_commit = dir.join(".git/hooks/pre-commit");
4439 let pre_push = dir.join(".git/hooks/pre-push");
4440 assert!(pre_commit.is_file(), "pre-commit hook should be written");
4441 assert!(pre_push.is_file(), "pre-push hook should be written");
4442
4443 let body = std::fs::read_to_string(&pre_commit).unwrap();
4444 assert!(
4445 body.contains("yui render --check"),
4446 "pre-commit hook should call `yui render --check`, got: {body}"
4447 );
4448 }
4449
4450 #[test]
4454 fn init_with_git_hooks_errors_outside_a_git_repo() {
4455 let tmp = TempDir::new().unwrap();
4456 let dir = utf8(tmp.path().join("not-a-repo"));
4457 std::fs::create_dir_all(&dir).unwrap();
4458 let err = init(Some(dir), true).unwrap_err();
4459 let msg = format!("{err:#}");
4460 assert!(
4461 msg.contains("git repo") || msg.contains("git rev-parse"),
4462 "expected error to mention the git issue, got: {msg}"
4463 );
4464 }
4465
4466 #[test]
4469 fn init_with_git_hooks_does_not_clobber_existing_hooks() {
4470 let tmp = TempDir::new().unwrap();
4471 let dir = utf8(tmp.path().join("dotfiles"));
4472 std::fs::create_dir_all(&dir).unwrap();
4473 let st = std::process::Command::new("git")
4474 .args(["init", "-q"])
4475 .current_dir(dir.as_std_path())
4476 .status()
4477 .expect("git init");
4478 if !st.success() {
4479 return;
4480 }
4481 let hooks = dir.join(".git/hooks");
4482 std::fs::create_dir_all(&hooks).unwrap();
4483 std::fs::write(hooks.join("pre-commit"), "#! /bin/sh\nexit 0\n").unwrap();
4484
4485 init(Some(dir.clone()), true).unwrap();
4486
4487 let pc = std::fs::read_to_string(hooks.join("pre-commit")).unwrap();
4489 assert!(
4490 !pc.contains("yui render --check"),
4491 "existing pre-commit must not be overwritten"
4492 );
4493 let pp = std::fs::read_to_string(hooks.join("pre-push")).unwrap();
4494 assert!(
4495 pp.contains("yui render --check"),
4496 "missing pre-push should be written: {pp}"
4497 );
4498 }
4499
4500 fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
4503 let source = utf8(tmp.path().join("dotfiles"));
4504 let target = utf8(tmp.path().join("target"));
4505 std::fs::create_dir_all(source.join("home")).unwrap();
4506 std::fs::create_dir_all(&target).unwrap();
4507 let cfg = format!(
4508 r#"
4509[[mount.entry]]
4510src = "home"
4511dst = "{}"
4512"#,
4513 toml_path(&target)
4514 );
4515 std::fs::write(source.join("config.toml"), cfg).unwrap();
4516 (source, target)
4517 }
4518
4519 fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
4520 std::fs::write(path, body).unwrap();
4521 let f = std::fs::OpenOptions::new()
4522 .write(true)
4523 .open(path)
4524 .expect("open writable");
4525 f.set_modified(when).expect("set_modified");
4526 }
4527
4528 #[test]
4529 fn apply_target_newer_absorbs_target_into_source() {
4530 let tmp = TempDir::new().unwrap();
4534 let (source, target) = setup_minimal_dotfiles(&tmp);
4535
4536 let now = std::time::SystemTime::now();
4537 let past = now - std::time::Duration::from_secs(120);
4538 write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
4539 write_with_mtime(&target.join(".bashrc"), "user's edit", now);
4541
4542 apply(Some(source.clone()), false).unwrap();
4543
4544 assert_eq!(
4546 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4547 "user's edit"
4548 );
4549 assert_eq!(
4551 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4552 "user's edit"
4553 );
4554 let backup_root = source.join(".yui/backup");
4556 let mut found_old = false;
4557 for entry in walkdir(&backup_root) {
4558 if let Ok(s) = std::fs::read_to_string(&entry) {
4559 if s == "default from repo" {
4560 found_old = true;
4561 break;
4562 }
4563 }
4564 }
4565 assert!(found_old, "expected backup containing 'default from repo'");
4566 }
4567
4568 #[test]
4569 fn apply_in_sync_target_is_a_no_op() {
4570 let tmp = TempDir::new().unwrap();
4573 let (source, target) = setup_minimal_dotfiles(&tmp);
4574 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4575 apply(Some(source.clone()), false).unwrap();
4576 let backup_root = source.join(".yui/backup");
4577 let backup_count_after_first = walkdir(&backup_root).len();
4578
4579 apply(Some(source.clone()), false).unwrap();
4581 assert_eq!(
4582 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4583 "echo hi\n"
4584 );
4585 let backup_count_after_second = walkdir(&backup_root).len();
4586 assert_eq!(
4587 backup_count_after_first, backup_count_after_second,
4588 "second apply on an in-sync tree should not produce backups"
4589 );
4590 }
4591
4592 #[test]
4593 fn apply_skip_policy_leaves_anomaly_alone() {
4594 let tmp = TempDir::new().unwrap();
4597 let source = utf8(tmp.path().join("dotfiles"));
4598 let target = utf8(tmp.path().join("target"));
4599 std::fs::create_dir_all(source.join("home")).unwrap();
4600 std::fs::create_dir_all(&target).unwrap();
4601 let cfg = format!(
4602 r#"
4603[absorb]
4604on_anomaly = "skip"
4605
4606[[mount.entry]]
4607src = "home"
4608dst = "{}"
4609"#,
4610 toml_path(&target)
4611 );
4612 std::fs::write(source.join("config.toml"), cfg).unwrap();
4613
4614 let now = std::time::SystemTime::now();
4615 let past = now - std::time::Duration::from_secs(120);
4616 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4617 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4618
4619 apply(Some(source.clone()), false).unwrap();
4620
4621 assert_eq!(
4623 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4624 "user's edit (older)"
4625 );
4626 assert_eq!(
4628 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4629 "fresh from upstream"
4630 );
4631 }
4632
4633 #[test]
4634 fn apply_force_policy_absorbs_anomaly_anyway() {
4635 let tmp = TempDir::new().unwrap();
4637 let source = utf8(tmp.path().join("dotfiles"));
4638 let target = utf8(tmp.path().join("target"));
4639 std::fs::create_dir_all(source.join("home")).unwrap();
4640 std::fs::create_dir_all(&target).unwrap();
4641 let cfg = format!(
4642 r#"
4643[absorb]
4644on_anomaly = "force"
4645
4646[[mount.entry]]
4647src = "home"
4648dst = "{}"
4649"#,
4650 toml_path(&target)
4651 );
4652 std::fs::write(source.join("config.toml"), cfg).unwrap();
4653
4654 let now = std::time::SystemTime::now();
4655 let past = now - std::time::Duration::from_secs(120);
4656 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4657 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4658
4659 apply(Some(source.clone()), false).unwrap();
4660
4661 assert_eq!(
4663 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4664 "user's edit (older)"
4665 );
4666 assert_eq!(
4667 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4668 "user's edit (older)"
4669 );
4670 }
4671
4672 #[test]
4684 fn apply_absorbs_non_empty_target_dir_target_wins() {
4685 let tmp = TempDir::new().unwrap();
4686 let source = utf8(tmp.path().join("dotfiles"));
4687 let target = utf8(tmp.path().join("target"));
4688 std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
4689 std::fs::create_dir_all(target.join(".config/app")).unwrap();
4690 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4693 std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
4694 std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
4696 std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
4699 std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
4700
4701 let cfg = format!(
4702 r#"
4703[absorb]
4704on_anomaly = "force"
4705
4706[[mount.entry]]
4707src = "home"
4708dst = "{}"
4709"#,
4710 toml_path(&target)
4711 );
4712 std::fs::write(source.join("config.toml"), cfg).unwrap();
4713
4714 apply(Some(source.clone()), false).unwrap();
4716
4717 assert_eq!(
4719 std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
4720 "target side"
4721 );
4722 assert_eq!(
4724 std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
4725 "{}"
4726 );
4727 let backup_root = source.join(".yui/backup");
4730 let mut backup_files: Vec<String> = Vec::new();
4731 for entry in walkdir(&backup_root) {
4732 if let Some(n) = entry.file_name() {
4733 backup_files.push(n.to_string());
4734 }
4735 }
4736 assert!(
4737 backup_files.iter().any(|f| f == "config.toml"),
4738 "expected source's config.toml to land in the backup tree, got {backup_files:?}"
4739 );
4740 assert!(
4742 source.join("home/.config/app/source-only.toml").exists(),
4743 "source-only file should survive a target-wins merge"
4744 );
4745 assert!(
4747 source.join("home/.config/app/state.json").exists(),
4748 "target-only state.json should be merged into source"
4749 );
4750 }
4751
4752 #[test]
4758 fn marker_dir_absorbs_with_default_ask_policy() {
4759 let tmp = TempDir::new().unwrap();
4760 let source = utf8(tmp.path().join("dotfiles"));
4761 let target = utf8(tmp.path().join("target"));
4762 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4763 std::fs::create_dir_all(target.join(".config/gh")).unwrap();
4764 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4766 std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\n").unwrap();
4768
4769 let cfg = format!(
4773 r#"
4774[[mount.entry]]
4775src = "home"
4776dst = "{}"
4777"#,
4778 toml_path(&target)
4779 );
4780 std::fs::write(source.join("config.toml"), cfg).unwrap();
4781
4782 apply(Some(source.clone()), false).unwrap();
4786
4787 assert!(target.join(".config/gh/hosts.yml").exists());
4790 assert!(source.join("home/.config/gh/hosts.yml").exists());
4791 }
4792
4793 #[test]
4799 fn merge_handles_file_vs_dir_collisions_target_wins() {
4800 let tmp = TempDir::new().unwrap();
4801 let source = utf8(tmp.path().join("dotfiles"));
4802 let target = utf8(tmp.path().join("target"));
4803 std::fs::create_dir_all(source.join("home/.config/foo")).unwrap();
4804 std::fs::create_dir_all(target.join(".config")).unwrap();
4805 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4806
4807 std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
4809 std::fs::write(target.join(".config/foo"), "target file body").unwrap();
4810 std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
4812 std::fs::create_dir_all(target.join(".config/bar")).unwrap();
4813 std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
4814
4815 let cfg = format!(
4816 r#"
4817[absorb]
4818on_anomaly = "force"
4819
4820[[mount.entry]]
4821src = "home"
4822dst = "{}"
4823"#,
4824 toml_path(&target)
4825 );
4826 std::fs::write(source.join("config.toml"), cfg).unwrap();
4827 apply(Some(source.clone()), false).unwrap();
4828
4829 let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
4833 assert!(foo_meta.file_type().is_file(), "foo should be a file");
4834 assert_eq!(
4835 std::fs::read_to_string(target.join(".config/foo")).unwrap(),
4836 "target file body"
4837 );
4838 let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
4840 assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
4841 assert_eq!(
4842 std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
4843 "target nested"
4844 );
4845 }
4846
4847 #[test]
4851 fn merge_per_file_target_newer_auto_absorbs() {
4852 let tmp = TempDir::new().unwrap();
4853 let source = utf8(tmp.path().join("dotfiles"));
4854 let target = utf8(tmp.path().join("target"));
4855 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4856 std::fs::create_dir_all(target.join(".config")).unwrap();
4857 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4858
4859 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4861 write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
4862 std::fs::write(target.join(".config/app.toml"), "user's live edit").unwrap();
4863
4864 let cfg = format!(
4868 r#"
4869[[mount.entry]]
4870src = "home"
4871dst = "{}"
4872"#,
4873 toml_path(&target)
4874 );
4875 std::fs::write(source.join("config.toml"), cfg).unwrap();
4876 apply(Some(source.clone()), false).unwrap();
4877
4878 assert_eq!(
4880 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4881 "user's live edit"
4882 );
4883 }
4884
4885 #[test]
4891 fn merge_per_file_source_newer_skip_keeps_source() {
4892 let tmp = TempDir::new().unwrap();
4893 let source = utf8(tmp.path().join("dotfiles"));
4894 let target = utf8(tmp.path().join("target"));
4895 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4896 std::fs::create_dir_all(target.join(".config")).unwrap();
4897 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4898
4899 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4901 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
4902 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
4903
4904 let cfg = format!(
4905 r#"
4906[absorb]
4907on_anomaly = "skip"
4908
4909[[mount.entry]]
4910src = "home"
4911dst = "{}"
4912"#,
4913 toml_path(&target)
4914 );
4915 std::fs::write(source.join("config.toml"), cfg).unwrap();
4916 apply(Some(source.clone()), false).unwrap();
4917
4918 assert_eq!(
4921 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4922 "fresh source"
4923 );
4924 }
4925
4926 #[test]
4929 fn merge_per_file_source_newer_force_overwrites_source() {
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/.config")).unwrap();
4934 std::fs::create_dir_all(target.join(".config")).unwrap();
4935 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4936
4937 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4938 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
4939 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
4940
4941 let cfg = format!(
4942 r#"
4943[absorb]
4944on_anomaly = "force"
4945
4946[[mount.entry]]
4947src = "home"
4948dst = "{}"
4949"#,
4950 toml_path(&target)
4951 );
4952 std::fs::write(source.join("config.toml"), cfg).unwrap();
4953 apply(Some(source.clone()), false).unwrap();
4954
4955 assert_eq!(
4957 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4958 "old target"
4959 );
4960 }
4961
4962 #[test]
4967 fn merge_per_file_identical_content_is_noop() {
4968 let tmp = TempDir::new().unwrap();
4969 let source = utf8(tmp.path().join("dotfiles"));
4970 let target = utf8(tmp.path().join("target"));
4971 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4972 std::fs::create_dir_all(target.join(".config")).unwrap();
4973 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4974 std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
4975 std::fs::write(target.join(".config/app.toml"), "same").unwrap();
4976
4977 let cfg = format!(
4980 r#"
4981[[mount.entry]]
4982src = "home"
4983dst = "{}"
4984"#,
4985 toml_path(&target)
4986 );
4987 std::fs::write(source.join("config.toml"), cfg).unwrap();
4988 apply(Some(source.clone()), false).unwrap();
4989
4990 assert_eq!(
4991 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4992 "same"
4993 );
4994 }
4995
4996 #[test]
4997 fn manual_absorb_command_pulls_target_into_source() {
4998 let tmp = TempDir::new().unwrap();
5000 let source = utf8(tmp.path().join("dotfiles"));
5001 let target = utf8(tmp.path().join("target"));
5002 std::fs::create_dir_all(source.join("home")).unwrap();
5003 std::fs::create_dir_all(&target).unwrap();
5004 let cfg = format!(
5006 r#"
5007[absorb]
5008on_anomaly = "skip"
5009
5010[[mount.entry]]
5011src = "home"
5012dst = "{}"
5013"#,
5014 toml_path(&target)
5015 );
5016 std::fs::write(source.join("config.toml"), cfg).unwrap();
5017 std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
5018 std::fs::write(source.join("home/.bashrc"), "default").unwrap();
5019
5020 absorb(
5023 Some(source.clone()),
5024 target.join(".bashrc"),
5025 false,
5026 true,
5027 )
5028 .unwrap();
5029
5030 assert_eq!(
5032 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
5033 "user picked this"
5034 );
5035 }
5036
5037 #[test]
5038 fn manual_absorb_errors_when_target_outside_known_mounts() {
5039 let tmp = TempDir::new().unwrap();
5040 let (source, _target) = setup_minimal_dotfiles(&tmp);
5041 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5042 let stranger = utf8(tmp.path().join("not-managed/foo"));
5043 std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
5044 std::fs::write(&stranger, "not yui's").unwrap();
5045 let err = absorb(Some(source), stranger, false, true).unwrap_err();
5046 assert!(format!("{err}").contains("no mount entry"));
5047 }
5048
5049 #[test]
5050 fn yuiignore_excludes_file_from_linking() {
5051 let tmp = TempDir::new().unwrap();
5052 let (source, target) = setup_minimal_dotfiles(&tmp);
5053 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
5054 std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
5055 std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
5057 apply(Some(source.clone()), false).unwrap();
5058 assert!(target.join(".bashrc").exists());
5059 assert!(
5060 !target.join("lock.json").exists(),
5061 "yuiignore should keep lock.json out of target"
5062 );
5063 }
5064
5065 #[test]
5066 fn yuiignore_excludes_directory_subtree() {
5067 let tmp = TempDir::new().unwrap();
5068 let (source, target) = setup_minimal_dotfiles(&tmp);
5069 std::fs::create_dir_all(source.join("home/cache")).unwrap();
5070 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
5071 std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
5072 std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
5073 std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
5075 apply(Some(source.clone()), false).unwrap();
5076 assert!(target.join(".bashrc").exists());
5077 assert!(
5078 !target.join("cache").exists(),
5079 "yuiignore'd subtree should not appear in target"
5080 );
5081 }
5082
5083 #[test]
5084 fn yuiignore_negation_re_includes_file() {
5085 let tmp = TempDir::new().unwrap();
5086 let (source, target) = setup_minimal_dotfiles(&tmp);
5087 std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
5088 std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
5089 std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
5091 apply(Some(source.clone()), false).unwrap();
5092 assert!(target.join("keep.cache").exists());
5093 assert!(!target.join("drop.cache").exists());
5094 }
5095
5096 #[test]
5101 fn nested_yuiignore_only_affects_its_subtree() {
5102 let tmp = TempDir::new().unwrap();
5103 let (source, target) = setup_minimal_dotfiles(&tmp);
5104 std::fs::create_dir_all(source.join("home/inner")).unwrap();
5105 std::fs::write(source.join("home/secret.txt"), "outer keep").unwrap();
5106 std::fs::write(source.join("home/inner/secret.txt"), "inner drop").unwrap();
5107 std::fs::write(source.join("home/inner/keep.txt"), "inner keep").unwrap();
5108 std::fs::write(source.join("home/inner/.yuiignore"), "secret*\n").unwrap();
5110 apply(Some(source.clone()), false).unwrap();
5111 assert!(
5112 target.join("secret.txt").exists(),
5113 "outer secret.txt is outside the nested .yuiignore scope"
5114 );
5115 assert!(target.join("inner/keep.txt").exists());
5116 assert!(
5117 !target.join("inner/secret.txt").exists(),
5118 "inner secret.txt should be excluded by the nested .yuiignore"
5119 );
5120 }
5121
5122 #[test]
5126 fn nested_yuiignore_negation_overrides_root_rule() {
5127 let tmp = TempDir::new().unwrap();
5128 let (source, target) = setup_minimal_dotfiles(&tmp);
5129 std::fs::create_dir_all(source.join("home/keepers")).unwrap();
5130 std::fs::write(source.join("home/drop.lock"), "outer drop").unwrap();
5131 std::fs::write(source.join("home/keepers/wanted.lock"), "inner keep").unwrap();
5132 std::fs::write(source.join(".yuiignore"), "*.lock\n").unwrap();
5133 std::fs::write(source.join("home/keepers/.yuiignore"), "!*.lock\n").unwrap();
5135 apply(Some(source.clone()), false).unwrap();
5136 assert!(
5137 !target.join("drop.lock").exists(),
5138 "root rule still drops outer .lock file"
5139 );
5140 assert!(
5141 target.join("keepers/wanted.lock").exists(),
5142 "nested negation re-includes .lock under keepers/"
5143 );
5144 }
5145
5146 #[test]
5150 fn nested_yuiignore_status_walk_scoped() {
5151 let tmp = TempDir::new().unwrap();
5152 let (source, _target) = setup_minimal_dotfiles(&tmp);
5153 std::fs::create_dir_all(source.join("home/a")).unwrap();
5154 std::fs::create_dir_all(source.join("home/b")).unwrap();
5155 std::fs::write(source.join("home/a/foo.txt"), "a-foo").unwrap();
5156 std::fs::write(source.join("home/b/foo.txt"), "b-foo").unwrap();
5157 std::fs::write(source.join("home/a/.yuiignore"), "foo.txt\n").unwrap();
5159 apply(Some(source.clone()), false).unwrap();
5160 let res = status(Some(source), None, true);
5162 assert!(res.is_ok() || matches!(&res, Err(e) if format!("{e}").contains("diverged")));
5163 }
5164
5165 #[test]
5166 fn yuiignore_skips_template_in_render() {
5167 let tmp = TempDir::new().unwrap();
5168 let source = utf8(tmp.path().join("dotfiles"));
5169 let target = utf8(tmp.path().join("target"));
5170 std::fs::create_dir_all(source.join("home")).unwrap();
5171 std::fs::create_dir_all(&target).unwrap();
5172 std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
5173 std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
5174 let cfg = format!(
5175 r#"
5176[[mount.entry]]
5177src = "home"
5178dst = "{}"
5179"#,
5180 toml_path(&target)
5181 );
5182 std::fs::write(source.join("config.toml"), cfg).unwrap();
5183 apply(Some(source.clone()), false).unwrap();
5184 assert!(!source.join("home/note").exists());
5186 assert!(!target.join("note").exists());
5187 assert!(!target.join("note.tera").exists());
5188 }
5189
5190 #[test]
5199 fn apply_decrypts_age_files_to_sibling_and_links() {
5200 let tmp = TempDir::new().unwrap();
5201 let source = utf8(tmp.path().join("dotfiles"));
5202 let target = utf8(tmp.path().join("target"));
5203 std::fs::create_dir_all(source.join("home/.ssh")).unwrap();
5204 std::fs::create_dir_all(&target).unwrap();
5205
5206 let identity_path = utf8(tmp.path().join("age.txt"));
5209 let (secret, public) = secret::generate_x25519_keypair();
5210 std::fs::write(&identity_path, format!("{secret}\n")).unwrap();
5211
5212 let recipient = secret::parse_x25519_recipient(&public).unwrap();
5214 let cipher = secret::encrypt_x25519(b"-- super secret key --\n", &[recipient]).unwrap();
5215 std::fs::write(source.join("home/.ssh/id_ed25519.age"), &cipher).unwrap();
5216
5217 let cfg = format!(
5219 r#"
5220[[mount.entry]]
5221src = "home"
5222dst = "{}"
5223
5224[secrets]
5225identity = "{}"
5226recipients = ["{}"]
5227"#,
5228 toml_path(&target),
5229 toml_path(&identity_path),
5230 public
5231 );
5232 std::fs::write(source.join("config.toml"), cfg).unwrap();
5233
5234 apply(Some(source.clone()), false).unwrap();
5235
5236 assert!(source.join("home/.ssh/id_ed25519").exists());
5238 let target_bytes = std::fs::read(target.join(".ssh/id_ed25519")).unwrap();
5240 assert_eq!(target_bytes, b"-- super secret key --\n");
5241 let gi = std::fs::read_to_string(source.join(".gitignore")).unwrap();
5243 assert!(
5244 gi.contains("home/.ssh/id_ed25519"),
5245 ".gitignore should list the decrypted plaintext sibling: {gi}"
5246 );
5247 }
5250
5251 #[test]
5256 fn apply_bails_on_secret_drift() {
5257 let tmp = TempDir::new().unwrap();
5258 let source = utf8(tmp.path().join("dotfiles"));
5259 let target = utf8(tmp.path().join("target"));
5260 std::fs::create_dir_all(source.join("home")).unwrap();
5261 std::fs::create_dir_all(&target).unwrap();
5262
5263 let identity_path = utf8(tmp.path().join("age.txt"));
5264 let (secret_key, public) = secret::generate_x25519_keypair();
5265 std::fs::write(&identity_path, format!("{secret_key}\n")).unwrap();
5266
5267 let recipient = secret::parse_x25519_recipient(&public).unwrap();
5268 let cipher = secret::encrypt_x25519(b"v1 content\n", &[recipient]).unwrap();
5269 std::fs::write(source.join("home/secret.age"), &cipher).unwrap();
5270 std::fs::write(source.join("home/secret"), "edited locally\n").unwrap();
5272
5273 let cfg = format!(
5274 r#"
5275[[mount.entry]]
5276src = "home"
5277dst = "{}"
5278
5279[secrets]
5280identity = "{}"
5281recipients = ["{}"]
5282"#,
5283 toml_path(&target),
5284 toml_path(&identity_path),
5285 public
5286 );
5287 std::fs::write(source.join("config.toml"), cfg).unwrap();
5288
5289 let err = apply(Some(source.clone()), false).unwrap_err();
5290 assert!(
5291 format!("{err:#}").contains("secret drift"),
5292 "expected secret drift error, got: {err:#}"
5293 );
5294 }
5295
5296 #[test]
5299 fn append_recipient_creates_secrets_table_when_missing() {
5300 let result =
5301 append_recipient_to_local("", "host alice", "age1abcrecipientpublickey").unwrap();
5302 let parsed: toml::Table = toml::from_str(&result).unwrap();
5304 let secrets = parsed.get("secrets").and_then(|v| v.as_table()).unwrap();
5305 let recipients = secrets
5306 .get("recipients")
5307 .and_then(|v| v.as_array())
5308 .unwrap();
5309 assert_eq!(recipients.len(), 1);
5310 assert_eq!(recipients[0].as_str(), Some("age1abcrecipientpublickey"));
5311 }
5312
5313 #[test]
5314 fn append_recipient_preserves_existing_other_tables() {
5315 let existing = r#"
5319[vars]
5320greet = "hi"
5321
5322[secrets]
5323recipients = ["age1machine_a"]
5324
5325[ui]
5326icons = "ascii"
5327"#;
5328 let result = append_recipient_to_local(existing, "host b", "age1machine_b").unwrap();
5329 let parsed: toml::Table = toml::from_str(&result).unwrap();
5330 assert!(parsed.get("vars").is_some());
5332 assert!(parsed.get("secrets").is_some());
5333 assert!(parsed.get("ui").is_some());
5334 let recipients = parsed["secrets"]["recipients"].as_array().unwrap();
5336 assert_eq!(recipients.len(), 2);
5337 let pubs: Vec<&str> = recipients.iter().filter_map(|v| v.as_str()).collect();
5338 assert!(pubs.contains(&"age1machine_a"));
5339 assert!(pubs.contains(&"age1machine_b"));
5340 }
5341
5342 #[test]
5343 fn append_recipient_is_idempotent_on_duplicate() {
5344 let existing = r#"[secrets]
5345recipients = ["age1same"]
5346"#;
5347 let result = append_recipient_to_local(existing, "anyone", "age1same").unwrap();
5348 let parsed: toml::Table = toml::from_str(&result).unwrap();
5349 let recipients = parsed["secrets"]["recipients"].as_array().unwrap();
5350 assert_eq!(recipients.len(), 1, "duplicate must not be appended twice");
5351 }
5352
5353 #[test]
5354 fn append_recipient_creates_recipients_array_when_secrets_table_empty() {
5355 let existing = r#"[secrets]
5358identity = "~/.config/yui/age.txt"
5359"#;
5360 let result = append_recipient_to_local(existing, "h", "age1new").unwrap();
5361 let parsed: toml::Table = toml::from_str(&result).unwrap();
5362 let secrets = parsed["secrets"].as_table().unwrap();
5363 assert_eq!(
5364 secrets["identity"].as_str(),
5365 Some("~/.config/yui/age.txt"),
5366 "existing identity field must survive"
5367 );
5368 let recipients = secrets["recipients"].as_array().unwrap();
5369 assert_eq!(recipients.len(), 1);
5370 assert_eq!(recipients[0].as_str(), Some("age1new"));
5371 }
5372
5373 #[test]
5377 fn apply_without_recipients_skips_secret_walker() {
5378 let tmp = TempDir::new().unwrap();
5379 let (source, _target) = setup_minimal_dotfiles(&tmp);
5380 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5382 std::fs::write(source.join("home/some.junk.age"), b"not actually a cipher").unwrap();
5386 apply(Some(source.clone()), false).unwrap();
5387 }
5388
5389 #[test]
5393 fn nested_marker_accumulates_extra_dst() {
5394 let tmp = TempDir::new().unwrap();
5395 let source = utf8(tmp.path().join("dotfiles"));
5396 let parent_target = utf8(tmp.path().join("home"));
5397 let extra_target = utf8(tmp.path().join("extra"));
5398 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
5399 std::fs::create_dir_all(&parent_target).unwrap();
5400 std::fs::create_dir_all(&extra_target).unwrap();
5401 std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
5402
5403 std::fs::write(
5405 source.join("home/.config/.yuilink"),
5406 format!(
5407 r#"
5408[[link]]
5409dst = "{}/.config"
5410"#,
5411 toml_path(&parent_target)
5412 ),
5413 )
5414 .unwrap();
5415 std::fs::write(
5418 source.join("home/.config/nvim/.yuilink"),
5419 format!(
5420 r#"
5421[[link]]
5422dst = "{}/nvim"
5423when = "{{{{ yui.os == '{}' }}}}"
5424"#,
5425 toml_path(&extra_target),
5426 std::env::consts::OS
5427 ),
5428 )
5429 .unwrap();
5430
5431 let cfg = format!(
5432 r#"
5433[[mount.entry]]
5434src = "home"
5435dst = "{}"
5436"#,
5437 toml_path(&parent_target)
5438 );
5439 std::fs::write(source.join("config.toml"), cfg).unwrap();
5440
5441 apply(Some(source.clone()), false).unwrap();
5442
5443 assert!(parent_target.join(".config/nvim/init.lua").exists());
5446 assert!(extra_target.join("nvim/init.lua").exists());
5447 }
5448
5449 #[test]
5454 fn marker_file_link_targets_specific_file() {
5455 let tmp = TempDir::new().unwrap();
5456 let source = utf8(tmp.path().join("dotfiles"));
5457 let parent_target = utf8(tmp.path().join("home"));
5458 let docs_target = utf8(tmp.path().join("docs"));
5459 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
5460 std::fs::create_dir_all(&parent_target).unwrap();
5461 std::fs::create_dir_all(&docs_target).unwrap();
5462 std::fs::write(
5463 source.join("home/.config/powershell/profile.ps1"),
5464 "# profile\n",
5465 )
5466 .unwrap();
5467 std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
5468
5469 std::fs::write(
5472 source.join("home/.config/powershell/.yuilink"),
5473 format!(
5474 r#"
5475[[link]]
5476src = "profile.ps1"
5477dst = "{}/Microsoft.PowerShell_profile.ps1"
5478"#,
5479 toml_path(&docs_target)
5480 ),
5481 )
5482 .unwrap();
5483
5484 let cfg = format!(
5485 r#"
5486[[mount.entry]]
5487src = "home"
5488dst = "{}"
5489"#,
5490 toml_path(&parent_target)
5491 );
5492 std::fs::write(source.join("config.toml"), cfg).unwrap();
5493
5494 apply(Some(source.clone()), false).unwrap();
5495
5496 assert!(
5498 docs_target
5499 .join("Microsoft.PowerShell_profile.ps1")
5500 .exists()
5501 );
5502 assert!(
5505 parent_target
5506 .join(".config/powershell/profile.ps1")
5507 .exists()
5508 );
5509 assert!(parent_target.join(".config/powershell/extra.txt").exists());
5510 }
5511
5512 #[test]
5515 fn marker_file_link_missing_src_errors() {
5516 let tmp = TempDir::new().unwrap();
5517 let source = utf8(tmp.path().join("dotfiles"));
5518 let parent_target = utf8(tmp.path().join("home"));
5519 let docs_target = utf8(tmp.path().join("docs"));
5520 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
5521 std::fs::create_dir_all(&parent_target).unwrap();
5522 std::fs::create_dir_all(&docs_target).unwrap();
5523
5524 std::fs::write(
5525 source.join("home/.config/powershell/.yuilink"),
5526 format!(
5527 r#"
5528[[link]]
5529src = "missing.ps1"
5530dst = "{}/profile.ps1"
5531"#,
5532 toml_path(&docs_target)
5533 ),
5534 )
5535 .unwrap();
5536
5537 let cfg = format!(
5538 r#"
5539[[mount.entry]]
5540src = "home"
5541dst = "{}"
5542"#,
5543 toml_path(&parent_target)
5544 );
5545 std::fs::write(source.join("config.toml"), cfg).unwrap();
5546
5547 let err = apply(Some(source.clone()), false).unwrap_err();
5548 assert!(format!("{err:#}").contains("missing.ps1"));
5549 }
5550
5551 #[test]
5560 fn unmanaged_finds_files_outside_any_mount() {
5561 let tmp = TempDir::new().unwrap();
5562 let (source, _target) = setup_minimal_dotfiles(&tmp);
5563 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5565 std::fs::write(source.join("orphan.txt"), "y").unwrap();
5567 std::fs::create_dir_all(source.join("notes")).unwrap();
5568 std::fs::write(source.join("notes/scratch.md"), "z").unwrap();
5569
5570 unmanaged(Some(source.clone()), None, true).unwrap();
5572
5573 let yui = YuiVars::detect(&source);
5575 let cfg = config::load(&source, &yui).unwrap();
5576 let mount_srcs: Vec<Utf8PathBuf> = cfg
5577 .mount
5578 .entry
5579 .iter()
5580 .map(|m| source.join(&m.src))
5581 .collect();
5582 let walker = paths::source_walker(&source).build();
5583 let mut unmanaged_paths = Vec::new();
5584 for entry in walker.flatten() {
5585 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
5586 continue;
5587 }
5588 let p = match Utf8PathBuf::from_path_buf(entry.path().to_path_buf()) {
5589 Ok(p) => p,
5590 Err(_) => continue,
5591 };
5592 if is_repo_meta(&p, &source, &cfg.mount.marker_filename) {
5593 continue;
5594 }
5595 if mount_srcs.iter().any(|m| p.starts_with(m)) {
5596 continue;
5597 }
5598 unmanaged_paths.push(p);
5599 }
5600 let names: Vec<String> = unmanaged_paths
5601 .iter()
5602 .filter_map(|p| p.file_name().map(String::from))
5603 .collect();
5604 assert!(names.contains(&"orphan.txt".into()));
5605 assert!(names.contains(&"scratch.md".into()));
5606 assert!(!names.contains(&".bashrc".into()), "mount-claimed file");
5607 assert!(!names.contains(&"config.toml".into()), "repo meta");
5608 }
5609
5610 #[test]
5611 fn is_repo_meta_recognises_yui_scaffold() {
5612 let source = Utf8Path::new("/dot");
5613 assert!(is_repo_meta(
5615 Utf8Path::new("/dot/config.toml"),
5616 source,
5617 ".yuilink",
5618 ));
5619 assert!(is_repo_meta(
5620 Utf8Path::new("/dot/config.local.toml"),
5621 source,
5622 ".yuilink",
5623 ));
5624 assert!(is_repo_meta(
5625 Utf8Path::new("/dot/config.linux.toml"),
5626 source,
5627 ".yuilink",
5628 ));
5629 assert!(is_repo_meta(
5630 Utf8Path::new("/dot/config.local.example.toml"),
5631 source,
5632 ".yuilink",
5633 ));
5634 assert!(is_repo_meta(
5636 Utf8Path::new("/dot/.gitignore"),
5637 source,
5638 ".yuilink",
5639 ));
5640 assert!(is_repo_meta(
5642 Utf8Path::new("/dot/home/.config/foo/.yuilink"),
5643 source,
5644 ".yuilink",
5645 ));
5646 assert!(is_repo_meta(
5647 Utf8Path::new("/dot/home/.gitconfig.tera"),
5648 source,
5649 ".yuilink",
5650 ));
5651 assert!(!is_repo_meta(
5653 Utf8Path::new("/dot/home/.config/myapp/config.toml"),
5654 source,
5655 ".yuilink",
5656 ));
5657 assert!(!is_repo_meta(
5661 Utf8Path::new("/dot/home/.config/git/.gitignore"),
5662 source,
5663 ".yuilink",
5664 ));
5665 }
5666
5667 #[test]
5674 fn unmanaged_respects_inactive_mount_entries() {
5675 let tmp = TempDir::new().unwrap();
5676 let source = utf8(tmp.path().join("dotfiles"));
5677 let target = utf8(tmp.path().join("target"));
5678 std::fs::create_dir_all(source.join("home_active")).unwrap();
5679 std::fs::create_dir_all(source.join("home_other_os")).unwrap();
5680 std::fs::create_dir_all(&target).unwrap();
5681 std::fs::write(source.join("home_active/.bashrc"), "active").unwrap();
5682 std::fs::write(source.join("home_other_os/.bashrc"), "inactive").unwrap();
5683 let cfg = format!(
5685 r#"
5686[[mount.entry]]
5687src = "home_active"
5688dst = "{target}"
5689
5690[[mount.entry]]
5691src = "home_other_os"
5692dst = "{target}"
5693when = "yui.os == 'definitely_not_a_real_os'"
5694"#,
5695 target = toml_path(&target)
5696 );
5697 std::fs::write(source.join("config.toml"), cfg).unwrap();
5698
5699 let yui = YuiVars::detect(&source);
5703 let cfg = config::load(&source, &yui).unwrap();
5704 let mount_srcs: Vec<Utf8PathBuf> = cfg
5705 .mount
5706 .entry
5707 .iter()
5708 .map(|m| source.join(&m.src))
5709 .collect();
5710 let inactive_file = source.join("home_other_os/.bashrc");
5711 let claimed = mount_srcs.iter().any(|m| inactive_file.starts_with(m));
5712 assert!(
5713 claimed,
5714 "raw config.mount.entry should claim files even under inactive mounts"
5715 );
5716 }
5717
5718 #[test]
5723 fn diff_shows_drift_skips_in_sync() {
5724 let tmp = TempDir::new().unwrap();
5725 let (source, target) = setup_minimal_dotfiles(&tmp);
5726 std::fs::write(source.join("home/.bashrc"), "first\nsecond\n").unwrap();
5727 apply(Some(source.clone()), false).unwrap();
5729 std::fs::remove_file(target.join(".bashrc")).unwrap();
5731 std::fs::write(target.join(".bashrc"), "first\nEDITED\n").unwrap();
5732
5733 diff(Some(source.clone()), None, true).unwrap();
5736 }
5737
5738 #[test]
5743 fn read_text_for_diff_classifies_correctly() {
5744 let tmp = TempDir::new().unwrap();
5745 let root = utf8(tmp.path().to_path_buf());
5746 let txt = root.join("a.txt");
5748 std::fs::write(&txt, "hello\n").unwrap();
5749 match read_text_for_diff(&txt) {
5750 DiffSide::Text(s) => assert_eq!(s, "hello\n"),
5751 DiffSide::Binary => panic!("text file misclassified as binary"),
5752 }
5753 let bin = root.join("b.bin");
5755 std::fs::write(&bin, [0xff, 0xfe, 0x00, 0xff]).unwrap();
5756 assert!(matches!(read_text_for_diff(&bin), DiffSide::Binary));
5757 let missing = root.join("missing.txt");
5759 match read_text_for_diff(&missing) {
5760 DiffSide::Text(s) => assert!(s.is_empty()),
5761 DiffSide::Binary => panic!("missing file misclassified as binary"),
5762 }
5763 }
5764
5765 #[test]
5772 fn diff_render_drift_uses_rendered_output_not_raw_template() {
5773 let tmp = TempDir::new().unwrap();
5774 let (source, _target) = setup_minimal_dotfiles(&tmp);
5775 std::fs::write(source.join("home/note.tera"), "os = {{ yui.os }}\n").unwrap();
5778 std::fs::write(source.join("home/note"), "os = ancient\n").unwrap();
5779 let yui = YuiVars::detect(&source);
5781 let cfg = config::load(&source, &yui).unwrap();
5782 let rendered =
5783 render::render_to_string(&source.join("home/note.tera"), &source, &cfg, &yui)
5784 .unwrap()
5785 .expect("template should render on this host");
5786 assert!(rendered.starts_with("os = "));
5787 assert!(
5788 !rendered.contains("{{"),
5789 "rendered output must not contain raw Tera tags"
5790 );
5791 }
5792
5793 #[test]
5801 fn resolve_diff_src_absolutizes_link_rows() {
5802 let source = Utf8Path::new("/dot");
5803 let link_item = StatusItem {
5804 src: Utf8PathBuf::from("home/.bashrc"),
5805 dst: Utf8PathBuf::from("/h/u/.bashrc"),
5806 state: StatusState::Link(absorb::AbsorbDecision::AutoAbsorb),
5807 };
5808 assert_eq!(
5809 resolve_diff_src(&link_item, source),
5810 Utf8PathBuf::from("/dot/home/.bashrc"),
5811 );
5812 let render_item = StatusItem {
5813 src: Utf8PathBuf::from("/dot/home/foo.tera"),
5814 dst: Utf8PathBuf::from("/dot/home/foo"),
5815 state: StatusState::RenderDrift,
5816 };
5817 assert_eq!(
5818 resolve_diff_src(&render_item, source),
5819 Utf8PathBuf::from("/dot/home/foo.tera"),
5820 );
5821 }
5822
5823 #[test]
5824 fn diff_classifier_skips_uninteresting_states() {
5825 use absorb::AbsorbDecision::*;
5826 assert!(!diff_worth_printing(&StatusState::Link(InSync)));
5828 assert!(!diff_worth_printing(&StatusState::Link(Restore)));
5829 assert!(!diff_worth_printing(&StatusState::Link(RelinkOnly)));
5830 assert!(diff_worth_printing(&StatusState::Link(AutoAbsorb)));
5832 assert!(diff_worth_printing(&StatusState::Link(NeedsConfirm)));
5833 assert!(diff_worth_printing(&StatusState::RenderDrift));
5834 }
5835
5836 #[test]
5847 fn update_errors_when_source_is_not_a_git_repo() {
5848 let tmp = TempDir::new().unwrap();
5849 let source = utf8(tmp.path().join("dotfiles"));
5850 std::fs::create_dir_all(&source).unwrap();
5851 std::fs::write(source.join("config.toml"), "").unwrap();
5852 let err = update(Some(source), false).unwrap_err();
5854 let msg = format!("{err:#}");
5855 assert!(
5856 msg.contains("not a git repository")
5857 || msg.contains("uncommitted")
5858 || msg.contains("git"),
5859 "unexpected error: {msg}",
5860 );
5861 }
5862
5863 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
5864 let mut out = Vec::new();
5865 let mut stack = vec![root.to_path_buf()];
5866 while let Some(dir) = stack.pop() {
5867 let Ok(entries) = std::fs::read_dir(&dir) else {
5868 continue;
5869 };
5870 for e in entries.flatten() {
5871 let p = utf8(e.path());
5872 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
5873 stack.push(p);
5874 } else {
5875 out.push(p);
5876 }
5877 }
5878 }
5879 out
5880 }
5881
5882 #[test]
5887 fn parse_backup_suffix_recognises_file_with_extension() {
5888 let dt = parse_backup_suffix("foo_20260429_143022123.yml").unwrap();
5889 assert_eq!(dt.year(), 2026);
5890 assert_eq!(dt.month(), 4);
5891 assert_eq!(dt.day(), 29);
5892 assert_eq!(dt.hour(), 14);
5893 assert_eq!(dt.minute(), 30);
5894 assert_eq!(dt.second(), 22);
5895 }
5896
5897 #[test]
5898 fn parse_backup_suffix_recognises_dotfile_no_extension() {
5899 let dt = parse_backup_suffix(".gitconfig_20260429_143022123").unwrap();
5900 assert_eq!(dt.year(), 2026);
5901 }
5902
5903 #[test]
5904 fn parse_backup_suffix_recognises_directory_form() {
5905 let dt = parse_backup_suffix("nvim_20260429_143022123").unwrap();
5906 assert_eq!(dt.day(), 29);
5907 }
5908
5909 #[test]
5910 fn parse_backup_suffix_recognises_multi_dot_filename() {
5911 let dt = parse_backup_suffix("archive.tar.gz_20260429_143022123.gz").unwrap();
5913 assert_eq!(dt.month(), 4);
5914 }
5915
5916 #[test]
5917 fn parse_backup_suffix_rejects_non_yui_names() {
5918 assert!(parse_backup_suffix("README.md").is_none());
5919 assert!(parse_backup_suffix("notes_2026.txt").is_none());
5920 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());
5924 }
5925
5926 #[test]
5927 fn parse_human_duration_basic_units() {
5928 let s = parse_human_duration("30d").unwrap();
5929 assert_eq!(s.get_days(), 30);
5930 let s = parse_human_duration("2w").unwrap();
5931 assert_eq!(s.get_weeks(), 2);
5932 let s = parse_human_duration("12h").unwrap();
5933 assert_eq!(s.get_hours(), 12);
5934 let s = parse_human_duration("5m").unwrap();
5936 assert_eq!(s.get_minutes(), 5);
5937 let s = parse_human_duration("6mo").unwrap();
5938 assert_eq!(s.get_months(), 6);
5939 let s = parse_human_duration("1y").unwrap();
5940 assert_eq!(s.get_years(), 1);
5941 }
5942
5943 #[test]
5944 fn parse_human_duration_case_insensitive_and_whitespace() {
5945 let s = parse_human_duration(" 90D ").unwrap();
5946 assert_eq!(s.get_days(), 90);
5947 let s = parse_human_duration("3WEEKS").unwrap();
5948 assert_eq!(s.get_weeks(), 3);
5949 }
5950
5951 #[test]
5952 fn parse_human_duration_rejects_garbage() {
5953 assert!(parse_human_duration("").is_err());
5954 assert!(parse_human_duration("d30").is_err());
5955 assert!(parse_human_duration("30").is_err()); assert!(parse_human_duration("30x").is_err()); assert!(parse_human_duration("-1d").is_err()); }
5959
5960 #[test]
5964 fn walk_gc_backups_collects_files_and_dir_snapshots() {
5965 let tmp = TempDir::new().unwrap();
5966 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
5967 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
5968 std::fs::write(
5970 root.join("C/Users/u/.config/foo_20260429_143022123.yml"),
5971 "old yml",
5972 )
5973 .unwrap();
5974 std::fs::create_dir_all(root.join("C/Users/u/nvim_20260101_000000000/lua")).unwrap();
5976 std::fs::write(
5977 root.join("C/Users/u/nvim_20260101_000000000/init.lua"),
5978 "ok",
5979 )
5980 .unwrap();
5981 std::fs::write(
5982 root.join("C/Users/u/nvim_20260101_000000000/lua/x.lua"),
5983 "kk",
5984 )
5985 .unwrap();
5986 std::fs::write(root.join("C/Users/u/.config/README.md"), "user note").unwrap();
5988
5989 let entries = walk_gc_backups(&root).unwrap();
5990 assert_eq!(entries.len(), 2, "two backup roots, not three");
5991 let kinds: Vec<_> = entries.iter().map(|e| e.kind).collect();
5992 assert!(kinds.contains(&BackupKind::File));
5993 assert!(kinds.contains(&BackupKind::Dir));
5994 let dir_entry = entries.iter().find(|e| e.kind == BackupKind::Dir).unwrap();
5996 assert!(dir_entry.size_bytes >= 4); }
5998
5999 #[test]
6000 fn cleanup_empty_parents_stops_at_root_and_at_non_empty() {
6001 let tmp = TempDir::new().unwrap();
6002 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
6003 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
6004 std::fs::write(root.join("C/Users/u/sibling_keep"), "x").unwrap();
6005
6006 cleanup_empty_parents(&root.join("C/Users/u/.config"), &root);
6010
6011 assert!(!root.join("C/Users/u/.config").exists(), "empty leaf gone");
6012 assert!(root.join("C/Users/u").exists(), "stops at non-empty parent");
6013 assert!(root.exists(), "backup root preserved");
6014 }
6015
6016 #[test]
6018 fn gc_backup_survey_keeps_all_entries() {
6019 let tmp = TempDir::new().unwrap();
6020 let source = utf8(tmp.path().join("dotfiles"));
6021 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
6022 std::fs::write(source.join("config.toml"), "").unwrap();
6023 let backup = source.join(".yui/backup");
6024 std::fs::write(backup.join("a_20260101_000000000.txt"), "old").unwrap();
6025 std::fs::write(backup.join("b_20260415_120000000.txt"), "fresh").unwrap();
6026
6027 gc_backup(Some(source.clone()), None, false, None, true).unwrap();
6028
6029 assert!(backup.join("a_20260101_000000000.txt").exists());
6031 assert!(backup.join("b_20260415_120000000.txt").exists());
6032 }
6033
6034 #[test]
6037 fn gc_backup_prune_removes_old_files_only() {
6038 let tmp = TempDir::new().unwrap();
6039 let source = utf8(tmp.path().join("dotfiles"));
6040 std::fs::create_dir_all(source.join(".yui/backup/sub")).unwrap();
6041 std::fs::write(source.join("config.toml"), "").unwrap();
6042 let backup = source.join(".yui/backup");
6043
6044 std::fs::write(backup.join("sub/old_20200101_000000000.txt"), "old").unwrap();
6046 let tomorrow = jiff::Zoned::now()
6048 .checked_add(jiff::Span::new().days(1))
6049 .unwrap();
6050 let bdt = jiff::fmt::strtime::BrokenDownTime::from(&tomorrow);
6051 let future_ts = bdt.to_string("%Y%m%d_%H%M%S%3f").unwrap();
6052 std::fs::write(backup.join(format!("fresh_{future_ts}.txt")), "fresh").unwrap();
6053 std::fs::write(backup.join("notes.md"), "mine").unwrap();
6055
6056 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
6057
6058 assert!(!backup.join("sub/old_20200101_000000000.txt").exists());
6059 assert!(!backup.join("sub").exists(), "empty parent removed");
6061 assert!(backup.exists());
6063 assert!(backup.join(format!("fresh_{future_ts}.txt")).exists());
6064 assert!(backup.join("notes.md").exists(), "user file untouched");
6065 }
6066
6067 #[test]
6069 fn gc_backup_dry_run_does_not_delete() {
6070 let tmp = TempDir::new().unwrap();
6071 let source = utf8(tmp.path().join("dotfiles"));
6072 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
6073 std::fs::write(source.join("config.toml"), "").unwrap();
6074 let backup = source.join(".yui/backup");
6075 std::fs::write(backup.join("old_20200101_000000000.txt"), "old").unwrap();
6076
6077 gc_backup(Some(source.clone()), Some("30d".into()), true, None, true).unwrap();
6078
6079 assert!(
6080 backup.join("old_20200101_000000000.txt").exists(),
6081 "dry-run keeps everything in place"
6082 );
6083 }
6084
6085 #[test]
6089 fn gc_backup_prune_handles_directory_snapshot() {
6090 let tmp = TempDir::new().unwrap();
6091 let source = utf8(tmp.path().join("dotfiles"));
6092 std::fs::create_dir_all(source.join(".yui/backup/mirror/u")).unwrap();
6093 std::fs::write(source.join("config.toml"), "").unwrap();
6094 let backup = source.join(".yui/backup");
6095 let snap = backup.join("mirror/u/nvim_20200101_000000000");
6096 std::fs::create_dir_all(snap.join("lua")).unwrap();
6097 std::fs::write(snap.join("init.lua"), "x").unwrap();
6098 std::fs::write(snap.join("lua/y.lua"), "y").unwrap();
6099
6100 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
6101
6102 assert!(!snap.exists(), "dir snapshot removed wholesale");
6103 assert!(!backup.join("mirror").exists(), "empty mirror chain pruned");
6104 assert!(backup.exists(), "backup root preserved");
6105 }
6106}