1use std::cell::Cell;
6use std::fmt::Write as _;
7
8use anyhow::{Context as _, Result};
9use camino::{Utf8Path, Utf8PathBuf};
10use tera::Context as TeraContext;
11use tracing::{info, warn};
12
13use crate::config::{self, Config, HookPhase, IconsMode, MountStrategy};
14use crate::hook::{self, HookOutcome};
15use crate::icons::Icons;
16use crate::link::{self, EffectiveDirMode, EffectiveFileMode, resolve_dir_mode, resolve_file_mode};
17use crate::marker::{self, MarkerSpec};
18use crate::mount::{self, ResolvedMount};
19use crate::render::{self, RenderReport};
20use crate::secret;
21use crate::template;
22use crate::vars::YuiVars;
23use crate::vault;
24use crate::{absorb, backup, paths};
25
26pub fn init(source: Option<Utf8PathBuf>, git_hooks: bool) -> Result<()> {
33 let dir = match source {
34 Some(s) => absolutize(&s)?,
35 None => current_dir_utf8()?,
36 };
37 std::fs::create_dir_all(&dir)?;
38 let config_path = dir.join("config.toml");
39 let scaffolded = if !config_path.exists() {
40 std::fs::write(&config_path, SKELETON_CONFIG)?;
41 info!("initialized yui source repo at {dir}");
42 info!("created: {config_path}");
43 true
44 } else if git_hooks {
45 info!(
50 "config.toml already exists at {config_path} \
51 — skipping scaffold, installing git hooks only"
52 );
53 false
54 } else {
55 anyhow::bail!("config.toml already exists at {config_path}");
56 };
57
58 ensure_gitignore_yui_entries(&dir)?;
65
66 if git_hooks {
67 install_git_hooks(&dir)?;
68 }
69 if scaffolded {
70 info!("next: edit config.toml, then run `yui apply`");
71 }
72 Ok(())
73}
74
75const YUI_REQUIRED_GITIGNORE: &[&str] = &[
80 "/.yui/state.json",
81 "/.yui/state.json.tmp",
82 "/.yui/backup/",
83 "config.local.toml",
84];
85
86fn ensure_gitignore_yui_entries(dir: &Utf8Path) -> Result<()> {
92 let path = dir.join(".gitignore");
93 if !path.exists() {
94 std::fs::write(&path, SKELETON_GITIGNORE)?;
95 info!("created: {path}");
96 return Ok(());
97 }
98 let existing = std::fs::read_to_string(&path)?;
99 let missing: Vec<&str> = YUI_REQUIRED_GITIGNORE
100 .iter()
101 .copied()
102 .filter(|entry| !existing.lines().any(|line| line.trim() == *entry))
103 .collect();
104 if missing.is_empty() {
105 return Ok(());
106 }
107 let mut next = existing;
108 if !next.is_empty() && !next.ends_with('\n') {
109 next.push('\n');
110 }
111 if !next.is_empty() {
112 next.push('\n');
113 }
114 next.push_str("# yui per-machine state and backups (added by `yui init`).\n");
115 for entry in &missing {
116 next.push_str(entry);
117 next.push('\n');
118 }
119 std::fs::write(&path, next)?;
120 info!(
121 "updated .gitignore: appended {} yui entr{} ({})",
122 missing.len(),
123 if missing.len() == 1 { "y" } else { "ies" },
124 missing.join(", ")
125 );
126 Ok(())
127}
128
129fn install_git_hooks(source: &Utf8Path) -> Result<()> {
143 let out = std::process::Command::new("git")
144 .args(["rev-parse", "--git-path", "hooks"])
145 .current_dir(source.as_std_path())
146 .output()
147 .with_context(|| format!("git rev-parse --git-path hooks in {source}"))?;
148 if !out.status.success() {
149 let stderr = String::from_utf8_lossy(&out.stderr);
150 anyhow::bail!(
151 "--git-hooks: {source} doesn't look like a git repo \
152 (run `git init` first). git: {}",
153 stderr.trim()
154 );
155 }
156 let raw = String::from_utf8(out.stdout)?;
157 let hooks_dir = {
158 let p = Utf8PathBuf::from(raw.trim());
159 if p.is_absolute() { p } else { source.join(p) }
160 };
161 std::fs::create_dir_all(&hooks_dir).with_context(|| format!("mkdir -p {hooks_dir}"))?;
162
163 for (name, body) in [("pre-commit", PRE_COMMIT_HOOK), ("pre-push", PRE_PUSH_HOOK)] {
164 let path = hooks_dir.join(name);
165 if path.exists() {
166 warn!("--git-hooks: {path} already exists — leaving it alone");
167 continue;
168 }
169 std::fs::write(&path, body).with_context(|| format!("write hook {path}"))?;
170 #[cfg(unix)]
171 {
172 use std::os::unix::fs::PermissionsExt;
173 let mut perms = std::fs::metadata(&path)?.permissions();
174 perms.set_mode(0o755);
175 std::fs::set_permissions(&path, perms)?;
176 }
177 info!("installed: {path}");
178 }
179 Ok(())
180}
181
182const PRE_COMMIT_HOOK: &str = r#"#!/bin/sh
183# Installed by `yui init --git-hooks`.
184# Reject the commit if any `*.tera` template would render to something
185# that diverges from the rendered output staged alongside it. Run
186# `yui apply` (or `yui render`) to refresh and re-commit.
187exec yui render --check
188"#;
189
190const PRE_PUSH_HOOK: &str = r#"#!/bin/sh
191# Installed by `yui init --git-hooks`.
192# Same render-drift check as pre-commit, mirrored on push so a
193# `--no-verify` commit doesn't sneak diverged state to the remote.
194exec yui render --check
195"#;
196
197pub fn apply(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
198 let source = resolve_source(source)?;
199 let yui = YuiVars::detect(&source);
200 let config = config::load(&source, &yui)?;
201
202 let mut engine = template::Engine::new();
203 let tera_ctx = template::template_context(&yui, &config.vars);
204
205 hook::run_phase(
208 &config,
209 &source,
210 &yui,
211 &mut engine,
212 &tera_ctx,
213 HookPhase::Pre,
214 dry_run,
215 )?;
216
217 let secret_report = secret::decrypt_all(&source, &config, dry_run)?;
223 log_secret_report(&secret_report);
224 if secret_report.has_drift() {
225 anyhow::bail!(
226 "secret drift detected ({} file(s)); the plaintext sibling diverged \
227 from the canonical .age — run `yui secret encrypt <path>` to roll \
228 the edit back into ciphertext before re-running apply",
229 secret_report.diverged.len()
230 );
231 }
232
233 let render_report = render::render_all(&source, &config, &yui, dry_run)?;
235 log_render_report(&render_report);
236 if render_report.has_drift() {
237 anyhow::bail!(
238 "render drift detected ({} file(s)); reflect target edits back into the .tera before re-running apply",
239 render_report.diverged.len()
240 );
241 }
242
243 if !dry_run && config.render.manage_gitignore {
250 let mut managed: Vec<Utf8PathBuf> = render::report_managed_paths(&render_report)
251 .into_iter()
252 .chain(secret_report.managed_paths().cloned())
253 .collect();
254 managed.sort();
255 managed.dedup();
256 render::write_managed_section(&source, &managed)?;
257 }
258
259 let mounts = mount::resolve(
261 &source,
262 &config.mount.entry,
263 config.mount.default_strategy,
264 &mut engine,
265 &tera_ctx,
266 )?;
267
268 let backup_root = source.join(&config.backup.dir);
269 let ctx = ApplyCtx {
270 config: &config,
271 source: &source,
272 file_mode: resolve_file_mode(config.link.file_mode),
273 dir_mode: resolve_dir_mode(config.link.dir_mode),
274 backup_root: &backup_root,
275 dry_run,
276 sticky_anomaly: Cell::new(None),
277 quit_requested: Cell::new(false),
278 };
279
280 info!("source: {source}");
281 info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
282 if dry_run {
283 info!("dry-run: nothing will be written");
284 }
285
286 let mut yuiignore = paths::YuiIgnoreStack::new();
290 yuiignore.push_dir(&source)?;
291 let walk_result = (|| -> Result<()> {
292 for m in &mounts {
293 info!("mount: {} → {}", m.src, m.dst);
294 process_mount(m, &ctx, &mut engine, &tera_ctx, &mut yuiignore)?;
295 }
296 Ok(())
297 })();
298 yuiignore.pop_dir(&source);
299 walk_result?;
300
301 hook::run_phase(
303 &config,
304 &source,
305 &yui,
306 &mut engine,
307 &tera_ctx,
308 HookPhase::Post,
309 dry_run,
310 )?;
311 Ok(())
312}
313
314fn log_render_report(r: &RenderReport) {
315 if !r.written.is_empty() {
316 info!("rendered {} new file(s)", r.written.len());
317 }
318 if !r.unchanged.is_empty() {
319 info!("rendered {} file(s) unchanged", r.unchanged.len());
320 }
321 if !r.skipped_when_false.is_empty() {
322 info!(
323 "skipped {} template(s) (when=false)",
324 r.skipped_when_false.len()
325 );
326 }
327 for d in &r.diverged {
328 warn!("rendered file diverged from template: {d}");
329 }
330}
331
332fn log_secret_report(r: &secret::SecretReport) {
333 if !r.written.is_empty() {
334 info!("decrypted {} secret file(s)", r.written.len());
335 }
336 if !r.unchanged.is_empty() {
337 info!("decrypted {} secret(s) unchanged", r.unchanged.len());
338 }
339 for d in &r.diverged {
340 warn!("plaintext sibling diverged from .age: {d}");
341 }
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356enum AnomalyChoice {
357 Absorb,
359 Overwrite,
361 Skip,
363 Quit,
365}
366
367struct ApplyCtx<'a> {
368 config: &'a Config,
369 source: &'a Utf8Path,
371 file_mode: EffectiveFileMode,
372 dir_mode: EffectiveDirMode,
373 backup_root: &'a Utf8Path,
374 dry_run: bool,
375 sticky_anomaly: Cell<Option<AnomalyChoice>>,
378 quit_requested: Cell<bool>,
382}
383
384pub fn list(
390 source: Option<Utf8PathBuf>,
391 all: bool,
392 icons_override: Option<IconsMode>,
393 no_color: bool,
394) -> Result<()> {
395 let source = resolve_source(source)?;
396 let yui = YuiVars::detect(&source);
397 let config = config::load(&source, &yui)?;
398
399 let icons_mode = icons_override.unwrap_or(config.ui.icons);
400 let icons = Icons::for_mode(icons_mode);
401 let color = !no_color && supports_color_stdout();
402
403 let items = collect_list_items(&source, &config, &yui)?;
404 let displayed: Vec<&ListItem> = if all {
405 items.iter().collect()
406 } else {
407 items.iter().filter(|i| i.active).collect()
408 };
409
410 print_list_table(&displayed, icons, color);
411
412 let total = items.len();
413 let active = items.iter().filter(|i| i.active).count();
414 let inactive = total - active;
415 println!();
416 if all {
417 println!(" {total} entries · {active} active · {inactive} inactive");
418 } else {
419 println!(
420 " {} of {} entries shown ({} inactive hidden — use --all)",
421 active, total, inactive
422 );
423 }
424 Ok(())
425}
426
427#[derive(Debug)]
428struct ListItem {
429 src: Utf8PathBuf,
430 dst: String,
431 when: Option<String>,
432 active: bool,
433}
434
435fn collect_list_items(source: &Utf8Path, config: &Config, yui: &YuiVars) -> Result<Vec<ListItem>> {
436 let mut engine = template::Engine::new();
437 let tera_ctx = template::template_context(yui, &config.vars);
438 let mut items = Vec::new();
439
440 for entry in &config.mount.entry {
442 let active = match &entry.when {
443 None => true,
444 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
445 };
446 let dst = engine
447 .render(&entry.dst, &tera_ctx)
448 .map(|s| paths::expand_tilde(s.trim()).to_string())
449 .unwrap_or_else(|_| entry.dst.clone());
450 items.push(ListItem {
451 src: entry.src.clone(),
452 dst,
453 when: entry.when.clone(),
454 active,
455 });
456 }
457
458 let walker = paths::source_walker(source).build();
460 let marker_filename = &config.mount.marker_filename;
461 for entry in walker {
462 let entry = match entry {
463 Ok(e) => e,
464 Err(_) => continue,
465 };
466 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
467 continue;
468 }
469 if entry.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
470 continue;
471 }
472 let dir = match entry.path().parent() {
473 Some(d) => d,
474 None => continue,
475 };
476 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
477 Ok(p) => p,
478 Err(_) => continue,
479 };
480 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
484 Some(s) => s,
485 None => continue,
486 };
487 let MarkerSpec::Explicit { links } = spec else {
488 continue; };
490 let rel = dir_utf8
491 .strip_prefix(source)
492 .map(Utf8PathBuf::from)
493 .unwrap_or(dir_utf8);
494 for link in &links {
495 let active = match &link.when {
496 None => true,
497 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
498 };
499 let dst = engine
500 .render(&link.dst, &tera_ctx)
501 .map(|s| paths::expand_tilde(s.trim()).to_string())
502 .unwrap_or_else(|_| link.dst.clone());
503 let src_display = match &link.src {
508 Some(filename) => rel.join(filename),
509 None => rel.clone(),
510 };
511 items.push(ListItem {
512 src: src_display,
513 dst,
514 when: link.when.clone(),
515 active,
516 });
517 }
518 }
519
520 items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
521 Ok(items)
522}
523
524fn supports_color_stdout() -> bool {
525 use std::io::IsTerminal;
526 std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
527}
528
529fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
530 let src_w = items
531 .iter()
532 .map(|i| i.src.as_str().chars().count())
533 .max()
534 .unwrap_or(0)
535 .max("SRC".len());
536 let dst_w = items
537 .iter()
538 .map(|i| i.dst.chars().count())
539 .max()
540 .unwrap_or(0)
541 .max("DST".len());
542
543 let status_w = "STATUS".len();
544 let arrow_w = icons.arrow.chars().count();
545
546 print_header(status_w, src_w, arrow_w, dst_w, color);
548
549 let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
551 if color {
552 use owo_colors::OwoColorize as _;
553 println!("{}", sep.dimmed());
554 } else {
555 println!("{sep}");
556 }
557
558 for item in items {
560 print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
561 }
562}
563
564fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
565 use owo_colors::OwoColorize as _;
566 let mut line = String::new();
567 let _ = write!(
568 &mut line,
569 " {:<status_w$} {:<src_w$} {:<arrow_w$} {:<dst_w$} WHEN",
570 "STATUS", "SRC", "", "DST"
571 );
572 if color {
573 println!("{}", line.bold());
574 } else {
575 println!("{line}");
576 }
577}
578
579fn render_separator(
580 sep_ch: char,
581 status_w: usize,
582 src_w: usize,
583 arrow_w: usize,
584 dst_w: usize,
585) -> String {
586 let bar = |n: usize| sep_ch.to_string().repeat(n);
587 format!(
588 " {} {} {} {} {}",
589 bar(status_w),
590 bar(src_w),
591 bar(arrow_w),
592 bar(dst_w),
593 bar("WHEN".len())
594 )
595}
596
597fn print_row(
598 item: &ListItem,
599 icons: Icons,
600 status_w: usize,
601 src_w: usize,
602 arrow_w: usize,
603 dst_w: usize,
604 color: bool,
605) {
606 use owo_colors::OwoColorize as _;
607 let status = if item.active {
608 icons.active
609 } else {
610 icons.inactive
611 };
612 let when_str = item
613 .when
614 .as_deref()
615 .map(strip_braces)
616 .unwrap_or_else(|| "(always)".to_string());
617
618 let src_display = item.src.as_str().replace('\\', "/");
620 let src = src_display.as_str();
621 let dst = &item.dst;
622 let arrow = icons.arrow;
623
624 let cell_status = format!("{:<status_w$}", status);
629 let cell_src = format!("{:<src_w$}", src);
630 let cell_arrow = format!("{:<arrow_w$}", arrow);
631 let cell_dst = format!("{:<dst_w$}", dst);
632
633 if !color {
634 println!(" {cell_status} {cell_src} {cell_arrow} {cell_dst} {when_str}");
635 return;
636 }
637
638 if item.active {
639 println!(
640 " {} {} {} {} {}",
641 cell_status.green(),
642 cell_src.cyan(),
643 cell_arrow.dimmed(),
644 cell_dst.green(),
645 when_str.dimmed()
646 );
647 } else {
648 println!(
649 " {} {} {} {} {}",
650 cell_status.red().dimmed(),
651 cell_src.dimmed(),
652 cell_arrow.dimmed(),
653 cell_dst.dimmed(),
654 when_str.dimmed()
655 );
656 }
657}
658
659fn strip_braces(expr: &str) -> String {
662 let trimmed = expr.trim();
663 if let Some(inner) = trimmed
664 .strip_prefix("{{")
665 .and_then(|s| s.strip_suffix("}}"))
666 {
667 inner.trim().to_string()
668 } else {
669 trimmed.to_string()
670 }
671}
672
673pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
674 let source = resolve_source(source)?;
675 let yui = YuiVars::detect(&source);
676 let config = config::load(&source, &yui)?;
677 let effective_dry_run = dry_run || check;
679 let report = render::render_all(&source, &config, &yui, effective_dry_run)?;
680 log_render_report(&report);
681 if !effective_dry_run && config.render.manage_gitignore {
686 let managed = render::report_managed_paths(&report);
687 render::write_managed_section(&source, &managed)?;
688 }
689 if check && report.has_drift() {
690 anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
691 }
692 Ok(())
693}
694
695pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
696 apply(source, dry_run)
698}
699
700pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
701 let _source = resolve_source(source)?;
702 if paths_arg.is_empty() {
703 anyhow::bail!("yui unlink: provide at least one target path");
704 }
705 for p in paths_arg {
706 let abs = absolutize(&p)?;
707 info!("unlink: {abs}");
708 link::unlink(&abs)?;
709 }
710 Ok(())
711}
712
713pub fn secret_init(source: Option<Utf8PathBuf>, comment: Option<String>) -> Result<()> {
741 let source = resolve_source(source)?;
742 let yui = YuiVars::detect(&source);
743 let config = config::load(&source, &yui)?;
744
745 let identity_path = paths::expand_tilde(&config.secrets.identity);
747 if identity_path.exists() {
748 anyhow::bail!(
749 "identity file already exists at {identity_path}; \
750 refusing to overwrite. Delete it first if you really \
751 mean to start fresh (you'll lose access to existing \
752 .age files encrypted to its public key)."
753 );
754 }
755
756 let (secret, public) = secret::generate_x25519_keypair();
760 let now = jiff::Zoned::now().to_string();
761 let body = format!(
762 "# created: {now}\n\
763 # public key: {public}\n\
764 {secret}\n"
765 );
766 secret::write_private_file(&identity_path, body.as_bytes())?;
769 info!("wrote identity file: {identity_path}");
770
771 let config_path = source.join("config.toml");
776 let comment = comment.unwrap_or_else(|| format!("{} {}", yui.host, yui.user));
777 let entry_comment = format!("{comment} — added by `yui secret init` on {now}");
778 let config_existing = match std::fs::read_to_string(&config_path) {
779 Ok(s) => s,
780 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
781 Err(e) => anyhow::bail!("read {config_path}: {e}"),
782 };
783 let updated_config = append_recipient_to_config(&config_existing, &entry_comment, &public)?;
784 std::fs::write(&config_path, updated_config)?;
785 info!("appended public key to {config_path}");
786 println!();
787 println!(" age identity: {identity_path}");
788 println!(" public key: {public}");
789 println!();
790 println!(
791 " Next: encrypt a file with `yui secret encrypt <path>`. \
792 The plaintext sibling will be auto-decrypted on every `yui apply`."
793 );
794 Ok(())
795}
796
797fn append_recipient_to_config(existing: &str, comment: &str, public: &str) -> Result<String> {
811 use toml_edit::{Array, DocumentMut, Item, Table, Value};
812
813 let mut doc: DocumentMut = if existing.trim().is_empty() {
814 DocumentMut::new()
815 } else {
816 existing
817 .parse()
818 .map_err(|e| anyhow::anyhow!("config.toml is not valid TOML: {e}"))?
819 };
820
821 if !doc.contains_key("secrets") {
823 let mut t = Table::new();
824 t.set_implicit(false);
825 doc.insert("secrets", Item::Table(t));
826 }
827 let secrets = doc["secrets"].as_table_mut().ok_or_else(|| {
828 anyhow::anyhow!("[secrets] in config.toml is not a table — refusing to clobber")
829 })?;
830
831 if !secrets.contains_key("recipients") {
833 secrets.insert("recipients", Item::Value(Value::Array(Array::new())));
834 }
835 let recipients = secrets["recipients"]
836 .as_array_mut()
837 .ok_or_else(|| anyhow::anyhow!("[secrets].recipients is not an array"))?;
838
839 let already_present = recipients.iter().any(|v| v.as_str() == Some(public));
841 if already_present {
842 return Ok(doc.to_string());
843 }
844
845 let mut value = Value::from(public);
849 let prefix = format!("\n # {comment}\n ");
850 *value.decor_mut() = toml_edit::Decor::new(prefix, "");
851 recipients.push_formatted(value);
852 recipients.set_trailing("\n");
856 recipients.set_trailing_comma(true);
857
858 Ok(doc.to_string())
859}
860
861pub fn secret_encrypt(
865 source: Option<Utf8PathBuf>,
866 path: Utf8PathBuf,
867 force: bool,
868 rm_plaintext: bool,
869) -> Result<()> {
870 let source = resolve_source(source)?;
871 let yui = YuiVars::detect(&source);
872 let config = config::load(&source, &yui)?;
873
874 if !config.secrets.enabled() {
875 anyhow::bail!(
876 "no recipients configured — run `yui secret init` to generate \
877 a keypair, or add at least one entry to `[secrets] recipients`."
878 );
879 }
880
881 let plaintext_path = if path.is_absolute() {
885 path.clone()
886 } else {
887 absolutize(&path)?
888 };
889 if !plaintext_path.is_file() {
890 anyhow::bail!("plaintext file not found: {plaintext_path}");
891 }
892 let cipher_path = Utf8PathBuf::from(format!("{plaintext_path}.age"));
893 if cipher_path.exists() && !force {
894 anyhow::bail!("{cipher_path} already exists; pass --force to overwrite");
895 }
896
897 let plaintext = std::fs::read(&plaintext_path)?;
898 let recipients = secret::parse_passkey_recipients(&config.secrets.recipients)?;
906 let cipher = secret::encrypt_to_passkeys(&plaintext, &recipients)?;
907 std::fs::write(&cipher_path, &cipher)?;
908 info!("encrypted {plaintext_path} → {cipher_path}");
909
910 if config.render.manage_gitignore && plaintext_path.starts_with(&source) {
919 render::add_to_managed_section(&source, &plaintext_path)?;
920 }
921 info!("run `yui apply` to refresh links and the rest of the managed section");
922
923 if rm_plaintext {
924 if plaintext_path.starts_with(&source) {
927 std::fs::remove_file(&plaintext_path)?;
928 info!("removed plaintext: {plaintext_path}");
929 } else {
930 warn!(
931 "plaintext lives outside source ({plaintext_path}); \
932 skipping --rm-plaintext as a safety check"
933 );
934 }
935 }
936 Ok(())
937}
938
939pub fn secret_store(source: Option<Utf8PathBuf>, force: bool) -> Result<()> {
949 let source = resolve_source(source)?;
950 let yui = YuiVars::detect(&source);
951 let config = config::load(&source, &yui)?;
952
953 let vault_cfg = config.secrets.vault.as_ref().ok_or_else(|| {
954 anyhow::anyhow!(
955 "[secrets.vault] is not configured — set provider \
956 (\"bitwarden\" or \"1password\") and item before \
957 calling store"
958 )
959 })?;
960
961 let identity_path = paths::expand_tilde(&config.secrets.identity);
962 if !identity_path.is_file() {
963 anyhow::bail!(
964 "no X25519 identity at {identity_path}; run `yui secret init` first \
965 (store needs that file's content to push to the vault)"
966 );
967 }
968 let plaintext = std::fs::read(&identity_path)?;
969 secret::validate_x25519_identity_bytes(&plaintext)?;
974
975 let vault = vault::driver(vault_cfg);
976 vault.precheck()?;
981 info!(
982 "pushing X25519 identity to {} item {:?}",
983 vault.provider_name(),
984 config::VAULT_ITEM_NAME
985 );
986 vault.store(config::VAULT_ITEM_NAME, &plaintext, force)?;
987
988 println!();
989 println!(
990 " X25519 identity pushed to {} item {:?}",
991 vault.provider_name(),
992 config::VAULT_ITEM_NAME
993 );
994 println!(" On a new machine, run `yui secret unlock`.");
995 Ok(())
996}
997
998pub fn secret_unlock(source: Option<Utf8PathBuf>) -> Result<()> {
1004 let source = resolve_source(source)?;
1005 let yui = YuiVars::detect(&source);
1006 let config = config::load(&source, &yui)?;
1007
1008 let vault_cfg = config.secrets.vault.as_ref().ok_or_else(|| {
1009 anyhow::anyhow!(
1010 "[secrets.vault] is not configured — nothing to unlock. \
1011 Run `yui secret init` + `yui secret store` on an existing \
1012 machine first, then commit + push the config."
1013 )
1014 })?;
1015 let identity_path = paths::expand_tilde(&config.secrets.identity);
1016 if identity_path.exists() {
1017 anyhow::bail!(
1018 "{identity_path} already exists — refusing to clobber a live \
1019 X25519 identity. Delete it first if you really mean to \
1020 re-unlock from scratch."
1021 );
1022 }
1023
1024 let vault = vault::driver(vault_cfg);
1025 vault.precheck()?;
1026 info!(
1027 "fetching X25519 identity from {} item {:?}",
1028 vault.provider_name(),
1029 config::VAULT_ITEM_NAME
1030 );
1031 let plaintext = vault.fetch(config::VAULT_ITEM_NAME)?;
1032
1033 secret::validate_x25519_identity_bytes(&plaintext)?;
1039
1040 secret::write_private_file(&identity_path, &plaintext)?;
1042 info!("wrote X25519 identity: {identity_path}");
1043 println!();
1044 println!(" X25519 identity restored at {identity_path}");
1045 println!(" Run `yui apply` next.");
1046 Ok(())
1047}
1048
1049pub fn update(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
1060 let source = resolve_source(source)?;
1061 if !crate::git::is_clean(&source)? {
1062 anyhow::bail!(
1063 "source repo {source} has uncommitted changes — \
1064 commit or stash before `yui update` (or run \
1065 `git pull` + `yui apply` manually if you know what \
1066 you're doing)"
1067 );
1068 }
1069 info!("git pull --ff-only at {source}");
1070 let status = std::process::Command::new("git")
1071 .arg("-C")
1072 .arg(source.as_str())
1073 .arg("pull")
1074 .arg("--ff-only")
1075 .status()
1076 .map_err(|e| anyhow::anyhow!("invoking git: {e}"))?;
1077 if !status.success() {
1078 anyhow::bail!("git pull --ff-only failed at {source}");
1079 }
1080 apply(Some(source), dry_run)
1081}
1082
1083pub fn unmanaged(
1094 source: Option<Utf8PathBuf>,
1095 icons_override: Option<IconsMode>,
1096 no_color: bool,
1097) -> Result<()> {
1098 let source = resolve_source(source)?;
1099 let yui = YuiVars::detect(&source);
1100 let config = config::load(&source, &yui)?;
1101
1102 let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
1103 let color = !no_color && supports_color_stdout();
1104
1105 let mut engine = template::Engine::new();
1120 let tera_ctx = template::template_context(&yui, &config.vars);
1121 let mount_srcs: Vec<Utf8PathBuf> = config
1122 .mount
1123 .entry
1124 .iter()
1125 .map(|e| -> Result<Utf8PathBuf> {
1126 let rendered = engine.render(e.src.as_str(), &tera_ctx)?;
1127 Ok(paths::resolve_mount_src(&source, rendered.trim()))
1128 })
1129 .collect::<Result<_>>()?;
1130
1131 let mut items: Vec<Utf8PathBuf> = Vec::new();
1132 let walker = paths::source_walker(&source).build();
1133 for entry in walker {
1134 let entry = match entry {
1135 Ok(e) => e,
1136 Err(_) => continue,
1137 };
1138 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
1139 continue;
1140 }
1141 let std_path = entry.path();
1142 let path = match Utf8PathBuf::from_path_buf(std_path.to_path_buf()) {
1143 Ok(p) => p,
1144 Err(_) => continue,
1145 };
1146 if is_repo_meta(&path, &source, &config.mount.marker_filename) {
1150 continue;
1151 }
1152 if mount_srcs.iter().any(|m| path.starts_with(m)) {
1153 continue;
1154 }
1155 items.push(path);
1156 }
1157 items.sort();
1158
1159 if items.is_empty() {
1160 println!(" no unmanaged files under {source}");
1161 return Ok(());
1162 }
1163
1164 print_unmanaged_table(&items, &source, color);
1165 println!();
1166 println!(" {} unmanaged file(s)", items.len());
1167 Ok(())
1168}
1169
1170fn is_repo_meta(path: &Utf8Path, source: &Utf8Path, marker_filename: &str) -> bool {
1186 let Some(name) = path.file_name() else {
1187 return false;
1188 };
1189 if name.ends_with(".tera") {
1190 return true;
1191 }
1192 if name == marker_filename || name == ".yuiignore" {
1193 return true;
1194 }
1195 let parent = path.parent().unwrap_or(Utf8Path::new(""));
1196 let at_root = parent == source;
1197 if at_root && name == ".gitignore" {
1198 return true;
1199 }
1200 if at_root && (name == "config.toml" || name == "config.local.toml") {
1201 return true;
1202 }
1203 if at_root
1204 && name.starts_with("config.")
1205 && (name.ends_with(".toml") || name.ends_with(".example.toml"))
1206 {
1207 return true;
1208 }
1209 false
1210}
1211
1212fn print_unmanaged_table(items: &[Utf8PathBuf], source: &Utf8Path, color: bool) {
1213 use owo_colors::OwoColorize as _;
1214 if color {
1215 println!(" {}", "PATH (relative to source)".dimmed());
1216 } else {
1217 println!(" PATH (relative to source)");
1218 }
1219 for p in items {
1220 let rel = p
1221 .strip_prefix(source)
1222 .map(Utf8PathBuf::from)
1223 .unwrap_or_else(|_| p.clone());
1224 if color {
1225 println!(" {}", rel.cyan());
1226 } else {
1227 println!(" {rel}");
1228 }
1229 }
1230}
1231
1232pub fn diff(
1240 source: Option<Utf8PathBuf>,
1241 icons_override: Option<IconsMode>,
1242 no_color: bool,
1243) -> Result<()> {
1244 let source = resolve_source(source)?;
1245 let yui = YuiVars::detect(&source);
1246 let config = config::load(&source, &yui)?;
1247 let mut engine = template::Engine::new();
1248 let tera_ctx = template::template_context(&yui, &config.vars);
1249 let mounts = mount::resolve(
1250 &source,
1251 &config.mount.entry,
1252 config.mount.default_strategy,
1253 &mut engine,
1254 &tera_ctx,
1255 )?;
1256
1257 let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
1258 let color = !no_color && supports_color_stdout();
1259
1260 let mut report: Vec<StatusItem> = Vec::new();
1262 let mut yuiignore = paths::YuiIgnoreStack::new();
1263 yuiignore.push_dir(&source)?;
1264 let walk_result = (|| -> Result<()> {
1265 for m in &mounts {
1266 let src_root = m.src.clone();
1267 if !src_root.is_dir() {
1268 continue;
1269 }
1270 classify_walk(
1271 &src_root,
1272 &m.dst,
1273 &config,
1274 m.strategy,
1275 &mut engine,
1276 &tera_ctx,
1277 &source,
1278 &mut yuiignore,
1279 &mut report,
1280 )?;
1281 }
1282 Ok(())
1283 })();
1284 yuiignore.pop_dir(&source);
1285 walk_result?;
1286
1287 let render_report = render::render_all(&source, &config, &yui, true)?;
1289 for rendered in &render_report.diverged {
1290 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
1291 report.push(StatusItem {
1292 src: tera_path,
1293 dst: rendered.clone(),
1294 state: StatusState::RenderDrift,
1295 });
1296 }
1297
1298 let mut printed = 0usize;
1299 for item in &report {
1300 if !diff_worth_printing(&item.state) {
1301 continue;
1302 }
1303 let src_abs = resolve_diff_src(item, &source);
1304 print_unified_diff(
1305 &src_abs,
1306 &item.dst,
1307 &item.state,
1308 &source,
1309 &config,
1310 &yui,
1311 color,
1312 );
1313 printed += 1;
1314 }
1315
1316 if printed == 0 {
1317 println!(" no diff — every entry is in sync (or only needs a relink)");
1318 } else {
1319 println!();
1320 println!(
1321 " {printed} entr{} with content drift",
1322 if printed == 1 { "y" } else { "ies" }
1323 );
1324 }
1325 Ok(())
1326}
1327
1328fn resolve_diff_src(item: &StatusItem, source: &Utf8Path) -> Utf8PathBuf {
1340 match item.state {
1341 StatusState::RenderDrift => item.src.clone(),
1342 StatusState::Link(_) => source.join(&item.src),
1343 }
1344}
1345
1346fn diff_worth_printing(state: &StatusState) -> bool {
1347 use absorb::AbsorbDecision::*;
1348 match state {
1349 StatusState::Link(InSync) => false,
1350 StatusState::Link(Restore) => false, StatusState::Link(RelinkOnly) => false, StatusState::Link(_) => true,
1353 StatusState::RenderDrift => true,
1354 }
1355}
1356
1357fn print_unified_diff(
1365 src: &Utf8Path,
1366 dst: &Utf8Path,
1367 state: &StatusState,
1368 source_root: &Utf8Path,
1369 config: &Config,
1370 yui: &YuiVars,
1371 color: bool,
1372) {
1373 use owo_colors::OwoColorize as _;
1374
1375 let header = match state {
1376 StatusState::RenderDrift => format!("--- render drift: {src} (template) vs {dst}"),
1377 _ => format!("--- {src} → {dst}"),
1378 };
1379 if color {
1380 println!("{}", header.bold());
1381 } else {
1382 println!("{header}");
1383 }
1384
1385 if src.is_dir() || dst.is_dir() {
1386 println!("(directory entry — content listing skipped)");
1387 println!();
1388 return;
1389 }
1390
1391 let src_content = match state {
1396 StatusState::RenderDrift => match render::render_to_string(src, source_root, config, yui) {
1397 Ok(Some(s)) => s,
1398 Ok(None) => {
1399 println!(
1400 "(template would be skipped on this host — drift will resolve on next render)"
1401 );
1402 println!();
1403 return;
1404 }
1405 Err(e) => {
1406 println!("(error rendering template: {e})");
1407 println!();
1408 return;
1409 }
1410 },
1411 _ => match read_text_for_diff(src) {
1412 DiffSide::Text(s) => s,
1413 DiffSide::Binary => {
1414 println!("(binary file or non-UTF-8 content — diff skipped)");
1415 println!();
1416 return;
1417 }
1418 },
1419 };
1420 let dst_content = match read_text_for_diff(dst) {
1421 DiffSide::Text(s) => s,
1422 DiffSide::Binary => {
1423 println!("(binary file or non-UTF-8 content — diff skipped)");
1424 println!();
1425 return;
1426 }
1427 };
1428 print_unified_text_diff(
1429 &src_content,
1430 &dst_content,
1431 src.as_str(),
1432 dst.as_str(),
1433 color,
1434 );
1435 println!();
1436}
1437
1438fn print_unified_text_diff(src: &str, dst: &str, src_label: &str, dst_label: &str, color: bool) {
1447 use owo_colors::OwoColorize as _;
1448 let diff = similar::TextDiff::from_lines(src, dst);
1449 let formatted = diff.unified_diff().header(src_label, dst_label).to_string();
1450 for line in formatted.lines() {
1451 if !color {
1452 println!("{line}");
1453 } else if line.starts_with("+++") || line.starts_with("---") {
1454 println!("{}", line.dimmed());
1455 } else if line.starts_with("@@") {
1456 println!("{}", line.cyan());
1457 } else if line.starts_with('+') {
1458 println!("{}", line.green());
1459 } else if line.starts_with('-') {
1460 println!("{}", line.red());
1461 } else {
1462 println!("{line}");
1463 }
1464 }
1465}
1466
1467enum DiffSide {
1473 Text(String),
1474 Binary,
1475}
1476
1477fn read_text_for_diff(p: &Utf8Path) -> DiffSide {
1478 match std::fs::read_to_string(p) {
1479 Ok(s) => DiffSide::Text(s),
1480 Err(e) if e.kind() == std::io::ErrorKind::InvalidData => DiffSide::Binary,
1481 Err(_) => DiffSide::Text(String::new()),
1482 }
1483}
1484
1485pub fn status(
1498 source: Option<Utf8PathBuf>,
1499 icons_override: Option<IconsMode>,
1500 no_color: bool,
1501) -> Result<()> {
1502 let source = resolve_source(source)?;
1503 let yui = YuiVars::detect(&source);
1504 let config = config::load(&source, &yui)?;
1505
1506 let mut engine = template::Engine::new();
1507 let tera_ctx = template::template_context(&yui, &config.vars);
1508 let mounts = mount::resolve(
1509 &source,
1510 &config.mount.entry,
1511 config.mount.default_strategy,
1512 &mut engine,
1513 &tera_ctx,
1514 )?;
1515
1516 let icons_mode = icons_override.unwrap_or(config.ui.icons);
1517 let icons = Icons::for_mode(icons_mode);
1518 let color = !no_color && supports_color_stdout();
1519
1520 let mut report: Vec<StatusItem> = Vec::new();
1521
1522 let render_report = render::render_all(&source, &config, &yui, true)?;
1525 for rendered in &render_report.diverged {
1526 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
1530 report.push(StatusItem {
1531 src: relative_for_display(&source, &tera_path),
1532 dst: rendered.clone(),
1533 state: StatusState::RenderDrift,
1534 });
1535 }
1536
1537 let mut yuiignore = paths::YuiIgnoreStack::new();
1541 yuiignore.push_dir(&source)?;
1542 let walk_result = (|| -> Result<()> {
1543 for m in &mounts {
1544 let src_root = m.src.clone();
1545 if !src_root.is_dir() {
1546 warn!("mount src missing: {src_root}");
1547 continue;
1548 }
1549 classify_walk(
1550 &src_root,
1551 &m.dst,
1552 &config,
1553 m.strategy,
1554 &mut engine,
1555 &tera_ctx,
1556 &source,
1557 &mut yuiignore,
1558 &mut report,
1559 )?;
1560 }
1561 Ok(())
1562 })();
1563 yuiignore.pop_dir(&source);
1564 walk_result?;
1565
1566 report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
1567
1568 print_status_table(&report, icons, color);
1569
1570 let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
1571
1572 println!();
1573 let total = report.len();
1574 let in_sync = total - drift;
1575 if drift == 0 {
1576 println!(" {total} entries · all in sync");
1577 Ok(())
1578 } else {
1579 println!(" {total} entries · {in_sync} in sync · {drift} diverged");
1580 anyhow::bail!("status: {drift} entries diverged from source")
1581 }
1582}
1583
1584#[derive(Debug)]
1585struct StatusItem {
1586 src: Utf8PathBuf,
1588 dst: Utf8PathBuf,
1590 state: StatusState,
1591}
1592
1593#[derive(Debug, Clone, Copy)]
1594enum StatusState {
1595 Link(absorb::AbsorbDecision),
1596 RenderDrift,
1599}
1600
1601impl StatusState {
1602 fn is_in_sync(self) -> bool {
1603 matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
1604 }
1605}
1606
1607#[allow(clippy::too_many_arguments)]
1608fn classify_walk(
1609 src_dir: &Utf8Path,
1610 dst_dir: &Utf8Path,
1611 config: &Config,
1612 strategy: MountStrategy,
1613 engine: &mut template::Engine,
1614 tera_ctx: &TeraContext,
1615 source_root: &Utf8Path,
1616 yuiignore: &mut paths::YuiIgnoreStack,
1617 report: &mut Vec<StatusItem>,
1618) -> Result<()> {
1619 classify_walk_inner(
1620 src_dir,
1621 dst_dir,
1622 config,
1623 strategy,
1624 engine,
1625 tera_ctx,
1626 source_root,
1627 yuiignore,
1628 report,
1629 false,
1630 )
1631}
1632
1633#[allow(clippy::too_many_arguments)]
1634fn classify_walk_inner(
1635 src_dir: &Utf8Path,
1636 dst_dir: &Utf8Path,
1637 config: &Config,
1638 strategy: MountStrategy,
1639 engine: &mut template::Engine,
1640 tera_ctx: &TeraContext,
1641 source_root: &Utf8Path,
1642 yuiignore: &mut paths::YuiIgnoreStack,
1643 report: &mut Vec<StatusItem>,
1644 parent_covered: bool,
1645) -> Result<()> {
1646 if yuiignore.is_ignored(src_dir, true) {
1647 return Ok(());
1648 }
1649 yuiignore.push_dir(src_dir)?;
1652 let result = classify_walk_inner_body(
1653 src_dir,
1654 dst_dir,
1655 config,
1656 strategy,
1657 engine,
1658 tera_ctx,
1659 source_root,
1660 yuiignore,
1661 report,
1662 parent_covered,
1663 );
1664 yuiignore.pop_dir(src_dir);
1665 result
1666}
1667
1668#[allow(clippy::too_many_arguments)]
1669fn classify_walk_inner_body(
1670 src_dir: &Utf8Path,
1671 dst_dir: &Utf8Path,
1672 config: &Config,
1673 strategy: MountStrategy,
1674 engine: &mut template::Engine,
1675 tera_ctx: &TeraContext,
1676 source_root: &Utf8Path,
1677 yuiignore: &mut paths::YuiIgnoreStack,
1678 report: &mut Vec<StatusItem>,
1679 parent_covered: bool,
1680) -> Result<()> {
1681 let marker_filename = &config.mount.marker_filename;
1682 let mut covered = parent_covered;
1683
1684 if strategy == MountStrategy::Marker {
1685 match marker::read_spec(src_dir, marker_filename)? {
1686 None => {}
1687 Some(MarkerSpec::PassThrough) => {
1688 let decision = absorb::classify(src_dir, dst_dir)?;
1689 report.push(StatusItem {
1690 src: relative_for_display(source_root, src_dir),
1691 dst: dst_dir.to_path_buf(),
1692 state: StatusState::Link(decision),
1693 });
1694 covered = true;
1695 }
1696 Some(MarkerSpec::Explicit { links }) => {
1697 let mut emitted_dir_link = false;
1698 for link in &links {
1699 if let Some(when) = &link.when {
1700 if !template::eval_truthy(when, engine, tera_ctx)? {
1701 continue;
1702 }
1703 }
1704 let dst_str = engine.render(&link.dst, tera_ctx)?;
1705 let dst = paths::expand_tilde(dst_str.trim());
1706 if let Some(filename) = &link.src {
1707 let file_src = src_dir.join(filename);
1708 if !file_src.is_file() {
1709 anyhow::bail!(
1710 "marker at {src_dir}: [[link]] src={filename:?} \
1711 not found"
1712 );
1713 }
1714 let decision = absorb::classify(&file_src, &dst)?;
1715 report.push(StatusItem {
1716 src: relative_for_display(source_root, &file_src),
1717 dst,
1718 state: StatusState::Link(decision),
1719 });
1720 } else {
1721 let decision = absorb::classify(src_dir, &dst)?;
1722 report.push(StatusItem {
1723 src: relative_for_display(source_root, src_dir),
1724 dst,
1725 state: StatusState::Link(decision),
1726 });
1727 emitted_dir_link = true;
1728 }
1729 }
1730 if emitted_dir_link {
1731 covered = true;
1732 }
1733 }
1734 }
1735 }
1736
1737 for entry in std::fs::read_dir(src_dir)? {
1738 let entry = entry?;
1739 let name_os = entry.file_name();
1740 let Some(name) = name_os.to_str() else {
1741 continue;
1742 };
1743 if name == marker_filename || name.ends_with(".tera") {
1744 continue;
1745 }
1746 let src_path = src_dir.join(name);
1747 let dst_path = dst_dir.join(name);
1748 let ft = entry.file_type()?;
1749 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
1750 continue;
1751 }
1752 if ft.is_dir() {
1753 classify_walk_inner(
1754 &src_path,
1755 &dst_path,
1756 config,
1757 strategy,
1758 engine,
1759 tera_ctx,
1760 source_root,
1761 yuiignore,
1762 report,
1763 covered,
1764 )?;
1765 } else if ft.is_file() && !covered {
1766 let decision = absorb::classify(&src_path, &dst_path)?;
1767 report.push(StatusItem {
1768 src: relative_for_display(source_root, &src_path),
1769 dst: dst_path,
1770 state: StatusState::Link(decision),
1771 });
1772 }
1773 }
1774 Ok(())
1775}
1776
1777fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
1778 p.strip_prefix(source_root)
1779 .map(Utf8PathBuf::from)
1780 .unwrap_or_else(|_| p.to_path_buf())
1781}
1782
1783fn print_status_table(items: &[StatusItem], icons: Icons, color: bool) {
1784 let src_w = items
1785 .iter()
1786 .map(|i| i.src.as_str().chars().count())
1787 .max()
1788 .unwrap_or(0)
1789 .max("SRC".len());
1790 let dst_w = items
1791 .iter()
1792 .map(|i| i.dst.as_str().chars().count())
1793 .max()
1794 .unwrap_or(0)
1795 .max("DST".len());
1796 let state_label_w = items
1798 .iter()
1799 .map(|i| state_label(i.state).len())
1800 .max()
1801 .unwrap_or(0)
1802 .max("STATE".len() - 2); let state_w = state_label_w + 2; print_status_header(state_w, src_w, dst_w, color);
1806 let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
1807 if color {
1808 use owo_colors::OwoColorize as _;
1809 println!("{}", sep.dimmed());
1810 } else {
1811 println!("{sep}");
1812 }
1813 for item in items {
1814 print_status_row(item, icons, state_w, src_w, dst_w, color);
1815 }
1816}
1817
1818fn state_label(s: StatusState) -> &'static str {
1819 use absorb::AbsorbDecision::*;
1820 match s {
1821 StatusState::Link(InSync) => "in-sync",
1822 StatusState::Link(RelinkOnly) => "relink",
1823 StatusState::Link(AutoAbsorb) => "drift (auto)",
1824 StatusState::Link(NeedsConfirm) => "drift (anomaly)",
1825 StatusState::Link(Restore) => "missing",
1826 StatusState::RenderDrift => "render drift",
1827 }
1828}
1829
1830fn state_icon(s: StatusState, icons: Icons) -> &'static str {
1831 use absorb::AbsorbDecision::*;
1832 match s {
1833 StatusState::Link(InSync) => icons.ok,
1834 StatusState::Link(RelinkOnly) => icons.warn,
1835 StatusState::Link(AutoAbsorb) => icons.warn,
1836 StatusState::Link(NeedsConfirm) => icons.error,
1837 StatusState::Link(Restore) => icons.info,
1838 StatusState::RenderDrift => icons.error,
1839 }
1840}
1841
1842fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
1843 use owo_colors::OwoColorize as _;
1844 let line = format!(
1847 " {:<state_w$} {:<src_w$} {:<dst_w$}",
1848 "STATE", "SRC", "DST"
1849 );
1850 if color {
1851 println!("{}", line.bold());
1852 } else {
1853 println!("{line}");
1854 }
1855}
1856
1857fn render_status_separator(
1858 sep_ch: char,
1859 state_w: usize,
1860 src_w: usize,
1861 dst_w: usize,
1862 arrow: &str,
1863) -> String {
1864 let bar = |n: usize| sep_ch.to_string().repeat(n);
1865 format!(
1866 " {} {} {} {}",
1867 bar(state_w),
1868 bar(src_w),
1869 bar(arrow.chars().count()),
1870 bar(dst_w)
1871 )
1872}
1873
1874fn print_status_row(
1875 item: &StatusItem,
1876 icons: Icons,
1877 state_w: usize,
1878 src_w: usize,
1879 dst_w: usize,
1880 color: bool,
1881) {
1882 use owo_colors::OwoColorize as _;
1883 let icon = state_icon(item.state, icons);
1884 let label = state_label(item.state);
1885 let state_text = format!("{icon} {label}");
1886 let src_display = item.src.as_str().replace('\\', "/");
1887 let dst_display = item.dst.as_str().replace('\\', "/");
1888 let arrow = icons.arrow;
1889
1890 let cell_state = format!("{:<state_w$}", state_text);
1891 let cell_src = format!("{:<src_w$}", src_display);
1892 let cell_dst = format!("{:<dst_w$}", dst_display);
1893
1894 if !color {
1895 println!(" {cell_state} {cell_src} {arrow} {cell_dst}");
1896 return;
1897 }
1898
1899 use absorb::AbsorbDecision::*;
1900 let state_colored = match item.state {
1901 StatusState::Link(InSync) => cell_state.green().to_string(),
1902 StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
1903 cell_state.yellow().to_string()
1904 }
1905 StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
1906 StatusState::Link(Restore) => cell_state.cyan().to_string(),
1907 StatusState::RenderDrift => cell_state.red().to_string(),
1908 };
1909 let src_colored = cell_src.cyan().to_string();
1910 let arrow_colored = arrow.dimmed().to_string();
1911 let dst_colored = cell_dst.dimmed().to_string();
1912 println!(" {state_colored} {src_colored} {arrow_colored} {dst_colored}");
1913}
1914
1915pub fn absorb(
1929 source: Option<Utf8PathBuf>,
1930 target: Utf8PathBuf,
1931 dry_run: bool,
1932 yes: bool,
1933) -> Result<()> {
1934 let source = resolve_source(source)?;
1935 let target = absolutize(&target)?;
1936 let yui = YuiVars::detect(&source);
1937 let config = config::load(&source, &yui)?;
1938
1939 let mut engine = template::Engine::new();
1940 let tera_ctx = template::template_context(&yui, &config.vars);
1941
1942 let src_path = match find_source_for_target(&source, &config, &target, &mut engine, &tera_ctx)?
1943 {
1944 Some(s) => s,
1945 None => anyhow::bail!(
1946 "no mount entry / .yuilink override claims target {target}; \
1947 pass a path inside a known dst"
1948 ),
1949 };
1950
1951 info!("source for {target}: {src_path}");
1952
1953 print_absorb_diff(&src_path, &target);
1958
1959 if dry_run {
1960 info!("[dry-run] would absorb {target} → {src_path}");
1961 return Ok(());
1962 }
1963
1964 if !yes {
1965 use std::io::IsTerminal;
1966 if !std::io::stdin().is_terminal() {
1967 anyhow::bail!(
1968 "manual absorb refuses to run off-TTY without --yes \
1969 (would silently overwrite {src_path})"
1970 );
1971 }
1972 if !prompt_yes_no("absorb target into source?")? {
1973 warn!("manual absorb cancelled by user: {target}");
1974 return Ok(());
1975 }
1976 }
1977
1978 let backup_root = source.join(&config.backup.dir);
1979 let ctx = ApplyCtx {
1980 config: &config,
1981 source: &source,
1982 file_mode: resolve_file_mode(config.link.file_mode),
1983 dir_mode: resolve_dir_mode(config.link.dir_mode),
1984 backup_root: &backup_root,
1985 dry_run: false,
1986 sticky_anomaly: Cell::new(None),
1987 quit_requested: Cell::new(false),
1988 };
1989
1990 absorb_target_into_source(&src_path, &target, &ctx)
1993}
1994
1995fn print_absorb_diff(src: &Utf8Path, dst: &Utf8Path) {
2000 use owo_colors::OwoColorize as _;
2001 use std::io::IsTerminal;
2002
2003 let color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none();
2006
2007 eprintln!();
2008 if color {
2009 eprintln!(
2010 "{} {} {}",
2011 "── unified diff ──".bold(),
2012 "[-] src".red().bold(),
2013 "[+] dst".green().bold()
2014 );
2015 eprintln!(" {} {}", "[-] src:".red(), src);
2016 eprintln!(" {} {}", "[+] dst:".green(), dst);
2017 } else {
2018 eprintln!("── unified diff ── [-] src [+] dst");
2019 eprintln!(" [-] src: {src}");
2020 eprintln!(" [+] dst: {dst}");
2021 }
2022 eprintln!();
2023
2024 if src.is_dir() || dst.is_dir() {
2025 eprintln!("(directory absorb — content listing skipped)");
2026 eprintln!();
2027 return;
2028 }
2029 let src_content = match read_text_for_diff(src) {
2030 DiffSide::Text(s) => s,
2031 DiffSide::Binary => {
2032 eprintln!("(binary file or non-UTF-8 content — diff skipped)");
2033 eprintln!();
2034 return;
2035 }
2036 };
2037 let dst_content = match read_text_for_diff(dst) {
2038 DiffSide::Text(s) => s,
2039 DiffSide::Binary => {
2040 eprintln!("(binary file or non-UTF-8 content — diff skipped)");
2041 eprintln!();
2042 return;
2043 }
2044 };
2045
2046 let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
2047 for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
2051 let header = hunk.header().to_string();
2052 if color {
2053 eprintln!("{}", header.cyan());
2054 } else {
2055 eprintln!("{header}");
2056 }
2057 for change in hunk.iter_changes() {
2058 let line = change.value();
2059 let line = line.strip_suffix('\n').unwrap_or(line);
2060 match change.tag() {
2061 similar::ChangeTag::Delete => {
2062 if color {
2063 eprintln!("{} {}", "-".red().bold(), line.red());
2064 } else {
2065 eprintln!("- {line}");
2066 }
2067 }
2068 similar::ChangeTag::Insert => {
2069 if color {
2070 eprintln!("{} {}", "+".green().bold(), line.green());
2071 } else {
2072 eprintln!("+ {line}");
2073 }
2074 }
2075 similar::ChangeTag::Equal => {
2076 if color {
2077 eprintln!(" {}", line.dimmed());
2078 } else {
2079 eprintln!(" {line}");
2080 }
2081 }
2082 }
2083 }
2084 }
2085 eprintln!();
2086}
2087
2088fn prompt_yes_no(question: &str) -> Result<bool> {
2089 use std::io::Write as _;
2090 eprint!("{question} [y/N]: ");
2091 std::io::stderr().flush().ok();
2092 let mut input = String::new();
2093 std::io::stdin().read_line(&mut input)?;
2094 let answer = input.trim();
2095 Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
2096}
2097
2098fn find_source_for_target(
2102 source: &Utf8Path,
2103 config: &Config,
2104 target: &Utf8Path,
2105 engine: &mut template::Engine,
2106 tera_ctx: &TeraContext,
2107) -> Result<Option<Utf8PathBuf>> {
2108 for entry in &config.mount.entry {
2110 if let Some(when) = &entry.when {
2111 if !template::eval_truthy(when, engine, tera_ctx)? {
2112 continue;
2113 }
2114 }
2115 let dst_str = engine.render(&entry.dst, tera_ctx)?;
2116 let dst_root = paths::expand_tilde(dst_str.trim());
2117 if let Ok(rel) = target.strip_prefix(&dst_root) {
2118 let src_str = engine.render(entry.src.as_str(), tera_ctx)?;
2119 let candidate = paths::resolve_mount_src(source, src_str.trim()).join(rel);
2120 if paths::is_ignored_at(source, &candidate, candidate.is_dir())? {
2125 continue;
2126 }
2127 return Ok(Some(candidate));
2128 }
2129 }
2130
2131 let walker = paths::source_walker(source).build();
2137 let marker_filename = &config.mount.marker_filename;
2138 for ent in walker {
2139 let ent = match ent {
2140 Ok(e) => e,
2141 Err(_) => continue,
2142 };
2143 if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
2144 continue;
2145 }
2146 if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
2147 continue;
2148 }
2149 let dir = match ent.path().parent() {
2150 Some(d) => d,
2151 None => continue,
2152 };
2153 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
2154 Ok(p) => p,
2155 Err(_) => continue,
2156 };
2157 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
2158 Some(s) => s,
2159 None => continue,
2160 };
2161 let MarkerSpec::Explicit { links } = spec else {
2162 continue;
2163 };
2164 for link in &links {
2165 if let Some(when) = &link.when {
2166 if !template::eval_truthy(when, engine, tera_ctx)? {
2167 continue;
2168 }
2169 }
2170 let dst_str = engine.render(&link.dst, tera_ctx)?;
2171 let dst = paths::expand_tilde(dst_str.trim());
2172 if let Some(filename) = &link.src {
2179 let file_src = dir_utf8.join(filename);
2180 if !file_src.is_file() {
2181 anyhow::bail!(
2182 "marker at {dir_utf8}: [[link]] src={filename:?} \
2183 not found"
2184 );
2185 }
2186 if target == dst {
2187 return Ok(Some(file_src));
2188 }
2189 continue;
2190 }
2191 if target == dst {
2192 return Ok(Some(dir_utf8));
2193 }
2194 if let Ok(rel) = target.strip_prefix(&dst) {
2195 return Ok(Some(dir_utf8.join(rel)));
2196 }
2197 }
2198 }
2199
2200 Ok(None)
2201}
2202
2203pub fn doctor(
2204 source: Option<Utf8PathBuf>,
2205 icons_override: Option<IconsMode>,
2206 no_color: bool,
2207) -> Result<()> {
2208 use owo_colors::OwoColorize as _;
2209
2210 let resolved_source = resolve_source(source);
2215
2216 let yui = match &resolved_source {
2221 Ok(s) => YuiVars::detect(s),
2222 Err(_) => YuiVars::detect(Utf8Path::new(".")),
2223 };
2224
2225 let cfg_res = match &resolved_source {
2230 Ok(s) => Some(config::load(s, &yui)),
2231 Err(_) => None,
2232 };
2233 let cfg = cfg_res.as_ref().and_then(|r| r.as_ref().ok());
2234 let icons_mode = icons_override
2235 .or_else(|| cfg.map(|c| c.ui.icons))
2236 .unwrap_or_default();
2237 let icons = Icons::for_mode(icons_mode);
2238 let color = !no_color && supports_color_stdout();
2239
2240 let mut probes: Vec<Probe> = Vec::new();
2241
2242 probes.push(Probe::group("identity"));
2244 probes.push(Probe::ok("os/arch", format!("{} / {}", yui.os, yui.arch)));
2245 probes.push(Probe::ok("user@host", format!("{}@{}", yui.user, yui.host)));
2246
2247 probes.push(Probe::group("repo"));
2249 let mut have_source = false;
2250 match &resolved_source {
2251 Ok(s) => {
2252 have_source = true;
2253 probes.push(Probe::ok("source", s.to_string()));
2254 match cfg_res.as_ref().expect("cfg_res set when source is Ok") {
2255 Ok(c) => {
2256 probes.push(Probe::ok(
2257 "config",
2258 format!(
2259 "{} mount{} · {} hook{} · {} render rule{}",
2260 c.mount.entry.len(),
2261 plural(c.mount.entry.len()),
2262 c.hook.len(),
2263 plural(c.hook.len()),
2264 c.render.rule.len(),
2265 plural(c.render.rule.len()),
2266 ),
2267 ));
2268 }
2269 Err(e) => probes.push(Probe::error("config", format!("{e}"))),
2270 }
2271 match crate::git::is_clean(s) {
2275 Ok(true) => probes.push(Probe::ok("git", "clean")),
2276 Ok(false) => probes.push(Probe::warn(
2277 "git",
2278 "uncommitted changes — `[absorb] require_clean_git` will defer auto-absorb",
2279 )),
2280 Err(_) => probes.push(Probe::warn(
2281 "git",
2282 "no git repo (auto-absorb still works; commit history won't track drift)",
2283 )),
2284 }
2285 }
2286 Err(e) => {
2287 probes.push(Probe::error("source", format!("not found — {e}")));
2288 }
2289 }
2290
2291 probes.push(Probe::group("links"));
2293 if cfg!(windows) {
2294 probes.push(Probe::ok(
2295 "default mode",
2296 "files=hardlink, dirs=junction (no admin needed)",
2297 ));
2298 } else {
2299 probes.push(Probe::ok("default mode", "files=symlink, dirs=symlink"));
2300 }
2301
2302 if have_source {
2304 if let (Ok(s), Some(c)) = (&resolved_source, cfg) {
2305 probes.push(Probe::group("hooks"));
2306 if c.hook.is_empty() {
2307 probes.push(Probe::ok("hooks", "(none configured)"));
2308 } else {
2309 let mut missing = 0usize;
2310 for h in &c.hook {
2311 if !s.join(&h.script).is_file() {
2312 missing += 1;
2313 probes.push(Probe::error(
2314 format!("hook[{}]", h.name),
2315 format!("script not found at {}", h.script),
2316 ));
2317 }
2318 }
2319 if missing == 0 {
2320 probes.push(Probe::ok(
2321 "scripts",
2322 format!(
2323 "{} hook{} configured, all scripts present",
2324 c.hook.len(),
2325 plural(c.hook.len())
2326 ),
2327 ));
2328 }
2329 }
2330 }
2331 }
2332
2333 if let Some(home) = paths::home_dir() {
2335 let chezmoi_src = home.join(".local/share/chezmoi");
2336 if chezmoi_src.is_dir() {
2337 probes.push(Probe::group("chezmoi"));
2338 probes.push(Probe::warn(
2339 "legacy source",
2340 format!(
2341 "{chezmoi_src} still exists — yui doesn't use it, safe to archive once your migration has settled"
2342 ),
2343 ));
2344 }
2345 }
2346
2347 println!();
2349 if color {
2350 println!(" {}", "yui doctor".bold().underline());
2351 } else {
2352 println!(" yui doctor");
2353 }
2354 println!();
2355 for probe in &probes {
2356 probe.print(&icons, color);
2357 }
2358
2359 let errors = probes.iter().filter(|p| p.is_error()).count();
2360 let warns = probes.iter().filter(|p| p.is_warn()).count();
2361 let oks = probes.iter().filter(|p| p.is_ok()).count();
2362 println!();
2363 let summary = format!("{oks} ok · {warns} warn · {errors} error");
2364 if color {
2365 if errors > 0 {
2366 println!(" {}", summary.red().bold());
2367 } else if warns > 0 {
2368 println!(" {}", summary.yellow());
2369 } else {
2370 println!(" {}", summary.green());
2371 }
2372 } else {
2373 println!(" {summary}");
2374 }
2375
2376 if errors > 0 {
2377 anyhow::bail!("doctor: {errors} probe(s) failed");
2378 }
2379 Ok(())
2380}
2381
2382#[derive(Debug)]
2383enum Probe {
2384 Group(&'static str),
2386 Ok {
2387 label: String,
2388 detail: String,
2389 },
2390 Warn {
2391 label: String,
2392 detail: String,
2393 },
2394 Error {
2395 label: String,
2396 detail: String,
2397 },
2398}
2399
2400impl Probe {
2401 fn group(label: &'static str) -> Self {
2402 Self::Group(label)
2403 }
2404 fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
2405 Self::Ok {
2406 label: label.into(),
2407 detail: detail.into(),
2408 }
2409 }
2410 fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
2411 Self::Warn {
2412 label: label.into(),
2413 detail: detail.into(),
2414 }
2415 }
2416 fn error(label: impl Into<String>, detail: impl Into<String>) -> Self {
2417 Self::Error {
2418 label: label.into(),
2419 detail: detail.into(),
2420 }
2421 }
2422 fn is_ok(&self) -> bool {
2423 matches!(self, Self::Ok { .. })
2424 }
2425 fn is_warn(&self) -> bool {
2426 matches!(self, Self::Warn { .. })
2427 }
2428 fn is_error(&self) -> bool {
2429 matches!(self, Self::Error { .. })
2430 }
2431 fn print(&self, icons: &Icons, color: bool) {
2432 use owo_colors::OwoColorize as _;
2433 match self {
2434 Self::Group(name) => {
2435 println!();
2436 if color {
2437 println!(" {}", name.cyan().bold());
2438 } else {
2439 println!(" {name}");
2440 }
2441 }
2442 Self::Ok { label, detail } => {
2443 let icon = icons.ok;
2444 let padded = format!("{label:<14}");
2448 if color {
2449 println!(
2450 " {} {} {}",
2451 icon.green(),
2452 padded.bold(),
2453 detail.dimmed()
2454 );
2455 } else {
2456 println!(" {icon} {padded} {detail}");
2457 }
2458 }
2459 Self::Warn { label, detail } => {
2460 let icon = icons.warn;
2461 let padded = format!("{label:<14}");
2462 if color {
2463 println!(
2464 " {} {} {}",
2465 icon.yellow(),
2466 padded.bold().yellow(),
2467 detail
2468 );
2469 } else {
2470 println!(" {icon} {padded} {detail}");
2471 }
2472 }
2473 Self::Error { label, detail } => {
2474 let icon = icons.error;
2475 let padded = format!("{label:<14}");
2476 if color {
2477 println!(
2478 " {} {} {}",
2479 icon.red().bold(),
2480 padded.bold().red(),
2481 detail.red()
2482 );
2483 } else {
2484 println!(" {icon} {padded} {detail}");
2485 }
2486 }
2487 }
2488 }
2489}
2490
2491fn plural(n: usize) -> &'static str {
2492 if n == 1 { "" } else { "s" }
2493}
2494
2495pub fn gc_backup(
2515 source: Option<Utf8PathBuf>,
2516 older_than: Option<String>,
2517 dry_run: bool,
2518 icons_override: Option<IconsMode>,
2519 no_color: bool,
2520) -> Result<()> {
2521 let source = resolve_source(source)?;
2522 let yui = YuiVars::detect(&source);
2523 let config = config::load(&source, &yui)?;
2524 let backup_root = source.join(&config.backup.dir);
2525 let icons_mode = icons_override.unwrap_or(config.ui.icons);
2526 let icons = Icons::for_mode(icons_mode);
2527 let color = !no_color && supports_color_stdout();
2528
2529 if !backup_root.is_dir() {
2530 println!(" no backup tree at {backup_root}");
2531 return Ok(());
2532 }
2533
2534 let mut entries = walk_gc_backups(&backup_root)?;
2535 if entries.is_empty() {
2536 println!(" no yui-stamped backups under {backup_root}");
2537 return Ok(());
2538 }
2539 entries.sort_by_key(|e| e.ts);
2541 let now = jiff::Zoned::now();
2542
2543 match older_than {
2544 None => {
2545 let refs: Vec<&BackupEntry> = entries.iter().collect();
2546 print_gc_table(&refs, &backup_root, &now, icons, color);
2547 println!();
2548 println!(
2549 " {} entries · {} total — pass --older-than DUR (e.g. 30d) to delete",
2550 entries.len(),
2551 format_bytes(entries.iter().map(|e| e.size_bytes).sum())
2552 );
2553 Ok(())
2554 }
2555 Some(dur_str) => {
2556 let span = parse_human_duration(&dur_str)?;
2557 let cutoff = now
2558 .checked_sub(span)
2559 .map_err(|e| anyhow::anyhow!("invalid duration {dur_str:?}: {e}"))?;
2560 let cutoff_dt = cutoff.datetime();
2561
2562 let total_before: u64 = entries.iter().map(|e| e.size_bytes).sum();
2563 let to_delete: Vec<&BackupEntry> =
2564 entries.iter().filter(|e| e.ts < cutoff_dt).collect();
2565
2566 if to_delete.is_empty() {
2567 println!(
2568 " no backups older than {dur_str} (oldest: {})",
2569 format_age(entries[0].ts, &now)
2570 );
2571 return Ok(());
2572 }
2573
2574 print_gc_table(&to_delete, &backup_root, &now, icons, color);
2575 println!();
2576 let total_freed: u64 = to_delete.iter().map(|e| e.size_bytes).sum();
2577
2578 if dry_run {
2579 println!(
2580 " [dry-run] would remove {} of {} entries · would free {} of {}",
2581 to_delete.len(),
2582 entries.len(),
2583 format_bytes(total_freed),
2584 format_bytes(total_before),
2585 );
2586 return Ok(());
2587 }
2588
2589 for entry in &to_delete {
2590 match entry.kind {
2591 BackupKind::File => std::fs::remove_file(&entry.path)?,
2592 BackupKind::Dir => std::fs::remove_dir_all(&entry.path)?,
2593 }
2594 if let Some(parent) = entry.path.parent() {
2595 cleanup_empty_parents(parent, &backup_root);
2596 }
2597 }
2598 println!(
2599 " removed {} of {} entries · freed {} (was {}, now {})",
2600 to_delete.len(),
2601 entries.len(),
2602 format_bytes(total_freed),
2603 format_bytes(total_before),
2604 format_bytes(total_before - total_freed),
2605 );
2606 Ok(())
2607 }
2608 }
2609}
2610
2611#[derive(Debug)]
2612struct BackupEntry {
2613 path: Utf8PathBuf,
2614 ts: jiff::civil::DateTime,
2615 kind: BackupKind,
2616 size_bytes: u64,
2617}
2618
2619#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2620enum BackupKind {
2621 File,
2622 Dir,
2623}
2624
2625fn walk_gc_backups(root: &Utf8Path) -> Result<Vec<BackupEntry>> {
2630 let mut out = Vec::new();
2631 walk_gc_backups_rec(root, &mut out)?;
2632 Ok(out)
2633}
2634
2635fn walk_gc_backups_rec(dir: &Utf8Path, out: &mut Vec<BackupEntry>) -> Result<()> {
2636 for entry in std::fs::read_dir(dir)? {
2637 let entry = entry?;
2638 let name_os = entry.file_name();
2639 let Some(name) = name_os.to_str() else {
2640 continue;
2641 };
2642 let path = dir.join(name);
2643 let ft = entry.file_type()?;
2644 if ft.is_dir() {
2645 if let Some(ts) = parse_backup_suffix(name) {
2646 let size = dir_size(&path)?;
2647 out.push(BackupEntry {
2648 path,
2649 ts,
2650 kind: BackupKind::Dir,
2651 size_bytes: size,
2652 });
2653 } else {
2654 walk_gc_backups_rec(&path, out)?;
2655 }
2656 } else if ft.is_file() {
2657 if let Some(ts) = parse_backup_suffix(name) {
2660 let size = entry.metadata()?.len();
2661 out.push(BackupEntry {
2662 path,
2663 ts,
2664 kind: BackupKind::File,
2665 size_bytes: size,
2666 });
2667 }
2668 }
2669 }
2670 Ok(())
2671}
2672
2673fn dir_size(dir: &Utf8Path) -> Result<u64> {
2674 let mut total: u64 = 0;
2675 for entry in std::fs::read_dir(dir)? {
2676 let entry = entry?;
2677 let ft = entry.file_type()?;
2678 if ft.is_dir() {
2679 let p = match Utf8PathBuf::from_path_buf(entry.path()) {
2680 Ok(p) => p,
2681 Err(_) => continue,
2682 };
2683 total = total.saturating_add(dir_size(&p)?);
2684 } else if ft.is_file() {
2685 total = total.saturating_add(entry.metadata()?.len());
2686 }
2687 }
2688 Ok(total)
2689}
2690
2691fn cleanup_empty_parents(start: &Utf8Path, root: &Utf8Path) {
2695 let mut cur = start.to_path_buf();
2696 loop {
2697 if cur == *root {
2698 return;
2699 }
2700 if std::fs::remove_dir(&cur).is_err() {
2702 return;
2703 }
2704 match cur.parent() {
2705 Some(p) => cur = p.to_path_buf(),
2706 None => return,
2707 }
2708 }
2709}
2710
2711fn parse_backup_suffix(name: &str) -> Option<jiff::civil::DateTime> {
2717 if let Some(ts) = parse_ts_at_end(name) {
2718 return Some(ts);
2719 }
2720 if let Some((before, _ext)) = name.rsplit_once('.') {
2723 if let Some(ts) = parse_ts_at_end(before) {
2724 return Some(ts);
2725 }
2726 }
2727 None
2728}
2729
2730fn parse_ts_at_end(s: &str) -> Option<jiff::civil::DateTime> {
2731 if s.len() < 20 {
2733 return None;
2734 }
2735 let split_at = s.len() - 19;
2736 if s.as_bytes()[split_at] != b'_' {
2737 return None;
2738 }
2739 parse_ts(&s[split_at + 1..])
2740}
2741
2742fn parse_ts(s: &str) -> Option<jiff::civil::DateTime> {
2744 if s.len() != 18 || s.as_bytes()[8] != b'_' {
2745 return None;
2746 }
2747 for (i, &b) in s.as_bytes().iter().enumerate() {
2748 if i == 8 {
2749 continue;
2750 }
2751 if !b.is_ascii_digit() {
2752 return None;
2753 }
2754 }
2755 let year: i16 = s[0..4].parse().ok()?;
2756 let month: i8 = s[4..6].parse().ok()?;
2757 let day: i8 = s[6..8].parse().ok()?;
2758 let hour: i8 = s[9..11].parse().ok()?;
2759 let minute: i8 = s[11..13].parse().ok()?;
2760 let second: i8 = s[13..15].parse().ok()?;
2761 let ms: i32 = s[15..18].parse().ok()?;
2762 jiff::civil::DateTime::new(year, month, day, hour, minute, second, ms * 1_000_000).ok()
2763}
2764
2765fn parse_human_duration(s: &str) -> Result<jiff::Span> {
2774 let s = s.trim();
2775 let split = s
2776 .bytes()
2777 .position(|b| b.is_ascii_alphabetic())
2778 .ok_or_else(|| anyhow::anyhow!("invalid duration {s:?}: missing unit (e.g. 30d, 2w)"))?;
2779 let n: i64 = s[..split]
2780 .trim()
2781 .parse()
2782 .map_err(|_| anyhow::anyhow!("invalid duration {s:?}: bad leading number"))?;
2783 if n < 0 {
2784 anyhow::bail!("invalid duration {s:?}: negative durations don't make sense");
2785 }
2786 let unit = s[split..].to_ascii_lowercase();
2787 let span = match unit.as_str() {
2788 "y" | "yr" | "year" | "years" => jiff::Span::new().years(n),
2789 "mo" | "month" | "months" => jiff::Span::new().months(n),
2790 "w" | "wk" | "week" | "weeks" => jiff::Span::new().weeks(n),
2791 "d" | "day" | "days" => jiff::Span::new().days(n),
2792 "h" | "hr" | "hour" | "hours" => jiff::Span::new().hours(n),
2793 "m" | "min" | "minute" | "minutes" => jiff::Span::new().minutes(n),
2794 other => {
2795 anyhow::bail!(
2796 "invalid duration {s:?}: unknown unit {other:?} \
2797 (use y / mo / w / d / h / m)"
2798 )
2799 }
2800 };
2801 Ok(span)
2802}
2803
2804fn format_bytes(n: u64) -> String {
2805 const KIB: u64 = 1024;
2806 const MIB: u64 = KIB * 1024;
2807 const GIB: u64 = MIB * 1024;
2808 if n >= GIB {
2809 format!("{:.1} GiB", n as f64 / GIB as f64)
2810 } else if n >= MIB {
2811 format!("{:.1} MiB", n as f64 / MIB as f64)
2812 } else if n >= KIB {
2813 format!("{:.1} KiB", n as f64 / KIB as f64)
2814 } else {
2815 format!("{n} B")
2816 }
2817}
2818
2819fn format_age(ts: jiff::civil::DateTime, now: &jiff::Zoned) -> String {
2820 let Ok(ts_zoned) = ts.to_zoned(now.time_zone().clone()) else {
2821 return "?".into();
2822 };
2823 let secs = match (now - &ts_zoned).total(jiff::Unit::Second) {
2824 Ok(s) => s as i64,
2825 Err(_) => return "?".into(),
2826 };
2827 if secs < 0 {
2828 return "future".into();
2829 }
2830 if secs < 60 {
2831 format!("{secs}s")
2832 } else if secs < 3600 {
2833 format!("{}m", secs / 60)
2834 } else if secs < 86_400 {
2835 format!("{}h", secs / 3600)
2836 } else if secs < 86_400 * 30 {
2837 format!("{}d", secs / 86_400)
2838 } else if secs < 86_400 * 365 {
2839 format!("{}mo", secs / (86_400 * 30))
2840 } else {
2841 format!("{}y", secs / (86_400 * 365))
2842 }
2843}
2844
2845fn print_gc_table(
2852 entries: &[&BackupEntry],
2853 backup_root: &Utf8Path,
2854 now: &jiff::Zoned,
2855 _icons: Icons,
2856 color: bool,
2857) {
2858 use owo_colors::OwoColorize as _;
2859
2860 let rows: Vec<(String, String, String)> = entries
2861 .iter()
2862 .map(|e| {
2863 let rel = e
2864 .path
2865 .strip_prefix(backup_root)
2866 .map(Utf8PathBuf::from)
2867 .unwrap_or_else(|_| e.path.clone());
2868 let path_disp = match e.kind {
2869 BackupKind::Dir => format!("{rel}/"),
2870 BackupKind::File => rel.to_string(),
2871 };
2872 (format_age(e.ts, now), format_bytes(e.size_bytes), path_disp)
2873 })
2874 .collect();
2875
2876 let age_w = rows.iter().map(|r| r.0.len()).max().unwrap_or(3);
2877 let size_w = rows.iter().map(|r| r.1.len()).max().unwrap_or(4);
2878
2879 if color {
2880 println!(
2881 " {:<age_w$} {:>size_w$} {}",
2882 "AGE".dimmed(),
2883 "SIZE".dimmed(),
2884 "PATH".dimmed(),
2885 );
2886 } else {
2887 println!(" {:<age_w$} {:>size_w$} PATH", "AGE", "SIZE");
2888 }
2889 for (age, size, path) in &rows {
2890 if color {
2891 println!(
2892 " {:<age_w$} {:>size_w$} {}",
2893 age.yellow(),
2894 size,
2895 path.cyan(),
2896 );
2897 } else {
2898 println!(" {:<age_w$} {:>size_w$} {}", age, size, path);
2899 }
2900 }
2901}
2902
2903pub fn hooks_list(
2905 source: Option<Utf8PathBuf>,
2906 icons_override: Option<IconsMode>,
2907 no_color: bool,
2908) -> Result<()> {
2909 let source = resolve_source(source)?;
2910 let yui = YuiVars::detect(&source);
2911 let config = config::load(&source, &yui)?;
2912 let state = hook::State::load(&source)?;
2913
2914 let icons_mode = icons_override.unwrap_or(config.ui.icons);
2915 let icons = Icons::for_mode(icons_mode);
2916 let color = !no_color && supports_color_stdout();
2917
2918 if config.hook.is_empty() {
2919 println!("(no [[hook]] entries in config)");
2920 return Ok(());
2921 }
2922
2923 let mut engine = template::Engine::new();
2927 let tera_ctx = template::template_context(&yui, &config.vars);
2928 let rows: Vec<HookRow> = config
2929 .hook
2930 .iter()
2931 .map(|h| -> Result<HookRow> {
2932 let active = match &h.when {
2936 None => true,
2937 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
2938 };
2939 let last_run_at = state.hooks.get(&h.name).and_then(|s| s.last_run_at.clone());
2940 Ok(HookRow {
2941 name: h.name.clone(),
2942 phase: match h.phase {
2943 HookPhase::Pre => "pre",
2944 HookPhase::Post => "post",
2945 },
2946 when_run: match h.when_run {
2947 config::WhenRun::Once => "once",
2948 config::WhenRun::Onchange => "onchange",
2949 config::WhenRun::Every => "every",
2950 },
2951 last_run_at,
2952 when: h.when.clone(),
2953 active,
2954 })
2955 })
2956 .collect::<Result<Vec<_>>>()?;
2957
2958 print_hooks_table(&rows, icons, color);
2959
2960 let total = rows.len();
2961 let active = rows.iter().filter(|r| r.active).count();
2962 let inactive = total - active;
2963 let ran = rows.iter().filter(|r| r.last_run_at.is_some()).count();
2964 let never = total - ran;
2965 println!();
2966 println!(
2967 " {total} hooks · {active} active · {inactive} inactive · {ran} ran · {never} never run"
2968 );
2969
2970 Ok(())
2971}
2972
2973#[derive(Debug)]
2974struct HookRow {
2975 name: String,
2976 phase: &'static str,
2977 when_run: &'static str,
2978 last_run_at: Option<String>,
2979 when: Option<String>,
2980 active: bool,
2981}
2982
2983fn print_hooks_table(rows: &[HookRow], icons: Icons, color: bool) {
2984 use owo_colors::OwoColorize as _;
2985 use std::fmt::Write as _;
2986
2987 let name_w = rows
2988 .iter()
2989 .map(|r| r.name.chars().count())
2990 .max()
2991 .unwrap_or(0)
2992 .max("NAME".len());
2993 let phase_w = rows
2994 .iter()
2995 .map(|r| r.phase.len())
2996 .max()
2997 .unwrap_or(0)
2998 .max("PHASE".len());
2999 let when_run_w = rows
3000 .iter()
3001 .map(|r| r.when_run.len())
3002 .max()
3003 .unwrap_or(0)
3004 .max("WHEN_RUN".len());
3005 let last_w = rows
3006 .iter()
3007 .map(|r| {
3008 r.last_run_at
3009 .as_deref()
3010 .map(|s| s.chars().count())
3011 .unwrap_or("(never)".len())
3012 })
3013 .max()
3014 .unwrap_or(0)
3015 .max("LAST_RUN".len());
3016 let status_w = "STATUS".len();
3017
3018 let mut header = String::new();
3020 let _ = write!(
3021 &mut header,
3022 " {:<status_w$} {:<name_w$} {:<phase_w$} {:<when_run_w$} {:<last_w$} WHEN",
3023 "STATUS", "NAME", "PHASE", "WHEN_RUN", "LAST_RUN"
3024 );
3025 if color {
3026 println!("{}", header.bold());
3027 } else {
3028 println!("{header}");
3029 }
3030
3031 let bar = |n: usize| icons.sep.to_string().repeat(n);
3033 let sep = format!(
3034 " {} {} {} {} {} {}",
3035 bar(status_w),
3036 bar(name_w),
3037 bar(phase_w),
3038 bar(when_run_w),
3039 bar(last_w),
3040 bar("WHEN".len())
3041 );
3042 if color {
3043 println!("{}", sep.dimmed());
3044 } else {
3045 println!("{sep}");
3046 }
3047
3048 for r in rows {
3050 let (icon, ran) = match (r.active, r.last_run_at.is_some()) {
3055 (false, _) => (icons.inactive, false),
3056 (true, true) => (icons.active, true),
3057 (true, false) => (icons.info, false),
3058 };
3059 let last = r.last_run_at.as_deref().unwrap_or("(never)");
3060 let when_str = r
3061 .when
3062 .as_deref()
3063 .map(strip_braces)
3064 .unwrap_or_else(|| "(always)".to_string());
3065
3066 let cell_status = format!("{icon:<status_w$}");
3067 let cell_name = format!("{:<name_w$}", r.name);
3068 let cell_phase = format!("{:<phase_w$}", r.phase);
3069 let cell_when_run = format!("{:<when_run_w$}", r.when_run);
3070 let cell_last = format!("{last:<last_w$}");
3071
3072 if !color {
3073 println!(
3074 " {cell_status} {cell_name} {cell_phase} {cell_when_run} {cell_last} {when_str}"
3075 );
3076 continue;
3077 }
3078
3079 if !r.active {
3083 println!(
3084 " {} {} {} {} {} {}",
3085 cell_status.dimmed(),
3086 cell_name.dimmed(),
3087 cell_phase.dimmed(),
3088 cell_when_run.dimmed(),
3089 cell_last.dimmed(),
3090 when_str.dimmed()
3091 );
3092 } else if ran {
3093 println!(
3094 " {} {} {} {} {} {}",
3095 cell_status.green(),
3096 cell_name.cyan().bold(),
3097 cell_phase.dimmed(),
3098 cell_when_run.dimmed(),
3099 cell_last.green(),
3100 when_str.dimmed()
3101 );
3102 } else {
3103 println!(
3104 " {} {} {} {} {} {}",
3105 cell_status.yellow(),
3106 cell_name.cyan().bold(),
3107 cell_phase.dimmed(),
3108 cell_when_run.dimmed(),
3109 cell_last.yellow(),
3110 when_str.dimmed()
3111 );
3112 }
3113 }
3114}
3115
3116pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
3120 let source = resolve_source(source)?;
3121 let yui = YuiVars::detect(&source);
3122 let config = config::load(&source, &yui)?;
3123 let mut engine = template::Engine::new();
3124 let tera_ctx = template::template_context(&yui, &config.vars);
3125
3126 let targets: Vec<&config::HookConfig> = match &name {
3127 Some(want) => {
3128 let m = config
3129 .hook
3130 .iter()
3131 .find(|h| &h.name == want)
3132 .ok_or_else(|| {
3133 anyhow::anyhow!(
3134 "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
3135 )
3136 })?;
3137 vec![m]
3138 }
3139 None => config.hook.iter().collect(),
3140 };
3141
3142 let mut state = hook::State::load(&source)?;
3143 for h in targets {
3144 let outcome = hook::run_hook(
3145 h,
3146 &source,
3147 &yui,
3148 &config.vars,
3149 &mut engine,
3150 &tera_ctx,
3151 &mut state,
3152 false,
3153 force,
3154 )?;
3155 let label = match outcome {
3156 HookOutcome::Ran => "ran",
3157 HookOutcome::SkippedOnce => "skipped (once: already ran)",
3158 HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
3159 HookOutcome::SkippedWhenFalse => "skipped (when=false)",
3160 HookOutcome::DryRun => "would run (dry-run)",
3161 };
3162 info!("hook[{}]: {label}", h.name);
3163 if outcome == HookOutcome::Ran {
3164 state.save(&source)?;
3165 }
3166 }
3167 Ok(())
3168}
3169
3170#[allow(clippy::too_many_arguments)]
3175fn process_mount(
3176 m: &ResolvedMount,
3177 ctx: &ApplyCtx<'_>,
3178 engine: &mut template::Engine,
3179 tera_ctx: &TeraContext,
3180 yuiignore: &mut paths::YuiIgnoreStack,
3181) -> Result<()> {
3182 let src_root = m.src.clone();
3185 if !src_root.is_dir() {
3186 warn!("mount src missing: {src_root}");
3187 return Ok(());
3188 }
3189 walk_and_link(
3190 &src_root, &m.dst, ctx, m.strategy, engine, tera_ctx, yuiignore, false,
3191 )
3192}
3193
3194#[allow(clippy::too_many_arguments)]
3195fn walk_and_link(
3196 src_dir: &Utf8Path,
3197 dst_dir: &Utf8Path,
3198 ctx: &ApplyCtx<'_>,
3199 strategy: MountStrategy,
3200 engine: &mut template::Engine,
3201 tera_ctx: &TeraContext,
3202 yuiignore: &mut paths::YuiIgnoreStack,
3203 parent_covered: bool,
3204) -> Result<()> {
3205 if yuiignore.is_ignored(src_dir, true) {
3208 return Ok(());
3209 }
3210 yuiignore.push_dir(src_dir)?;
3213 let result = walk_and_link_body(
3214 src_dir,
3215 dst_dir,
3216 ctx,
3217 strategy,
3218 engine,
3219 tera_ctx,
3220 yuiignore,
3221 parent_covered,
3222 );
3223 yuiignore.pop_dir(src_dir);
3224 result
3225}
3226
3227#[allow(clippy::too_many_arguments)]
3228fn walk_and_link_body(
3229 src_dir: &Utf8Path,
3230 dst_dir: &Utf8Path,
3231 ctx: &ApplyCtx<'_>,
3232 strategy: MountStrategy,
3233 engine: &mut template::Engine,
3234 tera_ctx: &TeraContext,
3235 yuiignore: &mut paths::YuiIgnoreStack,
3236 parent_covered: bool,
3237) -> Result<()> {
3238 let marker_filename = &ctx.config.mount.marker_filename;
3239 let mut covered = parent_covered;
3240
3241 if strategy == MountStrategy::Marker {
3242 match marker::read_spec(src_dir, marker_filename)? {
3243 None => {} Some(MarkerSpec::PassThrough) => {
3245 link_dir_with_backup(src_dir, dst_dir, ctx)?;
3249 covered = true;
3250 }
3251 Some(MarkerSpec::Explicit { links }) => {
3252 let mut emitted_dir_link = false;
3253 let mut emitted_any = false;
3254 for link in &links {
3255 if let Some(when) = &link.when {
3258 if !template::eval_truthy(when, engine, tera_ctx)? {
3259 continue;
3260 }
3261 }
3262 let dst_str = engine.render(&link.dst, tera_ctx)?;
3263 let dst = paths::expand_tilde(dst_str.trim());
3264 if let Some(filename) = &link.src {
3265 let file_src = src_dir.join(filename);
3266 if !file_src.is_file() {
3267 anyhow::bail!(
3268 "marker at {src_dir}: [[link]] src={filename:?} \
3269 not found"
3270 );
3271 }
3272 link_file_with_backup(&file_src, &dst, ctx)?;
3273 } else {
3274 link_dir_with_backup(src_dir, &dst, ctx)?;
3275 emitted_dir_link = true;
3276 }
3277 emitted_any = true;
3278 }
3279 if !emitted_any {
3280 info!(
3285 "marker at {src_dir} had no active links \
3286 — falling back to defaults"
3287 );
3288 }
3289 if emitted_dir_link {
3290 covered = true;
3291 }
3292 }
3293 }
3294 }
3295
3296 for entry in std::fs::read_dir(src_dir)? {
3297 let entry = entry?;
3298 let name_os = entry.file_name();
3299 let Some(name) = name_os.to_str() else {
3300 continue;
3301 };
3302 if name == marker_filename {
3303 continue;
3304 }
3305 if name.ends_with(".tera") {
3306 continue;
3308 }
3309 let src_path = src_dir.join(name);
3310 let dst_path = dst_dir.join(name);
3311 let ft = entry.file_type()?;
3312
3313 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
3314 continue;
3315 }
3316
3317 if ft.is_dir() {
3318 walk_and_link(
3319 &src_path, &dst_path, ctx, strategy, engine, tera_ctx, yuiignore, covered,
3320 )?;
3321 } else if ft.is_file() {
3322 if !covered {
3328 link_file_with_backup(&src_path, &dst_path, ctx)?;
3329 }
3330 }
3331 }
3332 Ok(())
3333}
3334
3335fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3336 use absorb::AbsorbDecision::*;
3337
3338 if ctx.quit_requested.get() {
3339 return Ok(());
3340 }
3341
3342 let decision = absorb::classify(src, dst)?;
3343
3344 if ctx.dry_run {
3345 info!("[dry-run] {decision:?}: {src} → {dst}");
3346 return Ok(());
3347 }
3348
3349 match decision {
3350 InSync => {
3351 Ok(())
3353 }
3354 Restore => {
3355 info!("link: {src} → {dst}");
3356 link::link_file(src, dst, ctx.file_mode)?;
3357 Ok(())
3358 }
3359 RelinkOnly => {
3360 info!("relink: {src} → {dst}");
3363 link::unlink(dst)?;
3364 link::link_file(src, dst, ctx.file_mode)?;
3365 Ok(())
3366 }
3367 AutoAbsorb => {
3368 if !ctx.config.absorb.auto {
3371 return handle_anomaly(
3372 src,
3373 dst,
3374 ctx,
3375 "absorb.auto = false; treating divergence as anomaly",
3376 );
3377 }
3378 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
3379 return handle_anomaly(
3380 src,
3381 dst,
3382 ctx,
3383 "source repo is dirty; deferring auto-absorb",
3384 );
3385 }
3386 absorb_target_into_source(src, dst, ctx)
3387 }
3388 NeedsConfirm => handle_anomaly(
3389 src,
3390 dst,
3391 ctx,
3392 "anomaly: source equals/newer than target but content differs",
3393 ),
3394 }
3395}
3396
3397fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3401 info!("absorb: {dst} → {src}");
3402 backup_existing(src, ctx.backup_root, false)?;
3403 std::fs::copy(dst, src)?;
3404 link::unlink(dst)?;
3405 link::link_file(src, dst, ctx.file_mode)?;
3406 Ok(())
3407}
3408
3409fn overwrite_source_into_target(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3415 info!("overwrite: {src} → {dst}");
3416 backup_existing(dst, ctx.backup_root, false)?;
3417 link::unlink(dst)?;
3418 link::link_file(src, dst, ctx.file_mode)?;
3419 Ok(())
3420}
3421
3422fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
3428 use crate::config::AnomalyAction::*;
3429 match ctx.config.absorb.on_anomaly {
3430 Skip => {
3431 warn!("anomaly skip: {dst} ({reason})");
3432 Ok(())
3433 }
3434 Force => {
3435 warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
3436 absorb_target_into_source(src, dst, ctx)
3437 }
3438 Ask => match prompt_anomaly(ctx, src, dst, reason)? {
3439 AnomalyChoice::Absorb => absorb_target_into_source(src, dst, ctx),
3440 AnomalyChoice::Overwrite => overwrite_source_into_target(src, dst, ctx),
3441 AnomalyChoice::Skip => {
3442 warn!("anomaly skipped by user: {dst}");
3443 Ok(())
3444 }
3445 AnomalyChoice::Quit => {
3446 warn!("anomaly: user requested quit; stopping apply at {dst}");
3447 ctx.quit_requested.set(true);
3448 Ok(())
3449 }
3450 },
3451 }
3452}
3453
3454fn prompt_anomaly(
3470 ctx: &ApplyCtx<'_>,
3471 src: &Utf8Path,
3472 dst: &Utf8Path,
3473 reason: &str,
3474) -> Result<AnomalyChoice> {
3475 if ctx.quit_requested.get() {
3480 return Ok(AnomalyChoice::Quit);
3481 }
3482 if let Some(c) = ctx.sticky_anomaly.get() {
3483 return Ok(c);
3484 }
3485
3486 use std::io::IsTerminal;
3487 use std::io::Write as _;
3488 if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
3489 return Ok(AnomalyChoice::Skip);
3490 }
3491
3492 eprintln!();
3493 eprintln!("anomaly: {reason}");
3494 eprintln!(" src: {src}");
3495 eprintln!(" dst: {dst}");
3496 print_absorb_diff(src, dst);
3497
3498 loop {
3499 eprintln!(" [a/A] absorb target → source (this / all remaining)");
3500 eprintln!(" [o/O] overwrite source → target (this / all remaining)");
3501 eprintln!(" [s/S] skip leave as-is (this / all remaining)");
3502 eprintln!(" [d] diff re-show the diff");
3503 eprintln!(" [q] quit skip this and stop apply");
3504 eprint!("choice [s]: ");
3505 std::io::stderr().flush().ok();
3506
3507 let mut input = String::new();
3508 std::io::stdin().read_line(&mut input)?;
3509 let trimmed = input.trim();
3510 let choice = match trimmed {
3514 "" | "s" | "n" => AnomalyChoice::Skip,
3515 "a" | "y" => AnomalyChoice::Absorb,
3516 "o" => AnomalyChoice::Overwrite,
3517 "q" => AnomalyChoice::Quit,
3518 "A" => {
3519 ctx.sticky_anomaly.set(Some(AnomalyChoice::Absorb));
3520 AnomalyChoice::Absorb
3521 }
3522 "O" => {
3523 ctx.sticky_anomaly.set(Some(AnomalyChoice::Overwrite));
3524 AnomalyChoice::Overwrite
3525 }
3526 "S" => {
3527 ctx.sticky_anomaly.set(Some(AnomalyChoice::Skip));
3528 AnomalyChoice::Skip
3529 }
3530 "d" => {
3531 print_absorb_diff(src, dst);
3532 continue;
3533 }
3534 other => {
3535 eprintln!("unknown choice: {other:?}");
3536 continue;
3537 }
3538 };
3539 return Ok(choice);
3540 }
3541}
3542
3543fn source_repo_is_clean(source: &Utf8Path) -> bool {
3548 match crate::git::is_clean(source) {
3549 Ok(b) => b,
3550 Err(e) => {
3551 warn!("git clean check failed at {source}: {e} — treating as clean");
3552 true
3553 }
3554 }
3555}
3556
3557fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3558 use absorb::AbsorbDecision::*;
3559
3560 if ctx.quit_requested.get() {
3561 return Ok(());
3562 }
3563
3564 let decision = absorb::classify(src, dst)?;
3565
3566 if ctx.dry_run {
3567 info!("[dry-run] dir {decision:?}: {src} → {dst}");
3568 return Ok(());
3569 }
3570
3571 match decision {
3572 InSync => Ok(()),
3573 Restore => {
3574 info!("link dir: {src} → {dst}");
3575 link::link_dir(src, dst, ctx.dir_mode)?;
3576 Ok(())
3577 }
3578 RelinkOnly => {
3579 info!("relink dir: {src} → {dst}");
3584 remove_dir_link_or_real(dst)?;
3585 link::link_dir(src, dst, ctx.dir_mode)?;
3586 Ok(())
3587 }
3588 AutoAbsorb | NeedsConfirm => {
3589 if !ctx.config.absorb.auto {
3610 return handle_anomaly_dir(
3611 src,
3612 dst,
3613 ctx,
3614 "absorb.auto = false; treating divergence as anomaly",
3615 );
3616 }
3617 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
3618 return handle_anomaly_dir(
3619 src,
3620 dst,
3621 ctx,
3622 "source repo is dirty; deferring auto-absorb",
3623 );
3624 }
3625 absorb_target_dir_into_source(src, dst, ctx)
3626 }
3627 }
3628}
3629
3630fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
3640 if let Err(unlink_err) = link::unlink(dst) {
3641 let meta = std::fs::symlink_metadata(dst)
3642 .with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
3643 let ft = meta.file_type();
3644 if ft.is_dir() && !ft.is_symlink() {
3645 std::fs::remove_dir_all(dst).with_context(|| {
3646 format!(
3647 "remove_dir_all({dst}) after link::unlink failed: \
3648 {unlink_err}"
3649 )
3650 })?;
3651 } else {
3652 return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
3653 }
3654 }
3655 Ok(())
3656}
3657
3658fn merge_dir_target_into_source(
3668 target: &Utf8Path,
3669 source: &Utf8Path,
3670 ctx: &ApplyCtx<'_>,
3671) -> Result<()> {
3672 for entry in std::fs::read_dir(target)? {
3673 if ctx.quit_requested.get() {
3680 return Ok(());
3681 }
3682 let entry = entry?;
3683 let name_os = entry.file_name();
3684 let Some(name) = name_os.to_str() else {
3685 continue;
3686 };
3687 let target_path = target.join(name);
3688 let source_path = source.join(name);
3689 let ft = entry.file_type()?;
3690
3691 if ft.is_dir() && !ft.is_symlink() {
3692 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
3698 let sft = src_meta.file_type();
3699 if !sft.is_dir() || sft.is_symlink() {
3700 link::unlink(&source_path).with_context(|| {
3701 format!("remove conflicting source entry before dir merge: {source_path}")
3702 })?;
3703 }
3704 }
3705 if !source_path.exists() {
3706 std::fs::create_dir_all(&source_path).with_context(|| {
3707 format!("create_dir_all({source_path}) during target→source merge")
3708 })?;
3709 }
3710 merge_dir_target_into_source(&target_path, &source_path, ctx)?;
3711 } else if ft.is_file() {
3712 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
3716 let sft = src_meta.file_type();
3717 if sft.is_dir() && !sft.is_symlink() {
3718 remove_dir_link_or_real(&source_path).with_context(|| {
3719 format!("remove conflicting source dir before file merge: {source_path}")
3720 })?;
3721 } else if sft.is_symlink() {
3722 link::unlink(&source_path).with_context(|| {
3723 format!(
3724 "remove conflicting source symlink before file merge: {source_path}"
3725 )
3726 })?;
3727 }
3728 }
3729 if let Some(parent) = source_path.parent() {
3730 if !parent.exists() {
3731 std::fs::create_dir_all(parent)?;
3732 }
3733 }
3734 if source_path.is_file() {
3748 merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
3749 } else {
3750 std::fs::copy(&target_path, &source_path)
3751 .with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
3752 }
3753 } else {
3754 warn!(
3755 "merge: skipping non-regular entry {target_path} \
3756 (symlink / junction / special — content not copied)"
3757 );
3758 }
3759 }
3760 Ok(())
3761}
3762
3763fn merge_resolve_file_conflict(
3777 target_path: &Utf8Path,
3778 source_path: &Utf8Path,
3779 ctx: &ApplyCtx<'_>,
3780) -> Result<()> {
3781 use absorb::AbsorbDecision::*;
3782 let decision = absorb::classify(source_path, target_path)?;
3783 match decision {
3784 InSync | RelinkOnly => Ok(()),
3785 AutoAbsorb => {
3786 std::fs::copy(target_path, source_path).with_context(|| {
3787 format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
3788 })?;
3789 Ok(())
3790 }
3791 Restore => {
3792 unreachable!(
3799 "merge_resolve_file_conflict reached with both files present, \
3800 but classify returned Restore (target {target_path} / source {source_path})"
3801 )
3802 }
3803 NeedsConfirm => {
3804 use crate::config::AnomalyAction::*;
3805 match ctx.config.absorb.on_anomaly {
3806 Skip => {
3807 warn!(
3808 "merge anomaly skip: {target_path} (source-newer / content drift) \
3809 — keeping source version, target version dropped"
3810 );
3811 Ok(())
3812 }
3813 Force => {
3814 warn!(
3815 "merge anomaly force: {target_path} \
3816 (source-newer / content drift) — overwriting source"
3817 );
3818 std::fs::copy(target_path, source_path)?;
3819 Ok(())
3820 }
3821 Ask => {
3822 let choice = prompt_anomaly(
3823 ctx,
3824 source_path,
3825 target_path,
3826 "merge: file content differs and source is newer",
3827 )?;
3828 match choice {
3829 AnomalyChoice::Absorb => {
3830 std::fs::copy(target_path, source_path)?;
3831 Ok(())
3832 }
3833 AnomalyChoice::Overwrite => {
3834 backup_existing(target_path, ctx.backup_root, false)?;
3841 std::fs::copy(source_path, target_path)?;
3842 Ok(())
3843 }
3844 AnomalyChoice::Skip => {
3845 warn!("merge: kept source version by user choice: {source_path}");
3846 Ok(())
3847 }
3848 AnomalyChoice::Quit => {
3849 warn!("merge: user requested quit; stopping at {target_path}");
3850 ctx.quit_requested.set(true);
3851 Ok(())
3852 }
3853 }
3854 }
3855 }
3856 }
3857 }
3858}
3859
3860fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3867 info!("absorb dir: {dst} → {src}");
3868 backup_existing(src, ctx.backup_root, true)?;
3869 merge_dir_target_into_source(dst, src, ctx)?;
3870 if ctx.quit_requested.get() {
3877 warn!(
3878 "absorb dir interrupted by user quit: {dst} \
3879 — leaving target tree intact; source backup at {}",
3880 ctx.backup_root
3881 );
3882 return Ok(());
3883 }
3884 remove_dir_link_or_real(dst)?;
3887 link::link_dir(src, dst, ctx.dir_mode)?;
3888 Ok(())
3889}
3890
3891fn overwrite_source_dir_into_target(
3896 src: &Utf8Path,
3897 dst: &Utf8Path,
3898 ctx: &ApplyCtx<'_>,
3899) -> Result<()> {
3900 info!("overwrite dir: {src} → {dst}");
3901 backup_existing(dst, ctx.backup_root, true)?;
3902 remove_dir_link_or_real(dst)?;
3903 link::link_dir(src, dst, ctx.dir_mode)?;
3904 Ok(())
3905}
3906
3907fn handle_anomaly_dir(
3911 src: &Utf8Path,
3912 dst: &Utf8Path,
3913 ctx: &ApplyCtx<'_>,
3914 reason: &str,
3915) -> Result<()> {
3916 use crate::config::AnomalyAction::*;
3917 match ctx.config.absorb.on_anomaly {
3918 Skip => {
3919 warn!("anomaly skip dir: {dst} ({reason})");
3920 Ok(())
3921 }
3922 Force => {
3923 warn!(
3924 "anomaly force dir: {dst} ({reason}) \
3925 — absorbing target into source"
3926 );
3927 absorb_target_dir_into_source(src, dst, ctx)
3928 }
3929 Ask => match prompt_anomaly(ctx, src, dst, reason)? {
3930 AnomalyChoice::Absorb => absorb_target_dir_into_source(src, dst, ctx),
3931 AnomalyChoice::Overwrite => overwrite_source_dir_into_target(src, dst, ctx),
3932 AnomalyChoice::Skip => {
3933 warn!("anomaly skipped by user: {dst}");
3934 Ok(())
3935 }
3936 AnomalyChoice::Quit => {
3937 warn!("anomaly dir: user requested quit; stopping apply at {dst}");
3938 ctx.quit_requested.set(true);
3939 Ok(())
3940 }
3941 },
3942 }
3943}
3944
3945fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
3946 let abs_target = absolutize(target)?;
3947 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
3948 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
3949 info!("backup → {bp}");
3950 if is_dir {
3951 backup::backup_dir(target, &bp)?;
3952 } else {
3953 backup::backup_file(target, &bp)?;
3954 }
3955 Ok(())
3956}
3957
3958fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
3959 if let Some(s) = source {
3960 return absolutize(&s);
3961 }
3962 if let Ok(s) = std::env::var("YUI_SOURCE") {
3963 return absolutize(Utf8Path::new(&s));
3964 }
3965 let cwd = current_dir_utf8()?;
3966 for ancestor in cwd.ancestors() {
3967 if ancestor.join("config.toml").is_file() {
3968 return Ok(ancestor.to_path_buf());
3969 }
3970 }
3971 if let Some(home) = paths::home_dir() {
3972 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
3973 let p = home.join(c);
3974 if p.join("config.toml").is_file() {
3975 return Ok(p);
3976 }
3977 }
3978 }
3979 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
3980}
3981
3982fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
3983 let expanded = paths::expand_tilde(p.as_str());
3985 if expanded.is_absolute() {
3986 return Ok(expanded);
3987 }
3988 let cwd = current_dir_utf8()?;
3989 Ok(cwd.join(expanded))
3990}
3991
3992fn current_dir_utf8() -> Result<Utf8PathBuf> {
3993 let cwd = std::env::current_dir().context("getting cwd")?;
3994 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
3995}
3996
3997const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
4001
4002[vars]
4003# user-defined values; templates can reference these as {{ vars.foo }}
4004
4005# [link]
4006# file_mode = "auto" # auto | symlink | hardlink
4007# dir_mode = "auto" # auto | symlink | junction
4008
4009[mount]
4010default_strategy = "marker"
4011
4012[[mount.entry]]
4013src = "home"
4014# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
4015dst = "~"
4016
4017# [[mount.entry]]
4018# src = "appdata"
4019# dst = "{{ env(name='APPDATA') }}"
4020# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
4021# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
4022# when = "yui.os == 'windows'"
4023"#;
4024
4025const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
4026# .yui/bin/ is intentionally tracked — it holds your hook scripts.
4027/.yui/state.json
4028/.yui/state.json.tmp
4029/.yui/backup/
4030
4031# >>> yui rendered (auto-managed, do not edit) >>>
4032# <<< yui rendered (auto-managed) <<<
4033
4034# config.local.toml is per-machine; commit a config.local.example.toml instead.
4035config.local.toml
4036"#;
4037
4038#[cfg(test)]
4039mod tests {
4040 use super::*;
4041 use tempfile::TempDir;
4042
4043 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
4044 Utf8PathBuf::from_path_buf(p).unwrap()
4045 }
4046
4047 fn toml_path(p: &Utf8Path) -> String {
4049 p.as_str().replace('\\', "/")
4050 }
4051
4052 #[test]
4053 fn apply_links_a_raw_file() {
4054 let tmp = TempDir::new().unwrap();
4055 let source = utf8(tmp.path().join("dotfiles"));
4056 let target = utf8(tmp.path().join("target"));
4057 std::fs::create_dir_all(source.join("home")).unwrap();
4058 std::fs::create_dir_all(&target).unwrap();
4059 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4060
4061 let cfg = format!(
4062 r#"
4063[[mount.entry]]
4064src = "home"
4065dst = "{}"
4066"#,
4067 toml_path(&target)
4068 );
4069 std::fs::write(source.join("config.toml"), cfg).unwrap();
4070
4071 apply(Some(source), false).unwrap();
4072
4073 let linked = target.join(".bashrc");
4074 assert!(linked.exists(), "expected {linked} to exist");
4075 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
4076 }
4077
4078 #[test]
4079 fn apply_with_marker_links_whole_directory() {
4080 let tmp = TempDir::new().unwrap();
4081 let source = utf8(tmp.path().join("dotfiles"));
4082 let target = utf8(tmp.path().join("target"));
4083 let nvim_src = source.join("home/nvim");
4084 std::fs::create_dir_all(&nvim_src).unwrap();
4085 std::fs::create_dir_all(&target).unwrap();
4086 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
4087 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
4088 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
4089
4090 let cfg = format!(
4091 r#"
4092[[mount.entry]]
4093src = "home"
4094dst = "{}"
4095"#,
4096 toml_path(&target)
4097 );
4098 std::fs::write(source.join("config.toml"), cfg).unwrap();
4099
4100 apply(Some(source.clone()), false).unwrap();
4101
4102 let nvim_dst = target.join("nvim");
4103 assert!(nvim_dst.exists());
4104 assert_eq!(
4105 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
4106 "-- hi\n"
4107 );
4108 }
4112
4113 #[test]
4114 fn apply_dry_run_does_not_write() {
4115 let tmp = TempDir::new().unwrap();
4116 let source = utf8(tmp.path().join("dotfiles"));
4117 let target = utf8(tmp.path().join("target"));
4118 std::fs::create_dir_all(source.join("home")).unwrap();
4119 std::fs::create_dir_all(&target).unwrap();
4120 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
4121
4122 let cfg = format!(
4123 r#"
4124[[mount.entry]]
4125src = "home"
4126dst = "{}"
4127"#,
4128 toml_path(&target)
4129 );
4130 std::fs::write(source.join("config.toml"), cfg).unwrap();
4131
4132 apply(Some(source), true).unwrap();
4133
4134 assert!(!target.join(".bashrc").exists());
4135 }
4136
4137 #[test]
4138 fn apply_renders_templates_then_links_rendered_outputs() {
4139 let tmp = TempDir::new().unwrap();
4140 let source = utf8(tmp.path().join("dotfiles"));
4141 let target = utf8(tmp.path().join("target"));
4142 std::fs::create_dir_all(source.join("home")).unwrap();
4143 std::fs::create_dir_all(&target).unwrap();
4144 std::fs::write(
4145 source.join("home/.gitconfig.tera"),
4146 "[user]\n os = {{ yui.os }}\n",
4147 )
4148 .unwrap();
4149 std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
4150
4151 let cfg = format!(
4152 r#"
4153[[mount.entry]]
4154src = "home"
4155dst = "{}"
4156"#,
4157 toml_path(&target)
4158 );
4159 std::fs::write(source.join("config.toml"), cfg).unwrap();
4160
4161 apply(Some(source.clone()), false).unwrap();
4162
4163 assert!(target.join(".bashrc").exists());
4165 assert!(source.join("home/.gitconfig").exists());
4167 assert!(target.join(".gitconfig").exists());
4168 assert!(!target.join(".gitconfig.tera").exists());
4170 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
4172 assert!(linked.contains("os = "));
4173 }
4174
4175 #[test]
4176 fn apply_marker_override_links_to_custom_dst() {
4177 let tmp = TempDir::new().unwrap();
4178 let source = utf8(tmp.path().join("dotfiles"));
4179 let target_a = utf8(tmp.path().join("target_a"));
4180 let target_b = utf8(tmp.path().join("target_b"));
4181 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
4182 std::fs::create_dir_all(&target_a).unwrap();
4183 std::fs::create_dir_all(&target_b).unwrap();
4184 std::fs::write(
4185 source.join("home/.config/nvim/init.lua"),
4186 "-- nvim config\n",
4187 )
4188 .unwrap();
4189
4190 std::fs::write(
4193 source.join("home/.config/nvim/.yuilink"),
4194 format!(
4195 r#"
4196[[link]]
4197dst = "{}/nvim"
4198
4199[[link]]
4200dst = "{}/nvim"
4201when = "{{{{ yui.os == '{}' }}}}"
4202"#,
4203 toml_path(&target_a),
4204 toml_path(&target_b),
4205 std::env::consts::OS
4206 ),
4207 )
4208 .unwrap();
4209
4210 let parent_target = utf8(tmp.path().join("parent_target"));
4211 std::fs::create_dir_all(&parent_target).unwrap();
4212 let cfg = format!(
4213 r#"
4214[[mount.entry]]
4215src = "home"
4216dst = "{}"
4217"#,
4218 toml_path(&parent_target)
4219 );
4220 std::fs::write(source.join("config.toml"), cfg).unwrap();
4221
4222 apply(Some(source.clone()), false).unwrap();
4223
4224 assert!(
4226 target_a.join("nvim/init.lua").exists(),
4227 "target_a/nvim/init.lua should be reachable through the link"
4228 );
4229 assert!(
4230 target_b.join("nvim/init.lua").exists(),
4231 "target_b/nvim/init.lua should be reachable through the link"
4232 );
4233 assert!(
4236 !parent_target.join(".config/nvim").exists(),
4237 "parent mount should have skipped the marker-claimed sub-dir"
4238 );
4239 }
4240
4241 #[test]
4242 fn apply_marker_inactive_link_falls_through_to_default() {
4243 let tmp = TempDir::new().unwrap();
4248 let source = utf8(tmp.path().join("dotfiles"));
4249 let target_inactive = utf8(tmp.path().join("inactive"));
4250 let parent_target = utf8(tmp.path().join("parent"));
4251 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
4252 std::fs::create_dir_all(&parent_target).unwrap();
4253 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
4254
4255 std::fs::write(
4257 source.join("home/.config/nvim/.yuilink"),
4258 format!(
4259 r#"
4260[[link]]
4261dst = "{}/nvim"
4262when = "{{{{ yui.os == 'no-such-os' }}}}"
4263"#,
4264 toml_path(&target_inactive)
4265 ),
4266 )
4267 .unwrap();
4268
4269 let cfg = format!(
4270 r#"
4271[[mount.entry]]
4272src = "home"
4273dst = "{}"
4274"#,
4275 toml_path(&parent_target)
4276 );
4277 std::fs::write(source.join("config.toml"), cfg).unwrap();
4278
4279 apply(Some(source.clone()), false).unwrap();
4280
4281 assert!(!target_inactive.join("nvim").exists());
4283 assert!(parent_target.join(".config/nvim/init.lua").exists());
4286 }
4287
4288 #[test]
4289 fn list_shows_mount_entries_and_marker_overrides() {
4290 let tmp = TempDir::new().unwrap();
4291 let source = utf8(tmp.path().join("dotfiles"));
4292 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
4293 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
4294 std::fs::write(
4295 source.join("home/.config/nvim/.yuilink"),
4296 r#"
4297[[link]]
4298dst = "/custom/nvim"
4299"#,
4300 )
4301 .unwrap();
4302 std::fs::write(
4303 source.join("config.toml"),
4304 r#"
4305[[mount.entry]]
4306src = "home"
4307dst = "/h"
4308"#,
4309 )
4310 .unwrap();
4311
4312 list(Some(source), false, None, true).unwrap();
4315 }
4316
4317 #[test]
4318 fn status_reports_in_sync_after_apply() {
4319 let tmp = TempDir::new().unwrap();
4320 let source = utf8(tmp.path().join("dotfiles"));
4321 let target = utf8(tmp.path().join("target"));
4322 std::fs::create_dir_all(source.join("home")).unwrap();
4323 std::fs::create_dir_all(&target).unwrap();
4324 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4325 let cfg = format!(
4326 r#"
4327[[mount.entry]]
4328src = "home"
4329dst = "{}"
4330"#,
4331 toml_path(&target)
4332 );
4333 std::fs::write(source.join("config.toml"), cfg).unwrap();
4334 apply(Some(source.clone()), false).unwrap();
4336 status(Some(source), None, true).unwrap();
4338 }
4339
4340 #[test]
4341 fn status_reports_template_drift() {
4342 let tmp = TempDir::new().unwrap();
4343 let source = utf8(tmp.path().join("dotfiles"));
4344 let target = utf8(tmp.path().join("target"));
4345 std::fs::create_dir_all(source.join("home")).unwrap();
4346 std::fs::create_dir_all(&target).unwrap();
4347 std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
4350 std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
4351
4352 let cfg = format!(
4353 r#"
4354[[mount.entry]]
4355src = "home"
4356dst = "{}"
4357"#,
4358 toml_path(&target)
4359 );
4360 std::fs::write(source.join("config.toml"), cfg).unwrap();
4361
4362 let err = status(Some(source), None, true).unwrap_err();
4363 assert!(format!("{err}").contains("diverged"));
4364 }
4365
4366 #[test]
4367 fn status_fails_when_target_missing() {
4368 let tmp = TempDir::new().unwrap();
4369 let source = utf8(tmp.path().join("dotfiles"));
4370 let target = utf8(tmp.path().join("target"));
4371 std::fs::create_dir_all(source.join("home")).unwrap();
4372 std::fs::create_dir_all(&target).unwrap();
4373 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4374 let cfg = format!(
4375 r#"
4376[[mount.entry]]
4377src = "home"
4378dst = "{}"
4379"#,
4380 toml_path(&target)
4381 );
4382 std::fs::write(source.join("config.toml"), cfg).unwrap();
4383 let err = status(Some(source), None, true).unwrap_err();
4385 assert!(format!("{err}").contains("diverged"));
4386 }
4387
4388 #[test]
4389 fn strip_braces_removes_outer_template_braces() {
4390 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
4391 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
4392 assert_eq!(strip_braces(" {{x}} "), "x");
4393 }
4394
4395 #[test]
4396 fn apply_aborts_on_render_drift() {
4397 let tmp = TempDir::new().unwrap();
4398 let source = utf8(tmp.path().join("dotfiles"));
4399 let target = utf8(tmp.path().join("target"));
4400 std::fs::create_dir_all(source.join("home")).unwrap();
4401 std::fs::create_dir_all(&target).unwrap();
4402 std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
4403 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
4404
4405 let cfg = format!(
4406 r#"
4407[[mount.entry]]
4408src = "home"
4409dst = "{}"
4410"#,
4411 toml_path(&target)
4412 );
4413 std::fs::write(source.join("config.toml"), cfg).unwrap();
4414
4415 let err = apply(Some(source.clone()), false).unwrap_err();
4416 assert!(format!("{err}").contains("drift"));
4417 assert_eq!(
4419 std::fs::read_to_string(source.join("home/foo")).unwrap(),
4420 "manually edited"
4421 );
4422 assert!(!target.join("foo").exists());
4424 }
4425
4426 #[test]
4427 fn init_creates_skeleton_when_dir_empty() {
4428 let tmp = TempDir::new().unwrap();
4429 let dir = utf8(tmp.path().join("new_dotfiles"));
4430 init(Some(dir.clone()), false).unwrap();
4431 assert!(dir.join("config.toml").is_file());
4432 assert!(dir.join(".gitignore").is_file());
4433 }
4434
4435 #[test]
4436 fn init_refuses_to_overwrite_existing_config() {
4437 let tmp = TempDir::new().unwrap();
4438 let dir = utf8(tmp.path().join("dotfiles"));
4439 std::fs::create_dir_all(&dir).unwrap();
4440 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
4441 let err = init(Some(dir), false).unwrap_err();
4442 assert!(format!("{err}").contains("already exists"));
4443 }
4444
4445 #[test]
4451 fn init_appends_missing_gitignore_entries_into_existing_file() {
4452 let tmp = TempDir::new().unwrap();
4453 let dir = utf8(tmp.path().join("dotfiles"));
4454 std::fs::create_dir_all(&dir).unwrap();
4455 let user_gitignore = "# user entries\n*.swp\nnode_modules/\n";
4457 std::fs::write(dir.join(".gitignore"), user_gitignore).unwrap();
4458
4459 init(Some(dir.clone()), false).unwrap();
4460
4461 let body = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
4462 assert!(body.contains("*.swp"));
4464 assert!(body.contains("node_modules/"));
4465 assert!(body.contains("/.yui/state.json"));
4467 assert!(body.contains("/.yui/backup/"));
4468 assert!(body.contains("config.local.toml"));
4469 let before_rerun = body.clone();
4471 std::fs::remove_file(dir.join("config.toml")).unwrap();
4474 init(Some(dir.clone()), false).unwrap();
4475 let after_rerun = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
4476 assert_eq!(
4477 before_rerun, after_rerun,
4478 "init must be idempotent when the gitignore already has every yui entry"
4479 );
4480 }
4481
4482 #[test]
4488 fn init_with_git_hooks_installs_into_existing_repo() {
4489 let tmp = TempDir::new().unwrap();
4490 let dir = utf8(tmp.path().join("dotfiles"));
4491 std::fs::create_dir_all(&dir).unwrap();
4492 let st = std::process::Command::new("git")
4493 .args(["init", "-q"])
4494 .current_dir(dir.as_std_path())
4495 .status()
4496 .expect("git init");
4497 if !st.success() {
4498 return;
4499 }
4500 let user_config = "# user already wrote this\n";
4502 std::fs::write(dir.join("config.toml"), user_config).unwrap();
4503
4504 init(Some(dir.clone()), true).unwrap();
4506
4507 assert_eq!(
4508 std::fs::read_to_string(dir.join("config.toml")).unwrap(),
4509 user_config
4510 );
4511 assert!(dir.join(".git/hooks/pre-commit").is_file());
4512 assert!(dir.join(".git/hooks/pre-push").is_file());
4513 }
4514
4515 #[test]
4520 fn init_with_git_hooks_writes_pre_commit_and_pre_push() {
4521 let tmp = TempDir::new().unwrap();
4522 let dir = utf8(tmp.path().join("dotfiles"));
4523 std::fs::create_dir_all(&dir).unwrap();
4524 let st = std::process::Command::new("git")
4526 .args(["init", "-q"])
4527 .current_dir(dir.as_std_path())
4528 .status()
4529 .expect("git init");
4530 if !st.success() {
4531 eprintln!("skipping: git not available");
4533 return;
4534 }
4535 init(Some(dir.clone()), true).unwrap();
4536
4537 let pre_commit = dir.join(".git/hooks/pre-commit");
4538 let pre_push = dir.join(".git/hooks/pre-push");
4539 assert!(pre_commit.is_file(), "pre-commit hook should be written");
4540 assert!(pre_push.is_file(), "pre-push hook should be written");
4541
4542 let body = std::fs::read_to_string(&pre_commit).unwrap();
4543 assert!(
4544 body.contains("yui render --check"),
4545 "pre-commit hook should call `yui render --check`, got: {body}"
4546 );
4547 }
4548
4549 #[test]
4553 fn init_with_git_hooks_errors_outside_a_git_repo() {
4554 let tmp = TempDir::new().unwrap();
4555 let dir = utf8(tmp.path().join("not-a-repo"));
4556 std::fs::create_dir_all(&dir).unwrap();
4557 let err = init(Some(dir), true).unwrap_err();
4558 let msg = format!("{err:#}");
4559 assert!(
4560 msg.contains("git repo") || msg.contains("git rev-parse"),
4561 "expected error to mention the git issue, got: {msg}"
4562 );
4563 }
4564
4565 #[test]
4568 fn init_with_git_hooks_does_not_clobber_existing_hooks() {
4569 let tmp = TempDir::new().unwrap();
4570 let dir = utf8(tmp.path().join("dotfiles"));
4571 std::fs::create_dir_all(&dir).unwrap();
4572 let st = std::process::Command::new("git")
4573 .args(["init", "-q"])
4574 .current_dir(dir.as_std_path())
4575 .status()
4576 .expect("git init");
4577 if !st.success() {
4578 return;
4579 }
4580 let hooks = dir.join(".git/hooks");
4581 std::fs::create_dir_all(&hooks).unwrap();
4582 std::fs::write(hooks.join("pre-commit"), "#! /bin/sh\nexit 0\n").unwrap();
4583
4584 init(Some(dir.clone()), true).unwrap();
4585
4586 let pc = std::fs::read_to_string(hooks.join("pre-commit")).unwrap();
4588 assert!(
4589 !pc.contains("yui render --check"),
4590 "existing pre-commit must not be overwritten"
4591 );
4592 let pp = std::fs::read_to_string(hooks.join("pre-push")).unwrap();
4593 assert!(
4594 pp.contains("yui render --check"),
4595 "missing pre-push should be written: {pp}"
4596 );
4597 }
4598
4599 fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
4602 let source = utf8(tmp.path().join("dotfiles"));
4603 let target = utf8(tmp.path().join("target"));
4604 std::fs::create_dir_all(source.join("home")).unwrap();
4605 std::fs::create_dir_all(&target).unwrap();
4606 let cfg = format!(
4607 r#"
4608[[mount.entry]]
4609src = "home"
4610dst = "{}"
4611"#,
4612 toml_path(&target)
4613 );
4614 std::fs::write(source.join("config.toml"), cfg).unwrap();
4615 (source, target)
4616 }
4617
4618 fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
4619 std::fs::write(path, body).unwrap();
4620 let f = std::fs::OpenOptions::new()
4621 .write(true)
4622 .open(path)
4623 .expect("open writable");
4624 f.set_modified(when).expect("set_modified");
4625 }
4626
4627 #[test]
4628 fn apply_target_newer_absorbs_target_into_source() {
4629 let tmp = TempDir::new().unwrap();
4633 let (source, target) = setup_minimal_dotfiles(&tmp);
4634
4635 let now = std::time::SystemTime::now();
4636 let past = now - std::time::Duration::from_secs(120);
4637 write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
4638 write_with_mtime(&target.join(".bashrc"), "user's edit", now);
4640
4641 apply(Some(source.clone()), false).unwrap();
4642
4643 assert_eq!(
4645 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4646 "user's edit"
4647 );
4648 assert_eq!(
4650 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4651 "user's edit"
4652 );
4653 let backup_root = source.join(".yui/backup");
4655 let mut found_old = false;
4656 for entry in walkdir(&backup_root) {
4657 if let Ok(s) = std::fs::read_to_string(&entry) {
4658 if s == "default from repo" {
4659 found_old = true;
4660 break;
4661 }
4662 }
4663 }
4664 assert!(found_old, "expected backup containing 'default from repo'");
4665 }
4666
4667 #[test]
4668 fn apply_in_sync_target_is_a_no_op() {
4669 let tmp = TempDir::new().unwrap();
4672 let (source, target) = setup_minimal_dotfiles(&tmp);
4673 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4674 apply(Some(source.clone()), false).unwrap();
4675 let backup_root = source.join(".yui/backup");
4676 let backup_count_after_first = walkdir(&backup_root).len();
4677
4678 apply(Some(source.clone()), false).unwrap();
4680 assert_eq!(
4681 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4682 "echo hi\n"
4683 );
4684 let backup_count_after_second = walkdir(&backup_root).len();
4685 assert_eq!(
4686 backup_count_after_first, backup_count_after_second,
4687 "second apply on an in-sync tree should not produce backups"
4688 );
4689 }
4690
4691 #[test]
4692 fn apply_skip_policy_leaves_anomaly_alone() {
4693 let tmp = TempDir::new().unwrap();
4696 let source = utf8(tmp.path().join("dotfiles"));
4697 let target = utf8(tmp.path().join("target"));
4698 std::fs::create_dir_all(source.join("home")).unwrap();
4699 std::fs::create_dir_all(&target).unwrap();
4700 let cfg = format!(
4701 r#"
4702[absorb]
4703on_anomaly = "skip"
4704
4705[[mount.entry]]
4706src = "home"
4707dst = "{}"
4708"#,
4709 toml_path(&target)
4710 );
4711 std::fs::write(source.join("config.toml"), cfg).unwrap();
4712
4713 let now = std::time::SystemTime::now();
4714 let past = now - std::time::Duration::from_secs(120);
4715 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4716 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4717
4718 apply(Some(source.clone()), false).unwrap();
4719
4720 assert_eq!(
4722 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4723 "user's edit (older)"
4724 );
4725 assert_eq!(
4727 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4728 "fresh from upstream"
4729 );
4730 }
4731
4732 #[test]
4733 fn apply_force_policy_absorbs_anomaly_anyway() {
4734 let tmp = TempDir::new().unwrap();
4736 let source = utf8(tmp.path().join("dotfiles"));
4737 let target = utf8(tmp.path().join("target"));
4738 std::fs::create_dir_all(source.join("home")).unwrap();
4739 std::fs::create_dir_all(&target).unwrap();
4740 let cfg = format!(
4741 r#"
4742[absorb]
4743on_anomaly = "force"
4744
4745[[mount.entry]]
4746src = "home"
4747dst = "{}"
4748"#,
4749 toml_path(&target)
4750 );
4751 std::fs::write(source.join("config.toml"), cfg).unwrap();
4752
4753 let now = std::time::SystemTime::now();
4754 let past = now - std::time::Duration::from_secs(120);
4755 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4756 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4757
4758 apply(Some(source.clone()), false).unwrap();
4759
4760 assert_eq!(
4762 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4763 "user's edit (older)"
4764 );
4765 assert_eq!(
4766 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4767 "user's edit (older)"
4768 );
4769 }
4770
4771 #[test]
4783 fn apply_absorbs_non_empty_target_dir_target_wins() {
4784 let tmp = TempDir::new().unwrap();
4785 let source = utf8(tmp.path().join("dotfiles"));
4786 let target = utf8(tmp.path().join("target"));
4787 std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
4788 std::fs::create_dir_all(target.join(".config/app")).unwrap();
4789 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4792 std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
4793 std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
4795 std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
4798 std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
4799
4800 let cfg = format!(
4801 r#"
4802[absorb]
4803on_anomaly = "force"
4804
4805[[mount.entry]]
4806src = "home"
4807dst = "{}"
4808"#,
4809 toml_path(&target)
4810 );
4811 std::fs::write(source.join("config.toml"), cfg).unwrap();
4812
4813 apply(Some(source.clone()), false).unwrap();
4815
4816 assert_eq!(
4818 std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
4819 "target side"
4820 );
4821 assert_eq!(
4823 std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
4824 "{}"
4825 );
4826 let backup_root = source.join(".yui/backup");
4829 let mut backup_files: Vec<String> = Vec::new();
4830 for entry in walkdir(&backup_root) {
4831 if let Some(n) = entry.file_name() {
4832 backup_files.push(n.to_string());
4833 }
4834 }
4835 assert!(
4836 backup_files.iter().any(|f| f == "config.toml"),
4837 "expected source's config.toml to land in the backup tree, got {backup_files:?}"
4838 );
4839 assert!(
4841 source.join("home/.config/app/source-only.toml").exists(),
4842 "source-only file should survive a target-wins merge"
4843 );
4844 assert!(
4846 source.join("home/.config/app/state.json").exists(),
4847 "target-only state.json should be merged into source"
4848 );
4849 }
4850
4851 #[test]
4857 fn marker_dir_absorbs_with_default_ask_policy() {
4858 let tmp = TempDir::new().unwrap();
4859 let source = utf8(tmp.path().join("dotfiles"));
4860 let target = utf8(tmp.path().join("target"));
4861 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4862 std::fs::create_dir_all(target.join(".config/gh")).unwrap();
4863 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4865 std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\n").unwrap();
4867
4868 let cfg = format!(
4872 r#"
4873[[mount.entry]]
4874src = "home"
4875dst = "{}"
4876"#,
4877 toml_path(&target)
4878 );
4879 std::fs::write(source.join("config.toml"), cfg).unwrap();
4880
4881 apply(Some(source.clone()), false).unwrap();
4885
4886 assert!(target.join(".config/gh/hosts.yml").exists());
4889 assert!(source.join("home/.config/gh/hosts.yml").exists());
4890 }
4891
4892 #[test]
4898 fn merge_handles_file_vs_dir_collisions_target_wins() {
4899 let tmp = TempDir::new().unwrap();
4900 let source = utf8(tmp.path().join("dotfiles"));
4901 let target = utf8(tmp.path().join("target"));
4902 std::fs::create_dir_all(source.join("home/.config/foo")).unwrap();
4903 std::fs::create_dir_all(target.join(".config")).unwrap();
4904 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4905
4906 std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
4908 std::fs::write(target.join(".config/foo"), "target file body").unwrap();
4909 std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
4911 std::fs::create_dir_all(target.join(".config/bar")).unwrap();
4912 std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
4913
4914 let cfg = format!(
4915 r#"
4916[absorb]
4917on_anomaly = "force"
4918
4919[[mount.entry]]
4920src = "home"
4921dst = "{}"
4922"#,
4923 toml_path(&target)
4924 );
4925 std::fs::write(source.join("config.toml"), cfg).unwrap();
4926 apply(Some(source.clone()), false).unwrap();
4927
4928 let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
4932 assert!(foo_meta.file_type().is_file(), "foo should be a file");
4933 assert_eq!(
4934 std::fs::read_to_string(target.join(".config/foo")).unwrap(),
4935 "target file body"
4936 );
4937 let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
4939 assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
4940 assert_eq!(
4941 std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
4942 "target nested"
4943 );
4944 }
4945
4946 #[test]
4950 fn merge_per_file_target_newer_auto_absorbs() {
4951 let tmp = TempDir::new().unwrap();
4952 let source = utf8(tmp.path().join("dotfiles"));
4953 let target = utf8(tmp.path().join("target"));
4954 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4955 std::fs::create_dir_all(target.join(".config")).unwrap();
4956 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4957
4958 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4960 write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
4961 std::fs::write(target.join(".config/app.toml"), "user's live edit").unwrap();
4962
4963 let cfg = format!(
4967 r#"
4968[[mount.entry]]
4969src = "home"
4970dst = "{}"
4971"#,
4972 toml_path(&target)
4973 );
4974 std::fs::write(source.join("config.toml"), cfg).unwrap();
4975 apply(Some(source.clone()), false).unwrap();
4976
4977 assert_eq!(
4979 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4980 "user's live edit"
4981 );
4982 }
4983
4984 #[test]
4990 fn merge_per_file_source_newer_skip_keeps_source() {
4991 let tmp = TempDir::new().unwrap();
4992 let source = utf8(tmp.path().join("dotfiles"));
4993 let target = utf8(tmp.path().join("target"));
4994 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4995 std::fs::create_dir_all(target.join(".config")).unwrap();
4996 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4997
4998 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
5000 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
5001 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
5002
5003 let cfg = format!(
5004 r#"
5005[absorb]
5006on_anomaly = "skip"
5007
5008[[mount.entry]]
5009src = "home"
5010dst = "{}"
5011"#,
5012 toml_path(&target)
5013 );
5014 std::fs::write(source.join("config.toml"), cfg).unwrap();
5015 apply(Some(source.clone()), false).unwrap();
5016
5017 assert_eq!(
5020 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
5021 "fresh source"
5022 );
5023 }
5024
5025 #[test]
5028 fn merge_per_file_source_newer_force_overwrites_source() {
5029 let tmp = TempDir::new().unwrap();
5030 let source = utf8(tmp.path().join("dotfiles"));
5031 let target = utf8(tmp.path().join("target"));
5032 std::fs::create_dir_all(source.join("home/.config")).unwrap();
5033 std::fs::create_dir_all(target.join(".config")).unwrap();
5034 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
5035
5036 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
5037 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
5038 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
5039
5040 let cfg = format!(
5041 r#"
5042[absorb]
5043on_anomaly = "force"
5044
5045[[mount.entry]]
5046src = "home"
5047dst = "{}"
5048"#,
5049 toml_path(&target)
5050 );
5051 std::fs::write(source.join("config.toml"), cfg).unwrap();
5052 apply(Some(source.clone()), false).unwrap();
5053
5054 assert_eq!(
5056 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
5057 "old target"
5058 );
5059 }
5060
5061 #[test]
5066 fn merge_per_file_identical_content_is_noop() {
5067 let tmp = TempDir::new().unwrap();
5068 let source = utf8(tmp.path().join("dotfiles"));
5069 let target = utf8(tmp.path().join("target"));
5070 std::fs::create_dir_all(source.join("home/.config")).unwrap();
5071 std::fs::create_dir_all(target.join(".config")).unwrap();
5072 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
5073 std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
5074 std::fs::write(target.join(".config/app.toml"), "same").unwrap();
5075
5076 let cfg = format!(
5079 r#"
5080[[mount.entry]]
5081src = "home"
5082dst = "{}"
5083"#,
5084 toml_path(&target)
5085 );
5086 std::fs::write(source.join("config.toml"), cfg).unwrap();
5087 apply(Some(source.clone()), false).unwrap();
5088
5089 assert_eq!(
5090 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
5091 "same"
5092 );
5093 }
5094
5095 #[test]
5096 fn manual_absorb_command_pulls_target_into_source() {
5097 let tmp = TempDir::new().unwrap();
5099 let source = utf8(tmp.path().join("dotfiles"));
5100 let target = utf8(tmp.path().join("target"));
5101 std::fs::create_dir_all(source.join("home")).unwrap();
5102 std::fs::create_dir_all(&target).unwrap();
5103 let cfg = format!(
5105 r#"
5106[absorb]
5107on_anomaly = "skip"
5108
5109[[mount.entry]]
5110src = "home"
5111dst = "{}"
5112"#,
5113 toml_path(&target)
5114 );
5115 std::fs::write(source.join("config.toml"), cfg).unwrap();
5116 std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
5117 std::fs::write(source.join("home/.bashrc"), "default").unwrap();
5118
5119 absorb(
5122 Some(source.clone()),
5123 target.join(".bashrc"),
5124 false,
5125 true,
5126 )
5127 .unwrap();
5128
5129 assert_eq!(
5131 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
5132 "user picked this"
5133 );
5134 }
5135
5136 #[test]
5137 fn manual_absorb_errors_when_target_outside_known_mounts() {
5138 let tmp = TempDir::new().unwrap();
5139 let (source, _target) = setup_minimal_dotfiles(&tmp);
5140 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5141 let stranger = utf8(tmp.path().join("not-managed/foo"));
5142 std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
5143 std::fs::write(&stranger, "not yui's").unwrap();
5144 let err = absorb(Some(source), stranger, false, true).unwrap_err();
5145 assert!(format!("{err}").contains("no mount entry"));
5146 }
5147
5148 #[test]
5149 fn yuiignore_excludes_file_from_linking() {
5150 let tmp = TempDir::new().unwrap();
5151 let (source, target) = setup_minimal_dotfiles(&tmp);
5152 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
5153 std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
5154 std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
5156 apply(Some(source.clone()), false).unwrap();
5157 assert!(target.join(".bashrc").exists());
5158 assert!(
5159 !target.join("lock.json").exists(),
5160 "yuiignore should keep lock.json out of target"
5161 );
5162 }
5163
5164 #[test]
5165 fn yuiignore_excludes_directory_subtree() {
5166 let tmp = TempDir::new().unwrap();
5167 let (source, target) = setup_minimal_dotfiles(&tmp);
5168 std::fs::create_dir_all(source.join("home/cache")).unwrap();
5169 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
5170 std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
5171 std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
5172 std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
5174 apply(Some(source.clone()), false).unwrap();
5175 assert!(target.join(".bashrc").exists());
5176 assert!(
5177 !target.join("cache").exists(),
5178 "yuiignore'd subtree should not appear in target"
5179 );
5180 }
5181
5182 #[test]
5183 fn yuiignore_negation_re_includes_file() {
5184 let tmp = TempDir::new().unwrap();
5185 let (source, target) = setup_minimal_dotfiles(&tmp);
5186 std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
5187 std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
5188 std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
5190 apply(Some(source.clone()), false).unwrap();
5191 assert!(target.join("keep.cache").exists());
5192 assert!(!target.join("drop.cache").exists());
5193 }
5194
5195 #[test]
5200 fn nested_yuiignore_only_affects_its_subtree() {
5201 let tmp = TempDir::new().unwrap();
5202 let (source, target) = setup_minimal_dotfiles(&tmp);
5203 std::fs::create_dir_all(source.join("home/inner")).unwrap();
5204 std::fs::write(source.join("home/secret.txt"), "outer keep").unwrap();
5205 std::fs::write(source.join("home/inner/secret.txt"), "inner drop").unwrap();
5206 std::fs::write(source.join("home/inner/keep.txt"), "inner keep").unwrap();
5207 std::fs::write(source.join("home/inner/.yuiignore"), "secret*\n").unwrap();
5209 apply(Some(source.clone()), false).unwrap();
5210 assert!(
5211 target.join("secret.txt").exists(),
5212 "outer secret.txt is outside the nested .yuiignore scope"
5213 );
5214 assert!(target.join("inner/keep.txt").exists());
5215 assert!(
5216 !target.join("inner/secret.txt").exists(),
5217 "inner secret.txt should be excluded by the nested .yuiignore"
5218 );
5219 }
5220
5221 #[test]
5225 fn nested_yuiignore_negation_overrides_root_rule() {
5226 let tmp = TempDir::new().unwrap();
5227 let (source, target) = setup_minimal_dotfiles(&tmp);
5228 std::fs::create_dir_all(source.join("home/keepers")).unwrap();
5229 std::fs::write(source.join("home/drop.lock"), "outer drop").unwrap();
5230 std::fs::write(source.join("home/keepers/wanted.lock"), "inner keep").unwrap();
5231 std::fs::write(source.join(".yuiignore"), "*.lock\n").unwrap();
5232 std::fs::write(source.join("home/keepers/.yuiignore"), "!*.lock\n").unwrap();
5234 apply(Some(source.clone()), false).unwrap();
5235 assert!(
5236 !target.join("drop.lock").exists(),
5237 "root rule still drops outer .lock file"
5238 );
5239 assert!(
5240 target.join("keepers/wanted.lock").exists(),
5241 "nested negation re-includes .lock under keepers/"
5242 );
5243 }
5244
5245 #[test]
5249 fn nested_yuiignore_status_walk_scoped() {
5250 let tmp = TempDir::new().unwrap();
5251 let (source, _target) = setup_minimal_dotfiles(&tmp);
5252 std::fs::create_dir_all(source.join("home/a")).unwrap();
5253 std::fs::create_dir_all(source.join("home/b")).unwrap();
5254 std::fs::write(source.join("home/a/foo.txt"), "a-foo").unwrap();
5255 std::fs::write(source.join("home/b/foo.txt"), "b-foo").unwrap();
5256 std::fs::write(source.join("home/a/.yuiignore"), "foo.txt\n").unwrap();
5258 apply(Some(source.clone()), false).unwrap();
5259 let res = status(Some(source), None, true);
5261 assert!(res.is_ok() || matches!(&res, Err(e) if format!("{e}").contains("diverged")));
5262 }
5263
5264 #[test]
5265 fn yuiignore_skips_template_in_render() {
5266 let tmp = TempDir::new().unwrap();
5267 let source = utf8(tmp.path().join("dotfiles"));
5268 let target = utf8(tmp.path().join("target"));
5269 std::fs::create_dir_all(source.join("home")).unwrap();
5270 std::fs::create_dir_all(&target).unwrap();
5271 std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
5272 std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
5273 let cfg = format!(
5274 r#"
5275[[mount.entry]]
5276src = "home"
5277dst = "{}"
5278"#,
5279 toml_path(&target)
5280 );
5281 std::fs::write(source.join("config.toml"), cfg).unwrap();
5282 apply(Some(source.clone()), false).unwrap();
5283 assert!(!source.join("home/note").exists());
5285 assert!(!target.join("note").exists());
5286 assert!(!target.join("note.tera").exists());
5287 }
5288
5289 #[test]
5298 fn apply_decrypts_age_files_to_sibling_and_links() {
5299 let tmp = TempDir::new().unwrap();
5300 let source = utf8(tmp.path().join("dotfiles"));
5301 let target = utf8(tmp.path().join("target"));
5302 std::fs::create_dir_all(source.join("home/.ssh")).unwrap();
5303 std::fs::create_dir_all(&target).unwrap();
5304
5305 let identity_path = utf8(tmp.path().join("age.txt"));
5308 let (secret, public) = secret::generate_x25519_keypair();
5309 std::fs::write(&identity_path, format!("{secret}\n")).unwrap();
5310
5311 let recipient = secret::parse_x25519_recipient(&public).unwrap();
5313 let cipher = secret::encrypt_x25519(b"-- super secret key --\n", &[recipient]).unwrap();
5314 std::fs::write(source.join("home/.ssh/id_ed25519.age"), &cipher).unwrap();
5315
5316 let cfg = format!(
5318 r#"
5319[[mount.entry]]
5320src = "home"
5321dst = "{}"
5322
5323[secrets]
5324identity = "{}"
5325recipients = ["{}"]
5326"#,
5327 toml_path(&target),
5328 toml_path(&identity_path),
5329 public
5330 );
5331 std::fs::write(source.join("config.toml"), cfg).unwrap();
5332
5333 apply(Some(source.clone()), false).unwrap();
5334
5335 assert!(source.join("home/.ssh/id_ed25519").exists());
5337 let target_bytes = std::fs::read(target.join(".ssh/id_ed25519")).unwrap();
5339 assert_eq!(target_bytes, b"-- super secret key --\n");
5340 let gi = std::fs::read_to_string(source.join(".gitignore")).unwrap();
5342 assert!(
5343 gi.contains("home/.ssh/id_ed25519"),
5344 ".gitignore should list the decrypted plaintext sibling: {gi}"
5345 );
5346 }
5349
5350 #[test]
5355 fn apply_bails_on_secret_drift() {
5356 let tmp = TempDir::new().unwrap();
5357 let source = utf8(tmp.path().join("dotfiles"));
5358 let target = utf8(tmp.path().join("target"));
5359 std::fs::create_dir_all(source.join("home")).unwrap();
5360 std::fs::create_dir_all(&target).unwrap();
5361
5362 let identity_path = utf8(tmp.path().join("age.txt"));
5363 let (secret_key, public) = secret::generate_x25519_keypair();
5364 std::fs::write(&identity_path, format!("{secret_key}\n")).unwrap();
5365
5366 let recipient = secret::parse_x25519_recipient(&public).unwrap();
5367 let cipher = secret::encrypt_x25519(b"v1 content\n", &[recipient]).unwrap();
5368 std::fs::write(source.join("home/secret.age"), &cipher).unwrap();
5369 std::fs::write(source.join("home/secret"), "edited locally\n").unwrap();
5371
5372 let cfg = format!(
5373 r#"
5374[[mount.entry]]
5375src = "home"
5376dst = "{}"
5377
5378[secrets]
5379identity = "{}"
5380recipients = ["{}"]
5381"#,
5382 toml_path(&target),
5383 toml_path(&identity_path),
5384 public
5385 );
5386 std::fs::write(source.join("config.toml"), cfg).unwrap();
5387
5388 let err = apply(Some(source.clone()), false).unwrap_err();
5389 assert!(
5390 format!("{err:#}").contains("secret drift"),
5391 "expected secret drift error, got: {err:#}"
5392 );
5393 }
5394
5395 #[test]
5398 fn append_recipient_creates_secrets_table_when_missing() {
5399 let result =
5400 append_recipient_to_config("", "host alice", "age1abcrecipientpublickey").unwrap();
5401 let parsed: toml::Table = toml::from_str(&result).unwrap();
5403 let secrets = parsed.get("secrets").and_then(|v| v.as_table()).unwrap();
5404 let recipients = secrets
5405 .get("recipients")
5406 .and_then(|v| v.as_array())
5407 .unwrap();
5408 assert_eq!(recipients.len(), 1);
5409 assert_eq!(recipients[0].as_str(), Some("age1abcrecipientpublickey"));
5410 }
5411
5412 #[test]
5413 fn append_recipient_preserves_existing_other_tables() {
5414 let existing = r#"
5418[vars]
5419greet = "hi"
5420
5421[secrets]
5422recipients = ["age1machine_a"]
5423
5424[ui]
5425icons = "ascii"
5426"#;
5427 let result = append_recipient_to_config(existing, "host b", "age1machine_b").unwrap();
5428 let parsed: toml::Table = toml::from_str(&result).unwrap();
5429 assert!(parsed.get("vars").is_some());
5431 assert!(parsed.get("secrets").is_some());
5432 assert!(parsed.get("ui").is_some());
5433 let recipients = parsed["secrets"]["recipients"].as_array().unwrap();
5435 assert_eq!(recipients.len(), 2);
5436 let pubs: Vec<&str> = recipients.iter().filter_map(|v| v.as_str()).collect();
5437 assert!(pubs.contains(&"age1machine_a"));
5438 assert!(pubs.contains(&"age1machine_b"));
5439 }
5440
5441 #[test]
5442 fn append_recipient_is_idempotent_on_duplicate() {
5443 let existing = r#"[secrets]
5444recipients = ["age1same"]
5445"#;
5446 let result = append_recipient_to_config(existing, "anyone", "age1same").unwrap();
5447 let parsed: toml::Table = toml::from_str(&result).unwrap();
5448 let recipients = parsed["secrets"]["recipients"].as_array().unwrap();
5449 assert_eq!(recipients.len(), 1, "duplicate must not be appended twice");
5450 }
5451
5452 #[test]
5453 fn append_recipient_creates_recipients_array_when_secrets_table_empty() {
5454 let existing = r#"[secrets]
5457identity = "~/.config/yui/age.txt"
5458"#;
5459 let result = append_recipient_to_config(existing, "h", "age1new").unwrap();
5460 let parsed: toml::Table = toml::from_str(&result).unwrap();
5461 let secrets = parsed["secrets"].as_table().unwrap();
5462 assert_eq!(
5463 secrets["identity"].as_str(),
5464 Some("~/.config/yui/age.txt"),
5465 "existing identity field must survive"
5466 );
5467 let recipients = secrets["recipients"].as_array().unwrap();
5468 assert_eq!(recipients.len(), 1);
5469 assert_eq!(recipients[0].as_str(), Some("age1new"));
5470 }
5471
5472 #[test]
5476 fn apply_without_recipients_skips_secret_walker() {
5477 let tmp = TempDir::new().unwrap();
5478 let (source, _target) = setup_minimal_dotfiles(&tmp);
5479 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5481 std::fs::write(source.join("home/some.junk.age"), b"not actually a cipher").unwrap();
5485 apply(Some(source.clone()), false).unwrap();
5486 }
5487
5488 #[test]
5492 fn nested_marker_accumulates_extra_dst() {
5493 let tmp = TempDir::new().unwrap();
5494 let source = utf8(tmp.path().join("dotfiles"));
5495 let parent_target = utf8(tmp.path().join("home"));
5496 let extra_target = utf8(tmp.path().join("extra"));
5497 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
5498 std::fs::create_dir_all(&parent_target).unwrap();
5499 std::fs::create_dir_all(&extra_target).unwrap();
5500 std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
5501
5502 std::fs::write(
5504 source.join("home/.config/.yuilink"),
5505 format!(
5506 r#"
5507[[link]]
5508dst = "{}/.config"
5509"#,
5510 toml_path(&parent_target)
5511 ),
5512 )
5513 .unwrap();
5514 std::fs::write(
5517 source.join("home/.config/nvim/.yuilink"),
5518 format!(
5519 r#"
5520[[link]]
5521dst = "{}/nvim"
5522when = "{{{{ yui.os == '{}' }}}}"
5523"#,
5524 toml_path(&extra_target),
5525 std::env::consts::OS
5526 ),
5527 )
5528 .unwrap();
5529
5530 let cfg = format!(
5531 r#"
5532[[mount.entry]]
5533src = "home"
5534dst = "{}"
5535"#,
5536 toml_path(&parent_target)
5537 );
5538 std::fs::write(source.join("config.toml"), cfg).unwrap();
5539
5540 apply(Some(source.clone()), false).unwrap();
5541
5542 assert!(parent_target.join(".config/nvim/init.lua").exists());
5545 assert!(extra_target.join("nvim/init.lua").exists());
5546 }
5547
5548 #[test]
5553 fn marker_file_link_targets_specific_file() {
5554 let tmp = TempDir::new().unwrap();
5555 let source = utf8(tmp.path().join("dotfiles"));
5556 let parent_target = utf8(tmp.path().join("home"));
5557 let docs_target = utf8(tmp.path().join("docs"));
5558 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
5559 std::fs::create_dir_all(&parent_target).unwrap();
5560 std::fs::create_dir_all(&docs_target).unwrap();
5561 std::fs::write(
5562 source.join("home/.config/powershell/profile.ps1"),
5563 "# profile\n",
5564 )
5565 .unwrap();
5566 std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
5567
5568 std::fs::write(
5571 source.join("home/.config/powershell/.yuilink"),
5572 format!(
5573 r#"
5574[[link]]
5575src = "profile.ps1"
5576dst = "{}/Microsoft.PowerShell_profile.ps1"
5577"#,
5578 toml_path(&docs_target)
5579 ),
5580 )
5581 .unwrap();
5582
5583 let cfg = format!(
5584 r#"
5585[[mount.entry]]
5586src = "home"
5587dst = "{}"
5588"#,
5589 toml_path(&parent_target)
5590 );
5591 std::fs::write(source.join("config.toml"), cfg).unwrap();
5592
5593 apply(Some(source.clone()), false).unwrap();
5594
5595 assert!(
5597 docs_target
5598 .join("Microsoft.PowerShell_profile.ps1")
5599 .exists()
5600 );
5601 assert!(
5604 parent_target
5605 .join(".config/powershell/profile.ps1")
5606 .exists()
5607 );
5608 assert!(parent_target.join(".config/powershell/extra.txt").exists());
5609 }
5610
5611 #[test]
5614 fn marker_file_link_missing_src_errors() {
5615 let tmp = TempDir::new().unwrap();
5616 let source = utf8(tmp.path().join("dotfiles"));
5617 let parent_target = utf8(tmp.path().join("home"));
5618 let docs_target = utf8(tmp.path().join("docs"));
5619 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
5620 std::fs::create_dir_all(&parent_target).unwrap();
5621 std::fs::create_dir_all(&docs_target).unwrap();
5622
5623 std::fs::write(
5624 source.join("home/.config/powershell/.yuilink"),
5625 format!(
5626 r#"
5627[[link]]
5628src = "missing.ps1"
5629dst = "{}/profile.ps1"
5630"#,
5631 toml_path(&docs_target)
5632 ),
5633 )
5634 .unwrap();
5635
5636 let cfg = format!(
5637 r#"
5638[[mount.entry]]
5639src = "home"
5640dst = "{}"
5641"#,
5642 toml_path(&parent_target)
5643 );
5644 std::fs::write(source.join("config.toml"), cfg).unwrap();
5645
5646 let err = apply(Some(source.clone()), false).unwrap_err();
5647 assert!(format!("{err:#}").contains("missing.ps1"));
5648 }
5649
5650 #[test]
5659 fn unmanaged_finds_files_outside_any_mount() {
5660 let tmp = TempDir::new().unwrap();
5661 let (source, _target) = setup_minimal_dotfiles(&tmp);
5662 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5664 std::fs::write(source.join("orphan.txt"), "y").unwrap();
5666 std::fs::create_dir_all(source.join("notes")).unwrap();
5667 std::fs::write(source.join("notes/scratch.md"), "z").unwrap();
5668
5669 unmanaged(Some(source.clone()), None, true).unwrap();
5671
5672 let yui = YuiVars::detect(&source);
5674 let cfg = config::load(&source, &yui).unwrap();
5675 let mount_srcs: Vec<Utf8PathBuf> = cfg
5676 .mount
5677 .entry
5678 .iter()
5679 .map(|m| source.join(&m.src))
5680 .collect();
5681 let walker = paths::source_walker(&source).build();
5682 let mut unmanaged_paths = Vec::new();
5683 for entry in walker.flatten() {
5684 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
5685 continue;
5686 }
5687 let p = match Utf8PathBuf::from_path_buf(entry.path().to_path_buf()) {
5688 Ok(p) => p,
5689 Err(_) => continue,
5690 };
5691 if is_repo_meta(&p, &source, &cfg.mount.marker_filename) {
5692 continue;
5693 }
5694 if mount_srcs.iter().any(|m| p.starts_with(m)) {
5695 continue;
5696 }
5697 unmanaged_paths.push(p);
5698 }
5699 let names: Vec<String> = unmanaged_paths
5700 .iter()
5701 .filter_map(|p| p.file_name().map(String::from))
5702 .collect();
5703 assert!(names.contains(&"orphan.txt".into()));
5704 assert!(names.contains(&"scratch.md".into()));
5705 assert!(!names.contains(&".bashrc".into()), "mount-claimed file");
5706 assert!(!names.contains(&"config.toml".into()), "repo meta");
5707 }
5708
5709 #[test]
5710 fn is_repo_meta_recognises_yui_scaffold() {
5711 let source = Utf8Path::new("/dot");
5712 assert!(is_repo_meta(
5714 Utf8Path::new("/dot/config.toml"),
5715 source,
5716 ".yuilink",
5717 ));
5718 assert!(is_repo_meta(
5719 Utf8Path::new("/dot/config.local.toml"),
5720 source,
5721 ".yuilink",
5722 ));
5723 assert!(is_repo_meta(
5724 Utf8Path::new("/dot/config.linux.toml"),
5725 source,
5726 ".yuilink",
5727 ));
5728 assert!(is_repo_meta(
5729 Utf8Path::new("/dot/config.local.example.toml"),
5730 source,
5731 ".yuilink",
5732 ));
5733 assert!(is_repo_meta(
5735 Utf8Path::new("/dot/.gitignore"),
5736 source,
5737 ".yuilink",
5738 ));
5739 assert!(is_repo_meta(
5741 Utf8Path::new("/dot/home/.config/foo/.yuilink"),
5742 source,
5743 ".yuilink",
5744 ));
5745 assert!(is_repo_meta(
5746 Utf8Path::new("/dot/home/.gitconfig.tera"),
5747 source,
5748 ".yuilink",
5749 ));
5750 assert!(!is_repo_meta(
5752 Utf8Path::new("/dot/home/.config/myapp/config.toml"),
5753 source,
5754 ".yuilink",
5755 ));
5756 assert!(!is_repo_meta(
5760 Utf8Path::new("/dot/home/.config/git/.gitignore"),
5761 source,
5762 ".yuilink",
5763 ));
5764 }
5765
5766 #[test]
5773 fn unmanaged_respects_inactive_mount_entries() {
5774 let tmp = TempDir::new().unwrap();
5775 let source = utf8(tmp.path().join("dotfiles"));
5776 let target = utf8(tmp.path().join("target"));
5777 std::fs::create_dir_all(source.join("home_active")).unwrap();
5778 std::fs::create_dir_all(source.join("home_other_os")).unwrap();
5779 std::fs::create_dir_all(&target).unwrap();
5780 std::fs::write(source.join("home_active/.bashrc"), "active").unwrap();
5781 std::fs::write(source.join("home_other_os/.bashrc"), "inactive").unwrap();
5782 let cfg = format!(
5784 r#"
5785[[mount.entry]]
5786src = "home_active"
5787dst = "{target}"
5788
5789[[mount.entry]]
5790src = "home_other_os"
5791dst = "{target}"
5792when = "yui.os == 'definitely_not_a_real_os'"
5793"#,
5794 target = toml_path(&target)
5795 );
5796 std::fs::write(source.join("config.toml"), cfg).unwrap();
5797
5798 let yui = YuiVars::detect(&source);
5802 let cfg = config::load(&source, &yui).unwrap();
5803 let mount_srcs: Vec<Utf8PathBuf> = cfg
5804 .mount
5805 .entry
5806 .iter()
5807 .map(|m| source.join(&m.src))
5808 .collect();
5809 let inactive_file = source.join("home_other_os/.bashrc");
5810 let claimed = mount_srcs.iter().any(|m| inactive_file.starts_with(m));
5811 assert!(
5812 claimed,
5813 "raw config.mount.entry should claim files even under inactive mounts"
5814 );
5815 }
5816
5817 #[test]
5822 fn diff_shows_drift_skips_in_sync() {
5823 let tmp = TempDir::new().unwrap();
5824 let (source, target) = setup_minimal_dotfiles(&tmp);
5825 std::fs::write(source.join("home/.bashrc"), "first\nsecond\n").unwrap();
5826 apply(Some(source.clone()), false).unwrap();
5828 std::fs::remove_file(target.join(".bashrc")).unwrap();
5830 std::fs::write(target.join(".bashrc"), "first\nEDITED\n").unwrap();
5831
5832 diff(Some(source.clone()), None, true).unwrap();
5835 }
5836
5837 #[test]
5842 fn read_text_for_diff_classifies_correctly() {
5843 let tmp = TempDir::new().unwrap();
5844 let root = utf8(tmp.path().to_path_buf());
5845 let txt = root.join("a.txt");
5847 std::fs::write(&txt, "hello\n").unwrap();
5848 match read_text_for_diff(&txt) {
5849 DiffSide::Text(s) => assert_eq!(s, "hello\n"),
5850 DiffSide::Binary => panic!("text file misclassified as binary"),
5851 }
5852 let bin = root.join("b.bin");
5854 std::fs::write(&bin, [0xff, 0xfe, 0x00, 0xff]).unwrap();
5855 assert!(matches!(read_text_for_diff(&bin), DiffSide::Binary));
5856 let missing = root.join("missing.txt");
5858 match read_text_for_diff(&missing) {
5859 DiffSide::Text(s) => assert!(s.is_empty()),
5860 DiffSide::Binary => panic!("missing file misclassified as binary"),
5861 }
5862 }
5863
5864 #[test]
5871 fn diff_render_drift_uses_rendered_output_not_raw_template() {
5872 let tmp = TempDir::new().unwrap();
5873 let (source, _target) = setup_minimal_dotfiles(&tmp);
5874 std::fs::write(source.join("home/note.tera"), "os = {{ yui.os }}\n").unwrap();
5877 std::fs::write(source.join("home/note"), "os = ancient\n").unwrap();
5878 let yui = YuiVars::detect(&source);
5880 let cfg = config::load(&source, &yui).unwrap();
5881 let rendered =
5882 render::render_to_string(&source.join("home/note.tera"), &source, &cfg, &yui)
5883 .unwrap()
5884 .expect("template should render on this host");
5885 assert!(rendered.starts_with("os = "));
5886 assert!(
5887 !rendered.contains("{{"),
5888 "rendered output must not contain raw Tera tags"
5889 );
5890 }
5891
5892 #[test]
5900 fn resolve_diff_src_absolutizes_link_rows() {
5901 let source = Utf8Path::new("/dot");
5902 let link_item = StatusItem {
5903 src: Utf8PathBuf::from("home/.bashrc"),
5904 dst: Utf8PathBuf::from("/h/u/.bashrc"),
5905 state: StatusState::Link(absorb::AbsorbDecision::AutoAbsorb),
5906 };
5907 assert_eq!(
5908 resolve_diff_src(&link_item, source),
5909 Utf8PathBuf::from("/dot/home/.bashrc"),
5910 );
5911 let render_item = StatusItem {
5912 src: Utf8PathBuf::from("/dot/home/foo.tera"),
5913 dst: Utf8PathBuf::from("/dot/home/foo"),
5914 state: StatusState::RenderDrift,
5915 };
5916 assert_eq!(
5917 resolve_diff_src(&render_item, source),
5918 Utf8PathBuf::from("/dot/home/foo.tera"),
5919 );
5920 }
5921
5922 #[test]
5923 fn diff_classifier_skips_uninteresting_states() {
5924 use absorb::AbsorbDecision::*;
5925 assert!(!diff_worth_printing(&StatusState::Link(InSync)));
5927 assert!(!diff_worth_printing(&StatusState::Link(Restore)));
5928 assert!(!diff_worth_printing(&StatusState::Link(RelinkOnly)));
5929 assert!(diff_worth_printing(&StatusState::Link(AutoAbsorb)));
5931 assert!(diff_worth_printing(&StatusState::Link(NeedsConfirm)));
5932 assert!(diff_worth_printing(&StatusState::RenderDrift));
5933 }
5934
5935 #[test]
5946 fn update_errors_when_source_is_not_a_git_repo() {
5947 let tmp = TempDir::new().unwrap();
5948 let source = utf8(tmp.path().join("dotfiles"));
5949 std::fs::create_dir_all(&source).unwrap();
5950 std::fs::write(source.join("config.toml"), "").unwrap();
5951 let err = update(Some(source), false).unwrap_err();
5953 let msg = format!("{err:#}");
5954 assert!(
5955 msg.contains("not a git repository")
5956 || msg.contains("uncommitted")
5957 || msg.contains("git"),
5958 "unexpected error: {msg}",
5959 );
5960 }
5961
5962 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
5963 let mut out = Vec::new();
5964 let mut stack = vec![root.to_path_buf()];
5965 while let Some(dir) = stack.pop() {
5966 let Ok(entries) = std::fs::read_dir(&dir) else {
5967 continue;
5968 };
5969 for e in entries.flatten() {
5970 let p = utf8(e.path());
5971 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
5972 stack.push(p);
5973 } else {
5974 out.push(p);
5975 }
5976 }
5977 }
5978 out
5979 }
5980
5981 #[test]
5986 fn parse_backup_suffix_recognises_file_with_extension() {
5987 let dt = parse_backup_suffix("foo_20260429_143022123.yml").unwrap();
5988 assert_eq!(dt.year(), 2026);
5989 assert_eq!(dt.month(), 4);
5990 assert_eq!(dt.day(), 29);
5991 assert_eq!(dt.hour(), 14);
5992 assert_eq!(dt.minute(), 30);
5993 assert_eq!(dt.second(), 22);
5994 }
5995
5996 #[test]
5997 fn parse_backup_suffix_recognises_dotfile_no_extension() {
5998 let dt = parse_backup_suffix(".gitconfig_20260429_143022123").unwrap();
5999 assert_eq!(dt.year(), 2026);
6000 }
6001
6002 #[test]
6003 fn parse_backup_suffix_recognises_directory_form() {
6004 let dt = parse_backup_suffix("nvim_20260429_143022123").unwrap();
6005 assert_eq!(dt.day(), 29);
6006 }
6007
6008 #[test]
6009 fn parse_backup_suffix_recognises_multi_dot_filename() {
6010 let dt = parse_backup_suffix("archive.tar.gz_20260429_143022123.gz").unwrap();
6012 assert_eq!(dt.month(), 4);
6013 }
6014
6015 #[test]
6016 fn parse_backup_suffix_rejects_non_yui_names() {
6017 assert!(parse_backup_suffix("README.md").is_none());
6018 assert!(parse_backup_suffix("notes_2026.txt").is_none());
6019 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());
6023 }
6024
6025 #[test]
6026 fn parse_human_duration_basic_units() {
6027 let s = parse_human_duration("30d").unwrap();
6028 assert_eq!(s.get_days(), 30);
6029 let s = parse_human_duration("2w").unwrap();
6030 assert_eq!(s.get_weeks(), 2);
6031 let s = parse_human_duration("12h").unwrap();
6032 assert_eq!(s.get_hours(), 12);
6033 let s = parse_human_duration("5m").unwrap();
6035 assert_eq!(s.get_minutes(), 5);
6036 let s = parse_human_duration("6mo").unwrap();
6037 assert_eq!(s.get_months(), 6);
6038 let s = parse_human_duration("1y").unwrap();
6039 assert_eq!(s.get_years(), 1);
6040 }
6041
6042 #[test]
6043 fn parse_human_duration_case_insensitive_and_whitespace() {
6044 let s = parse_human_duration(" 90D ").unwrap();
6045 assert_eq!(s.get_days(), 90);
6046 let s = parse_human_duration("3WEEKS").unwrap();
6047 assert_eq!(s.get_weeks(), 3);
6048 }
6049
6050 #[test]
6051 fn parse_human_duration_rejects_garbage() {
6052 assert!(parse_human_duration("").is_err());
6053 assert!(parse_human_duration("d30").is_err());
6054 assert!(parse_human_duration("30").is_err()); assert!(parse_human_duration("30x").is_err()); assert!(parse_human_duration("-1d").is_err()); }
6058
6059 #[test]
6063 fn walk_gc_backups_collects_files_and_dir_snapshots() {
6064 let tmp = TempDir::new().unwrap();
6065 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
6066 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
6067 std::fs::write(
6069 root.join("C/Users/u/.config/foo_20260429_143022123.yml"),
6070 "old yml",
6071 )
6072 .unwrap();
6073 std::fs::create_dir_all(root.join("C/Users/u/nvim_20260101_000000000/lua")).unwrap();
6075 std::fs::write(
6076 root.join("C/Users/u/nvim_20260101_000000000/init.lua"),
6077 "ok",
6078 )
6079 .unwrap();
6080 std::fs::write(
6081 root.join("C/Users/u/nvim_20260101_000000000/lua/x.lua"),
6082 "kk",
6083 )
6084 .unwrap();
6085 std::fs::write(root.join("C/Users/u/.config/README.md"), "user note").unwrap();
6087
6088 let entries = walk_gc_backups(&root).unwrap();
6089 assert_eq!(entries.len(), 2, "two backup roots, not three");
6090 let kinds: Vec<_> = entries.iter().map(|e| e.kind).collect();
6091 assert!(kinds.contains(&BackupKind::File));
6092 assert!(kinds.contains(&BackupKind::Dir));
6093 let dir_entry = entries.iter().find(|e| e.kind == BackupKind::Dir).unwrap();
6095 assert!(dir_entry.size_bytes >= 4); }
6097
6098 #[test]
6099 fn cleanup_empty_parents_stops_at_root_and_at_non_empty() {
6100 let tmp = TempDir::new().unwrap();
6101 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
6102 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
6103 std::fs::write(root.join("C/Users/u/sibling_keep"), "x").unwrap();
6104
6105 cleanup_empty_parents(&root.join("C/Users/u/.config"), &root);
6109
6110 assert!(!root.join("C/Users/u/.config").exists(), "empty leaf gone");
6111 assert!(root.join("C/Users/u").exists(), "stops at non-empty parent");
6112 assert!(root.exists(), "backup root preserved");
6113 }
6114
6115 #[test]
6117 fn gc_backup_survey_keeps_all_entries() {
6118 let tmp = TempDir::new().unwrap();
6119 let source = utf8(tmp.path().join("dotfiles"));
6120 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
6121 std::fs::write(source.join("config.toml"), "").unwrap();
6122 let backup = source.join(".yui/backup");
6123 std::fs::write(backup.join("a_20260101_000000000.txt"), "old").unwrap();
6124 std::fs::write(backup.join("b_20260415_120000000.txt"), "fresh").unwrap();
6125
6126 gc_backup(Some(source.clone()), None, false, None, true).unwrap();
6127
6128 assert!(backup.join("a_20260101_000000000.txt").exists());
6130 assert!(backup.join("b_20260415_120000000.txt").exists());
6131 }
6132
6133 #[test]
6136 fn gc_backup_prune_removes_old_files_only() {
6137 let tmp = TempDir::new().unwrap();
6138 let source = utf8(tmp.path().join("dotfiles"));
6139 std::fs::create_dir_all(source.join(".yui/backup/sub")).unwrap();
6140 std::fs::write(source.join("config.toml"), "").unwrap();
6141 let backup = source.join(".yui/backup");
6142
6143 std::fs::write(backup.join("sub/old_20200101_000000000.txt"), "old").unwrap();
6145 let tomorrow = jiff::Zoned::now()
6147 .checked_add(jiff::Span::new().days(1))
6148 .unwrap();
6149 let bdt = jiff::fmt::strtime::BrokenDownTime::from(&tomorrow);
6150 let future_ts = bdt.to_string("%Y%m%d_%H%M%S%3f").unwrap();
6151 std::fs::write(backup.join(format!("fresh_{future_ts}.txt")), "fresh").unwrap();
6152 std::fs::write(backup.join("notes.md"), "mine").unwrap();
6154
6155 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
6156
6157 assert!(!backup.join("sub/old_20200101_000000000.txt").exists());
6158 assert!(!backup.join("sub").exists(), "empty parent removed");
6160 assert!(backup.exists());
6162 assert!(backup.join(format!("fresh_{future_ts}.txt")).exists());
6163 assert!(backup.join("notes.md").exists(), "user file untouched");
6164 }
6165
6166 #[test]
6168 fn gc_backup_dry_run_does_not_delete() {
6169 let tmp = TempDir::new().unwrap();
6170 let source = utf8(tmp.path().join("dotfiles"));
6171 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
6172 std::fs::write(source.join("config.toml"), "").unwrap();
6173 let backup = source.join(".yui/backup");
6174 std::fs::write(backup.join("old_20200101_000000000.txt"), "old").unwrap();
6175
6176 gc_backup(Some(source.clone()), Some("30d".into()), true, None, true).unwrap();
6177
6178 assert!(
6179 backup.join("old_20200101_000000000.txt").exists(),
6180 "dry-run keeps everything in place"
6181 );
6182 }
6183
6184 #[test]
6188 fn gc_backup_prune_handles_directory_snapshot() {
6189 let tmp = TempDir::new().unwrap();
6190 let source = utf8(tmp.path().join("dotfiles"));
6191 std::fs::create_dir_all(source.join(".yui/backup/mirror/u")).unwrap();
6192 std::fs::write(source.join("config.toml"), "").unwrap();
6193 let backup = source.join(".yui/backup");
6194 let snap = backup.join("mirror/u/nvim_20200101_000000000");
6195 std::fs::create_dir_all(snap.join("lua")).unwrap();
6196 std::fs::write(snap.join("init.lua"), "x").unwrap();
6197 std::fs::write(snap.join("lua/y.lua"), "y").unwrap();
6198
6199 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
6200
6201 assert!(!snap.exists(), "dir snapshot removed wholesale");
6202 assert!(!backup.join("mirror").exists(), "empty mirror chain pruned");
6203 assert!(backup.exists(), "backup root preserved");
6204 }
6205
6206 fn ctx_for_test(tmp: &TempDir) -> (Config, Utf8PathBuf, Utf8PathBuf) {
6211 let source = utf8(tmp.path().join("src"));
6212 let backup_root = source.join(".yui/backup");
6213 std::fs::create_dir_all(&source).unwrap();
6214 let cfg = Config::default();
6215 (cfg, source, backup_root)
6216 }
6217
6218 #[test]
6219 fn prompt_anomaly_short_circuits_on_quit_requested() {
6220 let tmp = TempDir::new().unwrap();
6225 let (cfg, source, backup_root) = ctx_for_test(&tmp);
6226 let src_file = source.join("a");
6227 let dst_file = utf8(tmp.path().join("dst"));
6228 std::fs::write(&src_file, "X").unwrap();
6229 std::fs::write(&dst_file, "Y").unwrap();
6230
6231 let ctx = ApplyCtx {
6232 config: &cfg,
6233 source: &source,
6234 file_mode: resolve_file_mode(cfg.link.file_mode),
6235 dir_mode: resolve_dir_mode(cfg.link.dir_mode),
6236 backup_root: &backup_root,
6237 dry_run: false,
6238 sticky_anomaly: Cell::new(None),
6239 quit_requested: Cell::new(true),
6240 };
6241
6242 let got = prompt_anomaly(&ctx, &src_file, &dst_file, "test").unwrap();
6243 assert_eq!(got, AnomalyChoice::Quit);
6244 }
6245
6246 #[test]
6247 fn prompt_anomaly_short_circuits_on_sticky_choice() {
6248 let tmp = TempDir::new().unwrap();
6253 let (cfg, source, backup_root) = ctx_for_test(&tmp);
6254 let src_file = source.join("a");
6255 let dst_file = utf8(tmp.path().join("dst"));
6256 std::fs::write(&src_file, "X").unwrap();
6257 std::fs::write(&dst_file, "Y").unwrap();
6258
6259 let ctx = ApplyCtx {
6260 config: &cfg,
6261 source: &source,
6262 file_mode: resolve_file_mode(cfg.link.file_mode),
6263 dir_mode: resolve_dir_mode(cfg.link.dir_mode),
6264 backup_root: &backup_root,
6265 dry_run: false,
6266 sticky_anomaly: Cell::new(Some(AnomalyChoice::Overwrite)),
6267 quit_requested: Cell::new(false),
6268 };
6269
6270 let got = prompt_anomaly(&ctx, &src_file, &dst_file, "test").unwrap();
6271 assert_eq!(got, AnomalyChoice::Overwrite);
6272 }
6273
6274 #[test]
6275 fn overwrite_source_into_target_replaces_target_and_backs_up() {
6276 let tmp = TempDir::new().unwrap();
6281 let (cfg, source, backup_root) = ctx_for_test(&tmp);
6282 let src_file = source.join("a");
6283 let dst_file = utf8(tmp.path().join("dst"));
6284 std::fs::write(&src_file, "from source").unwrap();
6285 std::fs::write(&dst_file, "diverged target content").unwrap();
6286
6287 let ctx = ApplyCtx {
6288 config: &cfg,
6289 source: &source,
6290 file_mode: resolve_file_mode(cfg.link.file_mode),
6291 dir_mode: resolve_dir_mode(cfg.link.dir_mode),
6292 backup_root: &backup_root,
6293 dry_run: false,
6294 sticky_anomaly: Cell::new(None),
6295 quit_requested: Cell::new(false),
6296 };
6297
6298 overwrite_source_into_target(&src_file, &dst_file, &ctx).unwrap();
6299
6300 assert_eq!(std::fs::read_to_string(&dst_file).unwrap(), "from source");
6302 assert_eq!(std::fs::read_to_string(&src_file).unwrap(), "from source");
6304 let mut found_old = false;
6306 for entry in walkdir(&backup_root) {
6307 if let Ok(s) = std::fs::read_to_string(&entry) {
6308 if s == "diverged target content" {
6309 found_old = true;
6310 break;
6311 }
6312 }
6313 }
6314 assert!(
6315 found_old,
6316 "expected backup containing target's diverged content"
6317 );
6318 }
6319
6320 #[test]
6321 fn link_file_with_backup_short_circuits_when_quit_requested() {
6322 let tmp = TempDir::new().unwrap();
6328 let (mut cfg, source, backup_root) = ctx_for_test(&tmp);
6329 cfg.absorb.on_anomaly = crate::config::AnomalyAction::Force;
6330
6331 let src_file = source.join("a");
6332 let dst_file = utf8(tmp.path().join("dst"));
6333 let now = std::time::SystemTime::now();
6334 let past = now - std::time::Duration::from_secs(120);
6335 write_with_mtime(&dst_file, "target old", past);
6336 write_with_mtime(&src_file, "source new", now);
6337 let dst_before = std::fs::read_to_string(&dst_file).unwrap();
6338 let src_before = std::fs::read_to_string(&src_file).unwrap();
6339
6340 let ctx = ApplyCtx {
6341 config: &cfg,
6342 source: &source,
6343 file_mode: resolve_file_mode(cfg.link.file_mode),
6344 dir_mode: resolve_dir_mode(cfg.link.dir_mode),
6345 backup_root: &backup_root,
6346 dry_run: false,
6347 sticky_anomaly: Cell::new(None),
6348 quit_requested: Cell::new(true),
6349 };
6350
6351 link_file_with_backup(&src_file, &dst_file, &ctx).unwrap();
6352
6353 assert_eq!(std::fs::read_to_string(&dst_file).unwrap(), dst_before);
6354 assert_eq!(std::fs::read_to_string(&src_file).unwrap(), src_before);
6355 assert!(
6356 !backup_root.exists() || walkdir(&backup_root).is_empty(),
6357 "no backup should be produced when quit is requested"
6358 );
6359 }
6360}