1use std::fmt::Write as _;
6
7use anyhow::{Context as _, Result};
8use camino::{Utf8Path, Utf8PathBuf};
9use tera::Context as TeraContext;
10use tracing::{info, warn};
11
12use crate::config::{self, Config, HookPhase, IconsMode, MountStrategy};
13use crate::hook::{self, HookOutcome};
14use crate::icons::Icons;
15use crate::link::{self, EffectiveDirMode, EffectiveFileMode, resolve_dir_mode, resolve_file_mode};
16use crate::marker::{self, MarkerSpec};
17use crate::mount::{self, ResolvedMount};
18use crate::render::{self, RenderReport};
19use crate::secret;
20use crate::template;
21use crate::vars::YuiVars;
22use crate::vault;
23use crate::{absorb, backup, paths};
24
25pub fn init(source: Option<Utf8PathBuf>, git_hooks: bool) -> Result<()> {
32 let dir = match source {
33 Some(s) => absolutize(&s)?,
34 None => current_dir_utf8()?,
35 };
36 std::fs::create_dir_all(&dir)?;
37 let config_path = dir.join("config.toml");
38 let scaffolded = if !config_path.exists() {
39 std::fs::write(&config_path, SKELETON_CONFIG)?;
40 info!("initialized yui source repo at {dir}");
41 info!("created: {config_path}");
42 true
43 } else if git_hooks {
44 info!(
49 "config.toml already exists at {config_path} \
50 — skipping scaffold, installing git hooks only"
51 );
52 false
53 } else {
54 anyhow::bail!("config.toml already exists at {config_path}");
55 };
56
57 ensure_gitignore_yui_entries(&dir)?;
64
65 if git_hooks {
66 install_git_hooks(&dir)?;
67 }
68 if scaffolded {
69 info!("next: edit config.toml, then run `yui apply`");
70 }
71 Ok(())
72}
73
74const YUI_REQUIRED_GITIGNORE: &[&str] = &[
79 "/.yui/state.json",
80 "/.yui/state.json.tmp",
81 "/.yui/backup/",
82 "config.local.toml",
83];
84
85fn ensure_gitignore_yui_entries(dir: &Utf8Path) -> Result<()> {
91 let path = dir.join(".gitignore");
92 if !path.exists() {
93 std::fs::write(&path, SKELETON_GITIGNORE)?;
94 info!("created: {path}");
95 return Ok(());
96 }
97 let existing = std::fs::read_to_string(&path)?;
98 let missing: Vec<&str> = YUI_REQUIRED_GITIGNORE
99 .iter()
100 .copied()
101 .filter(|entry| !existing.lines().any(|line| line.trim() == *entry))
102 .collect();
103 if missing.is_empty() {
104 return Ok(());
105 }
106 let mut next = existing;
107 if !next.is_empty() && !next.ends_with('\n') {
108 next.push('\n');
109 }
110 if !next.is_empty() {
111 next.push('\n');
112 }
113 next.push_str("# yui per-machine state and backups (added by `yui init`).\n");
114 for entry in &missing {
115 next.push_str(entry);
116 next.push('\n');
117 }
118 std::fs::write(&path, next)?;
119 info!(
120 "updated .gitignore: appended {} yui entr{} ({})",
121 missing.len(),
122 if missing.len() == 1 { "y" } else { "ies" },
123 missing.join(", ")
124 );
125 Ok(())
126}
127
128fn install_git_hooks(source: &Utf8Path) -> Result<()> {
142 let out = std::process::Command::new("git")
143 .args(["rev-parse", "--git-path", "hooks"])
144 .current_dir(source.as_std_path())
145 .output()
146 .with_context(|| format!("git rev-parse --git-path hooks in {source}"))?;
147 if !out.status.success() {
148 let stderr = String::from_utf8_lossy(&out.stderr);
149 anyhow::bail!(
150 "--git-hooks: {source} doesn't look like a git repo \
151 (run `git init` first). git: {}",
152 stderr.trim()
153 );
154 }
155 let raw = String::from_utf8(out.stdout)?;
156 let hooks_dir = {
157 let p = Utf8PathBuf::from(raw.trim());
158 if p.is_absolute() { p } else { source.join(p) }
159 };
160 std::fs::create_dir_all(&hooks_dir).with_context(|| format!("mkdir -p {hooks_dir}"))?;
161
162 for (name, body) in [("pre-commit", PRE_COMMIT_HOOK), ("pre-push", PRE_PUSH_HOOK)] {
163 let path = hooks_dir.join(name);
164 if path.exists() {
165 warn!("--git-hooks: {path} already exists — leaving it alone");
166 continue;
167 }
168 std::fs::write(&path, body).with_context(|| format!("write hook {path}"))?;
169 #[cfg(unix)]
170 {
171 use std::os::unix::fs::PermissionsExt;
172 let mut perms = std::fs::metadata(&path)?.permissions();
173 perms.set_mode(0o755);
174 std::fs::set_permissions(&path, perms)?;
175 }
176 info!("installed: {path}");
177 }
178 Ok(())
179}
180
181const PRE_COMMIT_HOOK: &str = r#"#!/bin/sh
182# Installed by `yui init --git-hooks`.
183# Reject the commit if any `*.tera` template would render to something
184# that diverges from the rendered output staged alongside it. Run
185# `yui apply` (or `yui render`) to refresh and re-commit.
186exec yui render --check
187"#;
188
189const PRE_PUSH_HOOK: &str = r#"#!/bin/sh
190# Installed by `yui init --git-hooks`.
191# Same render-drift check as pre-commit, mirrored on push so a
192# `--no-verify` commit doesn't sneak diverged state to the remote.
193exec yui render --check
194"#;
195
196pub fn apply(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
197 let source = resolve_source(source)?;
198 let yui = YuiVars::detect(&source);
199 let config = config::load(&source, &yui)?;
200
201 let mut engine = template::Engine::new();
202 let tera_ctx = template::template_context(&yui, &config.vars);
203
204 hook::run_phase(
207 &config,
208 &source,
209 &yui,
210 &mut engine,
211 &tera_ctx,
212 HookPhase::Pre,
213 dry_run,
214 )?;
215
216 let secret_report = secret::decrypt_all(&source, &config, dry_run)?;
222 log_secret_report(&secret_report);
223 if secret_report.has_drift() {
224 anyhow::bail!(
225 "secret drift detected ({} file(s)); the plaintext sibling diverged \
226 from the canonical .age — run `yui secret encrypt <path>` to roll \
227 the edit back into ciphertext before re-running apply",
228 secret_report.diverged.len()
229 );
230 }
231
232 let render_report = render::render_all(&source, &config, &yui, dry_run)?;
234 log_render_report(&render_report);
235 if render_report.has_drift() {
236 anyhow::bail!(
237 "render drift detected ({} file(s)); reflect target edits back into the .tera before re-running apply",
238 render_report.diverged.len()
239 );
240 }
241
242 if !dry_run && config.render.manage_gitignore {
249 let mut managed: Vec<Utf8PathBuf> = render::report_managed_paths(&render_report)
250 .into_iter()
251 .chain(secret_report.managed_paths().cloned())
252 .collect();
253 managed.sort();
254 managed.dedup();
255 render::write_managed_section(&source, &managed)?;
256 }
257
258 let mounts = mount::resolve(
260 &source,
261 &config.mount.entry,
262 config.mount.default_strategy,
263 &mut engine,
264 &tera_ctx,
265 )?;
266
267 let backup_root = source.join(&config.backup.dir);
268 let ctx = ApplyCtx {
269 config: &config,
270 source: &source,
271 file_mode: resolve_file_mode(config.link.file_mode),
272 dir_mode: resolve_dir_mode(config.link.dir_mode),
273 backup_root: &backup_root,
274 dry_run,
275 };
276
277 info!("source: {source}");
278 info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
279 if dry_run {
280 info!("dry-run: nothing will be written");
281 }
282
283 let mut yuiignore = paths::YuiIgnoreStack::new();
287 yuiignore.push_dir(&source)?;
288 let walk_result = (|| -> Result<()> {
289 for m in &mounts {
290 info!("mount: {} → {}", m.src, m.dst);
291 process_mount(m, &ctx, &mut engine, &tera_ctx, &mut yuiignore)?;
292 }
293 Ok(())
294 })();
295 yuiignore.pop_dir(&source);
296 walk_result?;
297
298 hook::run_phase(
300 &config,
301 &source,
302 &yui,
303 &mut engine,
304 &tera_ctx,
305 HookPhase::Post,
306 dry_run,
307 )?;
308 Ok(())
309}
310
311fn log_render_report(r: &RenderReport) {
312 if !r.written.is_empty() {
313 info!("rendered {} new file(s)", r.written.len());
314 }
315 if !r.unchanged.is_empty() {
316 info!("rendered {} file(s) unchanged", r.unchanged.len());
317 }
318 if !r.skipped_when_false.is_empty() {
319 info!(
320 "skipped {} template(s) (when=false)",
321 r.skipped_when_false.len()
322 );
323 }
324 for d in &r.diverged {
325 warn!("rendered file diverged from template: {d}");
326 }
327}
328
329fn log_secret_report(r: &secret::SecretReport) {
330 if !r.written.is_empty() {
331 info!("decrypted {} secret file(s)", r.written.len());
332 }
333 if !r.unchanged.is_empty() {
334 info!("decrypted {} secret(s) unchanged", r.unchanged.len());
335 }
336 for d in &r.diverged {
337 warn!("plaintext sibling diverged from .age: {d}");
338 }
339}
340
341struct ApplyCtx<'a> {
348 config: &'a Config,
349 source: &'a Utf8Path,
351 file_mode: EffectiveFileMode,
352 dir_mode: EffectiveDirMode,
353 backup_root: &'a Utf8Path,
354 dry_run: bool,
355}
356
357pub fn list(
363 source: Option<Utf8PathBuf>,
364 all: bool,
365 icons_override: Option<IconsMode>,
366 no_color: bool,
367) -> Result<()> {
368 let source = resolve_source(source)?;
369 let yui = YuiVars::detect(&source);
370 let config = config::load(&source, &yui)?;
371
372 let icons_mode = icons_override.unwrap_or(config.ui.icons);
373 let icons = Icons::for_mode(icons_mode);
374 let color = !no_color && supports_color_stdout();
375
376 let items = collect_list_items(&source, &config, &yui)?;
377 let displayed: Vec<&ListItem> = if all {
378 items.iter().collect()
379 } else {
380 items.iter().filter(|i| i.active).collect()
381 };
382
383 print_list_table(&displayed, icons, color);
384
385 let total = items.len();
386 let active = items.iter().filter(|i| i.active).count();
387 let inactive = total - active;
388 println!();
389 if all {
390 println!(" {total} entries · {active} active · {inactive} inactive");
391 } else {
392 println!(
393 " {} of {} entries shown ({} inactive hidden — use --all)",
394 active, total, inactive
395 );
396 }
397 Ok(())
398}
399
400#[derive(Debug)]
401struct ListItem {
402 src: Utf8PathBuf,
403 dst: String,
404 when: Option<String>,
405 active: bool,
406}
407
408fn collect_list_items(source: &Utf8Path, config: &Config, yui: &YuiVars) -> Result<Vec<ListItem>> {
409 let mut engine = template::Engine::new();
410 let tera_ctx = template::template_context(yui, &config.vars);
411 let mut items = Vec::new();
412
413 for entry in &config.mount.entry {
415 let active = match &entry.when {
416 None => true,
417 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
418 };
419 let dst = engine
420 .render(&entry.dst, &tera_ctx)
421 .map(|s| paths::expand_tilde(s.trim()).to_string())
422 .unwrap_or_else(|_| entry.dst.clone());
423 items.push(ListItem {
424 src: entry.src.clone(),
425 dst,
426 when: entry.when.clone(),
427 active,
428 });
429 }
430
431 let walker = paths::source_walker(source).build();
433 let marker_filename = &config.mount.marker_filename;
434 for entry in walker {
435 let entry = match entry {
436 Ok(e) => e,
437 Err(_) => continue,
438 };
439 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
440 continue;
441 }
442 if entry.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
443 continue;
444 }
445 let dir = match entry.path().parent() {
446 Some(d) => d,
447 None => continue,
448 };
449 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
450 Ok(p) => p,
451 Err(_) => continue,
452 };
453 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
457 Some(s) => s,
458 None => continue,
459 };
460 let MarkerSpec::Explicit { links } = spec else {
461 continue; };
463 let rel = dir_utf8
464 .strip_prefix(source)
465 .map(Utf8PathBuf::from)
466 .unwrap_or(dir_utf8);
467 for link in &links {
468 let active = match &link.when {
469 None => true,
470 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
471 };
472 let dst = engine
473 .render(&link.dst, &tera_ctx)
474 .map(|s| paths::expand_tilde(s.trim()).to_string())
475 .unwrap_or_else(|_| link.dst.clone());
476 let src_display = match &link.src {
481 Some(filename) => rel.join(filename),
482 None => rel.clone(),
483 };
484 items.push(ListItem {
485 src: src_display,
486 dst,
487 when: link.when.clone(),
488 active,
489 });
490 }
491 }
492
493 items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
494 Ok(items)
495}
496
497fn supports_color_stdout() -> bool {
498 use std::io::IsTerminal;
499 std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
500}
501
502fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
503 let src_w = items
504 .iter()
505 .map(|i| i.src.as_str().chars().count())
506 .max()
507 .unwrap_or(0)
508 .max("SRC".len());
509 let dst_w = items
510 .iter()
511 .map(|i| i.dst.chars().count())
512 .max()
513 .unwrap_or(0)
514 .max("DST".len());
515
516 let status_w = "STATUS".len();
517 let arrow_w = icons.arrow.chars().count();
518
519 print_header(status_w, src_w, arrow_w, dst_w, color);
521
522 let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
524 if color {
525 use owo_colors::OwoColorize as _;
526 println!("{}", sep.dimmed());
527 } else {
528 println!("{sep}");
529 }
530
531 for item in items {
533 print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
534 }
535}
536
537fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
538 use owo_colors::OwoColorize as _;
539 let mut line = String::new();
540 let _ = write!(
541 &mut line,
542 " {:<status_w$} {:<src_w$} {:<arrow_w$} {:<dst_w$} WHEN",
543 "STATUS", "SRC", "", "DST"
544 );
545 if color {
546 println!("{}", line.bold());
547 } else {
548 println!("{line}");
549 }
550}
551
552fn render_separator(
553 sep_ch: char,
554 status_w: usize,
555 src_w: usize,
556 arrow_w: usize,
557 dst_w: usize,
558) -> String {
559 let bar = |n: usize| sep_ch.to_string().repeat(n);
560 format!(
561 " {} {} {} {} {}",
562 bar(status_w),
563 bar(src_w),
564 bar(arrow_w),
565 bar(dst_w),
566 bar("WHEN".len())
567 )
568}
569
570fn print_row(
571 item: &ListItem,
572 icons: Icons,
573 status_w: usize,
574 src_w: usize,
575 arrow_w: usize,
576 dst_w: usize,
577 color: bool,
578) {
579 use owo_colors::OwoColorize as _;
580 let status = if item.active {
581 icons.active
582 } else {
583 icons.inactive
584 };
585 let when_str = item
586 .when
587 .as_deref()
588 .map(strip_braces)
589 .unwrap_or_else(|| "(always)".to_string());
590
591 let src_display = item.src.as_str().replace('\\', "/");
593 let src = src_display.as_str();
594 let dst = &item.dst;
595 let arrow = icons.arrow;
596
597 let cell_status = format!("{:<status_w$}", status);
602 let cell_src = format!("{:<src_w$}", src);
603 let cell_arrow = format!("{:<arrow_w$}", arrow);
604 let cell_dst = format!("{:<dst_w$}", dst);
605
606 if !color {
607 println!(" {cell_status} {cell_src} {cell_arrow} {cell_dst} {when_str}");
608 return;
609 }
610
611 if item.active {
612 println!(
613 " {} {} {} {} {}",
614 cell_status.green(),
615 cell_src.cyan(),
616 cell_arrow.dimmed(),
617 cell_dst.green(),
618 when_str.dimmed()
619 );
620 } else {
621 println!(
622 " {} {} {} {} {}",
623 cell_status.red().dimmed(),
624 cell_src.dimmed(),
625 cell_arrow.dimmed(),
626 cell_dst.dimmed(),
627 when_str.dimmed()
628 );
629 }
630}
631
632fn strip_braces(expr: &str) -> String {
635 let trimmed = expr.trim();
636 if let Some(inner) = trimmed
637 .strip_prefix("{{")
638 .and_then(|s| s.strip_suffix("}}"))
639 {
640 inner.trim().to_string()
641 } else {
642 trimmed.to_string()
643 }
644}
645
646pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
647 let source = resolve_source(source)?;
648 let yui = YuiVars::detect(&source);
649 let config = config::load(&source, &yui)?;
650 let effective_dry_run = dry_run || check;
652 let report = render::render_all(&source, &config, &yui, effective_dry_run)?;
653 log_render_report(&report);
654 if !effective_dry_run && config.render.manage_gitignore {
659 let managed = render::report_managed_paths(&report);
660 render::write_managed_section(&source, &managed)?;
661 }
662 if check && report.has_drift() {
663 anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
664 }
665 Ok(())
666}
667
668pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
669 apply(source, dry_run)
671}
672
673pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
674 let _source = resolve_source(source)?;
675 if paths_arg.is_empty() {
676 anyhow::bail!("yui unlink: provide at least one target path");
677 }
678 for p in paths_arg {
679 let abs = absolutize(&p)?;
680 info!("unlink: {abs}");
681 link::unlink(&abs)?;
682 }
683 Ok(())
684}
685
686pub fn secret_init(source: Option<Utf8PathBuf>, comment: Option<String>) -> Result<()> {
714 let source = resolve_source(source)?;
715 let yui = YuiVars::detect(&source);
716 let config = config::load(&source, &yui)?;
717
718 let identity_path = paths::expand_tilde(&config.secrets.identity);
720 if identity_path.exists() {
721 anyhow::bail!(
722 "identity file already exists at {identity_path}; \
723 refusing to overwrite. Delete it first if you really \
724 mean to start fresh (you'll lose access to existing \
725 .age files encrypted to its public key)."
726 );
727 }
728
729 let (secret, public) = secret::generate_x25519_keypair();
733 let now = jiff::Zoned::now().to_string();
734 let body = format!(
735 "# created: {now}\n\
736 # public key: {public}\n\
737 {secret}\n"
738 );
739 secret::write_private_file(&identity_path, body.as_bytes())?;
742 info!("wrote identity file: {identity_path}");
743
744 let config_path = source.join("config.toml");
749 let comment = comment.unwrap_or_else(|| format!("{} {}", yui.host, yui.user));
750 let entry_comment = format!("{comment} — added by `yui secret init` on {now}");
751 let config_existing = match std::fs::read_to_string(&config_path) {
752 Ok(s) => s,
753 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
754 Err(e) => anyhow::bail!("read {config_path}: {e}"),
755 };
756 let updated_config = append_recipient_to_config(&config_existing, &entry_comment, &public)?;
757 std::fs::write(&config_path, updated_config)?;
758 info!("appended public key to {config_path}");
759 println!();
760 println!(" age identity: {identity_path}");
761 println!(" public key: {public}");
762 println!();
763 println!(
764 " Next: encrypt a file with `yui secret encrypt <path>`. \
765 The plaintext sibling will be auto-decrypted on every `yui apply`."
766 );
767 Ok(())
768}
769
770fn append_recipient_to_config(existing: &str, comment: &str, public: &str) -> Result<String> {
784 use toml_edit::{Array, DocumentMut, Item, Table, Value};
785
786 let mut doc: DocumentMut = if existing.trim().is_empty() {
787 DocumentMut::new()
788 } else {
789 existing
790 .parse()
791 .map_err(|e| anyhow::anyhow!("config.toml is not valid TOML: {e}"))?
792 };
793
794 if !doc.contains_key("secrets") {
796 let mut t = Table::new();
797 t.set_implicit(false);
798 doc.insert("secrets", Item::Table(t));
799 }
800 let secrets = doc["secrets"].as_table_mut().ok_or_else(|| {
801 anyhow::anyhow!("[secrets] in config.toml is not a table — refusing to clobber")
802 })?;
803
804 if !secrets.contains_key("recipients") {
806 secrets.insert("recipients", Item::Value(Value::Array(Array::new())));
807 }
808 let recipients = secrets["recipients"]
809 .as_array_mut()
810 .ok_or_else(|| anyhow::anyhow!("[secrets].recipients is not an array"))?;
811
812 let already_present = recipients.iter().any(|v| v.as_str() == Some(public));
814 if already_present {
815 return Ok(doc.to_string());
816 }
817
818 let mut value = Value::from(public);
822 let prefix = format!("\n # {comment}\n ");
823 *value.decor_mut() = toml_edit::Decor::new(prefix, "");
824 recipients.push_formatted(value);
825 recipients.set_trailing("\n");
829 recipients.set_trailing_comma(true);
830
831 Ok(doc.to_string())
832}
833
834pub fn secret_encrypt(
838 source: Option<Utf8PathBuf>,
839 path: Utf8PathBuf,
840 force: bool,
841 rm_plaintext: bool,
842) -> Result<()> {
843 let source = resolve_source(source)?;
844 let yui = YuiVars::detect(&source);
845 let config = config::load(&source, &yui)?;
846
847 if !config.secrets.enabled() {
848 anyhow::bail!(
849 "no recipients configured — run `yui secret init` to generate \
850 a keypair, or add at least one entry to `[secrets] recipients`."
851 );
852 }
853
854 let plaintext_path = if path.is_absolute() {
858 path.clone()
859 } else {
860 absolutize(&path)?
861 };
862 if !plaintext_path.is_file() {
863 anyhow::bail!("plaintext file not found: {plaintext_path}");
864 }
865 let cipher_path = Utf8PathBuf::from(format!("{plaintext_path}.age"));
866 if cipher_path.exists() && !force {
867 anyhow::bail!("{cipher_path} already exists; pass --force to overwrite");
868 }
869
870 let plaintext = std::fs::read(&plaintext_path)?;
871 let recipients = secret::parse_passkey_recipients(&config.secrets.recipients)?;
879 let cipher = secret::encrypt_to_passkeys(&plaintext, &recipients)?;
880 std::fs::write(&cipher_path, &cipher)?;
881 info!("encrypted {plaintext_path} → {cipher_path}");
882
883 if rm_plaintext {
884 if plaintext_path.starts_with(&source) {
887 std::fs::remove_file(&plaintext_path)?;
888 info!("removed plaintext: {plaintext_path}");
889 } else {
890 warn!(
891 "plaintext lives outside source ({plaintext_path}); \
892 skipping --rm-plaintext as a safety check"
893 );
894 }
895 }
896 Ok(())
897}
898
899pub fn secret_store(source: Option<Utf8PathBuf>, force: bool) -> Result<()> {
909 let source = resolve_source(source)?;
910 let yui = YuiVars::detect(&source);
911 let config = config::load(&source, &yui)?;
912
913 let vault_cfg = config.secrets.vault.as_ref().ok_or_else(|| {
914 anyhow::anyhow!(
915 "[secrets.vault] is not configured — set provider \
916 (\"bitwarden\" or \"1password\") and item before \
917 calling store"
918 )
919 })?;
920
921 let identity_path = paths::expand_tilde(&config.secrets.identity);
922 if !identity_path.is_file() {
923 anyhow::bail!(
924 "no X25519 identity at {identity_path}; run `yui secret init` first \
925 (store needs that file's content to push to the vault)"
926 );
927 }
928 let plaintext = std::fs::read(&identity_path)?;
929 secret::validate_x25519_identity_bytes(&plaintext)?;
934
935 let vault = vault::driver(vault_cfg);
936 vault.precheck()?;
941 info!(
942 "pushing X25519 identity to {} item {:?}",
943 vault.provider_name(),
944 vault_cfg.item
945 );
946 vault.store(&vault_cfg.item, &plaintext, force)?;
947
948 println!();
949 println!(
950 " X25519 identity pushed to {} item {:?}",
951 vault.provider_name(),
952 vault_cfg.item
953 );
954 println!(" On a new machine, run `yui secret unlock`.");
955 Ok(())
956}
957
958pub fn secret_unlock(source: Option<Utf8PathBuf>) -> Result<()> {
964 let source = resolve_source(source)?;
965 let yui = YuiVars::detect(&source);
966 let config = config::load(&source, &yui)?;
967
968 let vault_cfg = config.secrets.vault.as_ref().ok_or_else(|| {
969 anyhow::anyhow!(
970 "[secrets.vault] is not configured — nothing to unlock. \
971 Run `yui secret init` + `yui secret store` on an existing \
972 machine first, then commit + push the config."
973 )
974 })?;
975 let identity_path = paths::expand_tilde(&config.secrets.identity);
976 if identity_path.exists() {
977 anyhow::bail!(
978 "{identity_path} already exists — refusing to clobber a live \
979 X25519 identity. Delete it first if you really mean to \
980 re-unlock from scratch."
981 );
982 }
983
984 let vault = vault::driver(vault_cfg);
985 vault.precheck()?;
986 info!(
987 "fetching X25519 identity from {} item {:?}",
988 vault.provider_name(),
989 vault_cfg.item
990 );
991 let plaintext = vault.fetch(&vault_cfg.item)?;
992
993 secret::validate_x25519_identity_bytes(&plaintext)?;
999
1000 secret::write_private_file(&identity_path, &plaintext)?;
1002 info!("wrote X25519 identity: {identity_path}");
1003 println!();
1004 println!(" X25519 identity restored at {identity_path}");
1005 println!(" Run `yui apply` next.");
1006 Ok(())
1007}
1008
1009pub fn update(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
1020 let source = resolve_source(source)?;
1021 if !crate::git::is_clean(&source)? {
1022 anyhow::bail!(
1023 "source repo {source} has uncommitted changes — \
1024 commit or stash before `yui update` (or run \
1025 `git pull` + `yui apply` manually if you know what \
1026 you're doing)"
1027 );
1028 }
1029 info!("git pull --ff-only at {source}");
1030 let status = std::process::Command::new("git")
1031 .arg("-C")
1032 .arg(source.as_str())
1033 .arg("pull")
1034 .arg("--ff-only")
1035 .status()
1036 .map_err(|e| anyhow::anyhow!("invoking git: {e}"))?;
1037 if !status.success() {
1038 anyhow::bail!("git pull --ff-only failed at {source}");
1039 }
1040 apply(Some(source), dry_run)
1041}
1042
1043pub fn unmanaged(
1054 source: Option<Utf8PathBuf>,
1055 icons_override: Option<IconsMode>,
1056 no_color: bool,
1057) -> Result<()> {
1058 let source = resolve_source(source)?;
1059 let yui = YuiVars::detect(&source);
1060 let config = config::load(&source, &yui)?;
1061
1062 let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
1063 let color = !no_color && supports_color_stdout();
1064
1065 let mut engine = template::Engine::new();
1080 let tera_ctx = template::template_context(&yui, &config.vars);
1081 let mount_srcs: Vec<Utf8PathBuf> = config
1082 .mount
1083 .entry
1084 .iter()
1085 .map(|e| -> Result<Utf8PathBuf> {
1086 let rendered = engine.render(e.src.as_str(), &tera_ctx)?;
1087 Ok(paths::resolve_mount_src(&source, rendered.trim()))
1088 })
1089 .collect::<Result<_>>()?;
1090
1091 let mut items: Vec<Utf8PathBuf> = Vec::new();
1092 let walker = paths::source_walker(&source).build();
1093 for entry in walker {
1094 let entry = match entry {
1095 Ok(e) => e,
1096 Err(_) => continue,
1097 };
1098 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
1099 continue;
1100 }
1101 let std_path = entry.path();
1102 let path = match Utf8PathBuf::from_path_buf(std_path.to_path_buf()) {
1103 Ok(p) => p,
1104 Err(_) => continue,
1105 };
1106 if is_repo_meta(&path, &source, &config.mount.marker_filename) {
1110 continue;
1111 }
1112 if mount_srcs.iter().any(|m| path.starts_with(m)) {
1113 continue;
1114 }
1115 items.push(path);
1116 }
1117 items.sort();
1118
1119 if items.is_empty() {
1120 println!(" no unmanaged files under {source}");
1121 return Ok(());
1122 }
1123
1124 print_unmanaged_table(&items, &source, color);
1125 println!();
1126 println!(" {} unmanaged file(s)", items.len());
1127 Ok(())
1128}
1129
1130fn is_repo_meta(path: &Utf8Path, source: &Utf8Path, marker_filename: &str) -> bool {
1146 let Some(name) = path.file_name() else {
1147 return false;
1148 };
1149 if name.ends_with(".tera") {
1150 return true;
1151 }
1152 if name == marker_filename || name == ".yuiignore" {
1153 return true;
1154 }
1155 let parent = path.parent().unwrap_or(Utf8Path::new(""));
1156 let at_root = parent == source;
1157 if at_root && name == ".gitignore" {
1158 return true;
1159 }
1160 if at_root && (name == "config.toml" || name == "config.local.toml") {
1161 return true;
1162 }
1163 if at_root
1164 && name.starts_with("config.")
1165 && (name.ends_with(".toml") || name.ends_with(".example.toml"))
1166 {
1167 return true;
1168 }
1169 false
1170}
1171
1172fn print_unmanaged_table(items: &[Utf8PathBuf], source: &Utf8Path, color: bool) {
1173 use owo_colors::OwoColorize as _;
1174 if color {
1175 println!(" {}", "PATH (relative to source)".dimmed());
1176 } else {
1177 println!(" PATH (relative to source)");
1178 }
1179 for p in items {
1180 let rel = p
1181 .strip_prefix(source)
1182 .map(Utf8PathBuf::from)
1183 .unwrap_or_else(|_| p.clone());
1184 if color {
1185 println!(" {}", rel.cyan());
1186 } else {
1187 println!(" {rel}");
1188 }
1189 }
1190}
1191
1192pub fn diff(
1200 source: Option<Utf8PathBuf>,
1201 icons_override: Option<IconsMode>,
1202 no_color: bool,
1203) -> Result<()> {
1204 let source = resolve_source(source)?;
1205 let yui = YuiVars::detect(&source);
1206 let config = config::load(&source, &yui)?;
1207 let mut engine = template::Engine::new();
1208 let tera_ctx = template::template_context(&yui, &config.vars);
1209 let mounts = mount::resolve(
1210 &source,
1211 &config.mount.entry,
1212 config.mount.default_strategy,
1213 &mut engine,
1214 &tera_ctx,
1215 )?;
1216
1217 let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
1218 let color = !no_color && supports_color_stdout();
1219
1220 let mut report: Vec<StatusItem> = Vec::new();
1222 let mut yuiignore = paths::YuiIgnoreStack::new();
1223 yuiignore.push_dir(&source)?;
1224 let walk_result = (|| -> Result<()> {
1225 for m in &mounts {
1226 let src_root = m.src.clone();
1227 if !src_root.is_dir() {
1228 continue;
1229 }
1230 classify_walk(
1231 &src_root,
1232 &m.dst,
1233 &config,
1234 m.strategy,
1235 &mut engine,
1236 &tera_ctx,
1237 &source,
1238 &mut yuiignore,
1239 &mut report,
1240 )?;
1241 }
1242 Ok(())
1243 })();
1244 yuiignore.pop_dir(&source);
1245 walk_result?;
1246
1247 let render_report = render::render_all(&source, &config, &yui, true)?;
1249 for rendered in &render_report.diverged {
1250 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
1251 report.push(StatusItem {
1252 src: tera_path,
1253 dst: rendered.clone(),
1254 state: StatusState::RenderDrift,
1255 });
1256 }
1257
1258 let mut printed = 0usize;
1259 for item in &report {
1260 if !diff_worth_printing(&item.state) {
1261 continue;
1262 }
1263 let src_abs = resolve_diff_src(item, &source);
1264 print_unified_diff(
1265 &src_abs,
1266 &item.dst,
1267 &item.state,
1268 &source,
1269 &config,
1270 &yui,
1271 color,
1272 );
1273 printed += 1;
1274 }
1275
1276 if printed == 0 {
1277 println!(" no diff — every entry is in sync (or only needs a relink)");
1278 } else {
1279 println!();
1280 println!(
1281 " {printed} entr{} with content drift",
1282 if printed == 1 { "y" } else { "ies" }
1283 );
1284 }
1285 Ok(())
1286}
1287
1288fn resolve_diff_src(item: &StatusItem, source: &Utf8Path) -> Utf8PathBuf {
1300 match item.state {
1301 StatusState::RenderDrift => item.src.clone(),
1302 StatusState::Link(_) => source.join(&item.src),
1303 }
1304}
1305
1306fn diff_worth_printing(state: &StatusState) -> bool {
1307 use absorb::AbsorbDecision::*;
1308 match state {
1309 StatusState::Link(InSync) => false,
1310 StatusState::Link(Restore) => false, StatusState::Link(RelinkOnly) => false, StatusState::Link(_) => true,
1313 StatusState::RenderDrift => true,
1314 }
1315}
1316
1317fn print_unified_diff(
1325 src: &Utf8Path,
1326 dst: &Utf8Path,
1327 state: &StatusState,
1328 source_root: &Utf8Path,
1329 config: &Config,
1330 yui: &YuiVars,
1331 color: bool,
1332) {
1333 use owo_colors::OwoColorize as _;
1334
1335 let header = match state {
1336 StatusState::RenderDrift => format!("--- render drift: {src} (template) vs {dst}"),
1337 _ => format!("--- {src} → {dst}"),
1338 };
1339 if color {
1340 println!("{}", header.bold());
1341 } else {
1342 println!("{header}");
1343 }
1344
1345 if src.is_dir() || dst.is_dir() {
1346 println!("(directory entry — content listing skipped)");
1347 println!();
1348 return;
1349 }
1350
1351 let src_content = match state {
1356 StatusState::RenderDrift => match render::render_to_string(src, source_root, config, yui) {
1357 Ok(Some(s)) => s,
1358 Ok(None) => {
1359 println!(
1360 "(template would be skipped on this host — drift will resolve on next render)"
1361 );
1362 println!();
1363 return;
1364 }
1365 Err(e) => {
1366 println!("(error rendering template: {e})");
1367 println!();
1368 return;
1369 }
1370 },
1371 _ => match read_text_for_diff(src) {
1372 DiffSide::Text(s) => s,
1373 DiffSide::Binary => {
1374 println!("(binary file or non-UTF-8 content — diff skipped)");
1375 println!();
1376 return;
1377 }
1378 },
1379 };
1380 let dst_content = match read_text_for_diff(dst) {
1381 DiffSide::Text(s) => s,
1382 DiffSide::Binary => {
1383 println!("(binary file or non-UTF-8 content — diff skipped)");
1384 println!();
1385 return;
1386 }
1387 };
1388 print_unified_text_diff(
1389 &src_content,
1390 &dst_content,
1391 src.as_str(),
1392 dst.as_str(),
1393 color,
1394 );
1395 println!();
1396}
1397
1398fn print_unified_text_diff(src: &str, dst: &str, src_label: &str, dst_label: &str, color: bool) {
1407 use owo_colors::OwoColorize as _;
1408 let diff = similar::TextDiff::from_lines(src, dst);
1409 let formatted = diff.unified_diff().header(src_label, dst_label).to_string();
1410 for line in formatted.lines() {
1411 if !color {
1412 println!("{line}");
1413 } else if line.starts_with("+++") || line.starts_with("---") {
1414 println!("{}", line.dimmed());
1415 } else if line.starts_with("@@") {
1416 println!("{}", line.cyan());
1417 } else if line.starts_with('+') {
1418 println!("{}", line.green());
1419 } else if line.starts_with('-') {
1420 println!("{}", line.red());
1421 } else {
1422 println!("{line}");
1423 }
1424 }
1425}
1426
1427enum DiffSide {
1433 Text(String),
1434 Binary,
1435}
1436
1437fn read_text_for_diff(p: &Utf8Path) -> DiffSide {
1438 match std::fs::read_to_string(p) {
1439 Ok(s) => DiffSide::Text(s),
1440 Err(e) if e.kind() == std::io::ErrorKind::InvalidData => DiffSide::Binary,
1441 Err(_) => DiffSide::Text(String::new()),
1442 }
1443}
1444
1445pub fn status(
1458 source: Option<Utf8PathBuf>,
1459 icons_override: Option<IconsMode>,
1460 no_color: bool,
1461) -> Result<()> {
1462 let source = resolve_source(source)?;
1463 let yui = YuiVars::detect(&source);
1464 let config = config::load(&source, &yui)?;
1465
1466 let mut engine = template::Engine::new();
1467 let tera_ctx = template::template_context(&yui, &config.vars);
1468 let mounts = mount::resolve(
1469 &source,
1470 &config.mount.entry,
1471 config.mount.default_strategy,
1472 &mut engine,
1473 &tera_ctx,
1474 )?;
1475
1476 let icons_mode = icons_override.unwrap_or(config.ui.icons);
1477 let icons = Icons::for_mode(icons_mode);
1478 let color = !no_color && supports_color_stdout();
1479
1480 let mut report: Vec<StatusItem> = Vec::new();
1481
1482 let render_report = render::render_all(&source, &config, &yui, true)?;
1485 for rendered in &render_report.diverged {
1486 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
1490 report.push(StatusItem {
1491 src: relative_for_display(&source, &tera_path),
1492 dst: rendered.clone(),
1493 state: StatusState::RenderDrift,
1494 });
1495 }
1496
1497 let mut yuiignore = paths::YuiIgnoreStack::new();
1501 yuiignore.push_dir(&source)?;
1502 let walk_result = (|| -> Result<()> {
1503 for m in &mounts {
1504 let src_root = m.src.clone();
1505 if !src_root.is_dir() {
1506 warn!("mount src missing: {src_root}");
1507 continue;
1508 }
1509 classify_walk(
1510 &src_root,
1511 &m.dst,
1512 &config,
1513 m.strategy,
1514 &mut engine,
1515 &tera_ctx,
1516 &source,
1517 &mut yuiignore,
1518 &mut report,
1519 )?;
1520 }
1521 Ok(())
1522 })();
1523 yuiignore.pop_dir(&source);
1524 walk_result?;
1525
1526 report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
1527
1528 print_status_table(&report, icons, color);
1529
1530 let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
1531
1532 println!();
1533 let total = report.len();
1534 let in_sync = total - drift;
1535 if drift == 0 {
1536 println!(" {total} entries · all in sync");
1537 Ok(())
1538 } else {
1539 println!(" {total} entries · {in_sync} in sync · {drift} diverged");
1540 anyhow::bail!("status: {drift} entries diverged from source")
1541 }
1542}
1543
1544#[derive(Debug)]
1545struct StatusItem {
1546 src: Utf8PathBuf,
1548 dst: Utf8PathBuf,
1550 state: StatusState,
1551}
1552
1553#[derive(Debug, Clone, Copy)]
1554enum StatusState {
1555 Link(absorb::AbsorbDecision),
1556 RenderDrift,
1559}
1560
1561impl StatusState {
1562 fn is_in_sync(self) -> bool {
1563 matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
1564 }
1565}
1566
1567#[allow(clippy::too_many_arguments)]
1568fn classify_walk(
1569 src_dir: &Utf8Path,
1570 dst_dir: &Utf8Path,
1571 config: &Config,
1572 strategy: MountStrategy,
1573 engine: &mut template::Engine,
1574 tera_ctx: &TeraContext,
1575 source_root: &Utf8Path,
1576 yuiignore: &mut paths::YuiIgnoreStack,
1577 report: &mut Vec<StatusItem>,
1578) -> Result<()> {
1579 classify_walk_inner(
1580 src_dir,
1581 dst_dir,
1582 config,
1583 strategy,
1584 engine,
1585 tera_ctx,
1586 source_root,
1587 yuiignore,
1588 report,
1589 false,
1590 )
1591}
1592
1593#[allow(clippy::too_many_arguments)]
1594fn classify_walk_inner(
1595 src_dir: &Utf8Path,
1596 dst_dir: &Utf8Path,
1597 config: &Config,
1598 strategy: MountStrategy,
1599 engine: &mut template::Engine,
1600 tera_ctx: &TeraContext,
1601 source_root: &Utf8Path,
1602 yuiignore: &mut paths::YuiIgnoreStack,
1603 report: &mut Vec<StatusItem>,
1604 parent_covered: bool,
1605) -> Result<()> {
1606 if yuiignore.is_ignored(src_dir, true) {
1607 return Ok(());
1608 }
1609 yuiignore.push_dir(src_dir)?;
1612 let result = classify_walk_inner_body(
1613 src_dir,
1614 dst_dir,
1615 config,
1616 strategy,
1617 engine,
1618 tera_ctx,
1619 source_root,
1620 yuiignore,
1621 report,
1622 parent_covered,
1623 );
1624 yuiignore.pop_dir(src_dir);
1625 result
1626}
1627
1628#[allow(clippy::too_many_arguments)]
1629fn classify_walk_inner_body(
1630 src_dir: &Utf8Path,
1631 dst_dir: &Utf8Path,
1632 config: &Config,
1633 strategy: MountStrategy,
1634 engine: &mut template::Engine,
1635 tera_ctx: &TeraContext,
1636 source_root: &Utf8Path,
1637 yuiignore: &mut paths::YuiIgnoreStack,
1638 report: &mut Vec<StatusItem>,
1639 parent_covered: bool,
1640) -> Result<()> {
1641 let marker_filename = &config.mount.marker_filename;
1642 let mut covered = parent_covered;
1643
1644 if strategy == MountStrategy::Marker {
1645 match marker::read_spec(src_dir, marker_filename)? {
1646 None => {}
1647 Some(MarkerSpec::PassThrough) => {
1648 let decision = absorb::classify(src_dir, dst_dir)?;
1649 report.push(StatusItem {
1650 src: relative_for_display(source_root, src_dir),
1651 dst: dst_dir.to_path_buf(),
1652 state: StatusState::Link(decision),
1653 });
1654 covered = true;
1655 }
1656 Some(MarkerSpec::Explicit { links }) => {
1657 let mut emitted_dir_link = false;
1658 for link in &links {
1659 if let Some(when) = &link.when {
1660 if !template::eval_truthy(when, engine, tera_ctx)? {
1661 continue;
1662 }
1663 }
1664 let dst_str = engine.render(&link.dst, tera_ctx)?;
1665 let dst = paths::expand_tilde(dst_str.trim());
1666 if let Some(filename) = &link.src {
1667 let file_src = src_dir.join(filename);
1668 if !file_src.is_file() {
1669 anyhow::bail!(
1670 "marker at {src_dir}: [[link]] src={filename:?} \
1671 not found"
1672 );
1673 }
1674 let decision = absorb::classify(&file_src, &dst)?;
1675 report.push(StatusItem {
1676 src: relative_for_display(source_root, &file_src),
1677 dst,
1678 state: StatusState::Link(decision),
1679 });
1680 } else {
1681 let decision = absorb::classify(src_dir, &dst)?;
1682 report.push(StatusItem {
1683 src: relative_for_display(source_root, src_dir),
1684 dst,
1685 state: StatusState::Link(decision),
1686 });
1687 emitted_dir_link = true;
1688 }
1689 }
1690 if emitted_dir_link {
1691 covered = true;
1692 }
1693 }
1694 }
1695 }
1696
1697 for entry in std::fs::read_dir(src_dir)? {
1698 let entry = entry?;
1699 let name_os = entry.file_name();
1700 let Some(name) = name_os.to_str() else {
1701 continue;
1702 };
1703 if name == marker_filename || name.ends_with(".tera") {
1704 continue;
1705 }
1706 let src_path = src_dir.join(name);
1707 let dst_path = dst_dir.join(name);
1708 let ft = entry.file_type()?;
1709 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
1710 continue;
1711 }
1712 if ft.is_dir() {
1713 classify_walk_inner(
1714 &src_path,
1715 &dst_path,
1716 config,
1717 strategy,
1718 engine,
1719 tera_ctx,
1720 source_root,
1721 yuiignore,
1722 report,
1723 covered,
1724 )?;
1725 } else if ft.is_file() && !covered {
1726 let decision = absorb::classify(&src_path, &dst_path)?;
1727 report.push(StatusItem {
1728 src: relative_for_display(source_root, &src_path),
1729 dst: dst_path,
1730 state: StatusState::Link(decision),
1731 });
1732 }
1733 }
1734 Ok(())
1735}
1736
1737fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
1738 p.strip_prefix(source_root)
1739 .map(Utf8PathBuf::from)
1740 .unwrap_or_else(|_| p.to_path_buf())
1741}
1742
1743fn print_status_table(items: &[StatusItem], icons: Icons, color: bool) {
1744 let src_w = items
1745 .iter()
1746 .map(|i| i.src.as_str().chars().count())
1747 .max()
1748 .unwrap_or(0)
1749 .max("SRC".len());
1750 let dst_w = items
1751 .iter()
1752 .map(|i| i.dst.as_str().chars().count())
1753 .max()
1754 .unwrap_or(0)
1755 .max("DST".len());
1756 let state_label_w = items
1758 .iter()
1759 .map(|i| state_label(i.state).len())
1760 .max()
1761 .unwrap_or(0)
1762 .max("STATE".len() - 2); let state_w = state_label_w + 2; print_status_header(state_w, src_w, dst_w, color);
1766 let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
1767 if color {
1768 use owo_colors::OwoColorize as _;
1769 println!("{}", sep.dimmed());
1770 } else {
1771 println!("{sep}");
1772 }
1773 for item in items {
1774 print_status_row(item, icons, state_w, src_w, dst_w, color);
1775 }
1776}
1777
1778fn state_label(s: StatusState) -> &'static str {
1779 use absorb::AbsorbDecision::*;
1780 match s {
1781 StatusState::Link(InSync) => "in-sync",
1782 StatusState::Link(RelinkOnly) => "relink",
1783 StatusState::Link(AutoAbsorb) => "drift (auto)",
1784 StatusState::Link(NeedsConfirm) => "drift (anomaly)",
1785 StatusState::Link(Restore) => "missing",
1786 StatusState::RenderDrift => "render drift",
1787 }
1788}
1789
1790fn state_icon(s: StatusState, icons: Icons) -> &'static str {
1791 use absorb::AbsorbDecision::*;
1792 match s {
1793 StatusState::Link(InSync) => icons.ok,
1794 StatusState::Link(RelinkOnly) => icons.warn,
1795 StatusState::Link(AutoAbsorb) => icons.warn,
1796 StatusState::Link(NeedsConfirm) => icons.error,
1797 StatusState::Link(Restore) => icons.info,
1798 StatusState::RenderDrift => icons.error,
1799 }
1800}
1801
1802fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
1803 use owo_colors::OwoColorize as _;
1804 let line = format!(
1807 " {:<state_w$} {:<src_w$} {:<dst_w$}",
1808 "STATE", "SRC", "DST"
1809 );
1810 if color {
1811 println!("{}", line.bold());
1812 } else {
1813 println!("{line}");
1814 }
1815}
1816
1817fn render_status_separator(
1818 sep_ch: char,
1819 state_w: usize,
1820 src_w: usize,
1821 dst_w: usize,
1822 arrow: &str,
1823) -> String {
1824 let bar = |n: usize| sep_ch.to_string().repeat(n);
1825 format!(
1826 " {} {} {} {}",
1827 bar(state_w),
1828 bar(src_w),
1829 bar(arrow.chars().count()),
1830 bar(dst_w)
1831 )
1832}
1833
1834fn print_status_row(
1835 item: &StatusItem,
1836 icons: Icons,
1837 state_w: usize,
1838 src_w: usize,
1839 dst_w: usize,
1840 color: bool,
1841) {
1842 use owo_colors::OwoColorize as _;
1843 let icon = state_icon(item.state, icons);
1844 let label = state_label(item.state);
1845 let state_text = format!("{icon} {label}");
1846 let src_display = item.src.as_str().replace('\\', "/");
1847 let dst_display = item.dst.as_str().replace('\\', "/");
1848 let arrow = icons.arrow;
1849
1850 let cell_state = format!("{:<state_w$}", state_text);
1851 let cell_src = format!("{:<src_w$}", src_display);
1852 let cell_dst = format!("{:<dst_w$}", dst_display);
1853
1854 if !color {
1855 println!(" {cell_state} {cell_src} {arrow} {cell_dst}");
1856 return;
1857 }
1858
1859 use absorb::AbsorbDecision::*;
1860 let state_colored = match item.state {
1861 StatusState::Link(InSync) => cell_state.green().to_string(),
1862 StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
1863 cell_state.yellow().to_string()
1864 }
1865 StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
1866 StatusState::Link(Restore) => cell_state.cyan().to_string(),
1867 StatusState::RenderDrift => cell_state.red().to_string(),
1868 };
1869 let src_colored = cell_src.cyan().to_string();
1870 let arrow_colored = arrow.dimmed().to_string();
1871 let dst_colored = cell_dst.dimmed().to_string();
1872 println!(" {state_colored} {src_colored} {arrow_colored} {dst_colored}");
1873}
1874
1875pub fn absorb(
1889 source: Option<Utf8PathBuf>,
1890 target: Utf8PathBuf,
1891 dry_run: bool,
1892 yes: bool,
1893) -> Result<()> {
1894 let source = resolve_source(source)?;
1895 let target = absolutize(&target)?;
1896 let yui = YuiVars::detect(&source);
1897 let config = config::load(&source, &yui)?;
1898
1899 let mut engine = template::Engine::new();
1900 let tera_ctx = template::template_context(&yui, &config.vars);
1901
1902 let src_path = match find_source_for_target(&source, &config, &target, &mut engine, &tera_ctx)?
1903 {
1904 Some(s) => s,
1905 None => anyhow::bail!(
1906 "no mount entry / .yuilink override claims target {target}; \
1907 pass a path inside a known dst"
1908 ),
1909 };
1910
1911 info!("source for {target}: {src_path}");
1912
1913 print_absorb_diff(&src_path, &target);
1918
1919 if dry_run {
1920 info!("[dry-run] would absorb {target} → {src_path}");
1921 return Ok(());
1922 }
1923
1924 if !yes {
1925 use std::io::IsTerminal;
1926 if !std::io::stdin().is_terminal() {
1927 anyhow::bail!(
1928 "manual absorb refuses to run off-TTY without --yes \
1929 (would silently overwrite {src_path})"
1930 );
1931 }
1932 if !prompt_yes_no("absorb target into source?")? {
1933 warn!("manual absorb cancelled by user: {target}");
1934 return Ok(());
1935 }
1936 }
1937
1938 let backup_root = source.join(&config.backup.dir);
1939 let ctx = ApplyCtx {
1940 config: &config,
1941 source: &source,
1942 file_mode: resolve_file_mode(config.link.file_mode),
1943 dir_mode: resolve_dir_mode(config.link.dir_mode),
1944 backup_root: &backup_root,
1945 dry_run: false,
1946 };
1947
1948 absorb_target_into_source(&src_path, &target, &ctx)
1951}
1952
1953fn print_absorb_diff(src: &Utf8Path, dst: &Utf8Path) {
1958 eprintln!();
1959 eprintln!("--- diff (- source, + target) ---");
1960 eprintln!(" src: {src}");
1961 eprintln!(" dst: {dst}");
1962 eprintln!();
1963 if src.is_dir() || dst.is_dir() {
1964 eprintln!("(directory absorb — content listing skipped)");
1965 eprintln!();
1966 return;
1967 }
1968 let src_content = match read_text_for_diff(src) {
1969 DiffSide::Text(s) => s,
1970 DiffSide::Binary => {
1971 eprintln!("(binary file or non-UTF-8 content — diff skipped)");
1972 eprintln!();
1973 return;
1974 }
1975 };
1976 let dst_content = match read_text_for_diff(dst) {
1977 DiffSide::Text(s) => s,
1978 DiffSide::Binary => {
1979 eprintln!("(binary file or non-UTF-8 content — diff skipped)");
1980 eprintln!();
1981 return;
1982 }
1983 };
1984 let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
1985 let formatted = diff
1986 .unified_diff()
1987 .header(src.as_str(), dst.as_str())
1988 .to_string();
1989 eprint!("{formatted}");
1990 eprintln!();
1991}
1992
1993fn prompt_yes_no(question: &str) -> Result<bool> {
1994 use std::io::Write as _;
1995 eprint!("{question} [y/N]: ");
1996 std::io::stderr().flush().ok();
1997 let mut input = String::new();
1998 std::io::stdin().read_line(&mut input)?;
1999 let answer = input.trim();
2000 Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
2001}
2002
2003fn find_source_for_target(
2007 source: &Utf8Path,
2008 config: &Config,
2009 target: &Utf8Path,
2010 engine: &mut template::Engine,
2011 tera_ctx: &TeraContext,
2012) -> Result<Option<Utf8PathBuf>> {
2013 for entry in &config.mount.entry {
2015 if let Some(when) = &entry.when {
2016 if !template::eval_truthy(when, engine, tera_ctx)? {
2017 continue;
2018 }
2019 }
2020 let dst_str = engine.render(&entry.dst, tera_ctx)?;
2021 let dst_root = paths::expand_tilde(dst_str.trim());
2022 if let Ok(rel) = target.strip_prefix(&dst_root) {
2023 let src_str = engine.render(entry.src.as_str(), tera_ctx)?;
2024 let candidate = paths::resolve_mount_src(source, src_str.trim()).join(rel);
2025 if paths::is_ignored_at(source, &candidate, candidate.is_dir())? {
2030 continue;
2031 }
2032 return Ok(Some(candidate));
2033 }
2034 }
2035
2036 let walker = paths::source_walker(source).build();
2042 let marker_filename = &config.mount.marker_filename;
2043 for ent in walker {
2044 let ent = match ent {
2045 Ok(e) => e,
2046 Err(_) => continue,
2047 };
2048 if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
2049 continue;
2050 }
2051 if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
2052 continue;
2053 }
2054 let dir = match ent.path().parent() {
2055 Some(d) => d,
2056 None => continue,
2057 };
2058 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
2059 Ok(p) => p,
2060 Err(_) => continue,
2061 };
2062 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
2063 Some(s) => s,
2064 None => continue,
2065 };
2066 let MarkerSpec::Explicit { links } = spec else {
2067 continue;
2068 };
2069 for link in &links {
2070 if let Some(when) = &link.when {
2071 if !template::eval_truthy(when, engine, tera_ctx)? {
2072 continue;
2073 }
2074 }
2075 let dst_str = engine.render(&link.dst, tera_ctx)?;
2076 let dst = paths::expand_tilde(dst_str.trim());
2077 if let Some(filename) = &link.src {
2084 let file_src = dir_utf8.join(filename);
2085 if !file_src.is_file() {
2086 anyhow::bail!(
2087 "marker at {dir_utf8}: [[link]] src={filename:?} \
2088 not found"
2089 );
2090 }
2091 if target == dst {
2092 return Ok(Some(file_src));
2093 }
2094 continue;
2095 }
2096 if target == dst {
2097 return Ok(Some(dir_utf8));
2098 }
2099 if let Ok(rel) = target.strip_prefix(&dst) {
2100 return Ok(Some(dir_utf8.join(rel)));
2101 }
2102 }
2103 }
2104
2105 Ok(None)
2106}
2107
2108pub fn doctor(
2109 source: Option<Utf8PathBuf>,
2110 icons_override: Option<IconsMode>,
2111 no_color: bool,
2112) -> Result<()> {
2113 use owo_colors::OwoColorize as _;
2114
2115 let resolved_source = resolve_source(source);
2120
2121 let yui = match &resolved_source {
2126 Ok(s) => YuiVars::detect(s),
2127 Err(_) => YuiVars::detect(Utf8Path::new(".")),
2128 };
2129
2130 let cfg_res = match &resolved_source {
2135 Ok(s) => Some(config::load(s, &yui)),
2136 Err(_) => None,
2137 };
2138 let cfg = cfg_res.as_ref().and_then(|r| r.as_ref().ok());
2139 let icons_mode = icons_override
2140 .or_else(|| cfg.map(|c| c.ui.icons))
2141 .unwrap_or_default();
2142 let icons = Icons::for_mode(icons_mode);
2143 let color = !no_color && supports_color_stdout();
2144
2145 let mut probes: Vec<Probe> = Vec::new();
2146
2147 probes.push(Probe::group("identity"));
2149 probes.push(Probe::ok("os/arch", format!("{} / {}", yui.os, yui.arch)));
2150 probes.push(Probe::ok("user@host", format!("{}@{}", yui.user, yui.host)));
2151
2152 probes.push(Probe::group("repo"));
2154 let mut have_source = false;
2155 match &resolved_source {
2156 Ok(s) => {
2157 have_source = true;
2158 probes.push(Probe::ok("source", s.to_string()));
2159 match cfg_res.as_ref().expect("cfg_res set when source is Ok") {
2160 Ok(c) => {
2161 probes.push(Probe::ok(
2162 "config",
2163 format!(
2164 "{} mount{} · {} hook{} · {} render rule{}",
2165 c.mount.entry.len(),
2166 plural(c.mount.entry.len()),
2167 c.hook.len(),
2168 plural(c.hook.len()),
2169 c.render.rule.len(),
2170 plural(c.render.rule.len()),
2171 ),
2172 ));
2173 }
2174 Err(e) => probes.push(Probe::error("config", format!("{e}"))),
2175 }
2176 match crate::git::is_clean(s) {
2180 Ok(true) => probes.push(Probe::ok("git", "clean")),
2181 Ok(false) => probes.push(Probe::warn(
2182 "git",
2183 "uncommitted changes — `[absorb] require_clean_git` will defer auto-absorb",
2184 )),
2185 Err(_) => probes.push(Probe::warn(
2186 "git",
2187 "no git repo (auto-absorb still works; commit history won't track drift)",
2188 )),
2189 }
2190 }
2191 Err(e) => {
2192 probes.push(Probe::error("source", format!("not found — {e}")));
2193 }
2194 }
2195
2196 probes.push(Probe::group("links"));
2198 if cfg!(windows) {
2199 probes.push(Probe::ok(
2200 "default mode",
2201 "files=hardlink, dirs=junction (no admin needed)",
2202 ));
2203 } else {
2204 probes.push(Probe::ok("default mode", "files=symlink, dirs=symlink"));
2205 }
2206
2207 if have_source {
2209 if let (Ok(s), Some(c)) = (&resolved_source, cfg) {
2210 probes.push(Probe::group("hooks"));
2211 if c.hook.is_empty() {
2212 probes.push(Probe::ok("hooks", "(none configured)"));
2213 } else {
2214 let mut missing = 0usize;
2215 for h in &c.hook {
2216 if !s.join(&h.script).is_file() {
2217 missing += 1;
2218 probes.push(Probe::error(
2219 format!("hook[{}]", h.name),
2220 format!("script not found at {}", h.script),
2221 ));
2222 }
2223 }
2224 if missing == 0 {
2225 probes.push(Probe::ok(
2226 "scripts",
2227 format!(
2228 "{} hook{} configured, all scripts present",
2229 c.hook.len(),
2230 plural(c.hook.len())
2231 ),
2232 ));
2233 }
2234 }
2235 }
2236 }
2237
2238 if let Some(home) = paths::home_dir() {
2240 let chezmoi_src = home.join(".local/share/chezmoi");
2241 if chezmoi_src.is_dir() {
2242 probes.push(Probe::group("chezmoi"));
2243 probes.push(Probe::warn(
2244 "legacy source",
2245 format!(
2246 "{chezmoi_src} still exists — yui doesn't use it, safe to archive once your migration has settled"
2247 ),
2248 ));
2249 }
2250 }
2251
2252 println!();
2254 if color {
2255 println!(" {}", "yui doctor".bold().underline());
2256 } else {
2257 println!(" yui doctor");
2258 }
2259 println!();
2260 for probe in &probes {
2261 probe.print(&icons, color);
2262 }
2263
2264 let errors = probes.iter().filter(|p| p.is_error()).count();
2265 let warns = probes.iter().filter(|p| p.is_warn()).count();
2266 let oks = probes.iter().filter(|p| p.is_ok()).count();
2267 println!();
2268 let summary = format!("{oks} ok · {warns} warn · {errors} error");
2269 if color {
2270 if errors > 0 {
2271 println!(" {}", summary.red().bold());
2272 } else if warns > 0 {
2273 println!(" {}", summary.yellow());
2274 } else {
2275 println!(" {}", summary.green());
2276 }
2277 } else {
2278 println!(" {summary}");
2279 }
2280
2281 if errors > 0 {
2282 anyhow::bail!("doctor: {errors} probe(s) failed");
2283 }
2284 Ok(())
2285}
2286
2287#[derive(Debug)]
2288enum Probe {
2289 Group(&'static str),
2291 Ok {
2292 label: String,
2293 detail: String,
2294 },
2295 Warn {
2296 label: String,
2297 detail: String,
2298 },
2299 Error {
2300 label: String,
2301 detail: String,
2302 },
2303}
2304
2305impl Probe {
2306 fn group(label: &'static str) -> Self {
2307 Self::Group(label)
2308 }
2309 fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
2310 Self::Ok {
2311 label: label.into(),
2312 detail: detail.into(),
2313 }
2314 }
2315 fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
2316 Self::Warn {
2317 label: label.into(),
2318 detail: detail.into(),
2319 }
2320 }
2321 fn error(label: impl Into<String>, detail: impl Into<String>) -> Self {
2322 Self::Error {
2323 label: label.into(),
2324 detail: detail.into(),
2325 }
2326 }
2327 fn is_ok(&self) -> bool {
2328 matches!(self, Self::Ok { .. })
2329 }
2330 fn is_warn(&self) -> bool {
2331 matches!(self, Self::Warn { .. })
2332 }
2333 fn is_error(&self) -> bool {
2334 matches!(self, Self::Error { .. })
2335 }
2336 fn print(&self, icons: &Icons, color: bool) {
2337 use owo_colors::OwoColorize as _;
2338 match self {
2339 Self::Group(name) => {
2340 println!();
2341 if color {
2342 println!(" {}", name.cyan().bold());
2343 } else {
2344 println!(" {name}");
2345 }
2346 }
2347 Self::Ok { label, detail } => {
2348 let icon = icons.ok;
2349 let padded = format!("{label:<14}");
2353 if color {
2354 println!(
2355 " {} {} {}",
2356 icon.green(),
2357 padded.bold(),
2358 detail.dimmed()
2359 );
2360 } else {
2361 println!(" {icon} {padded} {detail}");
2362 }
2363 }
2364 Self::Warn { label, detail } => {
2365 let icon = icons.warn;
2366 let padded = format!("{label:<14}");
2367 if color {
2368 println!(
2369 " {} {} {}",
2370 icon.yellow(),
2371 padded.bold().yellow(),
2372 detail
2373 );
2374 } else {
2375 println!(" {icon} {padded} {detail}");
2376 }
2377 }
2378 Self::Error { label, detail } => {
2379 let icon = icons.error;
2380 let padded = format!("{label:<14}");
2381 if color {
2382 println!(
2383 " {} {} {}",
2384 icon.red().bold(),
2385 padded.bold().red(),
2386 detail.red()
2387 );
2388 } else {
2389 println!(" {icon} {padded} {detail}");
2390 }
2391 }
2392 }
2393 }
2394}
2395
2396fn plural(n: usize) -> &'static str {
2397 if n == 1 { "" } else { "s" }
2398}
2399
2400pub fn gc_backup(
2420 source: Option<Utf8PathBuf>,
2421 older_than: Option<String>,
2422 dry_run: bool,
2423 icons_override: Option<IconsMode>,
2424 no_color: bool,
2425) -> Result<()> {
2426 let source = resolve_source(source)?;
2427 let yui = YuiVars::detect(&source);
2428 let config = config::load(&source, &yui)?;
2429 let backup_root = source.join(&config.backup.dir);
2430 let icons_mode = icons_override.unwrap_or(config.ui.icons);
2431 let icons = Icons::for_mode(icons_mode);
2432 let color = !no_color && supports_color_stdout();
2433
2434 if !backup_root.is_dir() {
2435 println!(" no backup tree at {backup_root}");
2436 return Ok(());
2437 }
2438
2439 let mut entries = walk_gc_backups(&backup_root)?;
2440 if entries.is_empty() {
2441 println!(" no yui-stamped backups under {backup_root}");
2442 return Ok(());
2443 }
2444 entries.sort_by_key(|e| e.ts);
2446 let now = jiff::Zoned::now();
2447
2448 match older_than {
2449 None => {
2450 let refs: Vec<&BackupEntry> = entries.iter().collect();
2451 print_gc_table(&refs, &backup_root, &now, icons, color);
2452 println!();
2453 println!(
2454 " {} entries · {} total — pass --older-than DUR (e.g. 30d) to delete",
2455 entries.len(),
2456 format_bytes(entries.iter().map(|e| e.size_bytes).sum())
2457 );
2458 Ok(())
2459 }
2460 Some(dur_str) => {
2461 let span = parse_human_duration(&dur_str)?;
2462 let cutoff = now
2463 .checked_sub(span)
2464 .map_err(|e| anyhow::anyhow!("invalid duration {dur_str:?}: {e}"))?;
2465 let cutoff_dt = cutoff.datetime();
2466
2467 let total_before: u64 = entries.iter().map(|e| e.size_bytes).sum();
2468 let to_delete: Vec<&BackupEntry> =
2469 entries.iter().filter(|e| e.ts < cutoff_dt).collect();
2470
2471 if to_delete.is_empty() {
2472 println!(
2473 " no backups older than {dur_str} (oldest: {})",
2474 format_age(entries[0].ts, &now)
2475 );
2476 return Ok(());
2477 }
2478
2479 print_gc_table(&to_delete, &backup_root, &now, icons, color);
2480 println!();
2481 let total_freed: u64 = to_delete.iter().map(|e| e.size_bytes).sum();
2482
2483 if dry_run {
2484 println!(
2485 " [dry-run] would remove {} of {} entries · would free {} of {}",
2486 to_delete.len(),
2487 entries.len(),
2488 format_bytes(total_freed),
2489 format_bytes(total_before),
2490 );
2491 return Ok(());
2492 }
2493
2494 for entry in &to_delete {
2495 match entry.kind {
2496 BackupKind::File => std::fs::remove_file(&entry.path)?,
2497 BackupKind::Dir => std::fs::remove_dir_all(&entry.path)?,
2498 }
2499 if let Some(parent) = entry.path.parent() {
2500 cleanup_empty_parents(parent, &backup_root);
2501 }
2502 }
2503 println!(
2504 " removed {} of {} entries · freed {} (was {}, now {})",
2505 to_delete.len(),
2506 entries.len(),
2507 format_bytes(total_freed),
2508 format_bytes(total_before),
2509 format_bytes(total_before - total_freed),
2510 );
2511 Ok(())
2512 }
2513 }
2514}
2515
2516#[derive(Debug)]
2517struct BackupEntry {
2518 path: Utf8PathBuf,
2519 ts: jiff::civil::DateTime,
2520 kind: BackupKind,
2521 size_bytes: u64,
2522}
2523
2524#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2525enum BackupKind {
2526 File,
2527 Dir,
2528}
2529
2530fn walk_gc_backups(root: &Utf8Path) -> Result<Vec<BackupEntry>> {
2535 let mut out = Vec::new();
2536 walk_gc_backups_rec(root, &mut out)?;
2537 Ok(out)
2538}
2539
2540fn walk_gc_backups_rec(dir: &Utf8Path, out: &mut Vec<BackupEntry>) -> Result<()> {
2541 for entry in std::fs::read_dir(dir)? {
2542 let entry = entry?;
2543 let name_os = entry.file_name();
2544 let Some(name) = name_os.to_str() else {
2545 continue;
2546 };
2547 let path = dir.join(name);
2548 let ft = entry.file_type()?;
2549 if ft.is_dir() {
2550 if let Some(ts) = parse_backup_suffix(name) {
2551 let size = dir_size(&path)?;
2552 out.push(BackupEntry {
2553 path,
2554 ts,
2555 kind: BackupKind::Dir,
2556 size_bytes: size,
2557 });
2558 } else {
2559 walk_gc_backups_rec(&path, out)?;
2560 }
2561 } else if ft.is_file() {
2562 if let Some(ts) = parse_backup_suffix(name) {
2565 let size = entry.metadata()?.len();
2566 out.push(BackupEntry {
2567 path,
2568 ts,
2569 kind: BackupKind::File,
2570 size_bytes: size,
2571 });
2572 }
2573 }
2574 }
2575 Ok(())
2576}
2577
2578fn dir_size(dir: &Utf8Path) -> Result<u64> {
2579 let mut total: u64 = 0;
2580 for entry in std::fs::read_dir(dir)? {
2581 let entry = entry?;
2582 let ft = entry.file_type()?;
2583 if ft.is_dir() {
2584 let p = match Utf8PathBuf::from_path_buf(entry.path()) {
2585 Ok(p) => p,
2586 Err(_) => continue,
2587 };
2588 total = total.saturating_add(dir_size(&p)?);
2589 } else if ft.is_file() {
2590 total = total.saturating_add(entry.metadata()?.len());
2591 }
2592 }
2593 Ok(total)
2594}
2595
2596fn cleanup_empty_parents(start: &Utf8Path, root: &Utf8Path) {
2600 let mut cur = start.to_path_buf();
2601 loop {
2602 if cur == *root {
2603 return;
2604 }
2605 if std::fs::remove_dir(&cur).is_err() {
2607 return;
2608 }
2609 match cur.parent() {
2610 Some(p) => cur = p.to_path_buf(),
2611 None => return,
2612 }
2613 }
2614}
2615
2616fn parse_backup_suffix(name: &str) -> Option<jiff::civil::DateTime> {
2622 if let Some(ts) = parse_ts_at_end(name) {
2623 return Some(ts);
2624 }
2625 if let Some((before, _ext)) = name.rsplit_once('.') {
2628 if let Some(ts) = parse_ts_at_end(before) {
2629 return Some(ts);
2630 }
2631 }
2632 None
2633}
2634
2635fn parse_ts_at_end(s: &str) -> Option<jiff::civil::DateTime> {
2636 if s.len() < 20 {
2638 return None;
2639 }
2640 let split_at = s.len() - 19;
2641 if s.as_bytes()[split_at] != b'_' {
2642 return None;
2643 }
2644 parse_ts(&s[split_at + 1..])
2645}
2646
2647fn parse_ts(s: &str) -> Option<jiff::civil::DateTime> {
2649 if s.len() != 18 || s.as_bytes()[8] != b'_' {
2650 return None;
2651 }
2652 for (i, &b) in s.as_bytes().iter().enumerate() {
2653 if i == 8 {
2654 continue;
2655 }
2656 if !b.is_ascii_digit() {
2657 return None;
2658 }
2659 }
2660 let year: i16 = s[0..4].parse().ok()?;
2661 let month: i8 = s[4..6].parse().ok()?;
2662 let day: i8 = s[6..8].parse().ok()?;
2663 let hour: i8 = s[9..11].parse().ok()?;
2664 let minute: i8 = s[11..13].parse().ok()?;
2665 let second: i8 = s[13..15].parse().ok()?;
2666 let ms: i32 = s[15..18].parse().ok()?;
2667 jiff::civil::DateTime::new(year, month, day, hour, minute, second, ms * 1_000_000).ok()
2668}
2669
2670fn parse_human_duration(s: &str) -> Result<jiff::Span> {
2679 let s = s.trim();
2680 let split = s
2681 .bytes()
2682 .position(|b| b.is_ascii_alphabetic())
2683 .ok_or_else(|| anyhow::anyhow!("invalid duration {s:?}: missing unit (e.g. 30d, 2w)"))?;
2684 let n: i64 = s[..split]
2685 .trim()
2686 .parse()
2687 .map_err(|_| anyhow::anyhow!("invalid duration {s:?}: bad leading number"))?;
2688 if n < 0 {
2689 anyhow::bail!("invalid duration {s:?}: negative durations don't make sense");
2690 }
2691 let unit = s[split..].to_ascii_lowercase();
2692 let span = match unit.as_str() {
2693 "y" | "yr" | "year" | "years" => jiff::Span::new().years(n),
2694 "mo" | "month" | "months" => jiff::Span::new().months(n),
2695 "w" | "wk" | "week" | "weeks" => jiff::Span::new().weeks(n),
2696 "d" | "day" | "days" => jiff::Span::new().days(n),
2697 "h" | "hr" | "hour" | "hours" => jiff::Span::new().hours(n),
2698 "m" | "min" | "minute" | "minutes" => jiff::Span::new().minutes(n),
2699 other => {
2700 anyhow::bail!(
2701 "invalid duration {s:?}: unknown unit {other:?} \
2702 (use y / mo / w / d / h / m)"
2703 )
2704 }
2705 };
2706 Ok(span)
2707}
2708
2709fn format_bytes(n: u64) -> String {
2710 const KIB: u64 = 1024;
2711 const MIB: u64 = KIB * 1024;
2712 const GIB: u64 = MIB * 1024;
2713 if n >= GIB {
2714 format!("{:.1} GiB", n as f64 / GIB as f64)
2715 } else if n >= MIB {
2716 format!("{:.1} MiB", n as f64 / MIB as f64)
2717 } else if n >= KIB {
2718 format!("{:.1} KiB", n as f64 / KIB as f64)
2719 } else {
2720 format!("{n} B")
2721 }
2722}
2723
2724fn format_age(ts: jiff::civil::DateTime, now: &jiff::Zoned) -> String {
2725 let Ok(ts_zoned) = ts.to_zoned(now.time_zone().clone()) else {
2726 return "?".into();
2727 };
2728 let secs = match (now - &ts_zoned).total(jiff::Unit::Second) {
2729 Ok(s) => s as i64,
2730 Err(_) => return "?".into(),
2731 };
2732 if secs < 0 {
2733 return "future".into();
2734 }
2735 if secs < 60 {
2736 format!("{secs}s")
2737 } else if secs < 3600 {
2738 format!("{}m", secs / 60)
2739 } else if secs < 86_400 {
2740 format!("{}h", secs / 3600)
2741 } else if secs < 86_400 * 30 {
2742 format!("{}d", secs / 86_400)
2743 } else if secs < 86_400 * 365 {
2744 format!("{}mo", secs / (86_400 * 30))
2745 } else {
2746 format!("{}y", secs / (86_400 * 365))
2747 }
2748}
2749
2750fn print_gc_table(
2757 entries: &[&BackupEntry],
2758 backup_root: &Utf8Path,
2759 now: &jiff::Zoned,
2760 _icons: Icons,
2761 color: bool,
2762) {
2763 use owo_colors::OwoColorize as _;
2764
2765 let rows: Vec<(String, String, String)> = entries
2766 .iter()
2767 .map(|e| {
2768 let rel = e
2769 .path
2770 .strip_prefix(backup_root)
2771 .map(Utf8PathBuf::from)
2772 .unwrap_or_else(|_| e.path.clone());
2773 let path_disp = match e.kind {
2774 BackupKind::Dir => format!("{rel}/"),
2775 BackupKind::File => rel.to_string(),
2776 };
2777 (format_age(e.ts, now), format_bytes(e.size_bytes), path_disp)
2778 })
2779 .collect();
2780
2781 let age_w = rows.iter().map(|r| r.0.len()).max().unwrap_or(3);
2782 let size_w = rows.iter().map(|r| r.1.len()).max().unwrap_or(4);
2783
2784 if color {
2785 println!(
2786 " {:<age_w$} {:>size_w$} {}",
2787 "AGE".dimmed(),
2788 "SIZE".dimmed(),
2789 "PATH".dimmed(),
2790 );
2791 } else {
2792 println!(" {:<age_w$} {:>size_w$} PATH", "AGE", "SIZE");
2793 }
2794 for (age, size, path) in &rows {
2795 if color {
2796 println!(
2797 " {:<age_w$} {:>size_w$} {}",
2798 age.yellow(),
2799 size,
2800 path.cyan(),
2801 );
2802 } else {
2803 println!(" {:<age_w$} {:>size_w$} {}", age, size, path);
2804 }
2805 }
2806}
2807
2808pub fn hooks_list(
2810 source: Option<Utf8PathBuf>,
2811 icons_override: Option<IconsMode>,
2812 no_color: bool,
2813) -> Result<()> {
2814 let source = resolve_source(source)?;
2815 let yui = YuiVars::detect(&source);
2816 let config = config::load(&source, &yui)?;
2817 let state = hook::State::load(&source)?;
2818
2819 let icons_mode = icons_override.unwrap_or(config.ui.icons);
2820 let icons = Icons::for_mode(icons_mode);
2821 let color = !no_color && supports_color_stdout();
2822
2823 if config.hook.is_empty() {
2824 println!("(no [[hook]] entries in config)");
2825 return Ok(());
2826 }
2827
2828 let mut engine = template::Engine::new();
2832 let tera_ctx = template::template_context(&yui, &config.vars);
2833 let rows: Vec<HookRow> = config
2834 .hook
2835 .iter()
2836 .map(|h| -> Result<HookRow> {
2837 let active = match &h.when {
2841 None => true,
2842 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
2843 };
2844 let last_run_at = state.hooks.get(&h.name).and_then(|s| s.last_run_at.clone());
2845 Ok(HookRow {
2846 name: h.name.clone(),
2847 phase: match h.phase {
2848 HookPhase::Pre => "pre",
2849 HookPhase::Post => "post",
2850 },
2851 when_run: match h.when_run {
2852 config::WhenRun::Once => "once",
2853 config::WhenRun::Onchange => "onchange",
2854 config::WhenRun::Every => "every",
2855 },
2856 last_run_at,
2857 when: h.when.clone(),
2858 active,
2859 })
2860 })
2861 .collect::<Result<Vec<_>>>()?;
2862
2863 print_hooks_table(&rows, icons, color);
2864
2865 let total = rows.len();
2866 let active = rows.iter().filter(|r| r.active).count();
2867 let inactive = total - active;
2868 let ran = rows.iter().filter(|r| r.last_run_at.is_some()).count();
2869 let never = total - ran;
2870 println!();
2871 println!(
2872 " {total} hooks · {active} active · {inactive} inactive · {ran} ran · {never} never run"
2873 );
2874
2875 Ok(())
2876}
2877
2878#[derive(Debug)]
2879struct HookRow {
2880 name: String,
2881 phase: &'static str,
2882 when_run: &'static str,
2883 last_run_at: Option<String>,
2884 when: Option<String>,
2885 active: bool,
2886}
2887
2888fn print_hooks_table(rows: &[HookRow], icons: Icons, color: bool) {
2889 use owo_colors::OwoColorize as _;
2890 use std::fmt::Write as _;
2891
2892 let name_w = rows
2893 .iter()
2894 .map(|r| r.name.chars().count())
2895 .max()
2896 .unwrap_or(0)
2897 .max("NAME".len());
2898 let phase_w = rows
2899 .iter()
2900 .map(|r| r.phase.len())
2901 .max()
2902 .unwrap_or(0)
2903 .max("PHASE".len());
2904 let when_run_w = rows
2905 .iter()
2906 .map(|r| r.when_run.len())
2907 .max()
2908 .unwrap_or(0)
2909 .max("WHEN_RUN".len());
2910 let last_w = rows
2911 .iter()
2912 .map(|r| {
2913 r.last_run_at
2914 .as_deref()
2915 .map(|s| s.chars().count())
2916 .unwrap_or("(never)".len())
2917 })
2918 .max()
2919 .unwrap_or(0)
2920 .max("LAST_RUN".len());
2921 let status_w = "STATUS".len();
2922
2923 let mut header = String::new();
2925 let _ = write!(
2926 &mut header,
2927 " {:<status_w$} {:<name_w$} {:<phase_w$} {:<when_run_w$} {:<last_w$} WHEN",
2928 "STATUS", "NAME", "PHASE", "WHEN_RUN", "LAST_RUN"
2929 );
2930 if color {
2931 println!("{}", header.bold());
2932 } else {
2933 println!("{header}");
2934 }
2935
2936 let bar = |n: usize| icons.sep.to_string().repeat(n);
2938 let sep = format!(
2939 " {} {} {} {} {} {}",
2940 bar(status_w),
2941 bar(name_w),
2942 bar(phase_w),
2943 bar(when_run_w),
2944 bar(last_w),
2945 bar("WHEN".len())
2946 );
2947 if color {
2948 println!("{}", sep.dimmed());
2949 } else {
2950 println!("{sep}");
2951 }
2952
2953 for r in rows {
2955 let (icon, ran) = match (r.active, r.last_run_at.is_some()) {
2960 (false, _) => (icons.inactive, false),
2961 (true, true) => (icons.active, true),
2962 (true, false) => (icons.info, false),
2963 };
2964 let last = r.last_run_at.as_deref().unwrap_or("(never)");
2965 let when_str = r
2966 .when
2967 .as_deref()
2968 .map(strip_braces)
2969 .unwrap_or_else(|| "(always)".to_string());
2970
2971 let cell_status = format!("{icon:<status_w$}");
2972 let cell_name = format!("{:<name_w$}", r.name);
2973 let cell_phase = format!("{:<phase_w$}", r.phase);
2974 let cell_when_run = format!("{:<when_run_w$}", r.when_run);
2975 let cell_last = format!("{last:<last_w$}");
2976
2977 if !color {
2978 println!(
2979 " {cell_status} {cell_name} {cell_phase} {cell_when_run} {cell_last} {when_str}"
2980 );
2981 continue;
2982 }
2983
2984 if !r.active {
2988 println!(
2989 " {} {} {} {} {} {}",
2990 cell_status.dimmed(),
2991 cell_name.dimmed(),
2992 cell_phase.dimmed(),
2993 cell_when_run.dimmed(),
2994 cell_last.dimmed(),
2995 when_str.dimmed()
2996 );
2997 } else if ran {
2998 println!(
2999 " {} {} {} {} {} {}",
3000 cell_status.green(),
3001 cell_name.cyan().bold(),
3002 cell_phase.dimmed(),
3003 cell_when_run.dimmed(),
3004 cell_last.green(),
3005 when_str.dimmed()
3006 );
3007 } else {
3008 println!(
3009 " {} {} {} {} {} {}",
3010 cell_status.yellow(),
3011 cell_name.cyan().bold(),
3012 cell_phase.dimmed(),
3013 cell_when_run.dimmed(),
3014 cell_last.yellow(),
3015 when_str.dimmed()
3016 );
3017 }
3018 }
3019}
3020
3021pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
3025 let source = resolve_source(source)?;
3026 let yui = YuiVars::detect(&source);
3027 let config = config::load(&source, &yui)?;
3028 let mut engine = template::Engine::new();
3029 let tera_ctx = template::template_context(&yui, &config.vars);
3030
3031 let targets: Vec<&config::HookConfig> = match &name {
3032 Some(want) => {
3033 let m = config
3034 .hook
3035 .iter()
3036 .find(|h| &h.name == want)
3037 .ok_or_else(|| {
3038 anyhow::anyhow!(
3039 "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
3040 )
3041 })?;
3042 vec![m]
3043 }
3044 None => config.hook.iter().collect(),
3045 };
3046
3047 let mut state = hook::State::load(&source)?;
3048 for h in targets {
3049 let outcome = hook::run_hook(
3050 h,
3051 &source,
3052 &yui,
3053 &config.vars,
3054 &mut engine,
3055 &tera_ctx,
3056 &mut state,
3057 false,
3058 force,
3059 )?;
3060 let label = match outcome {
3061 HookOutcome::Ran => "ran",
3062 HookOutcome::SkippedOnce => "skipped (once: already ran)",
3063 HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
3064 HookOutcome::SkippedWhenFalse => "skipped (when=false)",
3065 HookOutcome::DryRun => "would run (dry-run)",
3066 };
3067 info!("hook[{}]: {label}", h.name);
3068 if outcome == HookOutcome::Ran {
3069 state.save(&source)?;
3070 }
3071 }
3072 Ok(())
3073}
3074
3075#[allow(clippy::too_many_arguments)]
3080fn process_mount(
3081 m: &ResolvedMount,
3082 ctx: &ApplyCtx<'_>,
3083 engine: &mut template::Engine,
3084 tera_ctx: &TeraContext,
3085 yuiignore: &mut paths::YuiIgnoreStack,
3086) -> Result<()> {
3087 let src_root = m.src.clone();
3090 if !src_root.is_dir() {
3091 warn!("mount src missing: {src_root}");
3092 return Ok(());
3093 }
3094 walk_and_link(
3095 &src_root, &m.dst, ctx, m.strategy, engine, tera_ctx, yuiignore, false,
3096 )
3097}
3098
3099#[allow(clippy::too_many_arguments)]
3100fn walk_and_link(
3101 src_dir: &Utf8Path,
3102 dst_dir: &Utf8Path,
3103 ctx: &ApplyCtx<'_>,
3104 strategy: MountStrategy,
3105 engine: &mut template::Engine,
3106 tera_ctx: &TeraContext,
3107 yuiignore: &mut paths::YuiIgnoreStack,
3108 parent_covered: bool,
3109) -> Result<()> {
3110 if yuiignore.is_ignored(src_dir, true) {
3113 return Ok(());
3114 }
3115 yuiignore.push_dir(src_dir)?;
3118 let result = walk_and_link_body(
3119 src_dir,
3120 dst_dir,
3121 ctx,
3122 strategy,
3123 engine,
3124 tera_ctx,
3125 yuiignore,
3126 parent_covered,
3127 );
3128 yuiignore.pop_dir(src_dir);
3129 result
3130}
3131
3132#[allow(clippy::too_many_arguments)]
3133fn walk_and_link_body(
3134 src_dir: &Utf8Path,
3135 dst_dir: &Utf8Path,
3136 ctx: &ApplyCtx<'_>,
3137 strategy: MountStrategy,
3138 engine: &mut template::Engine,
3139 tera_ctx: &TeraContext,
3140 yuiignore: &mut paths::YuiIgnoreStack,
3141 parent_covered: bool,
3142) -> Result<()> {
3143 let marker_filename = &ctx.config.mount.marker_filename;
3144 let mut covered = parent_covered;
3145
3146 if strategy == MountStrategy::Marker {
3147 match marker::read_spec(src_dir, marker_filename)? {
3148 None => {} Some(MarkerSpec::PassThrough) => {
3150 link_dir_with_backup(src_dir, dst_dir, ctx)?;
3154 covered = true;
3155 }
3156 Some(MarkerSpec::Explicit { links }) => {
3157 let mut emitted_dir_link = false;
3158 let mut emitted_any = false;
3159 for link in &links {
3160 if let Some(when) = &link.when {
3163 if !template::eval_truthy(when, engine, tera_ctx)? {
3164 continue;
3165 }
3166 }
3167 let dst_str = engine.render(&link.dst, tera_ctx)?;
3168 let dst = paths::expand_tilde(dst_str.trim());
3169 if let Some(filename) = &link.src {
3170 let file_src = src_dir.join(filename);
3171 if !file_src.is_file() {
3172 anyhow::bail!(
3173 "marker at {src_dir}: [[link]] src={filename:?} \
3174 not found"
3175 );
3176 }
3177 link_file_with_backup(&file_src, &dst, ctx)?;
3178 } else {
3179 link_dir_with_backup(src_dir, &dst, ctx)?;
3180 emitted_dir_link = true;
3181 }
3182 emitted_any = true;
3183 }
3184 if !emitted_any {
3185 info!(
3190 "marker at {src_dir} had no active links \
3191 — falling back to defaults"
3192 );
3193 }
3194 if emitted_dir_link {
3195 covered = true;
3196 }
3197 }
3198 }
3199 }
3200
3201 for entry in std::fs::read_dir(src_dir)? {
3202 let entry = entry?;
3203 let name_os = entry.file_name();
3204 let Some(name) = name_os.to_str() else {
3205 continue;
3206 };
3207 if name == marker_filename {
3208 continue;
3209 }
3210 if name.ends_with(".tera") {
3211 continue;
3213 }
3214 let src_path = src_dir.join(name);
3215 let dst_path = dst_dir.join(name);
3216 let ft = entry.file_type()?;
3217
3218 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
3219 continue;
3220 }
3221
3222 if ft.is_dir() {
3223 walk_and_link(
3224 &src_path, &dst_path, ctx, strategy, engine, tera_ctx, yuiignore, covered,
3225 )?;
3226 } else if ft.is_file() {
3227 if !covered {
3233 link_file_with_backup(&src_path, &dst_path, ctx)?;
3234 }
3235 }
3236 }
3237 Ok(())
3238}
3239
3240fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3241 use absorb::AbsorbDecision::*;
3242
3243 let decision = absorb::classify(src, dst)?;
3244
3245 if ctx.dry_run {
3246 info!("[dry-run] {decision:?}: {src} → {dst}");
3247 return Ok(());
3248 }
3249
3250 match decision {
3251 InSync => {
3252 Ok(())
3254 }
3255 Restore => {
3256 info!("link: {src} → {dst}");
3257 link::link_file(src, dst, ctx.file_mode)?;
3258 Ok(())
3259 }
3260 RelinkOnly => {
3261 info!("relink: {src} → {dst}");
3264 link::unlink(dst)?;
3265 link::link_file(src, dst, ctx.file_mode)?;
3266 Ok(())
3267 }
3268 AutoAbsorb => {
3269 if !ctx.config.absorb.auto {
3272 return handle_anomaly(
3273 src,
3274 dst,
3275 ctx,
3276 "absorb.auto = false; treating divergence as anomaly",
3277 );
3278 }
3279 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
3280 return handle_anomaly(
3281 src,
3282 dst,
3283 ctx,
3284 "source repo is dirty; deferring auto-absorb",
3285 );
3286 }
3287 absorb_target_into_source(src, dst, ctx)
3288 }
3289 NeedsConfirm => handle_anomaly(
3290 src,
3291 dst,
3292 ctx,
3293 "anomaly: source equals/newer than target but content differs",
3294 ),
3295 }
3296}
3297
3298fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3302 info!("absorb: {dst} → {src}");
3303 backup_existing(src, ctx.backup_root, false)?;
3304 std::fs::copy(dst, src)?;
3305 link::unlink(dst)?;
3306 link::link_file(src, dst, ctx.file_mode)?;
3307 Ok(())
3308}
3309
3310fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
3316 use crate::config::AnomalyAction::*;
3317 match ctx.config.absorb.on_anomaly {
3318 Skip => {
3319 warn!("anomaly skip: {dst} ({reason})");
3320 Ok(())
3321 }
3322 Force => {
3323 warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
3324 absorb_target_into_source(src, dst, ctx)
3325 }
3326 Ask => {
3327 use std::io::IsTerminal;
3328 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
3329 if prompt_absorb_with_diff(src, dst, reason)? {
3330 absorb_target_into_source(src, dst, ctx)
3331 } else {
3332 warn!("anomaly skipped by user: {dst}");
3333 Ok(())
3334 }
3335 } else {
3336 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
3337 Ok(())
3338 }
3339 }
3340 }
3341}
3342
3343fn prompt_absorb_with_diff(src: &Utf8Path, dst: &Utf8Path, reason: &str) -> Result<bool> {
3344 eprintln!();
3345 eprintln!("anomaly: {reason}");
3346 print_absorb_diff(src, dst);
3347 prompt_yes_no("absorb target into source?")
3348}
3349
3350fn source_repo_is_clean(source: &Utf8Path) -> bool {
3355 match crate::git::is_clean(source) {
3356 Ok(b) => b,
3357 Err(e) => {
3358 warn!("git clean check failed at {source}: {e} — treating as clean");
3359 true
3360 }
3361 }
3362}
3363
3364fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3365 use absorb::AbsorbDecision::*;
3366 let decision = absorb::classify(src, dst)?;
3367
3368 if ctx.dry_run {
3369 info!("[dry-run] dir {decision:?}: {src} → {dst}");
3370 return Ok(());
3371 }
3372
3373 match decision {
3374 InSync => Ok(()),
3375 Restore => {
3376 info!("link dir: {src} → {dst}");
3377 link::link_dir(src, dst, ctx.dir_mode)?;
3378 Ok(())
3379 }
3380 RelinkOnly => {
3381 info!("relink dir: {src} → {dst}");
3386 remove_dir_link_or_real(dst)?;
3387 link::link_dir(src, dst, ctx.dir_mode)?;
3388 Ok(())
3389 }
3390 AutoAbsorb | NeedsConfirm => {
3391 if !ctx.config.absorb.auto {
3412 return handle_anomaly_dir(
3413 src,
3414 dst,
3415 ctx,
3416 "absorb.auto = false; treating divergence as anomaly",
3417 );
3418 }
3419 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
3420 return handle_anomaly_dir(
3421 src,
3422 dst,
3423 ctx,
3424 "source repo is dirty; deferring auto-absorb",
3425 );
3426 }
3427 absorb_target_dir_into_source(src, dst, ctx)
3428 }
3429 }
3430}
3431
3432fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
3442 if let Err(unlink_err) = link::unlink(dst) {
3443 let meta = std::fs::symlink_metadata(dst)
3444 .with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
3445 let ft = meta.file_type();
3446 if ft.is_dir() && !ft.is_symlink() {
3447 std::fs::remove_dir_all(dst).with_context(|| {
3448 format!(
3449 "remove_dir_all({dst}) after link::unlink failed: \
3450 {unlink_err}"
3451 )
3452 })?;
3453 } else {
3454 return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
3455 }
3456 }
3457 Ok(())
3458}
3459
3460fn merge_dir_target_into_source(
3470 target: &Utf8Path,
3471 source: &Utf8Path,
3472 ctx: &ApplyCtx<'_>,
3473) -> Result<()> {
3474 for entry in std::fs::read_dir(target)? {
3475 let entry = entry?;
3476 let name_os = entry.file_name();
3477 let Some(name) = name_os.to_str() else {
3478 continue;
3479 };
3480 let target_path = target.join(name);
3481 let source_path = source.join(name);
3482 let ft = entry.file_type()?;
3483
3484 if ft.is_dir() && !ft.is_symlink() {
3485 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
3491 let sft = src_meta.file_type();
3492 if !sft.is_dir() || sft.is_symlink() {
3493 link::unlink(&source_path).with_context(|| {
3494 format!("remove conflicting source entry before dir merge: {source_path}")
3495 })?;
3496 }
3497 }
3498 if !source_path.exists() {
3499 std::fs::create_dir_all(&source_path).with_context(|| {
3500 format!("create_dir_all({source_path}) during target→source merge")
3501 })?;
3502 }
3503 merge_dir_target_into_source(&target_path, &source_path, ctx)?;
3504 } else if ft.is_file() {
3505 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
3509 let sft = src_meta.file_type();
3510 if sft.is_dir() && !sft.is_symlink() {
3511 remove_dir_link_or_real(&source_path).with_context(|| {
3512 format!("remove conflicting source dir before file merge: {source_path}")
3513 })?;
3514 } else if sft.is_symlink() {
3515 link::unlink(&source_path).with_context(|| {
3516 format!(
3517 "remove conflicting source symlink before file merge: {source_path}"
3518 )
3519 })?;
3520 }
3521 }
3522 if let Some(parent) = source_path.parent() {
3523 if !parent.exists() {
3524 std::fs::create_dir_all(parent)?;
3525 }
3526 }
3527 if source_path.is_file() {
3541 merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
3542 } else {
3543 std::fs::copy(&target_path, &source_path)
3544 .with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
3545 }
3546 } else {
3547 warn!(
3548 "merge: skipping non-regular entry {target_path} \
3549 (symlink / junction / special — content not copied)"
3550 );
3551 }
3552 }
3553 Ok(())
3554}
3555
3556fn merge_resolve_file_conflict(
3570 target_path: &Utf8Path,
3571 source_path: &Utf8Path,
3572 ctx: &ApplyCtx<'_>,
3573) -> Result<()> {
3574 use absorb::AbsorbDecision::*;
3575 let decision = absorb::classify(source_path, target_path)?;
3576 match decision {
3577 InSync | RelinkOnly => Ok(()),
3578 AutoAbsorb => {
3579 std::fs::copy(target_path, source_path).with_context(|| {
3580 format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
3581 })?;
3582 Ok(())
3583 }
3584 Restore => {
3585 unreachable!(
3592 "merge_resolve_file_conflict reached with both files present, \
3593 but classify returned Restore (target {target_path} / source {source_path})"
3594 )
3595 }
3596 NeedsConfirm => {
3597 use crate::config::AnomalyAction::*;
3598 match ctx.config.absorb.on_anomaly {
3599 Skip => {
3600 warn!(
3601 "merge anomaly skip: {target_path} (source-newer / content drift) \
3602 — keeping source version, target version dropped"
3603 );
3604 Ok(())
3605 }
3606 Force => {
3607 warn!(
3608 "merge anomaly force: {target_path} \
3609 (source-newer / content drift) — overwriting source"
3610 );
3611 std::fs::copy(target_path, source_path)?;
3612 Ok(())
3613 }
3614 Ask => {
3615 use std::io::IsTerminal;
3616 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
3617 if prompt_absorb_with_diff(
3618 source_path,
3619 target_path,
3620 "merge: file content differs and source is newer",
3621 )? {
3622 std::fs::copy(target_path, source_path)?;
3623 } else {
3624 warn!("merge: kept source version by user choice: {source_path}");
3625 }
3626 Ok(())
3627 } else {
3628 warn!(
3629 "merge anomaly skip (non-TTY ask mode): {target_path} \
3630 — keeping source version"
3631 );
3632 Ok(())
3633 }
3634 }
3635 }
3636 }
3637 }
3638}
3639
3640fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3647 info!("absorb dir: {dst} → {src}");
3648 backup_existing(src, ctx.backup_root, true)?;
3649 merge_dir_target_into_source(dst, src, ctx)?;
3650 remove_dir_link_or_real(dst)?;
3653 link::link_dir(src, dst, ctx.dir_mode)?;
3654 Ok(())
3655}
3656
3657fn handle_anomaly_dir(
3661 src: &Utf8Path,
3662 dst: &Utf8Path,
3663 ctx: &ApplyCtx<'_>,
3664 reason: &str,
3665) -> Result<()> {
3666 use crate::config::AnomalyAction::*;
3667 match ctx.config.absorb.on_anomaly {
3668 Skip => {
3669 warn!("anomaly skip dir: {dst} ({reason})");
3670 Ok(())
3671 }
3672 Force => {
3673 warn!(
3674 "anomaly force dir: {dst} ({reason}) \
3675 — absorbing target into source"
3676 );
3677 absorb_target_dir_into_source(src, dst, ctx)
3678 }
3679 Ask => {
3680 use std::io::IsTerminal;
3681 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
3682 eprintln!();
3683 eprintln!("anomaly: {dst}");
3684 eprintln!(" {reason}");
3685 eprintln!(" source: {src}");
3686 eprint!(" absorb target dir into source? (y/N) ");
3687 use std::io::{BufRead as _, Write as _};
3688 std::io::stderr().flush().ok();
3689 let mut buf = String::new();
3690 std::io::stdin().lock().read_line(&mut buf)?;
3691 let answer = buf.trim();
3692 if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
3693 absorb_target_dir_into_source(src, dst, ctx)
3694 } else {
3695 warn!("anomaly skipped by user: {dst}");
3696 Ok(())
3697 }
3698 } else {
3699 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
3700 Ok(())
3701 }
3702 }
3703 }
3704}
3705
3706fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
3707 let abs_target = absolutize(target)?;
3708 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
3709 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
3710 info!("backup → {bp}");
3711 if is_dir {
3712 backup::backup_dir(target, &bp)?;
3713 } else {
3714 backup::backup_file(target, &bp)?;
3715 }
3716 Ok(())
3717}
3718
3719fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
3720 if let Some(s) = source {
3721 return absolutize(&s);
3722 }
3723 if let Ok(s) = std::env::var("YUI_SOURCE") {
3724 return absolutize(Utf8Path::new(&s));
3725 }
3726 let cwd = current_dir_utf8()?;
3727 for ancestor in cwd.ancestors() {
3728 if ancestor.join("config.toml").is_file() {
3729 return Ok(ancestor.to_path_buf());
3730 }
3731 }
3732 if let Some(home) = paths::home_dir() {
3733 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
3734 let p = home.join(c);
3735 if p.join("config.toml").is_file() {
3736 return Ok(p);
3737 }
3738 }
3739 }
3740 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
3741}
3742
3743fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
3744 let expanded = paths::expand_tilde(p.as_str());
3746 if expanded.is_absolute() {
3747 return Ok(expanded);
3748 }
3749 let cwd = current_dir_utf8()?;
3750 Ok(cwd.join(expanded))
3751}
3752
3753fn current_dir_utf8() -> Result<Utf8PathBuf> {
3754 let cwd = std::env::current_dir().context("getting cwd")?;
3755 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
3756}
3757
3758const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
3762
3763[vars]
3764# user-defined values; templates can reference these as {{ vars.foo }}
3765
3766# [link]
3767# file_mode = "auto" # auto | symlink | hardlink
3768# dir_mode = "auto" # auto | symlink | junction
3769
3770[mount]
3771default_strategy = "marker"
3772
3773[[mount.entry]]
3774src = "home"
3775# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
3776dst = "~"
3777
3778# [[mount.entry]]
3779# src = "appdata"
3780# dst = "{{ env(name='APPDATA') }}"
3781# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
3782# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
3783# when = "yui.os == 'windows'"
3784"#;
3785
3786const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
3787# .yui/bin/ is intentionally tracked — it holds your hook scripts.
3788/.yui/state.json
3789/.yui/state.json.tmp
3790/.yui/backup/
3791
3792# >>> yui rendered (auto-managed, do not edit) >>>
3793# <<< yui rendered (auto-managed) <<<
3794
3795# config.local.toml is per-machine; commit a config.local.example.toml instead.
3796config.local.toml
3797"#;
3798
3799#[cfg(test)]
3800mod tests {
3801 use super::*;
3802 use tempfile::TempDir;
3803
3804 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
3805 Utf8PathBuf::from_path_buf(p).unwrap()
3806 }
3807
3808 fn toml_path(p: &Utf8Path) -> String {
3810 p.as_str().replace('\\', "/")
3811 }
3812
3813 #[test]
3814 fn apply_links_a_raw_file() {
3815 let tmp = TempDir::new().unwrap();
3816 let source = utf8(tmp.path().join("dotfiles"));
3817 let target = utf8(tmp.path().join("target"));
3818 std::fs::create_dir_all(source.join("home")).unwrap();
3819 std::fs::create_dir_all(&target).unwrap();
3820 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3821
3822 let cfg = format!(
3823 r#"
3824[[mount.entry]]
3825src = "home"
3826dst = "{}"
3827"#,
3828 toml_path(&target)
3829 );
3830 std::fs::write(source.join("config.toml"), cfg).unwrap();
3831
3832 apply(Some(source), false).unwrap();
3833
3834 let linked = target.join(".bashrc");
3835 assert!(linked.exists(), "expected {linked} to exist");
3836 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
3837 }
3838
3839 #[test]
3840 fn apply_with_marker_links_whole_directory() {
3841 let tmp = TempDir::new().unwrap();
3842 let source = utf8(tmp.path().join("dotfiles"));
3843 let target = utf8(tmp.path().join("target"));
3844 let nvim_src = source.join("home/nvim");
3845 std::fs::create_dir_all(&nvim_src).unwrap();
3846 std::fs::create_dir_all(&target).unwrap();
3847 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
3848 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
3849 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
3850
3851 let cfg = format!(
3852 r#"
3853[[mount.entry]]
3854src = "home"
3855dst = "{}"
3856"#,
3857 toml_path(&target)
3858 );
3859 std::fs::write(source.join("config.toml"), cfg).unwrap();
3860
3861 apply(Some(source.clone()), false).unwrap();
3862
3863 let nvim_dst = target.join("nvim");
3864 assert!(nvim_dst.exists());
3865 assert_eq!(
3866 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
3867 "-- hi\n"
3868 );
3869 }
3873
3874 #[test]
3875 fn apply_dry_run_does_not_write() {
3876 let tmp = TempDir::new().unwrap();
3877 let source = utf8(tmp.path().join("dotfiles"));
3878 let target = utf8(tmp.path().join("target"));
3879 std::fs::create_dir_all(source.join("home")).unwrap();
3880 std::fs::create_dir_all(&target).unwrap();
3881 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
3882
3883 let cfg = format!(
3884 r#"
3885[[mount.entry]]
3886src = "home"
3887dst = "{}"
3888"#,
3889 toml_path(&target)
3890 );
3891 std::fs::write(source.join("config.toml"), cfg).unwrap();
3892
3893 apply(Some(source), true).unwrap();
3894
3895 assert!(!target.join(".bashrc").exists());
3896 }
3897
3898 #[test]
3899 fn apply_renders_templates_then_links_rendered_outputs() {
3900 let tmp = TempDir::new().unwrap();
3901 let source = utf8(tmp.path().join("dotfiles"));
3902 let target = utf8(tmp.path().join("target"));
3903 std::fs::create_dir_all(source.join("home")).unwrap();
3904 std::fs::create_dir_all(&target).unwrap();
3905 std::fs::write(
3906 source.join("home/.gitconfig.tera"),
3907 "[user]\n os = {{ yui.os }}\n",
3908 )
3909 .unwrap();
3910 std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
3911
3912 let cfg = format!(
3913 r#"
3914[[mount.entry]]
3915src = "home"
3916dst = "{}"
3917"#,
3918 toml_path(&target)
3919 );
3920 std::fs::write(source.join("config.toml"), cfg).unwrap();
3921
3922 apply(Some(source.clone()), false).unwrap();
3923
3924 assert!(target.join(".bashrc").exists());
3926 assert!(source.join("home/.gitconfig").exists());
3928 assert!(target.join(".gitconfig").exists());
3929 assert!(!target.join(".gitconfig.tera").exists());
3931 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
3933 assert!(linked.contains("os = "));
3934 }
3935
3936 #[test]
3937 fn apply_marker_override_links_to_custom_dst() {
3938 let tmp = TempDir::new().unwrap();
3939 let source = utf8(tmp.path().join("dotfiles"));
3940 let target_a = utf8(tmp.path().join("target_a"));
3941 let target_b = utf8(tmp.path().join("target_b"));
3942 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3943 std::fs::create_dir_all(&target_a).unwrap();
3944 std::fs::create_dir_all(&target_b).unwrap();
3945 std::fs::write(
3946 source.join("home/.config/nvim/init.lua"),
3947 "-- nvim config\n",
3948 )
3949 .unwrap();
3950
3951 std::fs::write(
3954 source.join("home/.config/nvim/.yuilink"),
3955 format!(
3956 r#"
3957[[link]]
3958dst = "{}/nvim"
3959
3960[[link]]
3961dst = "{}/nvim"
3962when = "{{{{ yui.os == '{}' }}}}"
3963"#,
3964 toml_path(&target_a),
3965 toml_path(&target_b),
3966 std::env::consts::OS
3967 ),
3968 )
3969 .unwrap();
3970
3971 let parent_target = utf8(tmp.path().join("parent_target"));
3972 std::fs::create_dir_all(&parent_target).unwrap();
3973 let cfg = format!(
3974 r#"
3975[[mount.entry]]
3976src = "home"
3977dst = "{}"
3978"#,
3979 toml_path(&parent_target)
3980 );
3981 std::fs::write(source.join("config.toml"), cfg).unwrap();
3982
3983 apply(Some(source.clone()), false).unwrap();
3984
3985 assert!(
3987 target_a.join("nvim/init.lua").exists(),
3988 "target_a/nvim/init.lua should be reachable through the link"
3989 );
3990 assert!(
3991 target_b.join("nvim/init.lua").exists(),
3992 "target_b/nvim/init.lua should be reachable through the link"
3993 );
3994 assert!(
3997 !parent_target.join(".config/nvim").exists(),
3998 "parent mount should have skipped the marker-claimed sub-dir"
3999 );
4000 }
4001
4002 #[test]
4003 fn apply_marker_inactive_link_falls_through_to_default() {
4004 let tmp = TempDir::new().unwrap();
4009 let source = utf8(tmp.path().join("dotfiles"));
4010 let target_inactive = utf8(tmp.path().join("inactive"));
4011 let parent_target = utf8(tmp.path().join("parent"));
4012 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
4013 std::fs::create_dir_all(&parent_target).unwrap();
4014 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
4015
4016 std::fs::write(
4018 source.join("home/.config/nvim/.yuilink"),
4019 format!(
4020 r#"
4021[[link]]
4022dst = "{}/nvim"
4023when = "{{{{ yui.os == 'no-such-os' }}}}"
4024"#,
4025 toml_path(&target_inactive)
4026 ),
4027 )
4028 .unwrap();
4029
4030 let cfg = format!(
4031 r#"
4032[[mount.entry]]
4033src = "home"
4034dst = "{}"
4035"#,
4036 toml_path(&parent_target)
4037 );
4038 std::fs::write(source.join("config.toml"), cfg).unwrap();
4039
4040 apply(Some(source.clone()), false).unwrap();
4041
4042 assert!(!target_inactive.join("nvim").exists());
4044 assert!(parent_target.join(".config/nvim/init.lua").exists());
4047 }
4048
4049 #[test]
4050 fn list_shows_mount_entries_and_marker_overrides() {
4051 let tmp = TempDir::new().unwrap();
4052 let source = utf8(tmp.path().join("dotfiles"));
4053 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
4054 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
4055 std::fs::write(
4056 source.join("home/.config/nvim/.yuilink"),
4057 r#"
4058[[link]]
4059dst = "/custom/nvim"
4060"#,
4061 )
4062 .unwrap();
4063 std::fs::write(
4064 source.join("config.toml"),
4065 r#"
4066[[mount.entry]]
4067src = "home"
4068dst = "/h"
4069"#,
4070 )
4071 .unwrap();
4072
4073 list(Some(source), false, None, true).unwrap();
4076 }
4077
4078 #[test]
4079 fn status_reports_in_sync_after_apply() {
4080 let tmp = TempDir::new().unwrap();
4081 let source = utf8(tmp.path().join("dotfiles"));
4082 let target = utf8(tmp.path().join("target"));
4083 std::fs::create_dir_all(source.join("home")).unwrap();
4084 std::fs::create_dir_all(&target).unwrap();
4085 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4086 let cfg = format!(
4087 r#"
4088[[mount.entry]]
4089src = "home"
4090dst = "{}"
4091"#,
4092 toml_path(&target)
4093 );
4094 std::fs::write(source.join("config.toml"), cfg).unwrap();
4095 apply(Some(source.clone()), false).unwrap();
4097 status(Some(source), None, true).unwrap();
4099 }
4100
4101 #[test]
4102 fn status_reports_template_drift() {
4103 let tmp = TempDir::new().unwrap();
4104 let source = utf8(tmp.path().join("dotfiles"));
4105 let target = utf8(tmp.path().join("target"));
4106 std::fs::create_dir_all(source.join("home")).unwrap();
4107 std::fs::create_dir_all(&target).unwrap();
4108 std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
4111 std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
4112
4113 let cfg = format!(
4114 r#"
4115[[mount.entry]]
4116src = "home"
4117dst = "{}"
4118"#,
4119 toml_path(&target)
4120 );
4121 std::fs::write(source.join("config.toml"), cfg).unwrap();
4122
4123 let err = status(Some(source), None, true).unwrap_err();
4124 assert!(format!("{err}").contains("diverged"));
4125 }
4126
4127 #[test]
4128 fn status_fails_when_target_missing() {
4129 let tmp = TempDir::new().unwrap();
4130 let source = utf8(tmp.path().join("dotfiles"));
4131 let target = utf8(tmp.path().join("target"));
4132 std::fs::create_dir_all(source.join("home")).unwrap();
4133 std::fs::create_dir_all(&target).unwrap();
4134 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4135 let cfg = format!(
4136 r#"
4137[[mount.entry]]
4138src = "home"
4139dst = "{}"
4140"#,
4141 toml_path(&target)
4142 );
4143 std::fs::write(source.join("config.toml"), cfg).unwrap();
4144 let err = status(Some(source), None, true).unwrap_err();
4146 assert!(format!("{err}").contains("diverged"));
4147 }
4148
4149 #[test]
4150 fn strip_braces_removes_outer_template_braces() {
4151 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
4152 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
4153 assert_eq!(strip_braces(" {{x}} "), "x");
4154 }
4155
4156 #[test]
4157 fn apply_aborts_on_render_drift() {
4158 let tmp = TempDir::new().unwrap();
4159 let source = utf8(tmp.path().join("dotfiles"));
4160 let target = utf8(tmp.path().join("target"));
4161 std::fs::create_dir_all(source.join("home")).unwrap();
4162 std::fs::create_dir_all(&target).unwrap();
4163 std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
4164 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
4165
4166 let cfg = format!(
4167 r#"
4168[[mount.entry]]
4169src = "home"
4170dst = "{}"
4171"#,
4172 toml_path(&target)
4173 );
4174 std::fs::write(source.join("config.toml"), cfg).unwrap();
4175
4176 let err = apply(Some(source.clone()), false).unwrap_err();
4177 assert!(format!("{err}").contains("drift"));
4178 assert_eq!(
4180 std::fs::read_to_string(source.join("home/foo")).unwrap(),
4181 "manually edited"
4182 );
4183 assert!(!target.join("foo").exists());
4185 }
4186
4187 #[test]
4188 fn init_creates_skeleton_when_dir_empty() {
4189 let tmp = TempDir::new().unwrap();
4190 let dir = utf8(tmp.path().join("new_dotfiles"));
4191 init(Some(dir.clone()), false).unwrap();
4192 assert!(dir.join("config.toml").is_file());
4193 assert!(dir.join(".gitignore").is_file());
4194 }
4195
4196 #[test]
4197 fn init_refuses_to_overwrite_existing_config() {
4198 let tmp = TempDir::new().unwrap();
4199 let dir = utf8(tmp.path().join("dotfiles"));
4200 std::fs::create_dir_all(&dir).unwrap();
4201 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
4202 let err = init(Some(dir), false).unwrap_err();
4203 assert!(format!("{err}").contains("already exists"));
4204 }
4205
4206 #[test]
4212 fn init_appends_missing_gitignore_entries_into_existing_file() {
4213 let tmp = TempDir::new().unwrap();
4214 let dir = utf8(tmp.path().join("dotfiles"));
4215 std::fs::create_dir_all(&dir).unwrap();
4216 let user_gitignore = "# user entries\n*.swp\nnode_modules/\n";
4218 std::fs::write(dir.join(".gitignore"), user_gitignore).unwrap();
4219
4220 init(Some(dir.clone()), false).unwrap();
4221
4222 let body = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
4223 assert!(body.contains("*.swp"));
4225 assert!(body.contains("node_modules/"));
4226 assert!(body.contains("/.yui/state.json"));
4228 assert!(body.contains("/.yui/backup/"));
4229 assert!(body.contains("config.local.toml"));
4230 let before_rerun = body.clone();
4232 std::fs::remove_file(dir.join("config.toml")).unwrap();
4235 init(Some(dir.clone()), false).unwrap();
4236 let after_rerun = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
4237 assert_eq!(
4238 before_rerun, after_rerun,
4239 "init must be idempotent when the gitignore already has every yui entry"
4240 );
4241 }
4242
4243 #[test]
4249 fn init_with_git_hooks_installs_into_existing_repo() {
4250 let tmp = TempDir::new().unwrap();
4251 let dir = utf8(tmp.path().join("dotfiles"));
4252 std::fs::create_dir_all(&dir).unwrap();
4253 let st = std::process::Command::new("git")
4254 .args(["init", "-q"])
4255 .current_dir(dir.as_std_path())
4256 .status()
4257 .expect("git init");
4258 if !st.success() {
4259 return;
4260 }
4261 let user_config = "# user already wrote this\n";
4263 std::fs::write(dir.join("config.toml"), user_config).unwrap();
4264
4265 init(Some(dir.clone()), true).unwrap();
4267
4268 assert_eq!(
4269 std::fs::read_to_string(dir.join("config.toml")).unwrap(),
4270 user_config
4271 );
4272 assert!(dir.join(".git/hooks/pre-commit").is_file());
4273 assert!(dir.join(".git/hooks/pre-push").is_file());
4274 }
4275
4276 #[test]
4281 fn init_with_git_hooks_writes_pre_commit_and_pre_push() {
4282 let tmp = TempDir::new().unwrap();
4283 let dir = utf8(tmp.path().join("dotfiles"));
4284 std::fs::create_dir_all(&dir).unwrap();
4285 let st = std::process::Command::new("git")
4287 .args(["init", "-q"])
4288 .current_dir(dir.as_std_path())
4289 .status()
4290 .expect("git init");
4291 if !st.success() {
4292 eprintln!("skipping: git not available");
4294 return;
4295 }
4296 init(Some(dir.clone()), true).unwrap();
4297
4298 let pre_commit = dir.join(".git/hooks/pre-commit");
4299 let pre_push = dir.join(".git/hooks/pre-push");
4300 assert!(pre_commit.is_file(), "pre-commit hook should be written");
4301 assert!(pre_push.is_file(), "pre-push hook should be written");
4302
4303 let body = std::fs::read_to_string(&pre_commit).unwrap();
4304 assert!(
4305 body.contains("yui render --check"),
4306 "pre-commit hook should call `yui render --check`, got: {body}"
4307 );
4308 }
4309
4310 #[test]
4314 fn init_with_git_hooks_errors_outside_a_git_repo() {
4315 let tmp = TempDir::new().unwrap();
4316 let dir = utf8(tmp.path().join("not-a-repo"));
4317 std::fs::create_dir_all(&dir).unwrap();
4318 let err = init(Some(dir), true).unwrap_err();
4319 let msg = format!("{err:#}");
4320 assert!(
4321 msg.contains("git repo") || msg.contains("git rev-parse"),
4322 "expected error to mention the git issue, got: {msg}"
4323 );
4324 }
4325
4326 #[test]
4329 fn init_with_git_hooks_does_not_clobber_existing_hooks() {
4330 let tmp = TempDir::new().unwrap();
4331 let dir = utf8(tmp.path().join("dotfiles"));
4332 std::fs::create_dir_all(&dir).unwrap();
4333 let st = std::process::Command::new("git")
4334 .args(["init", "-q"])
4335 .current_dir(dir.as_std_path())
4336 .status()
4337 .expect("git init");
4338 if !st.success() {
4339 return;
4340 }
4341 let hooks = dir.join(".git/hooks");
4342 std::fs::create_dir_all(&hooks).unwrap();
4343 std::fs::write(hooks.join("pre-commit"), "#! /bin/sh\nexit 0\n").unwrap();
4344
4345 init(Some(dir.clone()), true).unwrap();
4346
4347 let pc = std::fs::read_to_string(hooks.join("pre-commit")).unwrap();
4349 assert!(
4350 !pc.contains("yui render --check"),
4351 "existing pre-commit must not be overwritten"
4352 );
4353 let pp = std::fs::read_to_string(hooks.join("pre-push")).unwrap();
4354 assert!(
4355 pp.contains("yui render --check"),
4356 "missing pre-push should be written: {pp}"
4357 );
4358 }
4359
4360 fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
4363 let source = utf8(tmp.path().join("dotfiles"));
4364 let target = utf8(tmp.path().join("target"));
4365 std::fs::create_dir_all(source.join("home")).unwrap();
4366 std::fs::create_dir_all(&target).unwrap();
4367 let cfg = format!(
4368 r#"
4369[[mount.entry]]
4370src = "home"
4371dst = "{}"
4372"#,
4373 toml_path(&target)
4374 );
4375 std::fs::write(source.join("config.toml"), cfg).unwrap();
4376 (source, target)
4377 }
4378
4379 fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
4380 std::fs::write(path, body).unwrap();
4381 let f = std::fs::OpenOptions::new()
4382 .write(true)
4383 .open(path)
4384 .expect("open writable");
4385 f.set_modified(when).expect("set_modified");
4386 }
4387
4388 #[test]
4389 fn apply_target_newer_absorbs_target_into_source() {
4390 let tmp = TempDir::new().unwrap();
4394 let (source, target) = setup_minimal_dotfiles(&tmp);
4395
4396 let now = std::time::SystemTime::now();
4397 let past = now - std::time::Duration::from_secs(120);
4398 write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
4399 write_with_mtime(&target.join(".bashrc"), "user's edit", now);
4401
4402 apply(Some(source.clone()), false).unwrap();
4403
4404 assert_eq!(
4406 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4407 "user's edit"
4408 );
4409 assert_eq!(
4411 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4412 "user's edit"
4413 );
4414 let backup_root = source.join(".yui/backup");
4416 let mut found_old = false;
4417 for entry in walkdir(&backup_root) {
4418 if let Ok(s) = std::fs::read_to_string(&entry) {
4419 if s == "default from repo" {
4420 found_old = true;
4421 break;
4422 }
4423 }
4424 }
4425 assert!(found_old, "expected backup containing 'default from repo'");
4426 }
4427
4428 #[test]
4429 fn apply_in_sync_target_is_a_no_op() {
4430 let tmp = TempDir::new().unwrap();
4433 let (source, target) = setup_minimal_dotfiles(&tmp);
4434 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4435 apply(Some(source.clone()), false).unwrap();
4436 let backup_root = source.join(".yui/backup");
4437 let backup_count_after_first = walkdir(&backup_root).len();
4438
4439 apply(Some(source.clone()), false).unwrap();
4441 assert_eq!(
4442 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4443 "echo hi\n"
4444 );
4445 let backup_count_after_second = walkdir(&backup_root).len();
4446 assert_eq!(
4447 backup_count_after_first, backup_count_after_second,
4448 "second apply on an in-sync tree should not produce backups"
4449 );
4450 }
4451
4452 #[test]
4453 fn apply_skip_policy_leaves_anomaly_alone() {
4454 let tmp = TempDir::new().unwrap();
4457 let source = utf8(tmp.path().join("dotfiles"));
4458 let target = utf8(tmp.path().join("target"));
4459 std::fs::create_dir_all(source.join("home")).unwrap();
4460 std::fs::create_dir_all(&target).unwrap();
4461 let cfg = format!(
4462 r#"
4463[absorb]
4464on_anomaly = "skip"
4465
4466[[mount.entry]]
4467src = "home"
4468dst = "{}"
4469"#,
4470 toml_path(&target)
4471 );
4472 std::fs::write(source.join("config.toml"), cfg).unwrap();
4473
4474 let now = std::time::SystemTime::now();
4475 let past = now - std::time::Duration::from_secs(120);
4476 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4477 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4478
4479 apply(Some(source.clone()), false).unwrap();
4480
4481 assert_eq!(
4483 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4484 "user's edit (older)"
4485 );
4486 assert_eq!(
4488 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4489 "fresh from upstream"
4490 );
4491 }
4492
4493 #[test]
4494 fn apply_force_policy_absorbs_anomaly_anyway() {
4495 let tmp = TempDir::new().unwrap();
4497 let source = utf8(tmp.path().join("dotfiles"));
4498 let target = utf8(tmp.path().join("target"));
4499 std::fs::create_dir_all(source.join("home")).unwrap();
4500 std::fs::create_dir_all(&target).unwrap();
4501 let cfg = format!(
4502 r#"
4503[absorb]
4504on_anomaly = "force"
4505
4506[[mount.entry]]
4507src = "home"
4508dst = "{}"
4509"#,
4510 toml_path(&target)
4511 );
4512 std::fs::write(source.join("config.toml"), cfg).unwrap();
4513
4514 let now = std::time::SystemTime::now();
4515 let past = now - std::time::Duration::from_secs(120);
4516 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4517 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4518
4519 apply(Some(source.clone()), false).unwrap();
4520
4521 assert_eq!(
4523 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4524 "user's edit (older)"
4525 );
4526 assert_eq!(
4527 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4528 "user's edit (older)"
4529 );
4530 }
4531
4532 #[test]
4544 fn apply_absorbs_non_empty_target_dir_target_wins() {
4545 let tmp = TempDir::new().unwrap();
4546 let source = utf8(tmp.path().join("dotfiles"));
4547 let target = utf8(tmp.path().join("target"));
4548 std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
4549 std::fs::create_dir_all(target.join(".config/app")).unwrap();
4550 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4553 std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
4554 std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
4556 std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
4559 std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
4560
4561 let cfg = format!(
4562 r#"
4563[absorb]
4564on_anomaly = "force"
4565
4566[[mount.entry]]
4567src = "home"
4568dst = "{}"
4569"#,
4570 toml_path(&target)
4571 );
4572 std::fs::write(source.join("config.toml"), cfg).unwrap();
4573
4574 apply(Some(source.clone()), false).unwrap();
4576
4577 assert_eq!(
4579 std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
4580 "target side"
4581 );
4582 assert_eq!(
4584 std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
4585 "{}"
4586 );
4587 let backup_root = source.join(".yui/backup");
4590 let mut backup_files: Vec<String> = Vec::new();
4591 for entry in walkdir(&backup_root) {
4592 if let Some(n) = entry.file_name() {
4593 backup_files.push(n.to_string());
4594 }
4595 }
4596 assert!(
4597 backup_files.iter().any(|f| f == "config.toml"),
4598 "expected source's config.toml to land in the backup tree, got {backup_files:?}"
4599 );
4600 assert!(
4602 source.join("home/.config/app/source-only.toml").exists(),
4603 "source-only file should survive a target-wins merge"
4604 );
4605 assert!(
4607 source.join("home/.config/app/state.json").exists(),
4608 "target-only state.json should be merged into source"
4609 );
4610 }
4611
4612 #[test]
4618 fn marker_dir_absorbs_with_default_ask_policy() {
4619 let tmp = TempDir::new().unwrap();
4620 let source = utf8(tmp.path().join("dotfiles"));
4621 let target = utf8(tmp.path().join("target"));
4622 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4623 std::fs::create_dir_all(target.join(".config/gh")).unwrap();
4624 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4626 std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\n").unwrap();
4628
4629 let cfg = format!(
4633 r#"
4634[[mount.entry]]
4635src = "home"
4636dst = "{}"
4637"#,
4638 toml_path(&target)
4639 );
4640 std::fs::write(source.join("config.toml"), cfg).unwrap();
4641
4642 apply(Some(source.clone()), false).unwrap();
4646
4647 assert!(target.join(".config/gh/hosts.yml").exists());
4650 assert!(source.join("home/.config/gh/hosts.yml").exists());
4651 }
4652
4653 #[test]
4659 fn merge_handles_file_vs_dir_collisions_target_wins() {
4660 let tmp = TempDir::new().unwrap();
4661 let source = utf8(tmp.path().join("dotfiles"));
4662 let target = utf8(tmp.path().join("target"));
4663 std::fs::create_dir_all(source.join("home/.config/foo")).unwrap();
4664 std::fs::create_dir_all(target.join(".config")).unwrap();
4665 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4666
4667 std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
4669 std::fs::write(target.join(".config/foo"), "target file body").unwrap();
4670 std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
4672 std::fs::create_dir_all(target.join(".config/bar")).unwrap();
4673 std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
4674
4675 let cfg = format!(
4676 r#"
4677[absorb]
4678on_anomaly = "force"
4679
4680[[mount.entry]]
4681src = "home"
4682dst = "{}"
4683"#,
4684 toml_path(&target)
4685 );
4686 std::fs::write(source.join("config.toml"), cfg).unwrap();
4687 apply(Some(source.clone()), false).unwrap();
4688
4689 let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
4693 assert!(foo_meta.file_type().is_file(), "foo should be a file");
4694 assert_eq!(
4695 std::fs::read_to_string(target.join(".config/foo")).unwrap(),
4696 "target file body"
4697 );
4698 let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
4700 assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
4701 assert_eq!(
4702 std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
4703 "target nested"
4704 );
4705 }
4706
4707 #[test]
4711 fn merge_per_file_target_newer_auto_absorbs() {
4712 let tmp = TempDir::new().unwrap();
4713 let source = utf8(tmp.path().join("dotfiles"));
4714 let target = utf8(tmp.path().join("target"));
4715 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4716 std::fs::create_dir_all(target.join(".config")).unwrap();
4717 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4718
4719 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4721 write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
4722 std::fs::write(target.join(".config/app.toml"), "user's live edit").unwrap();
4723
4724 let cfg = format!(
4728 r#"
4729[[mount.entry]]
4730src = "home"
4731dst = "{}"
4732"#,
4733 toml_path(&target)
4734 );
4735 std::fs::write(source.join("config.toml"), cfg).unwrap();
4736 apply(Some(source.clone()), false).unwrap();
4737
4738 assert_eq!(
4740 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4741 "user's live edit"
4742 );
4743 }
4744
4745 #[test]
4751 fn merge_per_file_source_newer_skip_keeps_source() {
4752 let tmp = TempDir::new().unwrap();
4753 let source = utf8(tmp.path().join("dotfiles"));
4754 let target = utf8(tmp.path().join("target"));
4755 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4756 std::fs::create_dir_all(target.join(".config")).unwrap();
4757 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4758
4759 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4761 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
4762 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
4763
4764 let cfg = format!(
4765 r#"
4766[absorb]
4767on_anomaly = "skip"
4768
4769[[mount.entry]]
4770src = "home"
4771dst = "{}"
4772"#,
4773 toml_path(&target)
4774 );
4775 std::fs::write(source.join("config.toml"), cfg).unwrap();
4776 apply(Some(source.clone()), false).unwrap();
4777
4778 assert_eq!(
4781 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4782 "fresh source"
4783 );
4784 }
4785
4786 #[test]
4789 fn merge_per_file_source_newer_force_overwrites_source() {
4790 let tmp = TempDir::new().unwrap();
4791 let source = utf8(tmp.path().join("dotfiles"));
4792 let target = utf8(tmp.path().join("target"));
4793 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4794 std::fs::create_dir_all(target.join(".config")).unwrap();
4795 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4796
4797 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4798 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
4799 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
4800
4801 let cfg = format!(
4802 r#"
4803[absorb]
4804on_anomaly = "force"
4805
4806[[mount.entry]]
4807src = "home"
4808dst = "{}"
4809"#,
4810 toml_path(&target)
4811 );
4812 std::fs::write(source.join("config.toml"), cfg).unwrap();
4813 apply(Some(source.clone()), false).unwrap();
4814
4815 assert_eq!(
4817 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4818 "old target"
4819 );
4820 }
4821
4822 #[test]
4827 fn merge_per_file_identical_content_is_noop() {
4828 let tmp = TempDir::new().unwrap();
4829 let source = utf8(tmp.path().join("dotfiles"));
4830 let target = utf8(tmp.path().join("target"));
4831 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4832 std::fs::create_dir_all(target.join(".config")).unwrap();
4833 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4834 std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
4835 std::fs::write(target.join(".config/app.toml"), "same").unwrap();
4836
4837 let cfg = format!(
4840 r#"
4841[[mount.entry]]
4842src = "home"
4843dst = "{}"
4844"#,
4845 toml_path(&target)
4846 );
4847 std::fs::write(source.join("config.toml"), cfg).unwrap();
4848 apply(Some(source.clone()), false).unwrap();
4849
4850 assert_eq!(
4851 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4852 "same"
4853 );
4854 }
4855
4856 #[test]
4857 fn manual_absorb_command_pulls_target_into_source() {
4858 let tmp = TempDir::new().unwrap();
4860 let source = utf8(tmp.path().join("dotfiles"));
4861 let target = utf8(tmp.path().join("target"));
4862 std::fs::create_dir_all(source.join("home")).unwrap();
4863 std::fs::create_dir_all(&target).unwrap();
4864 let cfg = format!(
4866 r#"
4867[absorb]
4868on_anomaly = "skip"
4869
4870[[mount.entry]]
4871src = "home"
4872dst = "{}"
4873"#,
4874 toml_path(&target)
4875 );
4876 std::fs::write(source.join("config.toml"), cfg).unwrap();
4877 std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
4878 std::fs::write(source.join("home/.bashrc"), "default").unwrap();
4879
4880 absorb(
4883 Some(source.clone()),
4884 target.join(".bashrc"),
4885 false,
4886 true,
4887 )
4888 .unwrap();
4889
4890 assert_eq!(
4892 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4893 "user picked this"
4894 );
4895 }
4896
4897 #[test]
4898 fn manual_absorb_errors_when_target_outside_known_mounts() {
4899 let tmp = TempDir::new().unwrap();
4900 let (source, _target) = setup_minimal_dotfiles(&tmp);
4901 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
4902 let stranger = utf8(tmp.path().join("not-managed/foo"));
4903 std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
4904 std::fs::write(&stranger, "not yui's").unwrap();
4905 let err = absorb(Some(source), stranger, false, true).unwrap_err();
4906 assert!(format!("{err}").contains("no mount entry"));
4907 }
4908
4909 #[test]
4910 fn yuiignore_excludes_file_from_linking() {
4911 let tmp = TempDir::new().unwrap();
4912 let (source, target) = setup_minimal_dotfiles(&tmp);
4913 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
4914 std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
4915 std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
4917 apply(Some(source.clone()), false).unwrap();
4918 assert!(target.join(".bashrc").exists());
4919 assert!(
4920 !target.join("lock.json").exists(),
4921 "yuiignore should keep lock.json out of target"
4922 );
4923 }
4924
4925 #[test]
4926 fn yuiignore_excludes_directory_subtree() {
4927 let tmp = TempDir::new().unwrap();
4928 let (source, target) = setup_minimal_dotfiles(&tmp);
4929 std::fs::create_dir_all(source.join("home/cache")).unwrap();
4930 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
4931 std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
4932 std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
4933 std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
4935 apply(Some(source.clone()), false).unwrap();
4936 assert!(target.join(".bashrc").exists());
4937 assert!(
4938 !target.join("cache").exists(),
4939 "yuiignore'd subtree should not appear in target"
4940 );
4941 }
4942
4943 #[test]
4944 fn yuiignore_negation_re_includes_file() {
4945 let tmp = TempDir::new().unwrap();
4946 let (source, target) = setup_minimal_dotfiles(&tmp);
4947 std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
4948 std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
4949 std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
4951 apply(Some(source.clone()), false).unwrap();
4952 assert!(target.join("keep.cache").exists());
4953 assert!(!target.join("drop.cache").exists());
4954 }
4955
4956 #[test]
4961 fn nested_yuiignore_only_affects_its_subtree() {
4962 let tmp = TempDir::new().unwrap();
4963 let (source, target) = setup_minimal_dotfiles(&tmp);
4964 std::fs::create_dir_all(source.join("home/inner")).unwrap();
4965 std::fs::write(source.join("home/secret.txt"), "outer keep").unwrap();
4966 std::fs::write(source.join("home/inner/secret.txt"), "inner drop").unwrap();
4967 std::fs::write(source.join("home/inner/keep.txt"), "inner keep").unwrap();
4968 std::fs::write(source.join("home/inner/.yuiignore"), "secret*\n").unwrap();
4970 apply(Some(source.clone()), false).unwrap();
4971 assert!(
4972 target.join("secret.txt").exists(),
4973 "outer secret.txt is outside the nested .yuiignore scope"
4974 );
4975 assert!(target.join("inner/keep.txt").exists());
4976 assert!(
4977 !target.join("inner/secret.txt").exists(),
4978 "inner secret.txt should be excluded by the nested .yuiignore"
4979 );
4980 }
4981
4982 #[test]
4986 fn nested_yuiignore_negation_overrides_root_rule() {
4987 let tmp = TempDir::new().unwrap();
4988 let (source, target) = setup_minimal_dotfiles(&tmp);
4989 std::fs::create_dir_all(source.join("home/keepers")).unwrap();
4990 std::fs::write(source.join("home/drop.lock"), "outer drop").unwrap();
4991 std::fs::write(source.join("home/keepers/wanted.lock"), "inner keep").unwrap();
4992 std::fs::write(source.join(".yuiignore"), "*.lock\n").unwrap();
4993 std::fs::write(source.join("home/keepers/.yuiignore"), "!*.lock\n").unwrap();
4995 apply(Some(source.clone()), false).unwrap();
4996 assert!(
4997 !target.join("drop.lock").exists(),
4998 "root rule still drops outer .lock file"
4999 );
5000 assert!(
5001 target.join("keepers/wanted.lock").exists(),
5002 "nested negation re-includes .lock under keepers/"
5003 );
5004 }
5005
5006 #[test]
5010 fn nested_yuiignore_status_walk_scoped() {
5011 let tmp = TempDir::new().unwrap();
5012 let (source, _target) = setup_minimal_dotfiles(&tmp);
5013 std::fs::create_dir_all(source.join("home/a")).unwrap();
5014 std::fs::create_dir_all(source.join("home/b")).unwrap();
5015 std::fs::write(source.join("home/a/foo.txt"), "a-foo").unwrap();
5016 std::fs::write(source.join("home/b/foo.txt"), "b-foo").unwrap();
5017 std::fs::write(source.join("home/a/.yuiignore"), "foo.txt\n").unwrap();
5019 apply(Some(source.clone()), false).unwrap();
5020 let res = status(Some(source), None, true);
5022 assert!(res.is_ok() || matches!(&res, Err(e) if format!("{e}").contains("diverged")));
5023 }
5024
5025 #[test]
5026 fn yuiignore_skips_template_in_render() {
5027 let tmp = TempDir::new().unwrap();
5028 let source = utf8(tmp.path().join("dotfiles"));
5029 let target = utf8(tmp.path().join("target"));
5030 std::fs::create_dir_all(source.join("home")).unwrap();
5031 std::fs::create_dir_all(&target).unwrap();
5032 std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
5033 std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
5034 let cfg = format!(
5035 r#"
5036[[mount.entry]]
5037src = "home"
5038dst = "{}"
5039"#,
5040 toml_path(&target)
5041 );
5042 std::fs::write(source.join("config.toml"), cfg).unwrap();
5043 apply(Some(source.clone()), false).unwrap();
5044 assert!(!source.join("home/note").exists());
5046 assert!(!target.join("note").exists());
5047 assert!(!target.join("note.tera").exists());
5048 }
5049
5050 #[test]
5059 fn apply_decrypts_age_files_to_sibling_and_links() {
5060 let tmp = TempDir::new().unwrap();
5061 let source = utf8(tmp.path().join("dotfiles"));
5062 let target = utf8(tmp.path().join("target"));
5063 std::fs::create_dir_all(source.join("home/.ssh")).unwrap();
5064 std::fs::create_dir_all(&target).unwrap();
5065
5066 let identity_path = utf8(tmp.path().join("age.txt"));
5069 let (secret, public) = secret::generate_x25519_keypair();
5070 std::fs::write(&identity_path, format!("{secret}\n")).unwrap();
5071
5072 let recipient = secret::parse_x25519_recipient(&public).unwrap();
5074 let cipher = secret::encrypt_x25519(b"-- super secret key --\n", &[recipient]).unwrap();
5075 std::fs::write(source.join("home/.ssh/id_ed25519.age"), &cipher).unwrap();
5076
5077 let cfg = format!(
5079 r#"
5080[[mount.entry]]
5081src = "home"
5082dst = "{}"
5083
5084[secrets]
5085identity = "{}"
5086recipients = ["{}"]
5087"#,
5088 toml_path(&target),
5089 toml_path(&identity_path),
5090 public
5091 );
5092 std::fs::write(source.join("config.toml"), cfg).unwrap();
5093
5094 apply(Some(source.clone()), false).unwrap();
5095
5096 assert!(source.join("home/.ssh/id_ed25519").exists());
5098 let target_bytes = std::fs::read(target.join(".ssh/id_ed25519")).unwrap();
5100 assert_eq!(target_bytes, b"-- super secret key --\n");
5101 let gi = std::fs::read_to_string(source.join(".gitignore")).unwrap();
5103 assert!(
5104 gi.contains("home/.ssh/id_ed25519"),
5105 ".gitignore should list the decrypted plaintext sibling: {gi}"
5106 );
5107 }
5110
5111 #[test]
5116 fn apply_bails_on_secret_drift() {
5117 let tmp = TempDir::new().unwrap();
5118 let source = utf8(tmp.path().join("dotfiles"));
5119 let target = utf8(tmp.path().join("target"));
5120 std::fs::create_dir_all(source.join("home")).unwrap();
5121 std::fs::create_dir_all(&target).unwrap();
5122
5123 let identity_path = utf8(tmp.path().join("age.txt"));
5124 let (secret_key, public) = secret::generate_x25519_keypair();
5125 std::fs::write(&identity_path, format!("{secret_key}\n")).unwrap();
5126
5127 let recipient = secret::parse_x25519_recipient(&public).unwrap();
5128 let cipher = secret::encrypt_x25519(b"v1 content\n", &[recipient]).unwrap();
5129 std::fs::write(source.join("home/secret.age"), &cipher).unwrap();
5130 std::fs::write(source.join("home/secret"), "edited locally\n").unwrap();
5132
5133 let cfg = format!(
5134 r#"
5135[[mount.entry]]
5136src = "home"
5137dst = "{}"
5138
5139[secrets]
5140identity = "{}"
5141recipients = ["{}"]
5142"#,
5143 toml_path(&target),
5144 toml_path(&identity_path),
5145 public
5146 );
5147 std::fs::write(source.join("config.toml"), cfg).unwrap();
5148
5149 let err = apply(Some(source.clone()), false).unwrap_err();
5150 assert!(
5151 format!("{err:#}").contains("secret drift"),
5152 "expected secret drift error, got: {err:#}"
5153 );
5154 }
5155
5156 #[test]
5159 fn append_recipient_creates_secrets_table_when_missing() {
5160 let result =
5161 append_recipient_to_config("", "host alice", "age1abcrecipientpublickey").unwrap();
5162 let parsed: toml::Table = toml::from_str(&result).unwrap();
5164 let secrets = parsed.get("secrets").and_then(|v| v.as_table()).unwrap();
5165 let recipients = secrets
5166 .get("recipients")
5167 .and_then(|v| v.as_array())
5168 .unwrap();
5169 assert_eq!(recipients.len(), 1);
5170 assert_eq!(recipients[0].as_str(), Some("age1abcrecipientpublickey"));
5171 }
5172
5173 #[test]
5174 fn append_recipient_preserves_existing_other_tables() {
5175 let existing = r#"
5179[vars]
5180greet = "hi"
5181
5182[secrets]
5183recipients = ["age1machine_a"]
5184
5185[ui]
5186icons = "ascii"
5187"#;
5188 let result = append_recipient_to_config(existing, "host b", "age1machine_b").unwrap();
5189 let parsed: toml::Table = toml::from_str(&result).unwrap();
5190 assert!(parsed.get("vars").is_some());
5192 assert!(parsed.get("secrets").is_some());
5193 assert!(parsed.get("ui").is_some());
5194 let recipients = parsed["secrets"]["recipients"].as_array().unwrap();
5196 assert_eq!(recipients.len(), 2);
5197 let pubs: Vec<&str> = recipients.iter().filter_map(|v| v.as_str()).collect();
5198 assert!(pubs.contains(&"age1machine_a"));
5199 assert!(pubs.contains(&"age1machine_b"));
5200 }
5201
5202 #[test]
5203 fn append_recipient_is_idempotent_on_duplicate() {
5204 let existing = r#"[secrets]
5205recipients = ["age1same"]
5206"#;
5207 let result = append_recipient_to_config(existing, "anyone", "age1same").unwrap();
5208 let parsed: toml::Table = toml::from_str(&result).unwrap();
5209 let recipients = parsed["secrets"]["recipients"].as_array().unwrap();
5210 assert_eq!(recipients.len(), 1, "duplicate must not be appended twice");
5211 }
5212
5213 #[test]
5214 fn append_recipient_creates_recipients_array_when_secrets_table_empty() {
5215 let existing = r#"[secrets]
5218identity = "~/.config/yui/age.txt"
5219"#;
5220 let result = append_recipient_to_config(existing, "h", "age1new").unwrap();
5221 let parsed: toml::Table = toml::from_str(&result).unwrap();
5222 let secrets = parsed["secrets"].as_table().unwrap();
5223 assert_eq!(
5224 secrets["identity"].as_str(),
5225 Some("~/.config/yui/age.txt"),
5226 "existing identity field must survive"
5227 );
5228 let recipients = secrets["recipients"].as_array().unwrap();
5229 assert_eq!(recipients.len(), 1);
5230 assert_eq!(recipients[0].as_str(), Some("age1new"));
5231 }
5232
5233 #[test]
5237 fn apply_without_recipients_skips_secret_walker() {
5238 let tmp = TempDir::new().unwrap();
5239 let (source, _target) = setup_minimal_dotfiles(&tmp);
5240 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5242 std::fs::write(source.join("home/some.junk.age"), b"not actually a cipher").unwrap();
5246 apply(Some(source.clone()), false).unwrap();
5247 }
5248
5249 #[test]
5253 fn nested_marker_accumulates_extra_dst() {
5254 let tmp = TempDir::new().unwrap();
5255 let source = utf8(tmp.path().join("dotfiles"));
5256 let parent_target = utf8(tmp.path().join("home"));
5257 let extra_target = utf8(tmp.path().join("extra"));
5258 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
5259 std::fs::create_dir_all(&parent_target).unwrap();
5260 std::fs::create_dir_all(&extra_target).unwrap();
5261 std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
5262
5263 std::fs::write(
5265 source.join("home/.config/.yuilink"),
5266 format!(
5267 r#"
5268[[link]]
5269dst = "{}/.config"
5270"#,
5271 toml_path(&parent_target)
5272 ),
5273 )
5274 .unwrap();
5275 std::fs::write(
5278 source.join("home/.config/nvim/.yuilink"),
5279 format!(
5280 r#"
5281[[link]]
5282dst = "{}/nvim"
5283when = "{{{{ yui.os == '{}' }}}}"
5284"#,
5285 toml_path(&extra_target),
5286 std::env::consts::OS
5287 ),
5288 )
5289 .unwrap();
5290
5291 let cfg = format!(
5292 r#"
5293[[mount.entry]]
5294src = "home"
5295dst = "{}"
5296"#,
5297 toml_path(&parent_target)
5298 );
5299 std::fs::write(source.join("config.toml"), cfg).unwrap();
5300
5301 apply(Some(source.clone()), false).unwrap();
5302
5303 assert!(parent_target.join(".config/nvim/init.lua").exists());
5306 assert!(extra_target.join("nvim/init.lua").exists());
5307 }
5308
5309 #[test]
5314 fn marker_file_link_targets_specific_file() {
5315 let tmp = TempDir::new().unwrap();
5316 let source = utf8(tmp.path().join("dotfiles"));
5317 let parent_target = utf8(tmp.path().join("home"));
5318 let docs_target = utf8(tmp.path().join("docs"));
5319 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
5320 std::fs::create_dir_all(&parent_target).unwrap();
5321 std::fs::create_dir_all(&docs_target).unwrap();
5322 std::fs::write(
5323 source.join("home/.config/powershell/profile.ps1"),
5324 "# profile\n",
5325 )
5326 .unwrap();
5327 std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
5328
5329 std::fs::write(
5332 source.join("home/.config/powershell/.yuilink"),
5333 format!(
5334 r#"
5335[[link]]
5336src = "profile.ps1"
5337dst = "{}/Microsoft.PowerShell_profile.ps1"
5338"#,
5339 toml_path(&docs_target)
5340 ),
5341 )
5342 .unwrap();
5343
5344 let cfg = format!(
5345 r#"
5346[[mount.entry]]
5347src = "home"
5348dst = "{}"
5349"#,
5350 toml_path(&parent_target)
5351 );
5352 std::fs::write(source.join("config.toml"), cfg).unwrap();
5353
5354 apply(Some(source.clone()), false).unwrap();
5355
5356 assert!(
5358 docs_target
5359 .join("Microsoft.PowerShell_profile.ps1")
5360 .exists()
5361 );
5362 assert!(
5365 parent_target
5366 .join(".config/powershell/profile.ps1")
5367 .exists()
5368 );
5369 assert!(parent_target.join(".config/powershell/extra.txt").exists());
5370 }
5371
5372 #[test]
5375 fn marker_file_link_missing_src_errors() {
5376 let tmp = TempDir::new().unwrap();
5377 let source = utf8(tmp.path().join("dotfiles"));
5378 let parent_target = utf8(tmp.path().join("home"));
5379 let docs_target = utf8(tmp.path().join("docs"));
5380 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
5381 std::fs::create_dir_all(&parent_target).unwrap();
5382 std::fs::create_dir_all(&docs_target).unwrap();
5383
5384 std::fs::write(
5385 source.join("home/.config/powershell/.yuilink"),
5386 format!(
5387 r#"
5388[[link]]
5389src = "missing.ps1"
5390dst = "{}/profile.ps1"
5391"#,
5392 toml_path(&docs_target)
5393 ),
5394 )
5395 .unwrap();
5396
5397 let cfg = format!(
5398 r#"
5399[[mount.entry]]
5400src = "home"
5401dst = "{}"
5402"#,
5403 toml_path(&parent_target)
5404 );
5405 std::fs::write(source.join("config.toml"), cfg).unwrap();
5406
5407 let err = apply(Some(source.clone()), false).unwrap_err();
5408 assert!(format!("{err:#}").contains("missing.ps1"));
5409 }
5410
5411 #[test]
5420 fn unmanaged_finds_files_outside_any_mount() {
5421 let tmp = TempDir::new().unwrap();
5422 let (source, _target) = setup_minimal_dotfiles(&tmp);
5423 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5425 std::fs::write(source.join("orphan.txt"), "y").unwrap();
5427 std::fs::create_dir_all(source.join("notes")).unwrap();
5428 std::fs::write(source.join("notes/scratch.md"), "z").unwrap();
5429
5430 unmanaged(Some(source.clone()), None, true).unwrap();
5432
5433 let yui = YuiVars::detect(&source);
5435 let cfg = config::load(&source, &yui).unwrap();
5436 let mount_srcs: Vec<Utf8PathBuf> = cfg
5437 .mount
5438 .entry
5439 .iter()
5440 .map(|m| source.join(&m.src))
5441 .collect();
5442 let walker = paths::source_walker(&source).build();
5443 let mut unmanaged_paths = Vec::new();
5444 for entry in walker.flatten() {
5445 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
5446 continue;
5447 }
5448 let p = match Utf8PathBuf::from_path_buf(entry.path().to_path_buf()) {
5449 Ok(p) => p,
5450 Err(_) => continue,
5451 };
5452 if is_repo_meta(&p, &source, &cfg.mount.marker_filename) {
5453 continue;
5454 }
5455 if mount_srcs.iter().any(|m| p.starts_with(m)) {
5456 continue;
5457 }
5458 unmanaged_paths.push(p);
5459 }
5460 let names: Vec<String> = unmanaged_paths
5461 .iter()
5462 .filter_map(|p| p.file_name().map(String::from))
5463 .collect();
5464 assert!(names.contains(&"orphan.txt".into()));
5465 assert!(names.contains(&"scratch.md".into()));
5466 assert!(!names.contains(&".bashrc".into()), "mount-claimed file");
5467 assert!(!names.contains(&"config.toml".into()), "repo meta");
5468 }
5469
5470 #[test]
5471 fn is_repo_meta_recognises_yui_scaffold() {
5472 let source = Utf8Path::new("/dot");
5473 assert!(is_repo_meta(
5475 Utf8Path::new("/dot/config.toml"),
5476 source,
5477 ".yuilink",
5478 ));
5479 assert!(is_repo_meta(
5480 Utf8Path::new("/dot/config.local.toml"),
5481 source,
5482 ".yuilink",
5483 ));
5484 assert!(is_repo_meta(
5485 Utf8Path::new("/dot/config.linux.toml"),
5486 source,
5487 ".yuilink",
5488 ));
5489 assert!(is_repo_meta(
5490 Utf8Path::new("/dot/config.local.example.toml"),
5491 source,
5492 ".yuilink",
5493 ));
5494 assert!(is_repo_meta(
5496 Utf8Path::new("/dot/.gitignore"),
5497 source,
5498 ".yuilink",
5499 ));
5500 assert!(is_repo_meta(
5502 Utf8Path::new("/dot/home/.config/foo/.yuilink"),
5503 source,
5504 ".yuilink",
5505 ));
5506 assert!(is_repo_meta(
5507 Utf8Path::new("/dot/home/.gitconfig.tera"),
5508 source,
5509 ".yuilink",
5510 ));
5511 assert!(!is_repo_meta(
5513 Utf8Path::new("/dot/home/.config/myapp/config.toml"),
5514 source,
5515 ".yuilink",
5516 ));
5517 assert!(!is_repo_meta(
5521 Utf8Path::new("/dot/home/.config/git/.gitignore"),
5522 source,
5523 ".yuilink",
5524 ));
5525 }
5526
5527 #[test]
5534 fn unmanaged_respects_inactive_mount_entries() {
5535 let tmp = TempDir::new().unwrap();
5536 let source = utf8(tmp.path().join("dotfiles"));
5537 let target = utf8(tmp.path().join("target"));
5538 std::fs::create_dir_all(source.join("home_active")).unwrap();
5539 std::fs::create_dir_all(source.join("home_other_os")).unwrap();
5540 std::fs::create_dir_all(&target).unwrap();
5541 std::fs::write(source.join("home_active/.bashrc"), "active").unwrap();
5542 std::fs::write(source.join("home_other_os/.bashrc"), "inactive").unwrap();
5543 let cfg = format!(
5545 r#"
5546[[mount.entry]]
5547src = "home_active"
5548dst = "{target}"
5549
5550[[mount.entry]]
5551src = "home_other_os"
5552dst = "{target}"
5553when = "yui.os == 'definitely_not_a_real_os'"
5554"#,
5555 target = toml_path(&target)
5556 );
5557 std::fs::write(source.join("config.toml"), cfg).unwrap();
5558
5559 let yui = YuiVars::detect(&source);
5563 let cfg = config::load(&source, &yui).unwrap();
5564 let mount_srcs: Vec<Utf8PathBuf> = cfg
5565 .mount
5566 .entry
5567 .iter()
5568 .map(|m| source.join(&m.src))
5569 .collect();
5570 let inactive_file = source.join("home_other_os/.bashrc");
5571 let claimed = mount_srcs.iter().any(|m| inactive_file.starts_with(m));
5572 assert!(
5573 claimed,
5574 "raw config.mount.entry should claim files even under inactive mounts"
5575 );
5576 }
5577
5578 #[test]
5583 fn diff_shows_drift_skips_in_sync() {
5584 let tmp = TempDir::new().unwrap();
5585 let (source, target) = setup_minimal_dotfiles(&tmp);
5586 std::fs::write(source.join("home/.bashrc"), "first\nsecond\n").unwrap();
5587 apply(Some(source.clone()), false).unwrap();
5589 std::fs::remove_file(target.join(".bashrc")).unwrap();
5591 std::fs::write(target.join(".bashrc"), "first\nEDITED\n").unwrap();
5592
5593 diff(Some(source.clone()), None, true).unwrap();
5596 }
5597
5598 #[test]
5603 fn read_text_for_diff_classifies_correctly() {
5604 let tmp = TempDir::new().unwrap();
5605 let root = utf8(tmp.path().to_path_buf());
5606 let txt = root.join("a.txt");
5608 std::fs::write(&txt, "hello\n").unwrap();
5609 match read_text_for_diff(&txt) {
5610 DiffSide::Text(s) => assert_eq!(s, "hello\n"),
5611 DiffSide::Binary => panic!("text file misclassified as binary"),
5612 }
5613 let bin = root.join("b.bin");
5615 std::fs::write(&bin, [0xff, 0xfe, 0x00, 0xff]).unwrap();
5616 assert!(matches!(read_text_for_diff(&bin), DiffSide::Binary));
5617 let missing = root.join("missing.txt");
5619 match read_text_for_diff(&missing) {
5620 DiffSide::Text(s) => assert!(s.is_empty()),
5621 DiffSide::Binary => panic!("missing file misclassified as binary"),
5622 }
5623 }
5624
5625 #[test]
5632 fn diff_render_drift_uses_rendered_output_not_raw_template() {
5633 let tmp = TempDir::new().unwrap();
5634 let (source, _target) = setup_minimal_dotfiles(&tmp);
5635 std::fs::write(source.join("home/note.tera"), "os = {{ yui.os }}\n").unwrap();
5638 std::fs::write(source.join("home/note"), "os = ancient\n").unwrap();
5639 let yui = YuiVars::detect(&source);
5641 let cfg = config::load(&source, &yui).unwrap();
5642 let rendered =
5643 render::render_to_string(&source.join("home/note.tera"), &source, &cfg, &yui)
5644 .unwrap()
5645 .expect("template should render on this host");
5646 assert!(rendered.starts_with("os = "));
5647 assert!(
5648 !rendered.contains("{{"),
5649 "rendered output must not contain raw Tera tags"
5650 );
5651 }
5652
5653 #[test]
5661 fn resolve_diff_src_absolutizes_link_rows() {
5662 let source = Utf8Path::new("/dot");
5663 let link_item = StatusItem {
5664 src: Utf8PathBuf::from("home/.bashrc"),
5665 dst: Utf8PathBuf::from("/h/u/.bashrc"),
5666 state: StatusState::Link(absorb::AbsorbDecision::AutoAbsorb),
5667 };
5668 assert_eq!(
5669 resolve_diff_src(&link_item, source),
5670 Utf8PathBuf::from("/dot/home/.bashrc"),
5671 );
5672 let render_item = StatusItem {
5673 src: Utf8PathBuf::from("/dot/home/foo.tera"),
5674 dst: Utf8PathBuf::from("/dot/home/foo"),
5675 state: StatusState::RenderDrift,
5676 };
5677 assert_eq!(
5678 resolve_diff_src(&render_item, source),
5679 Utf8PathBuf::from("/dot/home/foo.tera"),
5680 );
5681 }
5682
5683 #[test]
5684 fn diff_classifier_skips_uninteresting_states() {
5685 use absorb::AbsorbDecision::*;
5686 assert!(!diff_worth_printing(&StatusState::Link(InSync)));
5688 assert!(!diff_worth_printing(&StatusState::Link(Restore)));
5689 assert!(!diff_worth_printing(&StatusState::Link(RelinkOnly)));
5690 assert!(diff_worth_printing(&StatusState::Link(AutoAbsorb)));
5692 assert!(diff_worth_printing(&StatusState::Link(NeedsConfirm)));
5693 assert!(diff_worth_printing(&StatusState::RenderDrift));
5694 }
5695
5696 #[test]
5707 fn update_errors_when_source_is_not_a_git_repo() {
5708 let tmp = TempDir::new().unwrap();
5709 let source = utf8(tmp.path().join("dotfiles"));
5710 std::fs::create_dir_all(&source).unwrap();
5711 std::fs::write(source.join("config.toml"), "").unwrap();
5712 let err = update(Some(source), false).unwrap_err();
5714 let msg = format!("{err:#}");
5715 assert!(
5716 msg.contains("not a git repository")
5717 || msg.contains("uncommitted")
5718 || msg.contains("git"),
5719 "unexpected error: {msg}",
5720 );
5721 }
5722
5723 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
5724 let mut out = Vec::new();
5725 let mut stack = vec![root.to_path_buf()];
5726 while let Some(dir) = stack.pop() {
5727 let Ok(entries) = std::fs::read_dir(&dir) else {
5728 continue;
5729 };
5730 for e in entries.flatten() {
5731 let p = utf8(e.path());
5732 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
5733 stack.push(p);
5734 } else {
5735 out.push(p);
5736 }
5737 }
5738 }
5739 out
5740 }
5741
5742 #[test]
5747 fn parse_backup_suffix_recognises_file_with_extension() {
5748 let dt = parse_backup_suffix("foo_20260429_143022123.yml").unwrap();
5749 assert_eq!(dt.year(), 2026);
5750 assert_eq!(dt.month(), 4);
5751 assert_eq!(dt.day(), 29);
5752 assert_eq!(dt.hour(), 14);
5753 assert_eq!(dt.minute(), 30);
5754 assert_eq!(dt.second(), 22);
5755 }
5756
5757 #[test]
5758 fn parse_backup_suffix_recognises_dotfile_no_extension() {
5759 let dt = parse_backup_suffix(".gitconfig_20260429_143022123").unwrap();
5760 assert_eq!(dt.year(), 2026);
5761 }
5762
5763 #[test]
5764 fn parse_backup_suffix_recognises_directory_form() {
5765 let dt = parse_backup_suffix("nvim_20260429_143022123").unwrap();
5766 assert_eq!(dt.day(), 29);
5767 }
5768
5769 #[test]
5770 fn parse_backup_suffix_recognises_multi_dot_filename() {
5771 let dt = parse_backup_suffix("archive.tar.gz_20260429_143022123.gz").unwrap();
5773 assert_eq!(dt.month(), 4);
5774 }
5775
5776 #[test]
5777 fn parse_backup_suffix_rejects_non_yui_names() {
5778 assert!(parse_backup_suffix("README.md").is_none());
5779 assert!(parse_backup_suffix("notes_2026.txt").is_none());
5780 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());
5784 }
5785
5786 #[test]
5787 fn parse_human_duration_basic_units() {
5788 let s = parse_human_duration("30d").unwrap();
5789 assert_eq!(s.get_days(), 30);
5790 let s = parse_human_duration("2w").unwrap();
5791 assert_eq!(s.get_weeks(), 2);
5792 let s = parse_human_duration("12h").unwrap();
5793 assert_eq!(s.get_hours(), 12);
5794 let s = parse_human_duration("5m").unwrap();
5796 assert_eq!(s.get_minutes(), 5);
5797 let s = parse_human_duration("6mo").unwrap();
5798 assert_eq!(s.get_months(), 6);
5799 let s = parse_human_duration("1y").unwrap();
5800 assert_eq!(s.get_years(), 1);
5801 }
5802
5803 #[test]
5804 fn parse_human_duration_case_insensitive_and_whitespace() {
5805 let s = parse_human_duration(" 90D ").unwrap();
5806 assert_eq!(s.get_days(), 90);
5807 let s = parse_human_duration("3WEEKS").unwrap();
5808 assert_eq!(s.get_weeks(), 3);
5809 }
5810
5811 #[test]
5812 fn parse_human_duration_rejects_garbage() {
5813 assert!(parse_human_duration("").is_err());
5814 assert!(parse_human_duration("d30").is_err());
5815 assert!(parse_human_duration("30").is_err()); assert!(parse_human_duration("30x").is_err()); assert!(parse_human_duration("-1d").is_err()); }
5819
5820 #[test]
5824 fn walk_gc_backups_collects_files_and_dir_snapshots() {
5825 let tmp = TempDir::new().unwrap();
5826 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
5827 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
5828 std::fs::write(
5830 root.join("C/Users/u/.config/foo_20260429_143022123.yml"),
5831 "old yml",
5832 )
5833 .unwrap();
5834 std::fs::create_dir_all(root.join("C/Users/u/nvim_20260101_000000000/lua")).unwrap();
5836 std::fs::write(
5837 root.join("C/Users/u/nvim_20260101_000000000/init.lua"),
5838 "ok",
5839 )
5840 .unwrap();
5841 std::fs::write(
5842 root.join("C/Users/u/nvim_20260101_000000000/lua/x.lua"),
5843 "kk",
5844 )
5845 .unwrap();
5846 std::fs::write(root.join("C/Users/u/.config/README.md"), "user note").unwrap();
5848
5849 let entries = walk_gc_backups(&root).unwrap();
5850 assert_eq!(entries.len(), 2, "two backup roots, not three");
5851 let kinds: Vec<_> = entries.iter().map(|e| e.kind).collect();
5852 assert!(kinds.contains(&BackupKind::File));
5853 assert!(kinds.contains(&BackupKind::Dir));
5854 let dir_entry = entries.iter().find(|e| e.kind == BackupKind::Dir).unwrap();
5856 assert!(dir_entry.size_bytes >= 4); }
5858
5859 #[test]
5860 fn cleanup_empty_parents_stops_at_root_and_at_non_empty() {
5861 let tmp = TempDir::new().unwrap();
5862 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
5863 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
5864 std::fs::write(root.join("C/Users/u/sibling_keep"), "x").unwrap();
5865
5866 cleanup_empty_parents(&root.join("C/Users/u/.config"), &root);
5870
5871 assert!(!root.join("C/Users/u/.config").exists(), "empty leaf gone");
5872 assert!(root.join("C/Users/u").exists(), "stops at non-empty parent");
5873 assert!(root.exists(), "backup root preserved");
5874 }
5875
5876 #[test]
5878 fn gc_backup_survey_keeps_all_entries() {
5879 let tmp = TempDir::new().unwrap();
5880 let source = utf8(tmp.path().join("dotfiles"));
5881 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
5882 std::fs::write(source.join("config.toml"), "").unwrap();
5883 let backup = source.join(".yui/backup");
5884 std::fs::write(backup.join("a_20260101_000000000.txt"), "old").unwrap();
5885 std::fs::write(backup.join("b_20260415_120000000.txt"), "fresh").unwrap();
5886
5887 gc_backup(Some(source.clone()), None, false, None, true).unwrap();
5888
5889 assert!(backup.join("a_20260101_000000000.txt").exists());
5891 assert!(backup.join("b_20260415_120000000.txt").exists());
5892 }
5893
5894 #[test]
5897 fn gc_backup_prune_removes_old_files_only() {
5898 let tmp = TempDir::new().unwrap();
5899 let source = utf8(tmp.path().join("dotfiles"));
5900 std::fs::create_dir_all(source.join(".yui/backup/sub")).unwrap();
5901 std::fs::write(source.join("config.toml"), "").unwrap();
5902 let backup = source.join(".yui/backup");
5903
5904 std::fs::write(backup.join("sub/old_20200101_000000000.txt"), "old").unwrap();
5906 let tomorrow = jiff::Zoned::now()
5908 .checked_add(jiff::Span::new().days(1))
5909 .unwrap();
5910 let bdt = jiff::fmt::strtime::BrokenDownTime::from(&tomorrow);
5911 let future_ts = bdt.to_string("%Y%m%d_%H%M%S%3f").unwrap();
5912 std::fs::write(backup.join(format!("fresh_{future_ts}.txt")), "fresh").unwrap();
5913 std::fs::write(backup.join("notes.md"), "mine").unwrap();
5915
5916 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
5917
5918 assert!(!backup.join("sub/old_20200101_000000000.txt").exists());
5919 assert!(!backup.join("sub").exists(), "empty parent removed");
5921 assert!(backup.exists());
5923 assert!(backup.join(format!("fresh_{future_ts}.txt")).exists());
5924 assert!(backup.join("notes.md").exists(), "user file untouched");
5925 }
5926
5927 #[test]
5929 fn gc_backup_dry_run_does_not_delete() {
5930 let tmp = TempDir::new().unwrap();
5931 let source = utf8(tmp.path().join("dotfiles"));
5932 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
5933 std::fs::write(source.join("config.toml"), "").unwrap();
5934 let backup = source.join(".yui/backup");
5935 std::fs::write(backup.join("old_20200101_000000000.txt"), "old").unwrap();
5936
5937 gc_backup(Some(source.clone()), Some("30d".into()), true, None, true).unwrap();
5938
5939 assert!(
5940 backup.join("old_20200101_000000000.txt").exists(),
5941 "dry-run keeps everything in place"
5942 );
5943 }
5944
5945 #[test]
5949 fn gc_backup_prune_handles_directory_snapshot() {
5950 let tmp = TempDir::new().unwrap();
5951 let source = utf8(tmp.path().join("dotfiles"));
5952 std::fs::create_dir_all(source.join(".yui/backup/mirror/u")).unwrap();
5953 std::fs::write(source.join("config.toml"), "").unwrap();
5954 let backup = source.join(".yui/backup");
5955 let snap = backup.join("mirror/u/nvim_20200101_000000000");
5956 std::fs::create_dir_all(snap.join("lua")).unwrap();
5957 std::fs::write(snap.join("init.lua"), "x").unwrap();
5958 std::fs::write(snap.join("lua/y.lua"), "y").unwrap();
5959
5960 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
5961
5962 assert!(!snap.exists(), "dir snapshot removed wholesale");
5963 assert!(!backup.join("mirror").exists(), "empty mirror chain pruned");
5964 assert!(backup.exists(), "backup root preserved");
5965 }
5966}