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<()> {
697 let source = resolve_source(source)?;
698 let yui = YuiVars::detect(&source);
699 let config = config::load(&source, &yui)?;
700
701 let identity_path = paths::expand_tilde(&config.secrets.identity);
703 if identity_path.exists() {
704 anyhow::bail!(
705 "identity file already exists at {identity_path}; \
706 refusing to overwrite. Delete it first if you really \
707 mean to start fresh (you'll lose access to existing \
708 .age files encrypted to its public key)."
709 );
710 }
711
712 let (secret, public) = secret::generate_x25519_keypair();
716 let now = jiff::Zoned::now().to_string();
717 let body = format!(
718 "# created: {now}\n\
719 # public key: {public}\n\
720 {secret}\n"
721 );
722 secret::write_private_file(&identity_path, body.as_bytes())?;
725 info!("wrote identity file: {identity_path}");
726
727 let local_path = source.join("config.local.toml");
731 let comment = comment.unwrap_or_else(|| format!("{} {}", yui.host, yui.user));
732 let entry_comment = format!("{comment} — added by `yui secret init` on {now}");
733 let local_existing = match std::fs::read_to_string(&local_path) {
734 Ok(s) => s,
735 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
736 Err(e) => anyhow::bail!("read {local_path}: {e}"),
737 };
738 let updated_local = append_recipient_to_local(&local_existing, &entry_comment, &public)?;
739 std::fs::write(&local_path, updated_local)?;
740 info!("appended public key to {local_path}");
741 println!();
742 println!(" age identity: {identity_path}");
743 println!(" public key: {public}");
744 println!();
745 println!(
746 " Next: encrypt a file with `yui secret encrypt <path>`. \
747 The plaintext sibling will be auto-decrypted on every `yui apply`."
748 );
749 Ok(())
750}
751
752fn append_recipient_to_local(existing: &str, comment: &str, public: &str) -> Result<String> {
766 use toml_edit::{Array, DocumentMut, Item, Table, Value};
767
768 let mut doc: DocumentMut = if existing.trim().is_empty() {
769 DocumentMut::new()
770 } else {
771 existing
772 .parse()
773 .map_err(|e| anyhow::anyhow!("config.local.toml is not valid TOML: {e}"))?
774 };
775
776 if !doc.contains_key("secrets") {
778 let mut t = Table::new();
779 t.set_implicit(false);
780 doc.insert("secrets", Item::Table(t));
781 }
782 let secrets = doc["secrets"].as_table_mut().ok_or_else(|| {
783 anyhow::anyhow!("[secrets] in config.local.toml is not a table — refusing to clobber")
784 })?;
785
786 if !secrets.contains_key("recipients") {
788 secrets.insert("recipients", Item::Value(Value::Array(Array::new())));
789 }
790 let recipients = secrets["recipients"]
791 .as_array_mut()
792 .ok_or_else(|| anyhow::anyhow!("[secrets].recipients is not an array"))?;
793
794 let already_present = recipients.iter().any(|v| v.as_str() == Some(public));
796 if already_present {
797 return Ok(doc.to_string());
798 }
799
800 let mut value = Value::from(public);
804 let prefix = format!("\n # {comment}\n ");
805 *value.decor_mut() = toml_edit::Decor::new(prefix, "");
806 recipients.push_formatted(value);
807 recipients.set_trailing("\n");
811 recipients.set_trailing_comma(true);
812
813 Ok(doc.to_string())
814}
815
816pub fn secret_encrypt(
820 source: Option<Utf8PathBuf>,
821 path: Utf8PathBuf,
822 force: bool,
823 rm_plaintext: bool,
824) -> Result<()> {
825 let source = resolve_source(source)?;
826 let yui = YuiVars::detect(&source);
827 let config = config::load(&source, &yui)?;
828
829 if !config.secrets.enabled() {
830 anyhow::bail!(
831 "no recipients configured — run `yui secret init` to generate \
832 a keypair, or add at least one entry to `[secrets] recipients`."
833 );
834 }
835
836 let plaintext_path = if path.is_absolute() {
840 path.clone()
841 } else {
842 absolutize(&path)?
843 };
844 if !plaintext_path.is_file() {
845 anyhow::bail!("plaintext file not found: {plaintext_path}");
846 }
847 let cipher_path = Utf8PathBuf::from(format!("{plaintext_path}.age"));
848 if cipher_path.exists() && !force {
849 anyhow::bail!("{cipher_path} already exists; pass --force to overwrite");
850 }
851
852 let plaintext = std::fs::read(&plaintext_path)?;
853 let recipients = secret::parse_passkey_recipients(&config.secrets.recipients)?;
861 let cipher = secret::encrypt_to_passkeys(&plaintext, &recipients)?;
862 std::fs::write(&cipher_path, &cipher)?;
863 info!("encrypted {plaintext_path} → {cipher_path}");
864
865 if rm_plaintext {
866 if plaintext_path.starts_with(&source) {
869 std::fs::remove_file(&plaintext_path)?;
870 info!("removed plaintext: {plaintext_path}");
871 } else {
872 warn!(
873 "plaintext lives outside source ({plaintext_path}); \
874 skipping --rm-plaintext as a safety check"
875 );
876 }
877 }
878 Ok(())
879}
880
881pub fn secret_store(source: Option<Utf8PathBuf>, force: bool) -> Result<()> {
891 let source = resolve_source(source)?;
892 let yui = YuiVars::detect(&source);
893 let config = config::load(&source, &yui)?;
894
895 let vault_cfg = config.secrets.vault.as_ref().ok_or_else(|| {
896 anyhow::anyhow!(
897 "[secrets.vault] is not configured — set provider \
898 (\"bitwarden\" or \"1password\") and item before \
899 calling store"
900 )
901 })?;
902
903 let identity_path = paths::expand_tilde(&config.secrets.identity);
904 if !identity_path.is_file() {
905 anyhow::bail!(
906 "no X25519 identity at {identity_path}; run `yui secret init` first \
907 (store needs that file's content to push to the vault)"
908 );
909 }
910 let plaintext = std::fs::read(&identity_path)?;
911 secret::validate_x25519_identity_bytes(&plaintext)?;
916
917 let vault = vault::driver(vault_cfg);
918 vault.precheck()?;
923 info!(
924 "pushing X25519 identity to {} item {:?}",
925 vault.provider_name(),
926 vault_cfg.item
927 );
928 vault.store(&vault_cfg.item, &plaintext, force)?;
929
930 println!();
931 println!(
932 " X25519 identity pushed to {} item {:?}",
933 vault.provider_name(),
934 vault_cfg.item
935 );
936 println!(" On a new machine, run `yui secret unlock`.");
937 Ok(())
938}
939
940pub fn secret_unlock(source: Option<Utf8PathBuf>) -> Result<()> {
946 let source = resolve_source(source)?;
947 let yui = YuiVars::detect(&source);
948 let config = config::load(&source, &yui)?;
949
950 let vault_cfg = config.secrets.vault.as_ref().ok_or_else(|| {
951 anyhow::anyhow!(
952 "[secrets.vault] is not configured — nothing to unlock. \
953 Run `yui secret init` + `yui secret store` on an existing \
954 machine first, then commit + push the config."
955 )
956 })?;
957 let identity_path = paths::expand_tilde(&config.secrets.identity);
958 if identity_path.exists() {
959 anyhow::bail!(
960 "{identity_path} already exists — refusing to clobber a live \
961 X25519 identity. Delete it first if you really mean to \
962 re-unlock from scratch."
963 );
964 }
965
966 let vault = vault::driver(vault_cfg);
967 vault.precheck()?;
968 info!(
969 "fetching X25519 identity from {} item {:?}",
970 vault.provider_name(),
971 vault_cfg.item
972 );
973 let plaintext = vault.fetch(&vault_cfg.item)?;
974
975 secret::validate_x25519_identity_bytes(&plaintext)?;
981
982 secret::write_private_file(&identity_path, &plaintext)?;
984 info!("wrote X25519 identity: {identity_path}");
985 println!();
986 println!(" X25519 identity restored at {identity_path}");
987 println!(" Run `yui apply` next.");
988 Ok(())
989}
990
991pub fn update(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
1002 let source = resolve_source(source)?;
1003 if !crate::git::is_clean(&source)? {
1004 anyhow::bail!(
1005 "source repo {source} has uncommitted changes — \
1006 commit or stash before `yui update` (or run \
1007 `git pull` + `yui apply` manually if you know what \
1008 you're doing)"
1009 );
1010 }
1011 info!("git pull --ff-only at {source}");
1012 let status = std::process::Command::new("git")
1013 .arg("-C")
1014 .arg(source.as_str())
1015 .arg("pull")
1016 .arg("--ff-only")
1017 .status()
1018 .map_err(|e| anyhow::anyhow!("invoking git: {e}"))?;
1019 if !status.success() {
1020 anyhow::bail!("git pull --ff-only failed at {source}");
1021 }
1022 apply(Some(source), dry_run)
1023}
1024
1025pub fn unmanaged(
1036 source: Option<Utf8PathBuf>,
1037 icons_override: Option<IconsMode>,
1038 no_color: bool,
1039) -> Result<()> {
1040 let source = resolve_source(source)?;
1041 let yui = YuiVars::detect(&source);
1042 let config = config::load(&source, &yui)?;
1043
1044 let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
1045 let color = !no_color && supports_color_stdout();
1046
1047 let mut engine = template::Engine::new();
1062 let tera_ctx = template::template_context(&yui, &config.vars);
1063 let mount_srcs: Vec<Utf8PathBuf> = config
1064 .mount
1065 .entry
1066 .iter()
1067 .map(|e| -> Result<Utf8PathBuf> {
1068 let rendered = engine.render(e.src.as_str(), &tera_ctx)?;
1069 Ok(paths::resolve_mount_src(&source, rendered.trim()))
1070 })
1071 .collect::<Result<_>>()?;
1072
1073 let mut items: Vec<Utf8PathBuf> = Vec::new();
1074 let walker = paths::source_walker(&source).build();
1075 for entry in walker {
1076 let entry = match entry {
1077 Ok(e) => e,
1078 Err(_) => continue,
1079 };
1080 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
1081 continue;
1082 }
1083 let std_path = entry.path();
1084 let path = match Utf8PathBuf::from_path_buf(std_path.to_path_buf()) {
1085 Ok(p) => p,
1086 Err(_) => continue,
1087 };
1088 if is_repo_meta(&path, &source, &config.mount.marker_filename) {
1092 continue;
1093 }
1094 if mount_srcs.iter().any(|m| path.starts_with(m)) {
1095 continue;
1096 }
1097 items.push(path);
1098 }
1099 items.sort();
1100
1101 if items.is_empty() {
1102 println!(" no unmanaged files under {source}");
1103 return Ok(());
1104 }
1105
1106 print_unmanaged_table(&items, &source, color);
1107 println!();
1108 println!(" {} unmanaged file(s)", items.len());
1109 Ok(())
1110}
1111
1112fn is_repo_meta(path: &Utf8Path, source: &Utf8Path, marker_filename: &str) -> bool {
1128 let Some(name) = path.file_name() else {
1129 return false;
1130 };
1131 if name.ends_with(".tera") {
1132 return true;
1133 }
1134 if name == marker_filename || name == ".yuiignore" {
1135 return true;
1136 }
1137 let parent = path.parent().unwrap_or(Utf8Path::new(""));
1138 let at_root = parent == source;
1139 if at_root && name == ".gitignore" {
1140 return true;
1141 }
1142 if at_root && (name == "config.toml" || name == "config.local.toml") {
1143 return true;
1144 }
1145 if at_root
1146 && name.starts_with("config.")
1147 && (name.ends_with(".toml") || name.ends_with(".example.toml"))
1148 {
1149 return true;
1150 }
1151 false
1152}
1153
1154fn print_unmanaged_table(items: &[Utf8PathBuf], source: &Utf8Path, color: bool) {
1155 use owo_colors::OwoColorize as _;
1156 if color {
1157 println!(" {}", "PATH (relative to source)".dimmed());
1158 } else {
1159 println!(" PATH (relative to source)");
1160 }
1161 for p in items {
1162 let rel = p
1163 .strip_prefix(source)
1164 .map(Utf8PathBuf::from)
1165 .unwrap_or_else(|_| p.clone());
1166 if color {
1167 println!(" {}", rel.cyan());
1168 } else {
1169 println!(" {rel}");
1170 }
1171 }
1172}
1173
1174pub fn diff(
1182 source: Option<Utf8PathBuf>,
1183 icons_override: Option<IconsMode>,
1184 no_color: bool,
1185) -> Result<()> {
1186 let source = resolve_source(source)?;
1187 let yui = YuiVars::detect(&source);
1188 let config = config::load(&source, &yui)?;
1189 let mut engine = template::Engine::new();
1190 let tera_ctx = template::template_context(&yui, &config.vars);
1191 let mounts = mount::resolve(
1192 &source,
1193 &config.mount.entry,
1194 config.mount.default_strategy,
1195 &mut engine,
1196 &tera_ctx,
1197 )?;
1198
1199 let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
1200 let color = !no_color && supports_color_stdout();
1201
1202 let mut report: Vec<StatusItem> = Vec::new();
1204 let mut yuiignore = paths::YuiIgnoreStack::new();
1205 yuiignore.push_dir(&source)?;
1206 let walk_result = (|| -> Result<()> {
1207 for m in &mounts {
1208 let src_root = m.src.clone();
1209 if !src_root.is_dir() {
1210 continue;
1211 }
1212 classify_walk(
1213 &src_root,
1214 &m.dst,
1215 &config,
1216 m.strategy,
1217 &mut engine,
1218 &tera_ctx,
1219 &source,
1220 &mut yuiignore,
1221 &mut report,
1222 )?;
1223 }
1224 Ok(())
1225 })();
1226 yuiignore.pop_dir(&source);
1227 walk_result?;
1228
1229 let render_report = render::render_all(&source, &config, &yui, true)?;
1231 for rendered in &render_report.diverged {
1232 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
1233 report.push(StatusItem {
1234 src: tera_path,
1235 dst: rendered.clone(),
1236 state: StatusState::RenderDrift,
1237 });
1238 }
1239
1240 let mut printed = 0usize;
1241 for item in &report {
1242 if !diff_worth_printing(&item.state) {
1243 continue;
1244 }
1245 let src_abs = resolve_diff_src(item, &source);
1246 print_unified_diff(
1247 &src_abs,
1248 &item.dst,
1249 &item.state,
1250 &source,
1251 &config,
1252 &yui,
1253 color,
1254 );
1255 printed += 1;
1256 }
1257
1258 if printed == 0 {
1259 println!(" no diff — every entry is in sync (or only needs a relink)");
1260 } else {
1261 println!();
1262 println!(
1263 " {printed} entr{} with content drift",
1264 if printed == 1 { "y" } else { "ies" }
1265 );
1266 }
1267 Ok(())
1268}
1269
1270fn resolve_diff_src(item: &StatusItem, source: &Utf8Path) -> Utf8PathBuf {
1282 match item.state {
1283 StatusState::RenderDrift => item.src.clone(),
1284 StatusState::Link(_) => source.join(&item.src),
1285 }
1286}
1287
1288fn diff_worth_printing(state: &StatusState) -> bool {
1289 use absorb::AbsorbDecision::*;
1290 match state {
1291 StatusState::Link(InSync) => false,
1292 StatusState::Link(Restore) => false, StatusState::Link(RelinkOnly) => false, StatusState::Link(_) => true,
1295 StatusState::RenderDrift => true,
1296 }
1297}
1298
1299fn print_unified_diff(
1307 src: &Utf8Path,
1308 dst: &Utf8Path,
1309 state: &StatusState,
1310 source_root: &Utf8Path,
1311 config: &Config,
1312 yui: &YuiVars,
1313 color: bool,
1314) {
1315 use owo_colors::OwoColorize as _;
1316
1317 let header = match state {
1318 StatusState::RenderDrift => format!("--- render drift: {src} (template) vs {dst}"),
1319 _ => format!("--- {src} → {dst}"),
1320 };
1321 if color {
1322 println!("{}", header.bold());
1323 } else {
1324 println!("{header}");
1325 }
1326
1327 if src.is_dir() || dst.is_dir() {
1328 println!("(directory entry — content listing skipped)");
1329 println!();
1330 return;
1331 }
1332
1333 let src_content = match state {
1338 StatusState::RenderDrift => match render::render_to_string(src, source_root, config, yui) {
1339 Ok(Some(s)) => s,
1340 Ok(None) => {
1341 println!(
1342 "(template would be skipped on this host — drift will resolve on next render)"
1343 );
1344 println!();
1345 return;
1346 }
1347 Err(e) => {
1348 println!("(error rendering template: {e})");
1349 println!();
1350 return;
1351 }
1352 },
1353 _ => match read_text_for_diff(src) {
1354 DiffSide::Text(s) => s,
1355 DiffSide::Binary => {
1356 println!("(binary file or non-UTF-8 content — diff skipped)");
1357 println!();
1358 return;
1359 }
1360 },
1361 };
1362 let dst_content = match read_text_for_diff(dst) {
1363 DiffSide::Text(s) => s,
1364 DiffSide::Binary => {
1365 println!("(binary file or non-UTF-8 content — diff skipped)");
1366 println!();
1367 return;
1368 }
1369 };
1370 print_unified_text_diff(
1371 &src_content,
1372 &dst_content,
1373 src.as_str(),
1374 dst.as_str(),
1375 color,
1376 );
1377 println!();
1378}
1379
1380fn print_unified_text_diff(src: &str, dst: &str, src_label: &str, dst_label: &str, color: bool) {
1389 use owo_colors::OwoColorize as _;
1390 let diff = similar::TextDiff::from_lines(src, dst);
1391 let formatted = diff.unified_diff().header(src_label, dst_label).to_string();
1392 for line in formatted.lines() {
1393 if !color {
1394 println!("{line}");
1395 } else if line.starts_with("+++") || line.starts_with("---") {
1396 println!("{}", line.dimmed());
1397 } else if line.starts_with("@@") {
1398 println!("{}", line.cyan());
1399 } else if line.starts_with('+') {
1400 println!("{}", line.green());
1401 } else if line.starts_with('-') {
1402 println!("{}", line.red());
1403 } else {
1404 println!("{line}");
1405 }
1406 }
1407}
1408
1409enum DiffSide {
1415 Text(String),
1416 Binary,
1417}
1418
1419fn read_text_for_diff(p: &Utf8Path) -> DiffSide {
1420 match std::fs::read_to_string(p) {
1421 Ok(s) => DiffSide::Text(s),
1422 Err(e) if e.kind() == std::io::ErrorKind::InvalidData => DiffSide::Binary,
1423 Err(_) => DiffSide::Text(String::new()),
1424 }
1425}
1426
1427pub fn status(
1440 source: Option<Utf8PathBuf>,
1441 icons_override: Option<IconsMode>,
1442 no_color: bool,
1443) -> Result<()> {
1444 let source = resolve_source(source)?;
1445 let yui = YuiVars::detect(&source);
1446 let config = config::load(&source, &yui)?;
1447
1448 let mut engine = template::Engine::new();
1449 let tera_ctx = template::template_context(&yui, &config.vars);
1450 let mounts = mount::resolve(
1451 &source,
1452 &config.mount.entry,
1453 config.mount.default_strategy,
1454 &mut engine,
1455 &tera_ctx,
1456 )?;
1457
1458 let icons_mode = icons_override.unwrap_or(config.ui.icons);
1459 let icons = Icons::for_mode(icons_mode);
1460 let color = !no_color && supports_color_stdout();
1461
1462 let mut report: Vec<StatusItem> = Vec::new();
1463
1464 let render_report = render::render_all(&source, &config, &yui, true)?;
1467 for rendered in &render_report.diverged {
1468 let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
1472 report.push(StatusItem {
1473 src: relative_for_display(&source, &tera_path),
1474 dst: rendered.clone(),
1475 state: StatusState::RenderDrift,
1476 });
1477 }
1478
1479 let mut yuiignore = paths::YuiIgnoreStack::new();
1483 yuiignore.push_dir(&source)?;
1484 let walk_result = (|| -> Result<()> {
1485 for m in &mounts {
1486 let src_root = m.src.clone();
1487 if !src_root.is_dir() {
1488 warn!("mount src missing: {src_root}");
1489 continue;
1490 }
1491 classify_walk(
1492 &src_root,
1493 &m.dst,
1494 &config,
1495 m.strategy,
1496 &mut engine,
1497 &tera_ctx,
1498 &source,
1499 &mut yuiignore,
1500 &mut report,
1501 )?;
1502 }
1503 Ok(())
1504 })();
1505 yuiignore.pop_dir(&source);
1506 walk_result?;
1507
1508 report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
1509
1510 print_status_table(&report, icons, color);
1511
1512 let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
1513
1514 println!();
1515 let total = report.len();
1516 let in_sync = total - drift;
1517 if drift == 0 {
1518 println!(" {total} entries · all in sync");
1519 Ok(())
1520 } else {
1521 println!(" {total} entries · {in_sync} in sync · {drift} diverged");
1522 anyhow::bail!("status: {drift} entries diverged from source")
1523 }
1524}
1525
1526#[derive(Debug)]
1527struct StatusItem {
1528 src: Utf8PathBuf,
1530 dst: Utf8PathBuf,
1532 state: StatusState,
1533}
1534
1535#[derive(Debug, Clone, Copy)]
1536enum StatusState {
1537 Link(absorb::AbsorbDecision),
1538 RenderDrift,
1541}
1542
1543impl StatusState {
1544 fn is_in_sync(self) -> bool {
1545 matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
1546 }
1547}
1548
1549#[allow(clippy::too_many_arguments)]
1550fn classify_walk(
1551 src_dir: &Utf8Path,
1552 dst_dir: &Utf8Path,
1553 config: &Config,
1554 strategy: MountStrategy,
1555 engine: &mut template::Engine,
1556 tera_ctx: &TeraContext,
1557 source_root: &Utf8Path,
1558 yuiignore: &mut paths::YuiIgnoreStack,
1559 report: &mut Vec<StatusItem>,
1560) -> Result<()> {
1561 classify_walk_inner(
1562 src_dir,
1563 dst_dir,
1564 config,
1565 strategy,
1566 engine,
1567 tera_ctx,
1568 source_root,
1569 yuiignore,
1570 report,
1571 false,
1572 )
1573}
1574
1575#[allow(clippy::too_many_arguments)]
1576fn classify_walk_inner(
1577 src_dir: &Utf8Path,
1578 dst_dir: &Utf8Path,
1579 config: &Config,
1580 strategy: MountStrategy,
1581 engine: &mut template::Engine,
1582 tera_ctx: &TeraContext,
1583 source_root: &Utf8Path,
1584 yuiignore: &mut paths::YuiIgnoreStack,
1585 report: &mut Vec<StatusItem>,
1586 parent_covered: bool,
1587) -> Result<()> {
1588 if yuiignore.is_ignored(src_dir, true) {
1589 return Ok(());
1590 }
1591 yuiignore.push_dir(src_dir)?;
1594 let result = classify_walk_inner_body(
1595 src_dir,
1596 dst_dir,
1597 config,
1598 strategy,
1599 engine,
1600 tera_ctx,
1601 source_root,
1602 yuiignore,
1603 report,
1604 parent_covered,
1605 );
1606 yuiignore.pop_dir(src_dir);
1607 result
1608}
1609
1610#[allow(clippy::too_many_arguments)]
1611fn classify_walk_inner_body(
1612 src_dir: &Utf8Path,
1613 dst_dir: &Utf8Path,
1614 config: &Config,
1615 strategy: MountStrategy,
1616 engine: &mut template::Engine,
1617 tera_ctx: &TeraContext,
1618 source_root: &Utf8Path,
1619 yuiignore: &mut paths::YuiIgnoreStack,
1620 report: &mut Vec<StatusItem>,
1621 parent_covered: bool,
1622) -> Result<()> {
1623 let marker_filename = &config.mount.marker_filename;
1624 let mut covered = parent_covered;
1625
1626 if strategy == MountStrategy::Marker {
1627 match marker::read_spec(src_dir, marker_filename)? {
1628 None => {}
1629 Some(MarkerSpec::PassThrough) => {
1630 let decision = absorb::classify(src_dir, dst_dir)?;
1631 report.push(StatusItem {
1632 src: relative_for_display(source_root, src_dir),
1633 dst: dst_dir.to_path_buf(),
1634 state: StatusState::Link(decision),
1635 });
1636 covered = true;
1637 }
1638 Some(MarkerSpec::Explicit { links }) => {
1639 let mut emitted_dir_link = false;
1640 for link in &links {
1641 if let Some(when) = &link.when {
1642 if !template::eval_truthy(when, engine, tera_ctx)? {
1643 continue;
1644 }
1645 }
1646 let dst_str = engine.render(&link.dst, tera_ctx)?;
1647 let dst = paths::expand_tilde(dst_str.trim());
1648 if let Some(filename) = &link.src {
1649 let file_src = src_dir.join(filename);
1650 if !file_src.is_file() {
1651 anyhow::bail!(
1652 "marker at {src_dir}: [[link]] src={filename:?} \
1653 not found"
1654 );
1655 }
1656 let decision = absorb::classify(&file_src, &dst)?;
1657 report.push(StatusItem {
1658 src: relative_for_display(source_root, &file_src),
1659 dst,
1660 state: StatusState::Link(decision),
1661 });
1662 } else {
1663 let decision = absorb::classify(src_dir, &dst)?;
1664 report.push(StatusItem {
1665 src: relative_for_display(source_root, src_dir),
1666 dst,
1667 state: StatusState::Link(decision),
1668 });
1669 emitted_dir_link = true;
1670 }
1671 }
1672 if emitted_dir_link {
1673 covered = true;
1674 }
1675 }
1676 }
1677 }
1678
1679 for entry in std::fs::read_dir(src_dir)? {
1680 let entry = entry?;
1681 let name_os = entry.file_name();
1682 let Some(name) = name_os.to_str() else {
1683 continue;
1684 };
1685 if name == marker_filename || name.ends_with(".tera") {
1686 continue;
1687 }
1688 let src_path = src_dir.join(name);
1689 let dst_path = dst_dir.join(name);
1690 let ft = entry.file_type()?;
1691 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
1692 continue;
1693 }
1694 if ft.is_dir() {
1695 classify_walk_inner(
1696 &src_path,
1697 &dst_path,
1698 config,
1699 strategy,
1700 engine,
1701 tera_ctx,
1702 source_root,
1703 yuiignore,
1704 report,
1705 covered,
1706 )?;
1707 } else if ft.is_file() && !covered {
1708 let decision = absorb::classify(&src_path, &dst_path)?;
1709 report.push(StatusItem {
1710 src: relative_for_display(source_root, &src_path),
1711 dst: dst_path,
1712 state: StatusState::Link(decision),
1713 });
1714 }
1715 }
1716 Ok(())
1717}
1718
1719fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
1720 p.strip_prefix(source_root)
1721 .map(Utf8PathBuf::from)
1722 .unwrap_or_else(|_| p.to_path_buf())
1723}
1724
1725fn print_status_table(items: &[StatusItem], icons: Icons, color: bool) {
1726 let src_w = items
1727 .iter()
1728 .map(|i| i.src.as_str().chars().count())
1729 .max()
1730 .unwrap_or(0)
1731 .max("SRC".len());
1732 let dst_w = items
1733 .iter()
1734 .map(|i| i.dst.as_str().chars().count())
1735 .max()
1736 .unwrap_or(0)
1737 .max("DST".len());
1738 let state_label_w = items
1740 .iter()
1741 .map(|i| state_label(i.state).len())
1742 .max()
1743 .unwrap_or(0)
1744 .max("STATE".len() - 2); let state_w = state_label_w + 2; print_status_header(state_w, src_w, dst_w, color);
1748 let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
1749 if color {
1750 use owo_colors::OwoColorize as _;
1751 println!("{}", sep.dimmed());
1752 } else {
1753 println!("{sep}");
1754 }
1755 for item in items {
1756 print_status_row(item, icons, state_w, src_w, dst_w, color);
1757 }
1758}
1759
1760fn state_label(s: StatusState) -> &'static str {
1761 use absorb::AbsorbDecision::*;
1762 match s {
1763 StatusState::Link(InSync) => "in-sync",
1764 StatusState::Link(RelinkOnly) => "relink",
1765 StatusState::Link(AutoAbsorb) => "drift (auto)",
1766 StatusState::Link(NeedsConfirm) => "drift (anomaly)",
1767 StatusState::Link(Restore) => "missing",
1768 StatusState::RenderDrift => "render drift",
1769 }
1770}
1771
1772fn state_icon(s: StatusState, icons: Icons) -> &'static str {
1773 use absorb::AbsorbDecision::*;
1774 match s {
1775 StatusState::Link(InSync) => icons.ok,
1776 StatusState::Link(RelinkOnly) => icons.warn,
1777 StatusState::Link(AutoAbsorb) => icons.warn,
1778 StatusState::Link(NeedsConfirm) => icons.error,
1779 StatusState::Link(Restore) => icons.info,
1780 StatusState::RenderDrift => icons.error,
1781 }
1782}
1783
1784fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
1785 use owo_colors::OwoColorize as _;
1786 let line = format!(
1789 " {:<state_w$} {:<src_w$} {:<dst_w$}",
1790 "STATE", "SRC", "DST"
1791 );
1792 if color {
1793 println!("{}", line.bold());
1794 } else {
1795 println!("{line}");
1796 }
1797}
1798
1799fn render_status_separator(
1800 sep_ch: char,
1801 state_w: usize,
1802 src_w: usize,
1803 dst_w: usize,
1804 arrow: &str,
1805) -> String {
1806 let bar = |n: usize| sep_ch.to_string().repeat(n);
1807 format!(
1808 " {} {} {} {}",
1809 bar(state_w),
1810 bar(src_w),
1811 bar(arrow.chars().count()),
1812 bar(dst_w)
1813 )
1814}
1815
1816fn print_status_row(
1817 item: &StatusItem,
1818 icons: Icons,
1819 state_w: usize,
1820 src_w: usize,
1821 dst_w: usize,
1822 color: bool,
1823) {
1824 use owo_colors::OwoColorize as _;
1825 let icon = state_icon(item.state, icons);
1826 let label = state_label(item.state);
1827 let state_text = format!("{icon} {label}");
1828 let src_display = item.src.as_str().replace('\\', "/");
1829 let dst_display = item.dst.as_str().replace('\\', "/");
1830 let arrow = icons.arrow;
1831
1832 let cell_state = format!("{:<state_w$}", state_text);
1833 let cell_src = format!("{:<src_w$}", src_display);
1834 let cell_dst = format!("{:<dst_w$}", dst_display);
1835
1836 if !color {
1837 println!(" {cell_state} {cell_src} {arrow} {cell_dst}");
1838 return;
1839 }
1840
1841 use absorb::AbsorbDecision::*;
1842 let state_colored = match item.state {
1843 StatusState::Link(InSync) => cell_state.green().to_string(),
1844 StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
1845 cell_state.yellow().to_string()
1846 }
1847 StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
1848 StatusState::Link(Restore) => cell_state.cyan().to_string(),
1849 StatusState::RenderDrift => cell_state.red().to_string(),
1850 };
1851 let src_colored = cell_src.cyan().to_string();
1852 let arrow_colored = arrow.dimmed().to_string();
1853 let dst_colored = cell_dst.dimmed().to_string();
1854 println!(" {state_colored} {src_colored} {arrow_colored} {dst_colored}");
1855}
1856
1857pub fn absorb(
1871 source: Option<Utf8PathBuf>,
1872 target: Utf8PathBuf,
1873 dry_run: bool,
1874 yes: bool,
1875) -> Result<()> {
1876 let source = resolve_source(source)?;
1877 let target = absolutize(&target)?;
1878 let yui = YuiVars::detect(&source);
1879 let config = config::load(&source, &yui)?;
1880
1881 let mut engine = template::Engine::new();
1882 let tera_ctx = template::template_context(&yui, &config.vars);
1883
1884 let src_path = match find_source_for_target(&source, &config, &target, &mut engine, &tera_ctx)?
1885 {
1886 Some(s) => s,
1887 None => anyhow::bail!(
1888 "no mount entry / .yuilink override claims target {target}; \
1889 pass a path inside a known dst"
1890 ),
1891 };
1892
1893 info!("source for {target}: {src_path}");
1894
1895 print_absorb_diff(&src_path, &target);
1900
1901 if dry_run {
1902 info!("[dry-run] would absorb {target} → {src_path}");
1903 return Ok(());
1904 }
1905
1906 if !yes {
1907 use std::io::IsTerminal;
1908 if !std::io::stdin().is_terminal() {
1909 anyhow::bail!(
1910 "manual absorb refuses to run off-TTY without --yes \
1911 (would silently overwrite {src_path})"
1912 );
1913 }
1914 if !prompt_yes_no("absorb target into source?")? {
1915 warn!("manual absorb cancelled by user: {target}");
1916 return Ok(());
1917 }
1918 }
1919
1920 let backup_root = source.join(&config.backup.dir);
1921 let ctx = ApplyCtx {
1922 config: &config,
1923 source: &source,
1924 file_mode: resolve_file_mode(config.link.file_mode),
1925 dir_mode: resolve_dir_mode(config.link.dir_mode),
1926 backup_root: &backup_root,
1927 dry_run: false,
1928 };
1929
1930 absorb_target_into_source(&src_path, &target, &ctx)
1933}
1934
1935fn print_absorb_diff(src: &Utf8Path, dst: &Utf8Path) {
1940 eprintln!();
1941 eprintln!("--- diff (- source, + target) ---");
1942 eprintln!(" src: {src}");
1943 eprintln!(" dst: {dst}");
1944 eprintln!();
1945 if src.is_dir() || dst.is_dir() {
1946 eprintln!("(directory absorb — content listing skipped)");
1947 eprintln!();
1948 return;
1949 }
1950 let src_content = match read_text_for_diff(src) {
1951 DiffSide::Text(s) => s,
1952 DiffSide::Binary => {
1953 eprintln!("(binary file or non-UTF-8 content — diff skipped)");
1954 eprintln!();
1955 return;
1956 }
1957 };
1958 let dst_content = match read_text_for_diff(dst) {
1959 DiffSide::Text(s) => s,
1960 DiffSide::Binary => {
1961 eprintln!("(binary file or non-UTF-8 content — diff skipped)");
1962 eprintln!();
1963 return;
1964 }
1965 };
1966 let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
1967 let formatted = diff
1968 .unified_diff()
1969 .header(src.as_str(), dst.as_str())
1970 .to_string();
1971 eprint!("{formatted}");
1972 eprintln!();
1973}
1974
1975fn prompt_yes_no(question: &str) -> Result<bool> {
1976 use std::io::Write as _;
1977 eprint!("{question} [y/N]: ");
1978 std::io::stderr().flush().ok();
1979 let mut input = String::new();
1980 std::io::stdin().read_line(&mut input)?;
1981 let answer = input.trim();
1982 Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
1983}
1984
1985fn find_source_for_target(
1989 source: &Utf8Path,
1990 config: &Config,
1991 target: &Utf8Path,
1992 engine: &mut template::Engine,
1993 tera_ctx: &TeraContext,
1994) -> Result<Option<Utf8PathBuf>> {
1995 for entry in &config.mount.entry {
1997 if let Some(when) = &entry.when {
1998 if !template::eval_truthy(when, engine, tera_ctx)? {
1999 continue;
2000 }
2001 }
2002 let dst_str = engine.render(&entry.dst, tera_ctx)?;
2003 let dst_root = paths::expand_tilde(dst_str.trim());
2004 if let Ok(rel) = target.strip_prefix(&dst_root) {
2005 let src_str = engine.render(entry.src.as_str(), tera_ctx)?;
2006 let candidate = paths::resolve_mount_src(source, src_str.trim()).join(rel);
2007 if paths::is_ignored_at(source, &candidate, candidate.is_dir())? {
2012 continue;
2013 }
2014 return Ok(Some(candidate));
2015 }
2016 }
2017
2018 let walker = paths::source_walker(source).build();
2024 let marker_filename = &config.mount.marker_filename;
2025 for ent in walker {
2026 let ent = match ent {
2027 Ok(e) => e,
2028 Err(_) => continue,
2029 };
2030 if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
2031 continue;
2032 }
2033 if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
2034 continue;
2035 }
2036 let dir = match ent.path().parent() {
2037 Some(d) => d,
2038 None => continue,
2039 };
2040 let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
2041 Ok(p) => p,
2042 Err(_) => continue,
2043 };
2044 let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
2045 Some(s) => s,
2046 None => continue,
2047 };
2048 let MarkerSpec::Explicit { links } = spec else {
2049 continue;
2050 };
2051 for link in &links {
2052 if let Some(when) = &link.when {
2053 if !template::eval_truthy(when, engine, tera_ctx)? {
2054 continue;
2055 }
2056 }
2057 let dst_str = engine.render(&link.dst, tera_ctx)?;
2058 let dst = paths::expand_tilde(dst_str.trim());
2059 if let Some(filename) = &link.src {
2066 let file_src = dir_utf8.join(filename);
2067 if !file_src.is_file() {
2068 anyhow::bail!(
2069 "marker at {dir_utf8}: [[link]] src={filename:?} \
2070 not found"
2071 );
2072 }
2073 if target == dst {
2074 return Ok(Some(file_src));
2075 }
2076 continue;
2077 }
2078 if target == dst {
2079 return Ok(Some(dir_utf8));
2080 }
2081 if let Ok(rel) = target.strip_prefix(&dst) {
2082 return Ok(Some(dir_utf8.join(rel)));
2083 }
2084 }
2085 }
2086
2087 Ok(None)
2088}
2089
2090pub fn doctor(
2091 source: Option<Utf8PathBuf>,
2092 icons_override: Option<IconsMode>,
2093 no_color: bool,
2094) -> Result<()> {
2095 use owo_colors::OwoColorize as _;
2096
2097 let resolved_source = resolve_source(source);
2102
2103 let yui = match &resolved_source {
2108 Ok(s) => YuiVars::detect(s),
2109 Err(_) => YuiVars::detect(Utf8Path::new(".")),
2110 };
2111
2112 let cfg_res = match &resolved_source {
2117 Ok(s) => Some(config::load(s, &yui)),
2118 Err(_) => None,
2119 };
2120 let cfg = cfg_res.as_ref().and_then(|r| r.as_ref().ok());
2121 let icons_mode = icons_override
2122 .or_else(|| cfg.map(|c| c.ui.icons))
2123 .unwrap_or_default();
2124 let icons = Icons::for_mode(icons_mode);
2125 let color = !no_color && supports_color_stdout();
2126
2127 let mut probes: Vec<Probe> = Vec::new();
2128
2129 probes.push(Probe::group("identity"));
2131 probes.push(Probe::ok("os/arch", format!("{} / {}", yui.os, yui.arch)));
2132 probes.push(Probe::ok("user@host", format!("{}@{}", yui.user, yui.host)));
2133
2134 probes.push(Probe::group("repo"));
2136 let mut have_source = false;
2137 match &resolved_source {
2138 Ok(s) => {
2139 have_source = true;
2140 probes.push(Probe::ok("source", s.to_string()));
2141 match cfg_res.as_ref().expect("cfg_res set when source is Ok") {
2142 Ok(c) => {
2143 probes.push(Probe::ok(
2144 "config",
2145 format!(
2146 "{} mount{} · {} hook{} · {} render rule{}",
2147 c.mount.entry.len(),
2148 plural(c.mount.entry.len()),
2149 c.hook.len(),
2150 plural(c.hook.len()),
2151 c.render.rule.len(),
2152 plural(c.render.rule.len()),
2153 ),
2154 ));
2155 }
2156 Err(e) => probes.push(Probe::error("config", format!("{e}"))),
2157 }
2158 match crate::git::is_clean(s) {
2162 Ok(true) => probes.push(Probe::ok("git", "clean")),
2163 Ok(false) => probes.push(Probe::warn(
2164 "git",
2165 "uncommitted changes — `[absorb] require_clean_git` will defer auto-absorb",
2166 )),
2167 Err(_) => probes.push(Probe::warn(
2168 "git",
2169 "no git repo (auto-absorb still works; commit history won't track drift)",
2170 )),
2171 }
2172 }
2173 Err(e) => {
2174 probes.push(Probe::error("source", format!("not found — {e}")));
2175 }
2176 }
2177
2178 probes.push(Probe::group("links"));
2180 if cfg!(windows) {
2181 probes.push(Probe::ok(
2182 "default mode",
2183 "files=hardlink, dirs=junction (no admin needed)",
2184 ));
2185 } else {
2186 probes.push(Probe::ok("default mode", "files=symlink, dirs=symlink"));
2187 }
2188
2189 if have_source {
2191 if let (Ok(s), Some(c)) = (&resolved_source, cfg) {
2192 probes.push(Probe::group("hooks"));
2193 if c.hook.is_empty() {
2194 probes.push(Probe::ok("hooks", "(none configured)"));
2195 } else {
2196 let mut missing = 0usize;
2197 for h in &c.hook {
2198 if !s.join(&h.script).is_file() {
2199 missing += 1;
2200 probes.push(Probe::error(
2201 format!("hook[{}]", h.name),
2202 format!("script not found at {}", h.script),
2203 ));
2204 }
2205 }
2206 if missing == 0 {
2207 probes.push(Probe::ok(
2208 "scripts",
2209 format!(
2210 "{} hook{} configured, all scripts present",
2211 c.hook.len(),
2212 plural(c.hook.len())
2213 ),
2214 ));
2215 }
2216 }
2217 }
2218 }
2219
2220 if let Some(home) = paths::home_dir() {
2222 let chezmoi_src = home.join(".local/share/chezmoi");
2223 if chezmoi_src.is_dir() {
2224 probes.push(Probe::group("chezmoi"));
2225 probes.push(Probe::warn(
2226 "legacy source",
2227 format!(
2228 "{chezmoi_src} still exists — yui doesn't use it, safe to archive once your migration has settled"
2229 ),
2230 ));
2231 }
2232 }
2233
2234 println!();
2236 if color {
2237 println!(" {}", "yui doctor".bold().underline());
2238 } else {
2239 println!(" yui doctor");
2240 }
2241 println!();
2242 for probe in &probes {
2243 probe.print(&icons, color);
2244 }
2245
2246 let errors = probes.iter().filter(|p| p.is_error()).count();
2247 let warns = probes.iter().filter(|p| p.is_warn()).count();
2248 let oks = probes.iter().filter(|p| p.is_ok()).count();
2249 println!();
2250 let summary = format!("{oks} ok · {warns} warn · {errors} error");
2251 if color {
2252 if errors > 0 {
2253 println!(" {}", summary.red().bold());
2254 } else if warns > 0 {
2255 println!(" {}", summary.yellow());
2256 } else {
2257 println!(" {}", summary.green());
2258 }
2259 } else {
2260 println!(" {summary}");
2261 }
2262
2263 if errors > 0 {
2264 anyhow::bail!("doctor: {errors} probe(s) failed");
2265 }
2266 Ok(())
2267}
2268
2269#[derive(Debug)]
2270enum Probe {
2271 Group(&'static str),
2273 Ok {
2274 label: String,
2275 detail: String,
2276 },
2277 Warn {
2278 label: String,
2279 detail: String,
2280 },
2281 Error {
2282 label: String,
2283 detail: String,
2284 },
2285}
2286
2287impl Probe {
2288 fn group(label: &'static str) -> Self {
2289 Self::Group(label)
2290 }
2291 fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
2292 Self::Ok {
2293 label: label.into(),
2294 detail: detail.into(),
2295 }
2296 }
2297 fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
2298 Self::Warn {
2299 label: label.into(),
2300 detail: detail.into(),
2301 }
2302 }
2303 fn error(label: impl Into<String>, detail: impl Into<String>) -> Self {
2304 Self::Error {
2305 label: label.into(),
2306 detail: detail.into(),
2307 }
2308 }
2309 fn is_ok(&self) -> bool {
2310 matches!(self, Self::Ok { .. })
2311 }
2312 fn is_warn(&self) -> bool {
2313 matches!(self, Self::Warn { .. })
2314 }
2315 fn is_error(&self) -> bool {
2316 matches!(self, Self::Error { .. })
2317 }
2318 fn print(&self, icons: &Icons, color: bool) {
2319 use owo_colors::OwoColorize as _;
2320 match self {
2321 Self::Group(name) => {
2322 println!();
2323 if color {
2324 println!(" {}", name.cyan().bold());
2325 } else {
2326 println!(" {name}");
2327 }
2328 }
2329 Self::Ok { label, detail } => {
2330 let icon = icons.ok;
2331 let padded = format!("{label:<14}");
2335 if color {
2336 println!(
2337 " {} {} {}",
2338 icon.green(),
2339 padded.bold(),
2340 detail.dimmed()
2341 );
2342 } else {
2343 println!(" {icon} {padded} {detail}");
2344 }
2345 }
2346 Self::Warn { label, detail } => {
2347 let icon = icons.warn;
2348 let padded = format!("{label:<14}");
2349 if color {
2350 println!(
2351 " {} {} {}",
2352 icon.yellow(),
2353 padded.bold().yellow(),
2354 detail
2355 );
2356 } else {
2357 println!(" {icon} {padded} {detail}");
2358 }
2359 }
2360 Self::Error { label, detail } => {
2361 let icon = icons.error;
2362 let padded = format!("{label:<14}");
2363 if color {
2364 println!(
2365 " {} {} {}",
2366 icon.red().bold(),
2367 padded.bold().red(),
2368 detail.red()
2369 );
2370 } else {
2371 println!(" {icon} {padded} {detail}");
2372 }
2373 }
2374 }
2375 }
2376}
2377
2378fn plural(n: usize) -> &'static str {
2379 if n == 1 { "" } else { "s" }
2380}
2381
2382pub fn gc_backup(
2402 source: Option<Utf8PathBuf>,
2403 older_than: Option<String>,
2404 dry_run: bool,
2405 icons_override: Option<IconsMode>,
2406 no_color: bool,
2407) -> Result<()> {
2408 let source = resolve_source(source)?;
2409 let yui = YuiVars::detect(&source);
2410 let config = config::load(&source, &yui)?;
2411 let backup_root = source.join(&config.backup.dir);
2412 let icons_mode = icons_override.unwrap_or(config.ui.icons);
2413 let icons = Icons::for_mode(icons_mode);
2414 let color = !no_color && supports_color_stdout();
2415
2416 if !backup_root.is_dir() {
2417 println!(" no backup tree at {backup_root}");
2418 return Ok(());
2419 }
2420
2421 let mut entries = walk_gc_backups(&backup_root)?;
2422 if entries.is_empty() {
2423 println!(" no yui-stamped backups under {backup_root}");
2424 return Ok(());
2425 }
2426 entries.sort_by_key(|e| e.ts);
2428 let now = jiff::Zoned::now();
2429
2430 match older_than {
2431 None => {
2432 let refs: Vec<&BackupEntry> = entries.iter().collect();
2433 print_gc_table(&refs, &backup_root, &now, icons, color);
2434 println!();
2435 println!(
2436 " {} entries · {} total — pass --older-than DUR (e.g. 30d) to delete",
2437 entries.len(),
2438 format_bytes(entries.iter().map(|e| e.size_bytes).sum())
2439 );
2440 Ok(())
2441 }
2442 Some(dur_str) => {
2443 let span = parse_human_duration(&dur_str)?;
2444 let cutoff = now
2445 .checked_sub(span)
2446 .map_err(|e| anyhow::anyhow!("invalid duration {dur_str:?}: {e}"))?;
2447 let cutoff_dt = cutoff.datetime();
2448
2449 let total_before: u64 = entries.iter().map(|e| e.size_bytes).sum();
2450 let to_delete: Vec<&BackupEntry> =
2451 entries.iter().filter(|e| e.ts < cutoff_dt).collect();
2452
2453 if to_delete.is_empty() {
2454 println!(
2455 " no backups older than {dur_str} (oldest: {})",
2456 format_age(entries[0].ts, &now)
2457 );
2458 return Ok(());
2459 }
2460
2461 print_gc_table(&to_delete, &backup_root, &now, icons, color);
2462 println!();
2463 let total_freed: u64 = to_delete.iter().map(|e| e.size_bytes).sum();
2464
2465 if dry_run {
2466 println!(
2467 " [dry-run] would remove {} of {} entries · would free {} of {}",
2468 to_delete.len(),
2469 entries.len(),
2470 format_bytes(total_freed),
2471 format_bytes(total_before),
2472 );
2473 return Ok(());
2474 }
2475
2476 for entry in &to_delete {
2477 match entry.kind {
2478 BackupKind::File => std::fs::remove_file(&entry.path)?,
2479 BackupKind::Dir => std::fs::remove_dir_all(&entry.path)?,
2480 }
2481 if let Some(parent) = entry.path.parent() {
2482 cleanup_empty_parents(parent, &backup_root);
2483 }
2484 }
2485 println!(
2486 " removed {} of {} entries · freed {} (was {}, now {})",
2487 to_delete.len(),
2488 entries.len(),
2489 format_bytes(total_freed),
2490 format_bytes(total_before),
2491 format_bytes(total_before - total_freed),
2492 );
2493 Ok(())
2494 }
2495 }
2496}
2497
2498#[derive(Debug)]
2499struct BackupEntry {
2500 path: Utf8PathBuf,
2501 ts: jiff::civil::DateTime,
2502 kind: BackupKind,
2503 size_bytes: u64,
2504}
2505
2506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2507enum BackupKind {
2508 File,
2509 Dir,
2510}
2511
2512fn walk_gc_backups(root: &Utf8Path) -> Result<Vec<BackupEntry>> {
2517 let mut out = Vec::new();
2518 walk_gc_backups_rec(root, &mut out)?;
2519 Ok(out)
2520}
2521
2522fn walk_gc_backups_rec(dir: &Utf8Path, out: &mut Vec<BackupEntry>) -> Result<()> {
2523 for entry in std::fs::read_dir(dir)? {
2524 let entry = entry?;
2525 let name_os = entry.file_name();
2526 let Some(name) = name_os.to_str() else {
2527 continue;
2528 };
2529 let path = dir.join(name);
2530 let ft = entry.file_type()?;
2531 if ft.is_dir() {
2532 if let Some(ts) = parse_backup_suffix(name) {
2533 let size = dir_size(&path)?;
2534 out.push(BackupEntry {
2535 path,
2536 ts,
2537 kind: BackupKind::Dir,
2538 size_bytes: size,
2539 });
2540 } else {
2541 walk_gc_backups_rec(&path, out)?;
2542 }
2543 } else if ft.is_file() {
2544 if let Some(ts) = parse_backup_suffix(name) {
2547 let size = entry.metadata()?.len();
2548 out.push(BackupEntry {
2549 path,
2550 ts,
2551 kind: BackupKind::File,
2552 size_bytes: size,
2553 });
2554 }
2555 }
2556 }
2557 Ok(())
2558}
2559
2560fn dir_size(dir: &Utf8Path) -> Result<u64> {
2561 let mut total: u64 = 0;
2562 for entry in std::fs::read_dir(dir)? {
2563 let entry = entry?;
2564 let ft = entry.file_type()?;
2565 if ft.is_dir() {
2566 let p = match Utf8PathBuf::from_path_buf(entry.path()) {
2567 Ok(p) => p,
2568 Err(_) => continue,
2569 };
2570 total = total.saturating_add(dir_size(&p)?);
2571 } else if ft.is_file() {
2572 total = total.saturating_add(entry.metadata()?.len());
2573 }
2574 }
2575 Ok(total)
2576}
2577
2578fn cleanup_empty_parents(start: &Utf8Path, root: &Utf8Path) {
2582 let mut cur = start.to_path_buf();
2583 loop {
2584 if cur == *root {
2585 return;
2586 }
2587 if std::fs::remove_dir(&cur).is_err() {
2589 return;
2590 }
2591 match cur.parent() {
2592 Some(p) => cur = p.to_path_buf(),
2593 None => return,
2594 }
2595 }
2596}
2597
2598fn parse_backup_suffix(name: &str) -> Option<jiff::civil::DateTime> {
2604 if let Some(ts) = parse_ts_at_end(name) {
2605 return Some(ts);
2606 }
2607 if let Some((before, _ext)) = name.rsplit_once('.') {
2610 if let Some(ts) = parse_ts_at_end(before) {
2611 return Some(ts);
2612 }
2613 }
2614 None
2615}
2616
2617fn parse_ts_at_end(s: &str) -> Option<jiff::civil::DateTime> {
2618 if s.len() < 20 {
2620 return None;
2621 }
2622 let split_at = s.len() - 19;
2623 if s.as_bytes()[split_at] != b'_' {
2624 return None;
2625 }
2626 parse_ts(&s[split_at + 1..])
2627}
2628
2629fn parse_ts(s: &str) -> Option<jiff::civil::DateTime> {
2631 if s.len() != 18 || s.as_bytes()[8] != b'_' {
2632 return None;
2633 }
2634 for (i, &b) in s.as_bytes().iter().enumerate() {
2635 if i == 8 {
2636 continue;
2637 }
2638 if !b.is_ascii_digit() {
2639 return None;
2640 }
2641 }
2642 let year: i16 = s[0..4].parse().ok()?;
2643 let month: i8 = s[4..6].parse().ok()?;
2644 let day: i8 = s[6..8].parse().ok()?;
2645 let hour: i8 = s[9..11].parse().ok()?;
2646 let minute: i8 = s[11..13].parse().ok()?;
2647 let second: i8 = s[13..15].parse().ok()?;
2648 let ms: i32 = s[15..18].parse().ok()?;
2649 jiff::civil::DateTime::new(year, month, day, hour, minute, second, ms * 1_000_000).ok()
2650}
2651
2652fn parse_human_duration(s: &str) -> Result<jiff::Span> {
2661 let s = s.trim();
2662 let split = s
2663 .bytes()
2664 .position(|b| b.is_ascii_alphabetic())
2665 .ok_or_else(|| anyhow::anyhow!("invalid duration {s:?}: missing unit (e.g. 30d, 2w)"))?;
2666 let n: i64 = s[..split]
2667 .trim()
2668 .parse()
2669 .map_err(|_| anyhow::anyhow!("invalid duration {s:?}: bad leading number"))?;
2670 if n < 0 {
2671 anyhow::bail!("invalid duration {s:?}: negative durations don't make sense");
2672 }
2673 let unit = s[split..].to_ascii_lowercase();
2674 let span = match unit.as_str() {
2675 "y" | "yr" | "year" | "years" => jiff::Span::new().years(n),
2676 "mo" | "month" | "months" => jiff::Span::new().months(n),
2677 "w" | "wk" | "week" | "weeks" => jiff::Span::new().weeks(n),
2678 "d" | "day" | "days" => jiff::Span::new().days(n),
2679 "h" | "hr" | "hour" | "hours" => jiff::Span::new().hours(n),
2680 "m" | "min" | "minute" | "minutes" => jiff::Span::new().minutes(n),
2681 other => {
2682 anyhow::bail!(
2683 "invalid duration {s:?}: unknown unit {other:?} \
2684 (use y / mo / w / d / h / m)"
2685 )
2686 }
2687 };
2688 Ok(span)
2689}
2690
2691fn format_bytes(n: u64) -> String {
2692 const KIB: u64 = 1024;
2693 const MIB: u64 = KIB * 1024;
2694 const GIB: u64 = MIB * 1024;
2695 if n >= GIB {
2696 format!("{:.1} GiB", n as f64 / GIB as f64)
2697 } else if n >= MIB {
2698 format!("{:.1} MiB", n as f64 / MIB as f64)
2699 } else if n >= KIB {
2700 format!("{:.1} KiB", n as f64 / KIB as f64)
2701 } else {
2702 format!("{n} B")
2703 }
2704}
2705
2706fn format_age(ts: jiff::civil::DateTime, now: &jiff::Zoned) -> String {
2707 let Ok(ts_zoned) = ts.to_zoned(now.time_zone().clone()) else {
2708 return "?".into();
2709 };
2710 let secs = match (now - &ts_zoned).total(jiff::Unit::Second) {
2711 Ok(s) => s as i64,
2712 Err(_) => return "?".into(),
2713 };
2714 if secs < 0 {
2715 return "future".into();
2716 }
2717 if secs < 60 {
2718 format!("{secs}s")
2719 } else if secs < 3600 {
2720 format!("{}m", secs / 60)
2721 } else if secs < 86_400 {
2722 format!("{}h", secs / 3600)
2723 } else if secs < 86_400 * 30 {
2724 format!("{}d", secs / 86_400)
2725 } else if secs < 86_400 * 365 {
2726 format!("{}mo", secs / (86_400 * 30))
2727 } else {
2728 format!("{}y", secs / (86_400 * 365))
2729 }
2730}
2731
2732fn print_gc_table(
2739 entries: &[&BackupEntry],
2740 backup_root: &Utf8Path,
2741 now: &jiff::Zoned,
2742 _icons: Icons,
2743 color: bool,
2744) {
2745 use owo_colors::OwoColorize as _;
2746
2747 let rows: Vec<(String, String, String)> = entries
2748 .iter()
2749 .map(|e| {
2750 let rel = e
2751 .path
2752 .strip_prefix(backup_root)
2753 .map(Utf8PathBuf::from)
2754 .unwrap_or_else(|_| e.path.clone());
2755 let path_disp = match e.kind {
2756 BackupKind::Dir => format!("{rel}/"),
2757 BackupKind::File => rel.to_string(),
2758 };
2759 (format_age(e.ts, now), format_bytes(e.size_bytes), path_disp)
2760 })
2761 .collect();
2762
2763 let age_w = rows.iter().map(|r| r.0.len()).max().unwrap_or(3);
2764 let size_w = rows.iter().map(|r| r.1.len()).max().unwrap_or(4);
2765
2766 if color {
2767 println!(
2768 " {:<age_w$} {:>size_w$} {}",
2769 "AGE".dimmed(),
2770 "SIZE".dimmed(),
2771 "PATH".dimmed(),
2772 );
2773 } else {
2774 println!(" {:<age_w$} {:>size_w$} PATH", "AGE", "SIZE");
2775 }
2776 for (age, size, path) in &rows {
2777 if color {
2778 println!(
2779 " {:<age_w$} {:>size_w$} {}",
2780 age.yellow(),
2781 size,
2782 path.cyan(),
2783 );
2784 } else {
2785 println!(" {:<age_w$} {:>size_w$} {}", age, size, path);
2786 }
2787 }
2788}
2789
2790pub fn hooks_list(
2792 source: Option<Utf8PathBuf>,
2793 icons_override: Option<IconsMode>,
2794 no_color: bool,
2795) -> Result<()> {
2796 let source = resolve_source(source)?;
2797 let yui = YuiVars::detect(&source);
2798 let config = config::load(&source, &yui)?;
2799 let state = hook::State::load(&source)?;
2800
2801 let icons_mode = icons_override.unwrap_or(config.ui.icons);
2802 let icons = Icons::for_mode(icons_mode);
2803 let color = !no_color && supports_color_stdout();
2804
2805 if config.hook.is_empty() {
2806 println!("(no [[hook]] entries in config)");
2807 return Ok(());
2808 }
2809
2810 let mut engine = template::Engine::new();
2814 let tera_ctx = template::template_context(&yui, &config.vars);
2815 let rows: Vec<HookRow> = config
2816 .hook
2817 .iter()
2818 .map(|h| -> Result<HookRow> {
2819 let active = match &h.when {
2823 None => true,
2824 Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
2825 };
2826 let last_run_at = state.hooks.get(&h.name).and_then(|s| s.last_run_at.clone());
2827 Ok(HookRow {
2828 name: h.name.clone(),
2829 phase: match h.phase {
2830 HookPhase::Pre => "pre",
2831 HookPhase::Post => "post",
2832 },
2833 when_run: match h.when_run {
2834 config::WhenRun::Once => "once",
2835 config::WhenRun::Onchange => "onchange",
2836 config::WhenRun::Every => "every",
2837 },
2838 last_run_at,
2839 when: h.when.clone(),
2840 active,
2841 })
2842 })
2843 .collect::<Result<Vec<_>>>()?;
2844
2845 print_hooks_table(&rows, icons, color);
2846
2847 let total = rows.len();
2848 let active = rows.iter().filter(|r| r.active).count();
2849 let inactive = total - active;
2850 let ran = rows.iter().filter(|r| r.last_run_at.is_some()).count();
2851 let never = total - ran;
2852 println!();
2853 println!(
2854 " {total} hooks · {active} active · {inactive} inactive · {ran} ran · {never} never run"
2855 );
2856
2857 Ok(())
2858}
2859
2860#[derive(Debug)]
2861struct HookRow {
2862 name: String,
2863 phase: &'static str,
2864 when_run: &'static str,
2865 last_run_at: Option<String>,
2866 when: Option<String>,
2867 active: bool,
2868}
2869
2870fn print_hooks_table(rows: &[HookRow], icons: Icons, color: bool) {
2871 use owo_colors::OwoColorize as _;
2872 use std::fmt::Write as _;
2873
2874 let name_w = rows
2875 .iter()
2876 .map(|r| r.name.chars().count())
2877 .max()
2878 .unwrap_or(0)
2879 .max("NAME".len());
2880 let phase_w = rows
2881 .iter()
2882 .map(|r| r.phase.len())
2883 .max()
2884 .unwrap_or(0)
2885 .max("PHASE".len());
2886 let when_run_w = rows
2887 .iter()
2888 .map(|r| r.when_run.len())
2889 .max()
2890 .unwrap_or(0)
2891 .max("WHEN_RUN".len());
2892 let last_w = rows
2893 .iter()
2894 .map(|r| {
2895 r.last_run_at
2896 .as_deref()
2897 .map(|s| s.chars().count())
2898 .unwrap_or("(never)".len())
2899 })
2900 .max()
2901 .unwrap_or(0)
2902 .max("LAST_RUN".len());
2903 let status_w = "STATUS".len();
2904
2905 let mut header = String::new();
2907 let _ = write!(
2908 &mut header,
2909 " {:<status_w$} {:<name_w$} {:<phase_w$} {:<when_run_w$} {:<last_w$} WHEN",
2910 "STATUS", "NAME", "PHASE", "WHEN_RUN", "LAST_RUN"
2911 );
2912 if color {
2913 println!("{}", header.bold());
2914 } else {
2915 println!("{header}");
2916 }
2917
2918 let bar = |n: usize| icons.sep.to_string().repeat(n);
2920 let sep = format!(
2921 " {} {} {} {} {} {}",
2922 bar(status_w),
2923 bar(name_w),
2924 bar(phase_w),
2925 bar(when_run_w),
2926 bar(last_w),
2927 bar("WHEN".len())
2928 );
2929 if color {
2930 println!("{}", sep.dimmed());
2931 } else {
2932 println!("{sep}");
2933 }
2934
2935 for r in rows {
2937 let (icon, ran) = match (r.active, r.last_run_at.is_some()) {
2942 (false, _) => (icons.inactive, false),
2943 (true, true) => (icons.active, true),
2944 (true, false) => (icons.info, false),
2945 };
2946 let last = r.last_run_at.as_deref().unwrap_or("(never)");
2947 let when_str = r
2948 .when
2949 .as_deref()
2950 .map(strip_braces)
2951 .unwrap_or_else(|| "(always)".to_string());
2952
2953 let cell_status = format!("{icon:<status_w$}");
2954 let cell_name = format!("{:<name_w$}", r.name);
2955 let cell_phase = format!("{:<phase_w$}", r.phase);
2956 let cell_when_run = format!("{:<when_run_w$}", r.when_run);
2957 let cell_last = format!("{last:<last_w$}");
2958
2959 if !color {
2960 println!(
2961 " {cell_status} {cell_name} {cell_phase} {cell_when_run} {cell_last} {when_str}"
2962 );
2963 continue;
2964 }
2965
2966 if !r.active {
2970 println!(
2971 " {} {} {} {} {} {}",
2972 cell_status.dimmed(),
2973 cell_name.dimmed(),
2974 cell_phase.dimmed(),
2975 cell_when_run.dimmed(),
2976 cell_last.dimmed(),
2977 when_str.dimmed()
2978 );
2979 } else if ran {
2980 println!(
2981 " {} {} {} {} {} {}",
2982 cell_status.green(),
2983 cell_name.cyan().bold(),
2984 cell_phase.dimmed(),
2985 cell_when_run.dimmed(),
2986 cell_last.green(),
2987 when_str.dimmed()
2988 );
2989 } else {
2990 println!(
2991 " {} {} {} {} {} {}",
2992 cell_status.yellow(),
2993 cell_name.cyan().bold(),
2994 cell_phase.dimmed(),
2995 cell_when_run.dimmed(),
2996 cell_last.yellow(),
2997 when_str.dimmed()
2998 );
2999 }
3000 }
3001}
3002
3003pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
3007 let source = resolve_source(source)?;
3008 let yui = YuiVars::detect(&source);
3009 let config = config::load(&source, &yui)?;
3010 let mut engine = template::Engine::new();
3011 let tera_ctx = template::template_context(&yui, &config.vars);
3012
3013 let targets: Vec<&config::HookConfig> = match &name {
3014 Some(want) => {
3015 let m = config
3016 .hook
3017 .iter()
3018 .find(|h| &h.name == want)
3019 .ok_or_else(|| {
3020 anyhow::anyhow!(
3021 "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
3022 )
3023 })?;
3024 vec![m]
3025 }
3026 None => config.hook.iter().collect(),
3027 };
3028
3029 let mut state = hook::State::load(&source)?;
3030 for h in targets {
3031 let outcome = hook::run_hook(
3032 h,
3033 &source,
3034 &yui,
3035 &config.vars,
3036 &mut engine,
3037 &tera_ctx,
3038 &mut state,
3039 false,
3040 force,
3041 )?;
3042 let label = match outcome {
3043 HookOutcome::Ran => "ran",
3044 HookOutcome::SkippedOnce => "skipped (once: already ran)",
3045 HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
3046 HookOutcome::SkippedWhenFalse => "skipped (when=false)",
3047 HookOutcome::DryRun => "would run (dry-run)",
3048 };
3049 info!("hook[{}]: {label}", h.name);
3050 if outcome == HookOutcome::Ran {
3051 state.save(&source)?;
3052 }
3053 }
3054 Ok(())
3055}
3056
3057#[allow(clippy::too_many_arguments)]
3062fn process_mount(
3063 m: &ResolvedMount,
3064 ctx: &ApplyCtx<'_>,
3065 engine: &mut template::Engine,
3066 tera_ctx: &TeraContext,
3067 yuiignore: &mut paths::YuiIgnoreStack,
3068) -> Result<()> {
3069 let src_root = m.src.clone();
3072 if !src_root.is_dir() {
3073 warn!("mount src missing: {src_root}");
3074 return Ok(());
3075 }
3076 walk_and_link(
3077 &src_root, &m.dst, ctx, m.strategy, engine, tera_ctx, yuiignore, false,
3078 )
3079}
3080
3081#[allow(clippy::too_many_arguments)]
3082fn walk_and_link(
3083 src_dir: &Utf8Path,
3084 dst_dir: &Utf8Path,
3085 ctx: &ApplyCtx<'_>,
3086 strategy: MountStrategy,
3087 engine: &mut template::Engine,
3088 tera_ctx: &TeraContext,
3089 yuiignore: &mut paths::YuiIgnoreStack,
3090 parent_covered: bool,
3091) -> Result<()> {
3092 if yuiignore.is_ignored(src_dir, true) {
3095 return Ok(());
3096 }
3097 yuiignore.push_dir(src_dir)?;
3100 let result = walk_and_link_body(
3101 src_dir,
3102 dst_dir,
3103 ctx,
3104 strategy,
3105 engine,
3106 tera_ctx,
3107 yuiignore,
3108 parent_covered,
3109 );
3110 yuiignore.pop_dir(src_dir);
3111 result
3112}
3113
3114#[allow(clippy::too_many_arguments)]
3115fn walk_and_link_body(
3116 src_dir: &Utf8Path,
3117 dst_dir: &Utf8Path,
3118 ctx: &ApplyCtx<'_>,
3119 strategy: MountStrategy,
3120 engine: &mut template::Engine,
3121 tera_ctx: &TeraContext,
3122 yuiignore: &mut paths::YuiIgnoreStack,
3123 parent_covered: bool,
3124) -> Result<()> {
3125 let marker_filename = &ctx.config.mount.marker_filename;
3126 let mut covered = parent_covered;
3127
3128 if strategy == MountStrategy::Marker {
3129 match marker::read_spec(src_dir, marker_filename)? {
3130 None => {} Some(MarkerSpec::PassThrough) => {
3132 link_dir_with_backup(src_dir, dst_dir, ctx)?;
3136 covered = true;
3137 }
3138 Some(MarkerSpec::Explicit { links }) => {
3139 let mut emitted_dir_link = false;
3140 let mut emitted_any = false;
3141 for link in &links {
3142 if let Some(when) = &link.when {
3145 if !template::eval_truthy(when, engine, tera_ctx)? {
3146 continue;
3147 }
3148 }
3149 let dst_str = engine.render(&link.dst, tera_ctx)?;
3150 let dst = paths::expand_tilde(dst_str.trim());
3151 if let Some(filename) = &link.src {
3152 let file_src = src_dir.join(filename);
3153 if !file_src.is_file() {
3154 anyhow::bail!(
3155 "marker at {src_dir}: [[link]] src={filename:?} \
3156 not found"
3157 );
3158 }
3159 link_file_with_backup(&file_src, &dst, ctx)?;
3160 } else {
3161 link_dir_with_backup(src_dir, &dst, ctx)?;
3162 emitted_dir_link = true;
3163 }
3164 emitted_any = true;
3165 }
3166 if !emitted_any {
3167 info!(
3172 "marker at {src_dir} had no active links \
3173 — falling back to defaults"
3174 );
3175 }
3176 if emitted_dir_link {
3177 covered = true;
3178 }
3179 }
3180 }
3181 }
3182
3183 for entry in std::fs::read_dir(src_dir)? {
3184 let entry = entry?;
3185 let name_os = entry.file_name();
3186 let Some(name) = name_os.to_str() else {
3187 continue;
3188 };
3189 if name == marker_filename {
3190 continue;
3191 }
3192 if name.ends_with(".tera") {
3193 continue;
3195 }
3196 let src_path = src_dir.join(name);
3197 let dst_path = dst_dir.join(name);
3198 let ft = entry.file_type()?;
3199
3200 if yuiignore.is_ignored(&src_path, ft.is_dir()) {
3201 continue;
3202 }
3203
3204 if ft.is_dir() {
3205 walk_and_link(
3206 &src_path, &dst_path, ctx, strategy, engine, tera_ctx, yuiignore, covered,
3207 )?;
3208 } else if ft.is_file() {
3209 if !covered {
3215 link_file_with_backup(&src_path, &dst_path, ctx)?;
3216 }
3217 }
3218 }
3219 Ok(())
3220}
3221
3222fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3223 use absorb::AbsorbDecision::*;
3224
3225 let decision = absorb::classify(src, dst)?;
3226
3227 if ctx.dry_run {
3228 info!("[dry-run] {decision:?}: {src} → {dst}");
3229 return Ok(());
3230 }
3231
3232 match decision {
3233 InSync => {
3234 Ok(())
3236 }
3237 Restore => {
3238 info!("link: {src} → {dst}");
3239 link::link_file(src, dst, ctx.file_mode)?;
3240 Ok(())
3241 }
3242 RelinkOnly => {
3243 info!("relink: {src} → {dst}");
3246 link::unlink(dst)?;
3247 link::link_file(src, dst, ctx.file_mode)?;
3248 Ok(())
3249 }
3250 AutoAbsorb => {
3251 if !ctx.config.absorb.auto {
3254 return handle_anomaly(
3255 src,
3256 dst,
3257 ctx,
3258 "absorb.auto = false; treating divergence as anomaly",
3259 );
3260 }
3261 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
3262 return handle_anomaly(
3263 src,
3264 dst,
3265 ctx,
3266 "source repo is dirty; deferring auto-absorb",
3267 );
3268 }
3269 absorb_target_into_source(src, dst, ctx)
3270 }
3271 NeedsConfirm => handle_anomaly(
3272 src,
3273 dst,
3274 ctx,
3275 "anomaly: source equals/newer than target but content differs",
3276 ),
3277 }
3278}
3279
3280fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3284 info!("absorb: {dst} → {src}");
3285 backup_existing(src, ctx.backup_root, false)?;
3286 std::fs::copy(dst, src)?;
3287 link::unlink(dst)?;
3288 link::link_file(src, dst, ctx.file_mode)?;
3289 Ok(())
3290}
3291
3292fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
3298 use crate::config::AnomalyAction::*;
3299 match ctx.config.absorb.on_anomaly {
3300 Skip => {
3301 warn!("anomaly skip: {dst} ({reason})");
3302 Ok(())
3303 }
3304 Force => {
3305 warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
3306 absorb_target_into_source(src, dst, ctx)
3307 }
3308 Ask => {
3309 use std::io::IsTerminal;
3310 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
3311 if prompt_absorb_with_diff(src, dst, reason)? {
3312 absorb_target_into_source(src, dst, ctx)
3313 } else {
3314 warn!("anomaly skipped by user: {dst}");
3315 Ok(())
3316 }
3317 } else {
3318 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
3319 Ok(())
3320 }
3321 }
3322 }
3323}
3324
3325fn prompt_absorb_with_diff(src: &Utf8Path, dst: &Utf8Path, reason: &str) -> Result<bool> {
3326 eprintln!();
3327 eprintln!("anomaly: {reason}");
3328 print_absorb_diff(src, dst);
3329 prompt_yes_no("absorb target into source?")
3330}
3331
3332fn source_repo_is_clean(source: &Utf8Path) -> bool {
3337 match crate::git::is_clean(source) {
3338 Ok(b) => b,
3339 Err(e) => {
3340 warn!("git clean check failed at {source}: {e} — treating as clean");
3341 true
3342 }
3343 }
3344}
3345
3346fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3347 use absorb::AbsorbDecision::*;
3348 let decision = absorb::classify(src, dst)?;
3349
3350 if ctx.dry_run {
3351 info!("[dry-run] dir {decision:?}: {src} → {dst}");
3352 return Ok(());
3353 }
3354
3355 match decision {
3356 InSync => Ok(()),
3357 Restore => {
3358 info!("link dir: {src} → {dst}");
3359 link::link_dir(src, dst, ctx.dir_mode)?;
3360 Ok(())
3361 }
3362 RelinkOnly => {
3363 info!("relink dir: {src} → {dst}");
3368 remove_dir_link_or_real(dst)?;
3369 link::link_dir(src, dst, ctx.dir_mode)?;
3370 Ok(())
3371 }
3372 AutoAbsorb | NeedsConfirm => {
3373 if !ctx.config.absorb.auto {
3394 return handle_anomaly_dir(
3395 src,
3396 dst,
3397 ctx,
3398 "absorb.auto = false; treating divergence as anomaly",
3399 );
3400 }
3401 if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
3402 return handle_anomaly_dir(
3403 src,
3404 dst,
3405 ctx,
3406 "source repo is dirty; deferring auto-absorb",
3407 );
3408 }
3409 absorb_target_dir_into_source(src, dst, ctx)
3410 }
3411 }
3412}
3413
3414fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
3424 if let Err(unlink_err) = link::unlink(dst) {
3425 let meta = std::fs::symlink_metadata(dst)
3426 .with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
3427 let ft = meta.file_type();
3428 if ft.is_dir() && !ft.is_symlink() {
3429 std::fs::remove_dir_all(dst).with_context(|| {
3430 format!(
3431 "remove_dir_all({dst}) after link::unlink failed: \
3432 {unlink_err}"
3433 )
3434 })?;
3435 } else {
3436 return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
3437 }
3438 }
3439 Ok(())
3440}
3441
3442fn merge_dir_target_into_source(
3452 target: &Utf8Path,
3453 source: &Utf8Path,
3454 ctx: &ApplyCtx<'_>,
3455) -> Result<()> {
3456 for entry in std::fs::read_dir(target)? {
3457 let entry = entry?;
3458 let name_os = entry.file_name();
3459 let Some(name) = name_os.to_str() else {
3460 continue;
3461 };
3462 let target_path = target.join(name);
3463 let source_path = source.join(name);
3464 let ft = entry.file_type()?;
3465
3466 if ft.is_dir() && !ft.is_symlink() {
3467 if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
3473 let sft = src_meta.file_type();
3474 if !sft.is_dir() || sft.is_symlink() {
3475 link::unlink(&source_path).with_context(|| {
3476 format!("remove conflicting source entry before dir merge: {source_path}")
3477 })?;
3478 }
3479 }
3480 if !source_path.exists() {
3481 std::fs::create_dir_all(&source_path).with_context(|| {
3482 format!("create_dir_all({source_path}) during target→source merge")
3483 })?;
3484 }
3485 merge_dir_target_into_source(&target_path, &source_path, ctx)?;
3486 } else if ft.is_file() {
3487 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 remove_dir_link_or_real(&source_path).with_context(|| {
3494 format!("remove conflicting source dir before file merge: {source_path}")
3495 })?;
3496 } else if sft.is_symlink() {
3497 link::unlink(&source_path).with_context(|| {
3498 format!(
3499 "remove conflicting source symlink before file merge: {source_path}"
3500 )
3501 })?;
3502 }
3503 }
3504 if let Some(parent) = source_path.parent() {
3505 if !parent.exists() {
3506 std::fs::create_dir_all(parent)?;
3507 }
3508 }
3509 if source_path.is_file() {
3523 merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
3524 } else {
3525 std::fs::copy(&target_path, &source_path)
3526 .with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
3527 }
3528 } else {
3529 warn!(
3530 "merge: skipping non-regular entry {target_path} \
3531 (symlink / junction / special — content not copied)"
3532 );
3533 }
3534 }
3535 Ok(())
3536}
3537
3538fn merge_resolve_file_conflict(
3552 target_path: &Utf8Path,
3553 source_path: &Utf8Path,
3554 ctx: &ApplyCtx<'_>,
3555) -> Result<()> {
3556 use absorb::AbsorbDecision::*;
3557 let decision = absorb::classify(source_path, target_path)?;
3558 match decision {
3559 InSync | RelinkOnly => Ok(()),
3560 AutoAbsorb => {
3561 std::fs::copy(target_path, source_path).with_context(|| {
3562 format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
3563 })?;
3564 Ok(())
3565 }
3566 Restore => {
3567 unreachable!(
3574 "merge_resolve_file_conflict reached with both files present, \
3575 but classify returned Restore (target {target_path} / source {source_path})"
3576 )
3577 }
3578 NeedsConfirm => {
3579 use crate::config::AnomalyAction::*;
3580 match ctx.config.absorb.on_anomaly {
3581 Skip => {
3582 warn!(
3583 "merge anomaly skip: {target_path} (source-newer / content drift) \
3584 — keeping source version, target version dropped"
3585 );
3586 Ok(())
3587 }
3588 Force => {
3589 warn!(
3590 "merge anomaly force: {target_path} \
3591 (source-newer / content drift) — overwriting source"
3592 );
3593 std::fs::copy(target_path, source_path)?;
3594 Ok(())
3595 }
3596 Ask => {
3597 use std::io::IsTerminal;
3598 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
3599 if prompt_absorb_with_diff(
3600 source_path,
3601 target_path,
3602 "merge: file content differs and source is newer",
3603 )? {
3604 std::fs::copy(target_path, source_path)?;
3605 } else {
3606 warn!("merge: kept source version by user choice: {source_path}");
3607 }
3608 Ok(())
3609 } else {
3610 warn!(
3611 "merge anomaly skip (non-TTY ask mode): {target_path} \
3612 — keeping source version"
3613 );
3614 Ok(())
3615 }
3616 }
3617 }
3618 }
3619 }
3620}
3621
3622fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3629 info!("absorb dir: {dst} → {src}");
3630 backup_existing(src, ctx.backup_root, true)?;
3631 merge_dir_target_into_source(dst, src, ctx)?;
3632 remove_dir_link_or_real(dst)?;
3635 link::link_dir(src, dst, ctx.dir_mode)?;
3636 Ok(())
3637}
3638
3639fn handle_anomaly_dir(
3643 src: &Utf8Path,
3644 dst: &Utf8Path,
3645 ctx: &ApplyCtx<'_>,
3646 reason: &str,
3647) -> Result<()> {
3648 use crate::config::AnomalyAction::*;
3649 match ctx.config.absorb.on_anomaly {
3650 Skip => {
3651 warn!("anomaly skip dir: {dst} ({reason})");
3652 Ok(())
3653 }
3654 Force => {
3655 warn!(
3656 "anomaly force dir: {dst} ({reason}) \
3657 — absorbing target into source"
3658 );
3659 absorb_target_dir_into_source(src, dst, ctx)
3660 }
3661 Ask => {
3662 use std::io::IsTerminal;
3663 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
3664 eprintln!();
3665 eprintln!("anomaly: {dst}");
3666 eprintln!(" {reason}");
3667 eprintln!(" source: {src}");
3668 eprint!(" absorb target dir into source? (y/N) ");
3669 use std::io::{BufRead as _, Write as _};
3670 std::io::stderr().flush().ok();
3671 let mut buf = String::new();
3672 std::io::stdin().lock().read_line(&mut buf)?;
3673 let answer = buf.trim();
3674 if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
3675 absorb_target_dir_into_source(src, dst, ctx)
3676 } else {
3677 warn!("anomaly skipped by user: {dst}");
3678 Ok(())
3679 }
3680 } else {
3681 warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
3682 Ok(())
3683 }
3684 }
3685 }
3686}
3687
3688fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
3689 let abs_target = absolutize(target)?;
3690 let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
3691 let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
3692 info!("backup → {bp}");
3693 if is_dir {
3694 backup::backup_dir(target, &bp)?;
3695 } else {
3696 backup::backup_file(target, &bp)?;
3697 }
3698 Ok(())
3699}
3700
3701fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
3702 if let Some(s) = source {
3703 return absolutize(&s);
3704 }
3705 if let Ok(s) = std::env::var("YUI_SOURCE") {
3706 return absolutize(Utf8Path::new(&s));
3707 }
3708 let cwd = current_dir_utf8()?;
3709 for ancestor in cwd.ancestors() {
3710 if ancestor.join("config.toml").is_file() {
3711 return Ok(ancestor.to_path_buf());
3712 }
3713 }
3714 if let Some(home) = paths::home_dir() {
3715 for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
3716 let p = home.join(c);
3717 if p.join("config.toml").is_file() {
3718 return Ok(p);
3719 }
3720 }
3721 }
3722 anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
3723}
3724
3725fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
3726 let expanded = paths::expand_tilde(p.as_str());
3728 if expanded.is_absolute() {
3729 return Ok(expanded);
3730 }
3731 let cwd = current_dir_utf8()?;
3732 Ok(cwd.join(expanded))
3733}
3734
3735fn current_dir_utf8() -> Result<Utf8PathBuf> {
3736 let cwd = std::env::current_dir().context("getting cwd")?;
3737 Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
3738}
3739
3740const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
3744
3745[vars]
3746# user-defined values; templates can reference these as {{ vars.foo }}
3747
3748# [link]
3749# file_mode = "auto" # auto | symlink | hardlink
3750# dir_mode = "auto" # auto | symlink | junction
3751
3752[mount]
3753default_strategy = "marker"
3754
3755[[mount.entry]]
3756src = "home"
3757# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
3758dst = "~"
3759
3760# [[mount.entry]]
3761# src = "appdata"
3762# dst = "{{ env(name='APPDATA') }}"
3763# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
3764# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
3765# when = "yui.os == 'windows'"
3766"#;
3767
3768const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
3769# .yui/bin/ is intentionally tracked — it holds your hook scripts.
3770/.yui/state.json
3771/.yui/state.json.tmp
3772/.yui/backup/
3773
3774# >>> yui rendered (auto-managed, do not edit) >>>
3775# <<< yui rendered (auto-managed) <<<
3776
3777# config.local.toml is per-machine; commit a config.local.example.toml instead.
3778config.local.toml
3779"#;
3780
3781#[cfg(test)]
3782mod tests {
3783 use super::*;
3784 use tempfile::TempDir;
3785
3786 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
3787 Utf8PathBuf::from_path_buf(p).unwrap()
3788 }
3789
3790 fn toml_path(p: &Utf8Path) -> String {
3792 p.as_str().replace('\\', "/")
3793 }
3794
3795 #[test]
3796 fn apply_links_a_raw_file() {
3797 let tmp = TempDir::new().unwrap();
3798 let source = utf8(tmp.path().join("dotfiles"));
3799 let target = utf8(tmp.path().join("target"));
3800 std::fs::create_dir_all(source.join("home")).unwrap();
3801 std::fs::create_dir_all(&target).unwrap();
3802 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3803
3804 let cfg = format!(
3805 r#"
3806[[mount.entry]]
3807src = "home"
3808dst = "{}"
3809"#,
3810 toml_path(&target)
3811 );
3812 std::fs::write(source.join("config.toml"), cfg).unwrap();
3813
3814 apply(Some(source), false).unwrap();
3815
3816 let linked = target.join(".bashrc");
3817 assert!(linked.exists(), "expected {linked} to exist");
3818 assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
3819 }
3820
3821 #[test]
3822 fn apply_with_marker_links_whole_directory() {
3823 let tmp = TempDir::new().unwrap();
3824 let source = utf8(tmp.path().join("dotfiles"));
3825 let target = utf8(tmp.path().join("target"));
3826 let nvim_src = source.join("home/nvim");
3827 std::fs::create_dir_all(&nvim_src).unwrap();
3828 std::fs::create_dir_all(&target).unwrap();
3829 std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
3830 std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
3831 std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
3832
3833 let cfg = format!(
3834 r#"
3835[[mount.entry]]
3836src = "home"
3837dst = "{}"
3838"#,
3839 toml_path(&target)
3840 );
3841 std::fs::write(source.join("config.toml"), cfg).unwrap();
3842
3843 apply(Some(source.clone()), false).unwrap();
3844
3845 let nvim_dst = target.join("nvim");
3846 assert!(nvim_dst.exists());
3847 assert_eq!(
3848 std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
3849 "-- hi\n"
3850 );
3851 }
3855
3856 #[test]
3857 fn apply_dry_run_does_not_write() {
3858 let tmp = TempDir::new().unwrap();
3859 let source = utf8(tmp.path().join("dotfiles"));
3860 let target = utf8(tmp.path().join("target"));
3861 std::fs::create_dir_all(source.join("home")).unwrap();
3862 std::fs::create_dir_all(&target).unwrap();
3863 std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
3864
3865 let cfg = format!(
3866 r#"
3867[[mount.entry]]
3868src = "home"
3869dst = "{}"
3870"#,
3871 toml_path(&target)
3872 );
3873 std::fs::write(source.join("config.toml"), cfg).unwrap();
3874
3875 apply(Some(source), true).unwrap();
3876
3877 assert!(!target.join(".bashrc").exists());
3878 }
3879
3880 #[test]
3881 fn apply_renders_templates_then_links_rendered_outputs() {
3882 let tmp = TempDir::new().unwrap();
3883 let source = utf8(tmp.path().join("dotfiles"));
3884 let target = utf8(tmp.path().join("target"));
3885 std::fs::create_dir_all(source.join("home")).unwrap();
3886 std::fs::create_dir_all(&target).unwrap();
3887 std::fs::write(
3888 source.join("home/.gitconfig.tera"),
3889 "[user]\n os = {{ yui.os }}\n",
3890 )
3891 .unwrap();
3892 std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
3893
3894 let cfg = format!(
3895 r#"
3896[[mount.entry]]
3897src = "home"
3898dst = "{}"
3899"#,
3900 toml_path(&target)
3901 );
3902 std::fs::write(source.join("config.toml"), cfg).unwrap();
3903
3904 apply(Some(source.clone()), false).unwrap();
3905
3906 assert!(target.join(".bashrc").exists());
3908 assert!(source.join("home/.gitconfig").exists());
3910 assert!(target.join(".gitconfig").exists());
3911 assert!(!target.join(".gitconfig.tera").exists());
3913 let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
3915 assert!(linked.contains("os = "));
3916 }
3917
3918 #[test]
3919 fn apply_marker_override_links_to_custom_dst() {
3920 let tmp = TempDir::new().unwrap();
3921 let source = utf8(tmp.path().join("dotfiles"));
3922 let target_a = utf8(tmp.path().join("target_a"));
3923 let target_b = utf8(tmp.path().join("target_b"));
3924 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3925 std::fs::create_dir_all(&target_a).unwrap();
3926 std::fs::create_dir_all(&target_b).unwrap();
3927 std::fs::write(
3928 source.join("home/.config/nvim/init.lua"),
3929 "-- nvim config\n",
3930 )
3931 .unwrap();
3932
3933 std::fs::write(
3936 source.join("home/.config/nvim/.yuilink"),
3937 format!(
3938 r#"
3939[[link]]
3940dst = "{}/nvim"
3941
3942[[link]]
3943dst = "{}/nvim"
3944when = "{{{{ yui.os == '{}' }}}}"
3945"#,
3946 toml_path(&target_a),
3947 toml_path(&target_b),
3948 std::env::consts::OS
3949 ),
3950 )
3951 .unwrap();
3952
3953 let parent_target = utf8(tmp.path().join("parent_target"));
3954 std::fs::create_dir_all(&parent_target).unwrap();
3955 let cfg = format!(
3956 r#"
3957[[mount.entry]]
3958src = "home"
3959dst = "{}"
3960"#,
3961 toml_path(&parent_target)
3962 );
3963 std::fs::write(source.join("config.toml"), cfg).unwrap();
3964
3965 apply(Some(source.clone()), false).unwrap();
3966
3967 assert!(
3969 target_a.join("nvim/init.lua").exists(),
3970 "target_a/nvim/init.lua should be reachable through the link"
3971 );
3972 assert!(
3973 target_b.join("nvim/init.lua").exists(),
3974 "target_b/nvim/init.lua should be reachable through the link"
3975 );
3976 assert!(
3979 !parent_target.join(".config/nvim").exists(),
3980 "parent mount should have skipped the marker-claimed sub-dir"
3981 );
3982 }
3983
3984 #[test]
3985 fn apply_marker_inactive_link_falls_through_to_default() {
3986 let tmp = TempDir::new().unwrap();
3991 let source = utf8(tmp.path().join("dotfiles"));
3992 let target_inactive = utf8(tmp.path().join("inactive"));
3993 let parent_target = utf8(tmp.path().join("parent"));
3994 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3995 std::fs::create_dir_all(&parent_target).unwrap();
3996 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
3997
3998 std::fs::write(
4000 source.join("home/.config/nvim/.yuilink"),
4001 format!(
4002 r#"
4003[[link]]
4004dst = "{}/nvim"
4005when = "{{{{ yui.os == 'no-such-os' }}}}"
4006"#,
4007 toml_path(&target_inactive)
4008 ),
4009 )
4010 .unwrap();
4011
4012 let cfg = format!(
4013 r#"
4014[[mount.entry]]
4015src = "home"
4016dst = "{}"
4017"#,
4018 toml_path(&parent_target)
4019 );
4020 std::fs::write(source.join("config.toml"), cfg).unwrap();
4021
4022 apply(Some(source.clone()), false).unwrap();
4023
4024 assert!(!target_inactive.join("nvim").exists());
4026 assert!(parent_target.join(".config/nvim/init.lua").exists());
4029 }
4030
4031 #[test]
4032 fn list_shows_mount_entries_and_marker_overrides() {
4033 let tmp = TempDir::new().unwrap();
4034 let source = utf8(tmp.path().join("dotfiles"));
4035 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
4036 std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
4037 std::fs::write(
4038 source.join("home/.config/nvim/.yuilink"),
4039 r#"
4040[[link]]
4041dst = "/custom/nvim"
4042"#,
4043 )
4044 .unwrap();
4045 std::fs::write(
4046 source.join("config.toml"),
4047 r#"
4048[[mount.entry]]
4049src = "home"
4050dst = "/h"
4051"#,
4052 )
4053 .unwrap();
4054
4055 list(Some(source), false, None, true).unwrap();
4058 }
4059
4060 #[test]
4061 fn status_reports_in_sync_after_apply() {
4062 let tmp = TempDir::new().unwrap();
4063 let source = utf8(tmp.path().join("dotfiles"));
4064 let target = utf8(tmp.path().join("target"));
4065 std::fs::create_dir_all(source.join("home")).unwrap();
4066 std::fs::create_dir_all(&target).unwrap();
4067 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4068 let cfg = format!(
4069 r#"
4070[[mount.entry]]
4071src = "home"
4072dst = "{}"
4073"#,
4074 toml_path(&target)
4075 );
4076 std::fs::write(source.join("config.toml"), cfg).unwrap();
4077 apply(Some(source.clone()), false).unwrap();
4079 status(Some(source), None, true).unwrap();
4081 }
4082
4083 #[test]
4084 fn status_reports_template_drift() {
4085 let tmp = TempDir::new().unwrap();
4086 let source = utf8(tmp.path().join("dotfiles"));
4087 let target = utf8(tmp.path().join("target"));
4088 std::fs::create_dir_all(source.join("home")).unwrap();
4089 std::fs::create_dir_all(&target).unwrap();
4090 std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
4093 std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
4094
4095 let cfg = format!(
4096 r#"
4097[[mount.entry]]
4098src = "home"
4099dst = "{}"
4100"#,
4101 toml_path(&target)
4102 );
4103 std::fs::write(source.join("config.toml"), cfg).unwrap();
4104
4105 let err = status(Some(source), None, true).unwrap_err();
4106 assert!(format!("{err}").contains("diverged"));
4107 }
4108
4109 #[test]
4110 fn status_fails_when_target_missing() {
4111 let tmp = TempDir::new().unwrap();
4112 let source = utf8(tmp.path().join("dotfiles"));
4113 let target = utf8(tmp.path().join("target"));
4114 std::fs::create_dir_all(source.join("home")).unwrap();
4115 std::fs::create_dir_all(&target).unwrap();
4116 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4117 let cfg = format!(
4118 r#"
4119[[mount.entry]]
4120src = "home"
4121dst = "{}"
4122"#,
4123 toml_path(&target)
4124 );
4125 std::fs::write(source.join("config.toml"), cfg).unwrap();
4126 let err = status(Some(source), None, true).unwrap_err();
4128 assert!(format!("{err}").contains("diverged"));
4129 }
4130
4131 #[test]
4132 fn strip_braces_removes_outer_template_braces() {
4133 assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
4134 assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
4135 assert_eq!(strip_braces(" {{x}} "), "x");
4136 }
4137
4138 #[test]
4139 fn apply_aborts_on_render_drift() {
4140 let tmp = TempDir::new().unwrap();
4141 let source = utf8(tmp.path().join("dotfiles"));
4142 let target = utf8(tmp.path().join("target"));
4143 std::fs::create_dir_all(source.join("home")).unwrap();
4144 std::fs::create_dir_all(&target).unwrap();
4145 std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
4146 std::fs::write(source.join("home/foo"), "manually edited").unwrap();
4147
4148 let cfg = format!(
4149 r#"
4150[[mount.entry]]
4151src = "home"
4152dst = "{}"
4153"#,
4154 toml_path(&target)
4155 );
4156 std::fs::write(source.join("config.toml"), cfg).unwrap();
4157
4158 let err = apply(Some(source.clone()), false).unwrap_err();
4159 assert!(format!("{err}").contains("drift"));
4160 assert_eq!(
4162 std::fs::read_to_string(source.join("home/foo")).unwrap(),
4163 "manually edited"
4164 );
4165 assert!(!target.join("foo").exists());
4167 }
4168
4169 #[test]
4170 fn init_creates_skeleton_when_dir_empty() {
4171 let tmp = TempDir::new().unwrap();
4172 let dir = utf8(tmp.path().join("new_dotfiles"));
4173 init(Some(dir.clone()), false).unwrap();
4174 assert!(dir.join("config.toml").is_file());
4175 assert!(dir.join(".gitignore").is_file());
4176 }
4177
4178 #[test]
4179 fn init_refuses_to_overwrite_existing_config() {
4180 let tmp = TempDir::new().unwrap();
4181 let dir = utf8(tmp.path().join("dotfiles"));
4182 std::fs::create_dir_all(&dir).unwrap();
4183 std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
4184 let err = init(Some(dir), false).unwrap_err();
4185 assert!(format!("{err}").contains("already exists"));
4186 }
4187
4188 #[test]
4194 fn init_appends_missing_gitignore_entries_into_existing_file() {
4195 let tmp = TempDir::new().unwrap();
4196 let dir = utf8(tmp.path().join("dotfiles"));
4197 std::fs::create_dir_all(&dir).unwrap();
4198 let user_gitignore = "# user entries\n*.swp\nnode_modules/\n";
4200 std::fs::write(dir.join(".gitignore"), user_gitignore).unwrap();
4201
4202 init(Some(dir.clone()), false).unwrap();
4203
4204 let body = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
4205 assert!(body.contains("*.swp"));
4207 assert!(body.contains("node_modules/"));
4208 assert!(body.contains("/.yui/state.json"));
4210 assert!(body.contains("/.yui/backup/"));
4211 assert!(body.contains("config.local.toml"));
4212 let before_rerun = body.clone();
4214 std::fs::remove_file(dir.join("config.toml")).unwrap();
4217 init(Some(dir.clone()), false).unwrap();
4218 let after_rerun = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
4219 assert_eq!(
4220 before_rerun, after_rerun,
4221 "init must be idempotent when the gitignore already has every yui entry"
4222 );
4223 }
4224
4225 #[test]
4231 fn init_with_git_hooks_installs_into_existing_repo() {
4232 let tmp = TempDir::new().unwrap();
4233 let dir = utf8(tmp.path().join("dotfiles"));
4234 std::fs::create_dir_all(&dir).unwrap();
4235 let st = std::process::Command::new("git")
4236 .args(["init", "-q"])
4237 .current_dir(dir.as_std_path())
4238 .status()
4239 .expect("git init");
4240 if !st.success() {
4241 return;
4242 }
4243 let user_config = "# user already wrote this\n";
4245 std::fs::write(dir.join("config.toml"), user_config).unwrap();
4246
4247 init(Some(dir.clone()), true).unwrap();
4249
4250 assert_eq!(
4251 std::fs::read_to_string(dir.join("config.toml")).unwrap(),
4252 user_config
4253 );
4254 assert!(dir.join(".git/hooks/pre-commit").is_file());
4255 assert!(dir.join(".git/hooks/pre-push").is_file());
4256 }
4257
4258 #[test]
4263 fn init_with_git_hooks_writes_pre_commit_and_pre_push() {
4264 let tmp = TempDir::new().unwrap();
4265 let dir = utf8(tmp.path().join("dotfiles"));
4266 std::fs::create_dir_all(&dir).unwrap();
4267 let st = std::process::Command::new("git")
4269 .args(["init", "-q"])
4270 .current_dir(dir.as_std_path())
4271 .status()
4272 .expect("git init");
4273 if !st.success() {
4274 eprintln!("skipping: git not available");
4276 return;
4277 }
4278 init(Some(dir.clone()), true).unwrap();
4279
4280 let pre_commit = dir.join(".git/hooks/pre-commit");
4281 let pre_push = dir.join(".git/hooks/pre-push");
4282 assert!(pre_commit.is_file(), "pre-commit hook should be written");
4283 assert!(pre_push.is_file(), "pre-push hook should be written");
4284
4285 let body = std::fs::read_to_string(&pre_commit).unwrap();
4286 assert!(
4287 body.contains("yui render --check"),
4288 "pre-commit hook should call `yui render --check`, got: {body}"
4289 );
4290 }
4291
4292 #[test]
4296 fn init_with_git_hooks_errors_outside_a_git_repo() {
4297 let tmp = TempDir::new().unwrap();
4298 let dir = utf8(tmp.path().join("not-a-repo"));
4299 std::fs::create_dir_all(&dir).unwrap();
4300 let err = init(Some(dir), true).unwrap_err();
4301 let msg = format!("{err:#}");
4302 assert!(
4303 msg.contains("git repo") || msg.contains("git rev-parse"),
4304 "expected error to mention the git issue, got: {msg}"
4305 );
4306 }
4307
4308 #[test]
4311 fn init_with_git_hooks_does_not_clobber_existing_hooks() {
4312 let tmp = TempDir::new().unwrap();
4313 let dir = utf8(tmp.path().join("dotfiles"));
4314 std::fs::create_dir_all(&dir).unwrap();
4315 let st = std::process::Command::new("git")
4316 .args(["init", "-q"])
4317 .current_dir(dir.as_std_path())
4318 .status()
4319 .expect("git init");
4320 if !st.success() {
4321 return;
4322 }
4323 let hooks = dir.join(".git/hooks");
4324 std::fs::create_dir_all(&hooks).unwrap();
4325 std::fs::write(hooks.join("pre-commit"), "#! /bin/sh\nexit 0\n").unwrap();
4326
4327 init(Some(dir.clone()), true).unwrap();
4328
4329 let pc = std::fs::read_to_string(hooks.join("pre-commit")).unwrap();
4331 assert!(
4332 !pc.contains("yui render --check"),
4333 "existing pre-commit must not be overwritten"
4334 );
4335 let pp = std::fs::read_to_string(hooks.join("pre-push")).unwrap();
4336 assert!(
4337 pp.contains("yui render --check"),
4338 "missing pre-push should be written: {pp}"
4339 );
4340 }
4341
4342 fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
4345 let source = utf8(tmp.path().join("dotfiles"));
4346 let target = utf8(tmp.path().join("target"));
4347 std::fs::create_dir_all(source.join("home")).unwrap();
4348 std::fs::create_dir_all(&target).unwrap();
4349 let cfg = format!(
4350 r#"
4351[[mount.entry]]
4352src = "home"
4353dst = "{}"
4354"#,
4355 toml_path(&target)
4356 );
4357 std::fs::write(source.join("config.toml"), cfg).unwrap();
4358 (source, target)
4359 }
4360
4361 fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
4362 std::fs::write(path, body).unwrap();
4363 let f = std::fs::OpenOptions::new()
4364 .write(true)
4365 .open(path)
4366 .expect("open writable");
4367 f.set_modified(when).expect("set_modified");
4368 }
4369
4370 #[test]
4371 fn apply_target_newer_absorbs_target_into_source() {
4372 let tmp = TempDir::new().unwrap();
4376 let (source, target) = setup_minimal_dotfiles(&tmp);
4377
4378 let now = std::time::SystemTime::now();
4379 let past = now - std::time::Duration::from_secs(120);
4380 write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
4381 write_with_mtime(&target.join(".bashrc"), "user's edit", now);
4383
4384 apply(Some(source.clone()), false).unwrap();
4385
4386 assert_eq!(
4388 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4389 "user's edit"
4390 );
4391 assert_eq!(
4393 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4394 "user's edit"
4395 );
4396 let backup_root = source.join(".yui/backup");
4398 let mut found_old = false;
4399 for entry in walkdir(&backup_root) {
4400 if let Ok(s) = std::fs::read_to_string(&entry) {
4401 if s == "default from repo" {
4402 found_old = true;
4403 break;
4404 }
4405 }
4406 }
4407 assert!(found_old, "expected backup containing 'default from repo'");
4408 }
4409
4410 #[test]
4411 fn apply_in_sync_target_is_a_no_op() {
4412 let tmp = TempDir::new().unwrap();
4415 let (source, target) = setup_minimal_dotfiles(&tmp);
4416 std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4417 apply(Some(source.clone()), false).unwrap();
4418 let backup_root = source.join(".yui/backup");
4419 let backup_count_after_first = walkdir(&backup_root).len();
4420
4421 apply(Some(source.clone()), false).unwrap();
4423 assert_eq!(
4424 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4425 "echo hi\n"
4426 );
4427 let backup_count_after_second = walkdir(&backup_root).len();
4428 assert_eq!(
4429 backup_count_after_first, backup_count_after_second,
4430 "second apply on an in-sync tree should not produce backups"
4431 );
4432 }
4433
4434 #[test]
4435 fn apply_skip_policy_leaves_anomaly_alone() {
4436 let tmp = TempDir::new().unwrap();
4439 let source = utf8(tmp.path().join("dotfiles"));
4440 let target = utf8(tmp.path().join("target"));
4441 std::fs::create_dir_all(source.join("home")).unwrap();
4442 std::fs::create_dir_all(&target).unwrap();
4443 let cfg = format!(
4444 r#"
4445[absorb]
4446on_anomaly = "skip"
4447
4448[[mount.entry]]
4449src = "home"
4450dst = "{}"
4451"#,
4452 toml_path(&target)
4453 );
4454 std::fs::write(source.join("config.toml"), cfg).unwrap();
4455
4456 let now = std::time::SystemTime::now();
4457 let past = now - std::time::Duration::from_secs(120);
4458 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4459 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4460
4461 apply(Some(source.clone()), false).unwrap();
4462
4463 assert_eq!(
4465 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4466 "user's edit (older)"
4467 );
4468 assert_eq!(
4470 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4471 "fresh from upstream"
4472 );
4473 }
4474
4475 #[test]
4476 fn apply_force_policy_absorbs_anomaly_anyway() {
4477 let tmp = TempDir::new().unwrap();
4479 let source = utf8(tmp.path().join("dotfiles"));
4480 let target = utf8(tmp.path().join("target"));
4481 std::fs::create_dir_all(source.join("home")).unwrap();
4482 std::fs::create_dir_all(&target).unwrap();
4483 let cfg = format!(
4484 r#"
4485[absorb]
4486on_anomaly = "force"
4487
4488[[mount.entry]]
4489src = "home"
4490dst = "{}"
4491"#,
4492 toml_path(&target)
4493 );
4494 std::fs::write(source.join("config.toml"), cfg).unwrap();
4495
4496 let now = std::time::SystemTime::now();
4497 let past = now - std::time::Duration::from_secs(120);
4498 write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4499 write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4500
4501 apply(Some(source.clone()), false).unwrap();
4502
4503 assert_eq!(
4505 std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4506 "user's edit (older)"
4507 );
4508 assert_eq!(
4509 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4510 "user's edit (older)"
4511 );
4512 }
4513
4514 #[test]
4526 fn apply_absorbs_non_empty_target_dir_target_wins() {
4527 let tmp = TempDir::new().unwrap();
4528 let source = utf8(tmp.path().join("dotfiles"));
4529 let target = utf8(tmp.path().join("target"));
4530 std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
4531 std::fs::create_dir_all(target.join(".config/app")).unwrap();
4532 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4535 std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
4536 std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
4538 std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
4541 std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
4542
4543 let cfg = format!(
4544 r#"
4545[absorb]
4546on_anomaly = "force"
4547
4548[[mount.entry]]
4549src = "home"
4550dst = "{}"
4551"#,
4552 toml_path(&target)
4553 );
4554 std::fs::write(source.join("config.toml"), cfg).unwrap();
4555
4556 apply(Some(source.clone()), false).unwrap();
4558
4559 assert_eq!(
4561 std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
4562 "target side"
4563 );
4564 assert_eq!(
4566 std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
4567 "{}"
4568 );
4569 let backup_root = source.join(".yui/backup");
4572 let mut backup_files: Vec<String> = Vec::new();
4573 for entry in walkdir(&backup_root) {
4574 if let Some(n) = entry.file_name() {
4575 backup_files.push(n.to_string());
4576 }
4577 }
4578 assert!(
4579 backup_files.iter().any(|f| f == "config.toml"),
4580 "expected source's config.toml to land in the backup tree, got {backup_files:?}"
4581 );
4582 assert!(
4584 source.join("home/.config/app/source-only.toml").exists(),
4585 "source-only file should survive a target-wins merge"
4586 );
4587 assert!(
4589 source.join("home/.config/app/state.json").exists(),
4590 "target-only state.json should be merged into source"
4591 );
4592 }
4593
4594 #[test]
4600 fn marker_dir_absorbs_with_default_ask_policy() {
4601 let tmp = TempDir::new().unwrap();
4602 let source = utf8(tmp.path().join("dotfiles"));
4603 let target = utf8(tmp.path().join("target"));
4604 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4605 std::fs::create_dir_all(target.join(".config/gh")).unwrap();
4606 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4608 std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\n").unwrap();
4610
4611 let cfg = format!(
4615 r#"
4616[[mount.entry]]
4617src = "home"
4618dst = "{}"
4619"#,
4620 toml_path(&target)
4621 );
4622 std::fs::write(source.join("config.toml"), cfg).unwrap();
4623
4624 apply(Some(source.clone()), false).unwrap();
4628
4629 assert!(target.join(".config/gh/hosts.yml").exists());
4632 assert!(source.join("home/.config/gh/hosts.yml").exists());
4633 }
4634
4635 #[test]
4641 fn merge_handles_file_vs_dir_collisions_target_wins() {
4642 let tmp = TempDir::new().unwrap();
4643 let source = utf8(tmp.path().join("dotfiles"));
4644 let target = utf8(tmp.path().join("target"));
4645 std::fs::create_dir_all(source.join("home/.config/foo")).unwrap();
4646 std::fs::create_dir_all(target.join(".config")).unwrap();
4647 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4648
4649 std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
4651 std::fs::write(target.join(".config/foo"), "target file body").unwrap();
4652 std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
4654 std::fs::create_dir_all(target.join(".config/bar")).unwrap();
4655 std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
4656
4657 let cfg = format!(
4658 r#"
4659[absorb]
4660on_anomaly = "force"
4661
4662[[mount.entry]]
4663src = "home"
4664dst = "{}"
4665"#,
4666 toml_path(&target)
4667 );
4668 std::fs::write(source.join("config.toml"), cfg).unwrap();
4669 apply(Some(source.clone()), false).unwrap();
4670
4671 let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
4675 assert!(foo_meta.file_type().is_file(), "foo should be a file");
4676 assert_eq!(
4677 std::fs::read_to_string(target.join(".config/foo")).unwrap(),
4678 "target file body"
4679 );
4680 let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
4682 assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
4683 assert_eq!(
4684 std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
4685 "target nested"
4686 );
4687 }
4688
4689 #[test]
4693 fn merge_per_file_target_newer_auto_absorbs() {
4694 let tmp = TempDir::new().unwrap();
4695 let source = utf8(tmp.path().join("dotfiles"));
4696 let target = utf8(tmp.path().join("target"));
4697 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4698 std::fs::create_dir_all(target.join(".config")).unwrap();
4699 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4700
4701 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4703 write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
4704 std::fs::write(target.join(".config/app.toml"), "user's live edit").unwrap();
4705
4706 let cfg = format!(
4710 r#"
4711[[mount.entry]]
4712src = "home"
4713dst = "{}"
4714"#,
4715 toml_path(&target)
4716 );
4717 std::fs::write(source.join("config.toml"), cfg).unwrap();
4718 apply(Some(source.clone()), false).unwrap();
4719
4720 assert_eq!(
4722 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4723 "user's live edit"
4724 );
4725 }
4726
4727 #[test]
4733 fn merge_per_file_source_newer_skip_keeps_source() {
4734 let tmp = TempDir::new().unwrap();
4735 let source = utf8(tmp.path().join("dotfiles"));
4736 let target = utf8(tmp.path().join("target"));
4737 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4738 std::fs::create_dir_all(target.join(".config")).unwrap();
4739 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4740
4741 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4743 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
4744 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
4745
4746 let cfg = format!(
4747 r#"
4748[absorb]
4749on_anomaly = "skip"
4750
4751[[mount.entry]]
4752src = "home"
4753dst = "{}"
4754"#,
4755 toml_path(&target)
4756 );
4757 std::fs::write(source.join("config.toml"), cfg).unwrap();
4758 apply(Some(source.clone()), false).unwrap();
4759
4760 assert_eq!(
4763 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4764 "fresh source"
4765 );
4766 }
4767
4768 #[test]
4771 fn merge_per_file_source_newer_force_overwrites_source() {
4772 let tmp = TempDir::new().unwrap();
4773 let source = utf8(tmp.path().join("dotfiles"));
4774 let target = utf8(tmp.path().join("target"));
4775 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4776 std::fs::create_dir_all(target.join(".config")).unwrap();
4777 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4778
4779 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4780 write_with_mtime(&target.join(".config/app.toml"), "old target", past);
4781 std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
4782
4783 let cfg = format!(
4784 r#"
4785[absorb]
4786on_anomaly = "force"
4787
4788[[mount.entry]]
4789src = "home"
4790dst = "{}"
4791"#,
4792 toml_path(&target)
4793 );
4794 std::fs::write(source.join("config.toml"), cfg).unwrap();
4795 apply(Some(source.clone()), false).unwrap();
4796
4797 assert_eq!(
4799 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4800 "old target"
4801 );
4802 }
4803
4804 #[test]
4809 fn merge_per_file_identical_content_is_noop() {
4810 let tmp = TempDir::new().unwrap();
4811 let source = utf8(tmp.path().join("dotfiles"));
4812 let target = utf8(tmp.path().join("target"));
4813 std::fs::create_dir_all(source.join("home/.config")).unwrap();
4814 std::fs::create_dir_all(target.join(".config")).unwrap();
4815 std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4816 std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
4817 std::fs::write(target.join(".config/app.toml"), "same").unwrap();
4818
4819 let cfg = format!(
4822 r#"
4823[[mount.entry]]
4824src = "home"
4825dst = "{}"
4826"#,
4827 toml_path(&target)
4828 );
4829 std::fs::write(source.join("config.toml"), cfg).unwrap();
4830 apply(Some(source.clone()), false).unwrap();
4831
4832 assert_eq!(
4833 std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4834 "same"
4835 );
4836 }
4837
4838 #[test]
4839 fn manual_absorb_command_pulls_target_into_source() {
4840 let tmp = TempDir::new().unwrap();
4842 let source = utf8(tmp.path().join("dotfiles"));
4843 let target = utf8(tmp.path().join("target"));
4844 std::fs::create_dir_all(source.join("home")).unwrap();
4845 std::fs::create_dir_all(&target).unwrap();
4846 let cfg = format!(
4848 r#"
4849[absorb]
4850on_anomaly = "skip"
4851
4852[[mount.entry]]
4853src = "home"
4854dst = "{}"
4855"#,
4856 toml_path(&target)
4857 );
4858 std::fs::write(source.join("config.toml"), cfg).unwrap();
4859 std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
4860 std::fs::write(source.join("home/.bashrc"), "default").unwrap();
4861
4862 absorb(
4865 Some(source.clone()),
4866 target.join(".bashrc"),
4867 false,
4868 true,
4869 )
4870 .unwrap();
4871
4872 assert_eq!(
4874 std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4875 "user picked this"
4876 );
4877 }
4878
4879 #[test]
4880 fn manual_absorb_errors_when_target_outside_known_mounts() {
4881 let tmp = TempDir::new().unwrap();
4882 let (source, _target) = setup_minimal_dotfiles(&tmp);
4883 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
4884 let stranger = utf8(tmp.path().join("not-managed/foo"));
4885 std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
4886 std::fs::write(&stranger, "not yui's").unwrap();
4887 let err = absorb(Some(source), stranger, false, true).unwrap_err();
4888 assert!(format!("{err}").contains("no mount entry"));
4889 }
4890
4891 #[test]
4892 fn yuiignore_excludes_file_from_linking() {
4893 let tmp = TempDir::new().unwrap();
4894 let (source, target) = setup_minimal_dotfiles(&tmp);
4895 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
4896 std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
4897 std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
4899 apply(Some(source.clone()), false).unwrap();
4900 assert!(target.join(".bashrc").exists());
4901 assert!(
4902 !target.join("lock.json").exists(),
4903 "yuiignore should keep lock.json out of target"
4904 );
4905 }
4906
4907 #[test]
4908 fn yuiignore_excludes_directory_subtree() {
4909 let tmp = TempDir::new().unwrap();
4910 let (source, target) = setup_minimal_dotfiles(&tmp);
4911 std::fs::create_dir_all(source.join("home/cache")).unwrap();
4912 std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
4913 std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
4914 std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
4915 std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
4917 apply(Some(source.clone()), false).unwrap();
4918 assert!(target.join(".bashrc").exists());
4919 assert!(
4920 !target.join("cache").exists(),
4921 "yuiignore'd subtree should not appear in target"
4922 );
4923 }
4924
4925 #[test]
4926 fn yuiignore_negation_re_includes_file() {
4927 let tmp = TempDir::new().unwrap();
4928 let (source, target) = setup_minimal_dotfiles(&tmp);
4929 std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
4930 std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
4931 std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
4933 apply(Some(source.clone()), false).unwrap();
4934 assert!(target.join("keep.cache").exists());
4935 assert!(!target.join("drop.cache").exists());
4936 }
4937
4938 #[test]
4943 fn nested_yuiignore_only_affects_its_subtree() {
4944 let tmp = TempDir::new().unwrap();
4945 let (source, target) = setup_minimal_dotfiles(&tmp);
4946 std::fs::create_dir_all(source.join("home/inner")).unwrap();
4947 std::fs::write(source.join("home/secret.txt"), "outer keep").unwrap();
4948 std::fs::write(source.join("home/inner/secret.txt"), "inner drop").unwrap();
4949 std::fs::write(source.join("home/inner/keep.txt"), "inner keep").unwrap();
4950 std::fs::write(source.join("home/inner/.yuiignore"), "secret*\n").unwrap();
4952 apply(Some(source.clone()), false).unwrap();
4953 assert!(
4954 target.join("secret.txt").exists(),
4955 "outer secret.txt is outside the nested .yuiignore scope"
4956 );
4957 assert!(target.join("inner/keep.txt").exists());
4958 assert!(
4959 !target.join("inner/secret.txt").exists(),
4960 "inner secret.txt should be excluded by the nested .yuiignore"
4961 );
4962 }
4963
4964 #[test]
4968 fn nested_yuiignore_negation_overrides_root_rule() {
4969 let tmp = TempDir::new().unwrap();
4970 let (source, target) = setup_minimal_dotfiles(&tmp);
4971 std::fs::create_dir_all(source.join("home/keepers")).unwrap();
4972 std::fs::write(source.join("home/drop.lock"), "outer drop").unwrap();
4973 std::fs::write(source.join("home/keepers/wanted.lock"), "inner keep").unwrap();
4974 std::fs::write(source.join(".yuiignore"), "*.lock\n").unwrap();
4975 std::fs::write(source.join("home/keepers/.yuiignore"), "!*.lock\n").unwrap();
4977 apply(Some(source.clone()), false).unwrap();
4978 assert!(
4979 !target.join("drop.lock").exists(),
4980 "root rule still drops outer .lock file"
4981 );
4982 assert!(
4983 target.join("keepers/wanted.lock").exists(),
4984 "nested negation re-includes .lock under keepers/"
4985 );
4986 }
4987
4988 #[test]
4992 fn nested_yuiignore_status_walk_scoped() {
4993 let tmp = TempDir::new().unwrap();
4994 let (source, _target) = setup_minimal_dotfiles(&tmp);
4995 std::fs::create_dir_all(source.join("home/a")).unwrap();
4996 std::fs::create_dir_all(source.join("home/b")).unwrap();
4997 std::fs::write(source.join("home/a/foo.txt"), "a-foo").unwrap();
4998 std::fs::write(source.join("home/b/foo.txt"), "b-foo").unwrap();
4999 std::fs::write(source.join("home/a/.yuiignore"), "foo.txt\n").unwrap();
5001 apply(Some(source.clone()), false).unwrap();
5002 let res = status(Some(source), None, true);
5004 assert!(res.is_ok() || matches!(&res, Err(e) if format!("{e}").contains("diverged")));
5005 }
5006
5007 #[test]
5008 fn yuiignore_skips_template_in_render() {
5009 let tmp = TempDir::new().unwrap();
5010 let source = utf8(tmp.path().join("dotfiles"));
5011 let target = utf8(tmp.path().join("target"));
5012 std::fs::create_dir_all(source.join("home")).unwrap();
5013 std::fs::create_dir_all(&target).unwrap();
5014 std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
5015 std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
5016 let cfg = format!(
5017 r#"
5018[[mount.entry]]
5019src = "home"
5020dst = "{}"
5021"#,
5022 toml_path(&target)
5023 );
5024 std::fs::write(source.join("config.toml"), cfg).unwrap();
5025 apply(Some(source.clone()), false).unwrap();
5026 assert!(!source.join("home/note").exists());
5028 assert!(!target.join("note").exists());
5029 assert!(!target.join("note.tera").exists());
5030 }
5031
5032 #[test]
5041 fn apply_decrypts_age_files_to_sibling_and_links() {
5042 let tmp = TempDir::new().unwrap();
5043 let source = utf8(tmp.path().join("dotfiles"));
5044 let target = utf8(tmp.path().join("target"));
5045 std::fs::create_dir_all(source.join("home/.ssh")).unwrap();
5046 std::fs::create_dir_all(&target).unwrap();
5047
5048 let identity_path = utf8(tmp.path().join("age.txt"));
5051 let (secret, public) = secret::generate_x25519_keypair();
5052 std::fs::write(&identity_path, format!("{secret}\n")).unwrap();
5053
5054 let recipient = secret::parse_x25519_recipient(&public).unwrap();
5056 let cipher = secret::encrypt_x25519(b"-- super secret key --\n", &[recipient]).unwrap();
5057 std::fs::write(source.join("home/.ssh/id_ed25519.age"), &cipher).unwrap();
5058
5059 let cfg = format!(
5061 r#"
5062[[mount.entry]]
5063src = "home"
5064dst = "{}"
5065
5066[secrets]
5067identity = "{}"
5068recipients = ["{}"]
5069"#,
5070 toml_path(&target),
5071 toml_path(&identity_path),
5072 public
5073 );
5074 std::fs::write(source.join("config.toml"), cfg).unwrap();
5075
5076 apply(Some(source.clone()), false).unwrap();
5077
5078 assert!(source.join("home/.ssh/id_ed25519").exists());
5080 let target_bytes = std::fs::read(target.join(".ssh/id_ed25519")).unwrap();
5082 assert_eq!(target_bytes, b"-- super secret key --\n");
5083 let gi = std::fs::read_to_string(source.join(".gitignore")).unwrap();
5085 assert!(
5086 gi.contains("home/.ssh/id_ed25519"),
5087 ".gitignore should list the decrypted plaintext sibling: {gi}"
5088 );
5089 }
5092
5093 #[test]
5098 fn apply_bails_on_secret_drift() {
5099 let tmp = TempDir::new().unwrap();
5100 let source = utf8(tmp.path().join("dotfiles"));
5101 let target = utf8(tmp.path().join("target"));
5102 std::fs::create_dir_all(source.join("home")).unwrap();
5103 std::fs::create_dir_all(&target).unwrap();
5104
5105 let identity_path = utf8(tmp.path().join("age.txt"));
5106 let (secret_key, public) = secret::generate_x25519_keypair();
5107 std::fs::write(&identity_path, format!("{secret_key}\n")).unwrap();
5108
5109 let recipient = secret::parse_x25519_recipient(&public).unwrap();
5110 let cipher = secret::encrypt_x25519(b"v1 content\n", &[recipient]).unwrap();
5111 std::fs::write(source.join("home/secret.age"), &cipher).unwrap();
5112 std::fs::write(source.join("home/secret"), "edited locally\n").unwrap();
5114
5115 let cfg = format!(
5116 r#"
5117[[mount.entry]]
5118src = "home"
5119dst = "{}"
5120
5121[secrets]
5122identity = "{}"
5123recipients = ["{}"]
5124"#,
5125 toml_path(&target),
5126 toml_path(&identity_path),
5127 public
5128 );
5129 std::fs::write(source.join("config.toml"), cfg).unwrap();
5130
5131 let err = apply(Some(source.clone()), false).unwrap_err();
5132 assert!(
5133 format!("{err:#}").contains("secret drift"),
5134 "expected secret drift error, got: {err:#}"
5135 );
5136 }
5137
5138 #[test]
5141 fn append_recipient_creates_secrets_table_when_missing() {
5142 let result =
5143 append_recipient_to_local("", "host alice", "age1abcrecipientpublickey").unwrap();
5144 let parsed: toml::Table = toml::from_str(&result).unwrap();
5146 let secrets = parsed.get("secrets").and_then(|v| v.as_table()).unwrap();
5147 let recipients = secrets
5148 .get("recipients")
5149 .and_then(|v| v.as_array())
5150 .unwrap();
5151 assert_eq!(recipients.len(), 1);
5152 assert_eq!(recipients[0].as_str(), Some("age1abcrecipientpublickey"));
5153 }
5154
5155 #[test]
5156 fn append_recipient_preserves_existing_other_tables() {
5157 let existing = r#"
5161[vars]
5162greet = "hi"
5163
5164[secrets]
5165recipients = ["age1machine_a"]
5166
5167[ui]
5168icons = "ascii"
5169"#;
5170 let result = append_recipient_to_local(existing, "host b", "age1machine_b").unwrap();
5171 let parsed: toml::Table = toml::from_str(&result).unwrap();
5172 assert!(parsed.get("vars").is_some());
5174 assert!(parsed.get("secrets").is_some());
5175 assert!(parsed.get("ui").is_some());
5176 let recipients = parsed["secrets"]["recipients"].as_array().unwrap();
5178 assert_eq!(recipients.len(), 2);
5179 let pubs: Vec<&str> = recipients.iter().filter_map(|v| v.as_str()).collect();
5180 assert!(pubs.contains(&"age1machine_a"));
5181 assert!(pubs.contains(&"age1machine_b"));
5182 }
5183
5184 #[test]
5185 fn append_recipient_is_idempotent_on_duplicate() {
5186 let existing = r#"[secrets]
5187recipients = ["age1same"]
5188"#;
5189 let result = append_recipient_to_local(existing, "anyone", "age1same").unwrap();
5190 let parsed: toml::Table = toml::from_str(&result).unwrap();
5191 let recipients = parsed["secrets"]["recipients"].as_array().unwrap();
5192 assert_eq!(recipients.len(), 1, "duplicate must not be appended twice");
5193 }
5194
5195 #[test]
5196 fn append_recipient_creates_recipients_array_when_secrets_table_empty() {
5197 let existing = r#"[secrets]
5200identity = "~/.config/yui/age.txt"
5201"#;
5202 let result = append_recipient_to_local(existing, "h", "age1new").unwrap();
5203 let parsed: toml::Table = toml::from_str(&result).unwrap();
5204 let secrets = parsed["secrets"].as_table().unwrap();
5205 assert_eq!(
5206 secrets["identity"].as_str(),
5207 Some("~/.config/yui/age.txt"),
5208 "existing identity field must survive"
5209 );
5210 let recipients = secrets["recipients"].as_array().unwrap();
5211 assert_eq!(recipients.len(), 1);
5212 assert_eq!(recipients[0].as_str(), Some("age1new"));
5213 }
5214
5215 #[test]
5219 fn apply_without_recipients_skips_secret_walker() {
5220 let tmp = TempDir::new().unwrap();
5221 let (source, _target) = setup_minimal_dotfiles(&tmp);
5222 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5224 std::fs::write(source.join("home/some.junk.age"), b"not actually a cipher").unwrap();
5228 apply(Some(source.clone()), false).unwrap();
5229 }
5230
5231 #[test]
5235 fn nested_marker_accumulates_extra_dst() {
5236 let tmp = TempDir::new().unwrap();
5237 let source = utf8(tmp.path().join("dotfiles"));
5238 let parent_target = utf8(tmp.path().join("home"));
5239 let extra_target = utf8(tmp.path().join("extra"));
5240 std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
5241 std::fs::create_dir_all(&parent_target).unwrap();
5242 std::fs::create_dir_all(&extra_target).unwrap();
5243 std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
5244
5245 std::fs::write(
5247 source.join("home/.config/.yuilink"),
5248 format!(
5249 r#"
5250[[link]]
5251dst = "{}/.config"
5252"#,
5253 toml_path(&parent_target)
5254 ),
5255 )
5256 .unwrap();
5257 std::fs::write(
5260 source.join("home/.config/nvim/.yuilink"),
5261 format!(
5262 r#"
5263[[link]]
5264dst = "{}/nvim"
5265when = "{{{{ yui.os == '{}' }}}}"
5266"#,
5267 toml_path(&extra_target),
5268 std::env::consts::OS
5269 ),
5270 )
5271 .unwrap();
5272
5273 let cfg = format!(
5274 r#"
5275[[mount.entry]]
5276src = "home"
5277dst = "{}"
5278"#,
5279 toml_path(&parent_target)
5280 );
5281 std::fs::write(source.join("config.toml"), cfg).unwrap();
5282
5283 apply(Some(source.clone()), false).unwrap();
5284
5285 assert!(parent_target.join(".config/nvim/init.lua").exists());
5288 assert!(extra_target.join("nvim/init.lua").exists());
5289 }
5290
5291 #[test]
5296 fn marker_file_link_targets_specific_file() {
5297 let tmp = TempDir::new().unwrap();
5298 let source = utf8(tmp.path().join("dotfiles"));
5299 let parent_target = utf8(tmp.path().join("home"));
5300 let docs_target = utf8(tmp.path().join("docs"));
5301 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
5302 std::fs::create_dir_all(&parent_target).unwrap();
5303 std::fs::create_dir_all(&docs_target).unwrap();
5304 std::fs::write(
5305 source.join("home/.config/powershell/profile.ps1"),
5306 "# profile\n",
5307 )
5308 .unwrap();
5309 std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
5310
5311 std::fs::write(
5314 source.join("home/.config/powershell/.yuilink"),
5315 format!(
5316 r#"
5317[[link]]
5318src = "profile.ps1"
5319dst = "{}/Microsoft.PowerShell_profile.ps1"
5320"#,
5321 toml_path(&docs_target)
5322 ),
5323 )
5324 .unwrap();
5325
5326 let cfg = format!(
5327 r#"
5328[[mount.entry]]
5329src = "home"
5330dst = "{}"
5331"#,
5332 toml_path(&parent_target)
5333 );
5334 std::fs::write(source.join("config.toml"), cfg).unwrap();
5335
5336 apply(Some(source.clone()), false).unwrap();
5337
5338 assert!(
5340 docs_target
5341 .join("Microsoft.PowerShell_profile.ps1")
5342 .exists()
5343 );
5344 assert!(
5347 parent_target
5348 .join(".config/powershell/profile.ps1")
5349 .exists()
5350 );
5351 assert!(parent_target.join(".config/powershell/extra.txt").exists());
5352 }
5353
5354 #[test]
5357 fn marker_file_link_missing_src_errors() {
5358 let tmp = TempDir::new().unwrap();
5359 let source = utf8(tmp.path().join("dotfiles"));
5360 let parent_target = utf8(tmp.path().join("home"));
5361 let docs_target = utf8(tmp.path().join("docs"));
5362 std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
5363 std::fs::create_dir_all(&parent_target).unwrap();
5364 std::fs::create_dir_all(&docs_target).unwrap();
5365
5366 std::fs::write(
5367 source.join("home/.config/powershell/.yuilink"),
5368 format!(
5369 r#"
5370[[link]]
5371src = "missing.ps1"
5372dst = "{}/profile.ps1"
5373"#,
5374 toml_path(&docs_target)
5375 ),
5376 )
5377 .unwrap();
5378
5379 let cfg = format!(
5380 r#"
5381[[mount.entry]]
5382src = "home"
5383dst = "{}"
5384"#,
5385 toml_path(&parent_target)
5386 );
5387 std::fs::write(source.join("config.toml"), cfg).unwrap();
5388
5389 let err = apply(Some(source.clone()), false).unwrap_err();
5390 assert!(format!("{err:#}").contains("missing.ps1"));
5391 }
5392
5393 #[test]
5402 fn unmanaged_finds_files_outside_any_mount() {
5403 let tmp = TempDir::new().unwrap();
5404 let (source, _target) = setup_minimal_dotfiles(&tmp);
5405 std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5407 std::fs::write(source.join("orphan.txt"), "y").unwrap();
5409 std::fs::create_dir_all(source.join("notes")).unwrap();
5410 std::fs::write(source.join("notes/scratch.md"), "z").unwrap();
5411
5412 unmanaged(Some(source.clone()), None, true).unwrap();
5414
5415 let yui = YuiVars::detect(&source);
5417 let cfg = config::load(&source, &yui).unwrap();
5418 let mount_srcs: Vec<Utf8PathBuf> = cfg
5419 .mount
5420 .entry
5421 .iter()
5422 .map(|m| source.join(&m.src))
5423 .collect();
5424 let walker = paths::source_walker(&source).build();
5425 let mut unmanaged_paths = Vec::new();
5426 for entry in walker.flatten() {
5427 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
5428 continue;
5429 }
5430 let p = match Utf8PathBuf::from_path_buf(entry.path().to_path_buf()) {
5431 Ok(p) => p,
5432 Err(_) => continue,
5433 };
5434 if is_repo_meta(&p, &source, &cfg.mount.marker_filename) {
5435 continue;
5436 }
5437 if mount_srcs.iter().any(|m| p.starts_with(m)) {
5438 continue;
5439 }
5440 unmanaged_paths.push(p);
5441 }
5442 let names: Vec<String> = unmanaged_paths
5443 .iter()
5444 .filter_map(|p| p.file_name().map(String::from))
5445 .collect();
5446 assert!(names.contains(&"orphan.txt".into()));
5447 assert!(names.contains(&"scratch.md".into()));
5448 assert!(!names.contains(&".bashrc".into()), "mount-claimed file");
5449 assert!(!names.contains(&"config.toml".into()), "repo meta");
5450 }
5451
5452 #[test]
5453 fn is_repo_meta_recognises_yui_scaffold() {
5454 let source = Utf8Path::new("/dot");
5455 assert!(is_repo_meta(
5457 Utf8Path::new("/dot/config.toml"),
5458 source,
5459 ".yuilink",
5460 ));
5461 assert!(is_repo_meta(
5462 Utf8Path::new("/dot/config.local.toml"),
5463 source,
5464 ".yuilink",
5465 ));
5466 assert!(is_repo_meta(
5467 Utf8Path::new("/dot/config.linux.toml"),
5468 source,
5469 ".yuilink",
5470 ));
5471 assert!(is_repo_meta(
5472 Utf8Path::new("/dot/config.local.example.toml"),
5473 source,
5474 ".yuilink",
5475 ));
5476 assert!(is_repo_meta(
5478 Utf8Path::new("/dot/.gitignore"),
5479 source,
5480 ".yuilink",
5481 ));
5482 assert!(is_repo_meta(
5484 Utf8Path::new("/dot/home/.config/foo/.yuilink"),
5485 source,
5486 ".yuilink",
5487 ));
5488 assert!(is_repo_meta(
5489 Utf8Path::new("/dot/home/.gitconfig.tera"),
5490 source,
5491 ".yuilink",
5492 ));
5493 assert!(!is_repo_meta(
5495 Utf8Path::new("/dot/home/.config/myapp/config.toml"),
5496 source,
5497 ".yuilink",
5498 ));
5499 assert!(!is_repo_meta(
5503 Utf8Path::new("/dot/home/.config/git/.gitignore"),
5504 source,
5505 ".yuilink",
5506 ));
5507 }
5508
5509 #[test]
5516 fn unmanaged_respects_inactive_mount_entries() {
5517 let tmp = TempDir::new().unwrap();
5518 let source = utf8(tmp.path().join("dotfiles"));
5519 let target = utf8(tmp.path().join("target"));
5520 std::fs::create_dir_all(source.join("home_active")).unwrap();
5521 std::fs::create_dir_all(source.join("home_other_os")).unwrap();
5522 std::fs::create_dir_all(&target).unwrap();
5523 std::fs::write(source.join("home_active/.bashrc"), "active").unwrap();
5524 std::fs::write(source.join("home_other_os/.bashrc"), "inactive").unwrap();
5525 let cfg = format!(
5527 r#"
5528[[mount.entry]]
5529src = "home_active"
5530dst = "{target}"
5531
5532[[mount.entry]]
5533src = "home_other_os"
5534dst = "{target}"
5535when = "yui.os == 'definitely_not_a_real_os'"
5536"#,
5537 target = toml_path(&target)
5538 );
5539 std::fs::write(source.join("config.toml"), cfg).unwrap();
5540
5541 let yui = YuiVars::detect(&source);
5545 let cfg = config::load(&source, &yui).unwrap();
5546 let mount_srcs: Vec<Utf8PathBuf> = cfg
5547 .mount
5548 .entry
5549 .iter()
5550 .map(|m| source.join(&m.src))
5551 .collect();
5552 let inactive_file = source.join("home_other_os/.bashrc");
5553 let claimed = mount_srcs.iter().any(|m| inactive_file.starts_with(m));
5554 assert!(
5555 claimed,
5556 "raw config.mount.entry should claim files even under inactive mounts"
5557 );
5558 }
5559
5560 #[test]
5565 fn diff_shows_drift_skips_in_sync() {
5566 let tmp = TempDir::new().unwrap();
5567 let (source, target) = setup_minimal_dotfiles(&tmp);
5568 std::fs::write(source.join("home/.bashrc"), "first\nsecond\n").unwrap();
5569 apply(Some(source.clone()), false).unwrap();
5571 std::fs::remove_file(target.join(".bashrc")).unwrap();
5573 std::fs::write(target.join(".bashrc"), "first\nEDITED\n").unwrap();
5574
5575 diff(Some(source.clone()), None, true).unwrap();
5578 }
5579
5580 #[test]
5585 fn read_text_for_diff_classifies_correctly() {
5586 let tmp = TempDir::new().unwrap();
5587 let root = utf8(tmp.path().to_path_buf());
5588 let txt = root.join("a.txt");
5590 std::fs::write(&txt, "hello\n").unwrap();
5591 match read_text_for_diff(&txt) {
5592 DiffSide::Text(s) => assert_eq!(s, "hello\n"),
5593 DiffSide::Binary => panic!("text file misclassified as binary"),
5594 }
5595 let bin = root.join("b.bin");
5597 std::fs::write(&bin, [0xff, 0xfe, 0x00, 0xff]).unwrap();
5598 assert!(matches!(read_text_for_diff(&bin), DiffSide::Binary));
5599 let missing = root.join("missing.txt");
5601 match read_text_for_diff(&missing) {
5602 DiffSide::Text(s) => assert!(s.is_empty()),
5603 DiffSide::Binary => panic!("missing file misclassified as binary"),
5604 }
5605 }
5606
5607 #[test]
5614 fn diff_render_drift_uses_rendered_output_not_raw_template() {
5615 let tmp = TempDir::new().unwrap();
5616 let (source, _target) = setup_minimal_dotfiles(&tmp);
5617 std::fs::write(source.join("home/note.tera"), "os = {{ yui.os }}\n").unwrap();
5620 std::fs::write(source.join("home/note"), "os = ancient\n").unwrap();
5621 let yui = YuiVars::detect(&source);
5623 let cfg = config::load(&source, &yui).unwrap();
5624 let rendered =
5625 render::render_to_string(&source.join("home/note.tera"), &source, &cfg, &yui)
5626 .unwrap()
5627 .expect("template should render on this host");
5628 assert!(rendered.starts_with("os = "));
5629 assert!(
5630 !rendered.contains("{{"),
5631 "rendered output must not contain raw Tera tags"
5632 );
5633 }
5634
5635 #[test]
5643 fn resolve_diff_src_absolutizes_link_rows() {
5644 let source = Utf8Path::new("/dot");
5645 let link_item = StatusItem {
5646 src: Utf8PathBuf::from("home/.bashrc"),
5647 dst: Utf8PathBuf::from("/h/u/.bashrc"),
5648 state: StatusState::Link(absorb::AbsorbDecision::AutoAbsorb),
5649 };
5650 assert_eq!(
5651 resolve_diff_src(&link_item, source),
5652 Utf8PathBuf::from("/dot/home/.bashrc"),
5653 );
5654 let render_item = StatusItem {
5655 src: Utf8PathBuf::from("/dot/home/foo.tera"),
5656 dst: Utf8PathBuf::from("/dot/home/foo"),
5657 state: StatusState::RenderDrift,
5658 };
5659 assert_eq!(
5660 resolve_diff_src(&render_item, source),
5661 Utf8PathBuf::from("/dot/home/foo.tera"),
5662 );
5663 }
5664
5665 #[test]
5666 fn diff_classifier_skips_uninteresting_states() {
5667 use absorb::AbsorbDecision::*;
5668 assert!(!diff_worth_printing(&StatusState::Link(InSync)));
5670 assert!(!diff_worth_printing(&StatusState::Link(Restore)));
5671 assert!(!diff_worth_printing(&StatusState::Link(RelinkOnly)));
5672 assert!(diff_worth_printing(&StatusState::Link(AutoAbsorb)));
5674 assert!(diff_worth_printing(&StatusState::Link(NeedsConfirm)));
5675 assert!(diff_worth_printing(&StatusState::RenderDrift));
5676 }
5677
5678 #[test]
5689 fn update_errors_when_source_is_not_a_git_repo() {
5690 let tmp = TempDir::new().unwrap();
5691 let source = utf8(tmp.path().join("dotfiles"));
5692 std::fs::create_dir_all(&source).unwrap();
5693 std::fs::write(source.join("config.toml"), "").unwrap();
5694 let err = update(Some(source), false).unwrap_err();
5696 let msg = format!("{err:#}");
5697 assert!(
5698 msg.contains("not a git repository")
5699 || msg.contains("uncommitted")
5700 || msg.contains("git"),
5701 "unexpected error: {msg}",
5702 );
5703 }
5704
5705 fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
5706 let mut out = Vec::new();
5707 let mut stack = vec![root.to_path_buf()];
5708 while let Some(dir) = stack.pop() {
5709 let Ok(entries) = std::fs::read_dir(&dir) else {
5710 continue;
5711 };
5712 for e in entries.flatten() {
5713 let p = utf8(e.path());
5714 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
5715 stack.push(p);
5716 } else {
5717 out.push(p);
5718 }
5719 }
5720 }
5721 out
5722 }
5723
5724 #[test]
5729 fn parse_backup_suffix_recognises_file_with_extension() {
5730 let dt = parse_backup_suffix("foo_20260429_143022123.yml").unwrap();
5731 assert_eq!(dt.year(), 2026);
5732 assert_eq!(dt.month(), 4);
5733 assert_eq!(dt.day(), 29);
5734 assert_eq!(dt.hour(), 14);
5735 assert_eq!(dt.minute(), 30);
5736 assert_eq!(dt.second(), 22);
5737 }
5738
5739 #[test]
5740 fn parse_backup_suffix_recognises_dotfile_no_extension() {
5741 let dt = parse_backup_suffix(".gitconfig_20260429_143022123").unwrap();
5742 assert_eq!(dt.year(), 2026);
5743 }
5744
5745 #[test]
5746 fn parse_backup_suffix_recognises_directory_form() {
5747 let dt = parse_backup_suffix("nvim_20260429_143022123").unwrap();
5748 assert_eq!(dt.day(), 29);
5749 }
5750
5751 #[test]
5752 fn parse_backup_suffix_recognises_multi_dot_filename() {
5753 let dt = parse_backup_suffix("archive.tar.gz_20260429_143022123.gz").unwrap();
5755 assert_eq!(dt.month(), 4);
5756 }
5757
5758 #[test]
5759 fn parse_backup_suffix_rejects_non_yui_names() {
5760 assert!(parse_backup_suffix("README.md").is_none());
5761 assert!(parse_backup_suffix("notes_2026.txt").is_none());
5762 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());
5766 }
5767
5768 #[test]
5769 fn parse_human_duration_basic_units() {
5770 let s = parse_human_duration("30d").unwrap();
5771 assert_eq!(s.get_days(), 30);
5772 let s = parse_human_duration("2w").unwrap();
5773 assert_eq!(s.get_weeks(), 2);
5774 let s = parse_human_duration("12h").unwrap();
5775 assert_eq!(s.get_hours(), 12);
5776 let s = parse_human_duration("5m").unwrap();
5778 assert_eq!(s.get_minutes(), 5);
5779 let s = parse_human_duration("6mo").unwrap();
5780 assert_eq!(s.get_months(), 6);
5781 let s = parse_human_duration("1y").unwrap();
5782 assert_eq!(s.get_years(), 1);
5783 }
5784
5785 #[test]
5786 fn parse_human_duration_case_insensitive_and_whitespace() {
5787 let s = parse_human_duration(" 90D ").unwrap();
5788 assert_eq!(s.get_days(), 90);
5789 let s = parse_human_duration("3WEEKS").unwrap();
5790 assert_eq!(s.get_weeks(), 3);
5791 }
5792
5793 #[test]
5794 fn parse_human_duration_rejects_garbage() {
5795 assert!(parse_human_duration("").is_err());
5796 assert!(parse_human_duration("d30").is_err());
5797 assert!(parse_human_duration("30").is_err()); assert!(parse_human_duration("30x").is_err()); assert!(parse_human_duration("-1d").is_err()); }
5801
5802 #[test]
5806 fn walk_gc_backups_collects_files_and_dir_snapshots() {
5807 let tmp = TempDir::new().unwrap();
5808 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
5809 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
5810 std::fs::write(
5812 root.join("C/Users/u/.config/foo_20260429_143022123.yml"),
5813 "old yml",
5814 )
5815 .unwrap();
5816 std::fs::create_dir_all(root.join("C/Users/u/nvim_20260101_000000000/lua")).unwrap();
5818 std::fs::write(
5819 root.join("C/Users/u/nvim_20260101_000000000/init.lua"),
5820 "ok",
5821 )
5822 .unwrap();
5823 std::fs::write(
5824 root.join("C/Users/u/nvim_20260101_000000000/lua/x.lua"),
5825 "kk",
5826 )
5827 .unwrap();
5828 std::fs::write(root.join("C/Users/u/.config/README.md"), "user note").unwrap();
5830
5831 let entries = walk_gc_backups(&root).unwrap();
5832 assert_eq!(entries.len(), 2, "two backup roots, not three");
5833 let kinds: Vec<_> = entries.iter().map(|e| e.kind).collect();
5834 assert!(kinds.contains(&BackupKind::File));
5835 assert!(kinds.contains(&BackupKind::Dir));
5836 let dir_entry = entries.iter().find(|e| e.kind == BackupKind::Dir).unwrap();
5838 assert!(dir_entry.size_bytes >= 4); }
5840
5841 #[test]
5842 fn cleanup_empty_parents_stops_at_root_and_at_non_empty() {
5843 let tmp = TempDir::new().unwrap();
5844 let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
5845 std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
5846 std::fs::write(root.join("C/Users/u/sibling_keep"), "x").unwrap();
5847
5848 cleanup_empty_parents(&root.join("C/Users/u/.config"), &root);
5852
5853 assert!(!root.join("C/Users/u/.config").exists(), "empty leaf gone");
5854 assert!(root.join("C/Users/u").exists(), "stops at non-empty parent");
5855 assert!(root.exists(), "backup root preserved");
5856 }
5857
5858 #[test]
5860 fn gc_backup_survey_keeps_all_entries() {
5861 let tmp = TempDir::new().unwrap();
5862 let source = utf8(tmp.path().join("dotfiles"));
5863 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
5864 std::fs::write(source.join("config.toml"), "").unwrap();
5865 let backup = source.join(".yui/backup");
5866 std::fs::write(backup.join("a_20260101_000000000.txt"), "old").unwrap();
5867 std::fs::write(backup.join("b_20260415_120000000.txt"), "fresh").unwrap();
5868
5869 gc_backup(Some(source.clone()), None, false, None, true).unwrap();
5870
5871 assert!(backup.join("a_20260101_000000000.txt").exists());
5873 assert!(backup.join("b_20260415_120000000.txt").exists());
5874 }
5875
5876 #[test]
5879 fn gc_backup_prune_removes_old_files_only() {
5880 let tmp = TempDir::new().unwrap();
5881 let source = utf8(tmp.path().join("dotfiles"));
5882 std::fs::create_dir_all(source.join(".yui/backup/sub")).unwrap();
5883 std::fs::write(source.join("config.toml"), "").unwrap();
5884 let backup = source.join(".yui/backup");
5885
5886 std::fs::write(backup.join("sub/old_20200101_000000000.txt"), "old").unwrap();
5888 let tomorrow = jiff::Zoned::now()
5890 .checked_add(jiff::Span::new().days(1))
5891 .unwrap();
5892 let bdt = jiff::fmt::strtime::BrokenDownTime::from(&tomorrow);
5893 let future_ts = bdt.to_string("%Y%m%d_%H%M%S%3f").unwrap();
5894 std::fs::write(backup.join(format!("fresh_{future_ts}.txt")), "fresh").unwrap();
5895 std::fs::write(backup.join("notes.md"), "mine").unwrap();
5897
5898 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
5899
5900 assert!(!backup.join("sub/old_20200101_000000000.txt").exists());
5901 assert!(!backup.join("sub").exists(), "empty parent removed");
5903 assert!(backup.exists());
5905 assert!(backup.join(format!("fresh_{future_ts}.txt")).exists());
5906 assert!(backup.join("notes.md").exists(), "user file untouched");
5907 }
5908
5909 #[test]
5911 fn gc_backup_dry_run_does_not_delete() {
5912 let tmp = TempDir::new().unwrap();
5913 let source = utf8(tmp.path().join("dotfiles"));
5914 std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
5915 std::fs::write(source.join("config.toml"), "").unwrap();
5916 let backup = source.join(".yui/backup");
5917 std::fs::write(backup.join("old_20200101_000000000.txt"), "old").unwrap();
5918
5919 gc_backup(Some(source.clone()), Some("30d".into()), true, None, true).unwrap();
5920
5921 assert!(
5922 backup.join("old_20200101_000000000.txt").exists(),
5923 "dry-run keeps everything in place"
5924 );
5925 }
5926
5927 #[test]
5931 fn gc_backup_prune_handles_directory_snapshot() {
5932 let tmp = TempDir::new().unwrap();
5933 let source = utf8(tmp.path().join("dotfiles"));
5934 std::fs::create_dir_all(source.join(".yui/backup/mirror/u")).unwrap();
5935 std::fs::write(source.join("config.toml"), "").unwrap();
5936 let backup = source.join(".yui/backup");
5937 let snap = backup.join("mirror/u/nvim_20200101_000000000");
5938 std::fs::create_dir_all(snap.join("lua")).unwrap();
5939 std::fs::write(snap.join("init.lua"), "x").unwrap();
5940 std::fs::write(snap.join("lua/y.lua"), "y").unwrap();
5941
5942 gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
5943
5944 assert!(!snap.exists(), "dir snapshot removed wholesale");
5945 assert!(!backup.join("mirror").exists(), "empty mirror chain pruned");
5946 assert!(backup.exists(), "backup root preserved");
5947 }
5948}