Skip to main content

yui/
cmd.rs

1//! Command implementations.
2//!
3//! Each `Command` variant in `cli.rs` calls one of these.
4
5use std::fmt::Write as _;
6
7use anyhow::{Context as _, Result};
8use camino::{Utf8Path, Utf8PathBuf};
9use tera::Context as TeraContext;
10use tracing::{info, warn};
11
12use crate::config::{self, Config, HookPhase, IconsMode, MountStrategy};
13use crate::hook::{self, HookOutcome};
14use crate::icons::Icons;
15use crate::link::{self, EffectiveDirMode, EffectiveFileMode, resolve_dir_mode, resolve_file_mode};
16use crate::marker::{self, MarkerSpec};
17use crate::mount::{self, ResolvedMount};
18use crate::render::{self, RenderReport};
19use crate::secret;
20use crate::template;
21use crate::vars::YuiVars;
22use crate::{absorb, backup, paths};
23
24// NOTE: `owo_colors::OwoColorize` is intentionally NOT imported at module
25// scope — its blanket impl shadows inherent methods of unrelated types
26// (e.g. `ignore::WalkBuilder::hidden(bool)` collides with
27// `OwoColorize::hidden(&self)`). Each print function imports the trait
28// locally with `use owo_colors::OwoColorize as _;`.
29
30pub fn init(source: Option<Utf8PathBuf>, git_hooks: bool) -> Result<()> {
31    let dir = match source {
32        Some(s) => absolutize(&s)?,
33        None => current_dir_utf8()?,
34    };
35    std::fs::create_dir_all(&dir)?;
36    let config_path = dir.join("config.toml");
37    let scaffolded = if !config_path.exists() {
38        std::fs::write(&config_path, SKELETON_CONFIG)?;
39        info!("initialized yui source repo at {dir}");
40        info!("created: {config_path}");
41        true
42    } else if git_hooks {
43        // Existing repo + hooks-only invocation: just install the
44        // hooks. Don't bail like we used to — a user who already has
45        // a populated dotfiles repo shouldn't need to delete
46        // config.toml to opt into the render-drift hooks.
47        info!(
48            "config.toml already exists at {config_path} \
49             — skipping scaffold, installing git hooks only"
50        );
51        false
52    } else {
53        anyhow::bail!("config.toml already exists at {config_path}");
54    };
55
56    // .gitignore upkeep is `init`'s responsibility — running it
57    // again on an existing repo (e.g. for a hooks-only install)
58    // should still backfill the yui-required ignore lines if the
59    // .gitignore has drifted. The rendered-template section is
60    // separately maintained by `apply`'s render flow, so we only
61    // touch the state / backup / config.local entries here.
62    ensure_gitignore_yui_entries(&dir)?;
63
64    if git_hooks {
65        install_git_hooks(&dir)?;
66    }
67    if scaffolded {
68        info!("next: edit config.toml, then run `yui apply`");
69    }
70    Ok(())
71}
72
73/// .gitignore lines yui needs every dotfiles repo to carry. Anything
74/// the render flow auto-manages (the `# >>> yui rendered ... <<<`
75/// section) lives there; what `init` owns is the per-machine state +
76/// backup pile + the `config.local.toml` carve-out.
77const YUI_REQUIRED_GITIGNORE: &[&str] = &[
78    "/.yui/state.json",
79    "/.yui/state.json.tmp",
80    "/.yui/backup/",
81    "config.local.toml",
82];
83
84/// Ensure each `YUI_REQUIRED_GITIGNORE` line is present in the repo's
85/// `.gitignore`. Creates the file with the full skeleton when it's
86/// missing entirely, and appends only the missing entries (in a
87/// labelled section) when it already exists. Idempotent — re-running
88/// `init` is a no-op once the entries are in place.
89fn ensure_gitignore_yui_entries(dir: &Utf8Path) -> Result<()> {
90    let path = dir.join(".gitignore");
91    if !path.exists() {
92        std::fs::write(&path, SKELETON_GITIGNORE)?;
93        info!("created: {path}");
94        return Ok(());
95    }
96    let existing = std::fs::read_to_string(&path)?;
97    let missing: Vec<&str> = YUI_REQUIRED_GITIGNORE
98        .iter()
99        .copied()
100        .filter(|entry| !existing.lines().any(|line| line.trim() == *entry))
101        .collect();
102    if missing.is_empty() {
103        return Ok(());
104    }
105    let mut next = existing;
106    if !next.is_empty() && !next.ends_with('\n') {
107        next.push('\n');
108    }
109    if !next.is_empty() {
110        next.push('\n');
111    }
112    next.push_str("# yui per-machine state and backups (added by `yui init`).\n");
113    for entry in &missing {
114        next.push_str(entry);
115        next.push('\n');
116    }
117    std::fs::write(&path, next)?;
118    info!(
119        "updated .gitignore: appended {} yui entr{} ({})",
120        missing.len(),
121        if missing.len() == 1 { "y" } else { "ies" },
122        missing.join(", ")
123    );
124    Ok(())
125}
126
127/// Install yui's render-drift hooks into the source repo's
128/// `.git/hooks/`. Both pre-commit and pre-push run `yui render --check`
129/// — pre-commit catches the easy case (you forgot to `apply` before
130/// committing), pre-push is the safety net that catches anything a
131/// bypassed pre-commit (or a `git commit --no-verify`) let slip
132/// through.
133///
134/// Asks git for the hooks directory via `rev-parse --git-path hooks`
135/// so `core.hooksPath` (configured globally or per-repo to redirect
136/// hooks elsewhere) is honoured, and worktrees / bare repos / GIT_DIR
137/// overrides come along for the ride. Refuses to overwrite existing
138/// hooks — the user has to delete them first if they want yui to
139/// manage that slot.
140fn install_git_hooks(source: &Utf8Path) -> Result<()> {
141    let out = std::process::Command::new("git")
142        .args(["rev-parse", "--git-path", "hooks"])
143        .current_dir(source.as_std_path())
144        .output()
145        .with_context(|| format!("git rev-parse --git-path hooks in {source}"))?;
146    if !out.status.success() {
147        let stderr = String::from_utf8_lossy(&out.stderr);
148        anyhow::bail!(
149            "--git-hooks: {source} doesn't look like a git repo \
150             (run `git init` first). git: {}",
151            stderr.trim()
152        );
153    }
154    let raw = String::from_utf8(out.stdout)?;
155    let hooks_dir = {
156        let p = Utf8PathBuf::from(raw.trim());
157        if p.is_absolute() { p } else { source.join(p) }
158    };
159    std::fs::create_dir_all(&hooks_dir).with_context(|| format!("mkdir -p {hooks_dir}"))?;
160
161    for (name, body) in [("pre-commit", PRE_COMMIT_HOOK), ("pre-push", PRE_PUSH_HOOK)] {
162        let path = hooks_dir.join(name);
163        if path.exists() {
164            warn!("--git-hooks: {path} already exists — leaving it alone");
165            continue;
166        }
167        std::fs::write(&path, body).with_context(|| format!("write hook {path}"))?;
168        #[cfg(unix)]
169        {
170            use std::os::unix::fs::PermissionsExt;
171            let mut perms = std::fs::metadata(&path)?.permissions();
172            perms.set_mode(0o755);
173            std::fs::set_permissions(&path, perms)?;
174        }
175        info!("installed: {path}");
176    }
177    Ok(())
178}
179
180const PRE_COMMIT_HOOK: &str = r#"#!/bin/sh
181# Installed by `yui init --git-hooks`.
182# Reject the commit if any `*.tera` template would render to something
183# that diverges from the rendered output staged alongside it. Run
184# `yui apply` (or `yui render`) to refresh and re-commit.
185exec yui render --check
186"#;
187
188const PRE_PUSH_HOOK: &str = r#"#!/bin/sh
189# Installed by `yui init --git-hooks`.
190# Same render-drift check as pre-commit, mirrored on push so a
191# `--no-verify` commit doesn't sneak diverged state to the remote.
192exec yui render --check
193"#;
194
195pub fn apply(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
196    let source = resolve_source(source)?;
197    let yui = YuiVars::detect(&source);
198    let config = config::load(&source, &yui)?;
199
200    let mut engine = template::Engine::new();
201    let tera_ctx = template::template_context(&yui, &config.vars);
202
203    // 0. Pre-apply hooks (before render / link). Bail on hook failure so
204    //    apply doesn't proceed past a broken bootstrap.
205    hook::run_phase(
206        &config,
207        &source,
208        &yui,
209        &mut engine,
210        &tera_ctx,
211        HookPhase::Pre,
212        dry_run,
213    )?;
214
215    // 1a. Decrypt `*.age` files first — the rendered templates
216    //     might `{{ ... }}`-reference plaintext siblings indirectly
217    //     (via env vars set by hooks), and even when they don't,
218    //     decrypting first keeps the order of "physical sibling
219    //     files appear" predictable.
220    let secret_report = secret::decrypt_all(&source, &config, dry_run)?;
221    log_secret_report(&secret_report);
222    if secret_report.has_drift() {
223        anyhow::bail!(
224            "secret drift detected ({} file(s)); the plaintext sibling diverged \
225             from the canonical .age — run `yui secret encrypt <path>` to roll \
226             the edit back into ciphertext before re-running apply",
227            secret_report.diverged.len()
228        );
229    }
230
231    // 1b. Render templates so the link walk picks up rendered files.
232    let render_report = render::render_all(&source, &config, &yui, dry_run)?;
233    log_render_report(&render_report);
234    if render_report.has_drift() {
235        anyhow::bail!(
236            "render drift detected ({} file(s)); reflect target edits back into the .tera before re-running apply",
237            render_report.diverged.len()
238        );
239    }
240
241    // 1c. Single deterministic write of the `.gitignore` managed
242    //     section, covering both `*.tera` outputs and `*.age`
243    //     plaintext siblings. (Earlier this was two writes — once
244    //     inside `render_all`, once here — which made the managed
245    //     section flicker if a reader read between them. PR #57
246    //     review caught it; render_all no longer touches gitignore.)
247    if !dry_run && config.render.manage_gitignore {
248        let mut managed: Vec<Utf8PathBuf> = render::report_managed_paths(&render_report)
249            .into_iter()
250            .chain(secret_report.managed_paths().cloned())
251            .collect();
252        managed.sort();
253        managed.dedup();
254        render::write_managed_section(&source, &managed)?;
255    }
256
257    // 2. Resolve mounts and link.
258    let mounts = mount::resolve(
259        &source,
260        &config.mount.entry,
261        config.mount.default_strategy,
262        &mut engine,
263        &tera_ctx,
264    )?;
265
266    let backup_root = source.join(&config.backup.dir);
267    let ctx = ApplyCtx {
268        config: &config,
269        source: &source,
270        file_mode: resolve_file_mode(config.link.file_mode),
271        dir_mode: resolve_dir_mode(config.link.dir_mode),
272        backup_root: &backup_root,
273        dry_run,
274    };
275
276    info!("source: {source}");
277    info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
278    if dry_run {
279        info!("dry-run: nothing will be written");
280    }
281
282    // Nested `.yuiignore` stack — push on dir entry, pop on exit.
283    // Seed with the source-root layer so root-level rules apply from
284    // the start without `walk_and_link` having to special-case it.
285    let mut yuiignore = paths::YuiIgnoreStack::new();
286    yuiignore.push_dir(&source)?;
287    let walk_result = (|| -> Result<()> {
288        for m in &mounts {
289            info!("mount: {} → {}", m.src, m.dst);
290            process_mount(m, &ctx, &mut engine, &tera_ctx, &mut yuiignore)?;
291        }
292        Ok(())
293    })();
294    yuiignore.pop_dir(&source);
295    walk_result?;
296
297    // 3. Post-apply hooks (after every link is in place).
298    hook::run_phase(
299        &config,
300        &source,
301        &yui,
302        &mut engine,
303        &tera_ctx,
304        HookPhase::Post,
305        dry_run,
306    )?;
307    Ok(())
308}
309
310fn log_render_report(r: &RenderReport) {
311    if !r.written.is_empty() {
312        info!("rendered {} new file(s)", r.written.len());
313    }
314    if !r.unchanged.is_empty() {
315        info!("rendered {} file(s) unchanged", r.unchanged.len());
316    }
317    if !r.skipped_when_false.is_empty() {
318        info!(
319            "skipped {} template(s) (when=false)",
320            r.skipped_when_false.len()
321        );
322    }
323    for d in &r.diverged {
324        warn!("rendered file diverged from template: {d}");
325    }
326}
327
328fn log_secret_report(r: &secret::SecretReport) {
329    if !r.written.is_empty() {
330        info!("decrypted {} secret file(s)", r.written.len());
331    }
332    if !r.unchanged.is_empty() {
333        info!("decrypted {} secret(s) unchanged", r.unchanged.len());
334    }
335    for d in &r.diverged {
336        warn!("plaintext sibling diverged from .age: {d}");
337    }
338}
339
340/// Bundle of immutable settings threaded through the apply walk.
341///
342/// `.yuiignore` rules are not in here — they need a `&mut` stack
343/// (push on dir entry, pop on dir exit) which doesn't compose with
344/// `ApplyCtx` being shared by `&`. The stack is plumbed through
345/// `walk_and_link` as its own parameter instead.
346struct ApplyCtx<'a> {
347    config: &'a Config,
348    /// Source repo root — needed for git-clean checks during absorb.
349    source: &'a Utf8Path,
350    file_mode: EffectiveFileMode,
351    dir_mode: EffectiveDirMode,
352    backup_root: &'a Utf8Path,
353    dry_run: bool,
354}
355
356/// Show the resolved src→dst mappings for the current source repo.
357///
358/// By default only entries whose `when` matches the current host are shown
359/// (`active`). With `--all`, inactive entries are included with a dim row
360/// and the `when` condition that excluded them.
361pub fn list(
362    source: Option<Utf8PathBuf>,
363    all: bool,
364    icons_override: Option<IconsMode>,
365    no_color: bool,
366) -> Result<()> {
367    let source = resolve_source(source)?;
368    let yui = YuiVars::detect(&source);
369    let config = config::load(&source, &yui)?;
370
371    let icons_mode = icons_override.unwrap_or(config.ui.icons);
372    let icons = Icons::for_mode(icons_mode);
373    let color = !no_color && supports_color_stdout();
374
375    let items = collect_list_items(&source, &config, &yui)?;
376    let displayed: Vec<&ListItem> = if all {
377        items.iter().collect()
378    } else {
379        items.iter().filter(|i| i.active).collect()
380    };
381
382    print_list_table(&displayed, icons, color);
383
384    let total = items.len();
385    let active = items.iter().filter(|i| i.active).count();
386    let inactive = total - active;
387    println!();
388    if all {
389        println!("  {total} entries · {active} active · {inactive} inactive");
390    } else {
391        println!(
392            "  {} of {} entries shown ({} inactive hidden — use --all)",
393            active, total, inactive
394        );
395    }
396    Ok(())
397}
398
399#[derive(Debug)]
400struct ListItem {
401    src: Utf8PathBuf,
402    dst: String,
403    when: Option<String>,
404    active: bool,
405}
406
407fn collect_list_items(source: &Utf8Path, config: &Config, yui: &YuiVars) -> Result<Vec<ListItem>> {
408    let mut engine = template::Engine::new();
409    let tera_ctx = template::template_context(yui, &config.vars);
410    let mut items = Vec::new();
411
412    // 1. config.toml [[mount.entry]] entries
413    for entry in &config.mount.entry {
414        let active = match &entry.when {
415            None => true,
416            Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
417        };
418        let dst = engine
419            .render(&entry.dst, &tera_ctx)
420            .map(|s| paths::expand_tilde(s.trim()).to_string())
421            .unwrap_or_else(|_| entry.dst.clone());
422        items.push(ListItem {
423            src: entry.src.clone(),
424            dst,
425            when: entry.when.clone(),
426            active,
427        });
428    }
429
430    // 2. .yuilink overrides under source
431    let walker = paths::source_walker(source).build();
432    let marker_filename = &config.mount.marker_filename;
433    for entry in walker {
434        let entry = match entry {
435            Ok(e) => e,
436            Err(_) => continue,
437        };
438        if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
439            continue;
440        }
441        if entry.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
442            continue;
443        }
444        let dir = match entry.path().parent() {
445            Some(d) => d,
446            None => continue,
447        };
448        let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
449            Ok(p) => p,
450            Err(_) => continue,
451        };
452        // .yuiignore filtering happens in `source_walker` via
453        // `add_custom_ignore_filename` — markers under ignored
454        // subtrees never reach here.
455        let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
456            Some(s) => s,
457            None => continue,
458        };
459        let MarkerSpec::Explicit { links } = spec else {
460            continue; // PassThrough markers are already implied by mount entry
461        };
462        let rel = dir_utf8
463            .strip_prefix(source)
464            .map(Utf8PathBuf::from)
465            .unwrap_or(dir_utf8);
466        for link in &links {
467            let active = match &link.when {
468                None => true,
469                Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
470            };
471            let dst = engine
472                .render(&link.dst, &tera_ctx)
473                .map(|s| paths::expand_tilde(s.trim()).to_string())
474                .unwrap_or_else(|_| link.dst.clone());
475            // File-level entry (`[[link]] src = "<filename>"`) targets a
476            // single file inside the marker dir; show that file path
477            // instead of the bare dir so `yui list` makes the scope
478            // obvious at a glance.
479            let src_display = match &link.src {
480                Some(filename) => rel.join(filename),
481                None => rel.clone(),
482            };
483            items.push(ListItem {
484                src: src_display,
485                dst,
486                when: link.when.clone(),
487                active,
488            });
489        }
490    }
491
492    items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
493    Ok(items)
494}
495
496fn supports_color_stdout() -> bool {
497    use std::io::IsTerminal;
498    std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
499}
500
501fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
502    let src_w = items
503        .iter()
504        .map(|i| i.src.as_str().chars().count())
505        .max()
506        .unwrap_or(0)
507        .max("SRC".len());
508    let dst_w = items
509        .iter()
510        .map(|i| i.dst.chars().count())
511        .max()
512        .unwrap_or(0)
513        .max("DST".len());
514
515    let status_w = "STATUS".len();
516    let arrow_w = icons.arrow.chars().count();
517
518    // Header
519    print_header(status_w, src_w, arrow_w, dst_w, color);
520
521    // Separator
522    let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
523    if color {
524        use owo_colors::OwoColorize as _;
525        println!("{}", sep.dimmed());
526    } else {
527        println!("{sep}");
528    }
529
530    // Rows
531    for item in items {
532        print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
533    }
534}
535
536fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
537    use owo_colors::OwoColorize as _;
538    let mut line = String::new();
539    let _ = write!(
540        &mut line,
541        "  {:<status_w$}  {:<src_w$}  {:<arrow_w$}  {:<dst_w$}  WHEN",
542        "STATUS", "SRC", "", "DST"
543    );
544    if color {
545        println!("{}", line.bold());
546    } else {
547        println!("{line}");
548    }
549}
550
551fn render_separator(
552    sep_ch: char,
553    status_w: usize,
554    src_w: usize,
555    arrow_w: usize,
556    dst_w: usize,
557) -> String {
558    let bar = |n: usize| sep_ch.to_string().repeat(n);
559    format!(
560        "  {}  {}  {}  {}  {}",
561        bar(status_w),
562        bar(src_w),
563        bar(arrow_w),
564        bar(dst_w),
565        bar("WHEN".len())
566    )
567}
568
569fn print_row(
570    item: &ListItem,
571    icons: Icons,
572    status_w: usize,
573    src_w: usize,
574    arrow_w: usize,
575    dst_w: usize,
576    color: bool,
577) {
578    use owo_colors::OwoColorize as _;
579    let status = if item.active {
580        icons.active
581    } else {
582        icons.inactive
583    };
584    let when_str = item
585        .when
586        .as_deref()
587        .map(strip_braces)
588        .unwrap_or_else(|| "(always)".to_string());
589
590    // Normalize backslashes to forward slashes for cross-platform display.
591    let src_display = item.src.as_str().replace('\\', "/");
592    let src = src_display.as_str();
593    let dst = &item.dst;
594    let arrow = icons.arrow;
595
596    // Pad each cell to its column width FIRST, then apply color. Doing it
597    // the other way round lets ANSI escape codes count as printable chars
598    // in `format!("{:<w$}")`, which silently breaks alignment when colors
599    // are enabled (caught in PR #11 review).
600    let cell_status = format!("{:<status_w$}", status);
601    let cell_src = format!("{:<src_w$}", src);
602    let cell_arrow = format!("{:<arrow_w$}", arrow);
603    let cell_dst = format!("{:<dst_w$}", dst);
604
605    if !color {
606        println!("  {cell_status}  {cell_src}  {cell_arrow}  {cell_dst}  {when_str}");
607        return;
608    }
609
610    if item.active {
611        println!(
612            "  {}  {}  {}  {}  {}",
613            cell_status.green(),
614            cell_src.cyan(),
615            cell_arrow.dimmed(),
616            cell_dst.green(),
617            when_str.dimmed()
618        );
619    } else {
620        println!(
621            "  {}  {}  {}  {}  {}",
622            cell_status.red().dimmed(),
623            cell_src.dimmed(),
624            cell_arrow.dimmed(),
625            cell_dst.dimmed(),
626            when_str.dimmed()
627        );
628    }
629}
630
631/// Strip the outer `{{ ... }}` Tera braces from a `when` expression for
632/// display purposes (shorter line, easier to read at a glance).
633fn strip_braces(expr: &str) -> String {
634    let trimmed = expr.trim();
635    if let Some(inner) = trimmed
636        .strip_prefix("{{")
637        .and_then(|s| s.strip_suffix("}}"))
638    {
639        inner.trim().to_string()
640    } else {
641        trimmed.to_string()
642    }
643}
644
645pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
646    let source = resolve_source(source)?;
647    let yui = YuiVars::detect(&source);
648    let config = config::load(&source, &yui)?;
649    // --check is a stricter dry-run: never writes, exits non-zero on drift.
650    let effective_dry_run = dry_run || check;
651    let report = render::render_all(&source, &config, &yui, effective_dry_run)?;
652    log_render_report(&report);
653    // Stand-alone `yui render` has no secrets pipeline running
654    // alongside, so the managed section here just covers `*.tera`
655    // outputs. (Use `yui apply` if you need both rendered AND
656    // decrypted siblings to land in the same write.)
657    if !effective_dry_run && config.render.manage_gitignore {
658        let managed = render::report_managed_paths(&report);
659        render::write_managed_section(&source, &managed)?;
660    }
661    if check && report.has_drift() {
662        anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
663    }
664    Ok(())
665}
666
667pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
668    // For now `link` and `apply` do the same thing (no render/absorb yet).
669    apply(source, dry_run)
670}
671
672pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
673    let _source = resolve_source(source)?;
674    if paths_arg.is_empty() {
675        anyhow::bail!("yui unlink: provide at least one target path");
676    }
677    for p in paths_arg {
678        let abs = absolutize(&p)?;
679        info!("unlink: {abs}");
680        link::unlink(&abs)?;
681    }
682    Ok(())
683}
684
685/// `yui secret init [--comment TEXT]` — generate an age X25519
686/// keypair on this machine, write the secret to the configured
687/// identity path, and append the public key to
688/// `$DOTFILES/config.local.toml` `[secrets] recipients`.
689///
690/// `config.local.toml` is the right place because it's
691/// machine-local and gitignored — committing per-machine recipient
692/// keys to the public repo is fine in principle (recipients are
693/// public information) but the convention has the keys live in
694/// the local file alongside other host-specific config.
695pub fn secret_init(source: Option<Utf8PathBuf>, comment: Option<String>) -> Result<()> {
696    let source = resolve_source(source)?;
697    let yui = YuiVars::detect(&source);
698    let config = config::load(&source, &yui)?;
699
700    // 1. Resolve identity path (default: ~/.config/yui/age.txt).
701    let identity_path = paths::expand_tilde(&config.secrets.identity);
702    if identity_path.exists() {
703        anyhow::bail!(
704            "identity file already exists at {identity_path}; \
705             refusing to overwrite. Delete it first if you really \
706             mean to start fresh (you'll lose access to existing \
707             .age files encrypted to its public key)."
708        );
709    }
710
711    // 2. Generate the keypair + serialise the identity file with
712    //    the same header age-keygen uses, so the file is
713    //    interoperable with the standalone CLI tools.
714    let (secret, public) = secret::generate_x25519_keypair();
715    if let Some(parent) = identity_path.parent() {
716        std::fs::create_dir_all(parent)?;
717    }
718    let now = jiff::Zoned::now().to_string();
719    let body = format!(
720        "# created: {now}\n\
721         # public key: {public}\n\
722         {secret}\n"
723    );
724    std::fs::write(&identity_path, body)?;
725    info!("wrote identity file: {identity_path}");
726
727    // 3. Append the public key to `[secrets] recipients` in
728    //    config.local.toml — appending is safe because that file
729    //    is the per-machine layer the user explicitly owns.
730    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
752/// Append a recipient entry to the user's `config.local.toml`.
753///
754/// Uses `toml_edit` to parse the file into an in-memory document
755/// tree, modify the `[secrets].recipients` array, then serialise
756/// back. This preserves user comments / spacing / table ordering,
757/// and survives quirky inputs (other tables after `[secrets]`,
758/// trailing comments, multi-line arrays, etc.) — string-pasting
759/// the same shape used to land tokens in the wrong place when the
760/// file's layout deviated from the most common case. (Caught in
761/// PR #57 review by gemini-code-assist.)
762///
763/// Returns the file unchanged when the public key is already in
764/// the recipients list (idempotent re-init).
765fn 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    // Make sure `[secrets]` exists as a table.
777    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    // Make sure `recipients` is an array.
787    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    // Idempotent: if the public key already appears, we're done.
795    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    // Append the new entry with a leading-comment decor block so
801    // the user can tell which key belongs to which machine just by
802    // reading the file.
803    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    // Force the array onto multiple lines so the comments above
808    // entries actually have a place to live (a single-line array
809    // can't carry per-element comments).
810    recipients.set_trailing("\n");
811    recipients.set_trailing_comma(true);
812
813    Ok(doc.to_string())
814}
815
816/// `yui secret encrypt <path> [--force] [--rm-plaintext]` — encrypt
817/// a plaintext file to every recipient in `[secrets] recipients`
818/// and write the ciphertext alongside as `<path>.age`.
819pub 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    // Resolve the plaintext path: absolute as-is, relative against
837    // CWD (so the user can `yui secret encrypt home/.ssh/id_ed25519`
838    // from inside `$DOTFILES`).
839    let plaintext_path = if path.is_absolute() {
840        path.clone()
841    } else {
842        absolutize(&path)?
843    };
844    if !plaintext_path.is_file() {
845        anyhow::bail!("plaintext file not found: {plaintext_path}");
846    }
847    let cipher_path = Utf8PathBuf::from(format!("{plaintext_path}.age"));
848    if cipher_path.exists() && !force {
849        anyhow::bail!("{cipher_path} already exists; pass --force to overwrite");
850    }
851
852    let plaintext = std::fs::read(&plaintext_path)?;
853    let recipients: Vec<age::x25519::Recipient> = config
854        .secrets
855        .recipients
856        .iter()
857        .map(|s| secret::parse_recipient(s))
858        .collect::<crate::Result<_>>()?;
859    let cipher = secret::encrypt(&plaintext, &recipients)?;
860    std::fs::write(&cipher_path, &cipher)?;
861    info!("encrypted {plaintext_path} → {cipher_path}");
862
863    if rm_plaintext {
864        // Only remove plaintext when it lives under `$DOTFILES` —
865        // erasing files outside the repo on a typo would be cruel.
866        if plaintext_path.starts_with(&source) {
867            std::fs::remove_file(&plaintext_path)?;
868            info!("removed plaintext: {plaintext_path}");
869        } else {
870            warn!(
871                "plaintext lives outside source ({plaintext_path}); \
872                 skipping --rm-plaintext as a safety check"
873            );
874        }
875    }
876    Ok(())
877}
878
879/// `yui update [--dry-run]` — pull source repo and re-apply.
880///
881/// Equivalent to `git -C $DOTFILES pull --ff-only && yui apply`,
882/// but with the safety check that the source tree is clean first
883/// (otherwise the pull could mix upstream commits with the user's
884/// in-progress edits in surprising ways). Bails on a dirty source
885/// rather than stashing — the user should commit consciously.
886///
887/// `--dry-run` only forwards to `apply --dry-run`; the pull itself
888/// always runs (it's a read+merge operation, no half-state).
889pub fn update(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
890    let source = resolve_source(source)?;
891    if !crate::git::is_clean(&source)? {
892        anyhow::bail!(
893            "source repo {source} has uncommitted changes — \
894             commit or stash before `yui update` (or run \
895             `git pull` + `yui apply` manually if you know what \
896             you're doing)"
897        );
898    }
899    info!("git pull --ff-only at {source}");
900    let status = std::process::Command::new("git")
901        .arg("-C")
902        .arg(source.as_str())
903        .arg("pull")
904        .arg("--ff-only")
905        .status()
906        .map_err(|e| anyhow::anyhow!("invoking git: {e}"))?;
907    if !status.success() {
908        anyhow::bail!("git pull --ff-only failed at {source}");
909    }
910    apply(Some(source), dry_run)
911}
912
913/// `yui unmanaged [--icons MODE] [--no-color]` — list source files
914/// that no `[[mount.entry]]` claims.
915///
916/// Useful for spotting orphans: files committed to the dotfiles
917/// repo that yui never propagates anywhere. The walk goes through
918/// `paths::source_walker`, which already honours nested
919/// `.yuiignore` and skips `.yui/`. We additionally skip the repo's
920/// own meta files (`config*.toml`, `.gitignore`, `.yuilink`,
921/// `.yuiignore`, `*.tera` template sources) since "expected
922/// unmanaged" entries would just bury the long tail.
923pub fn unmanaged(
924    source: Option<Utf8PathBuf>,
925    icons_override: Option<IconsMode>,
926    no_color: bool,
927) -> Result<()> {
928    let source = resolve_source(source)?;
929    let yui = YuiVars::detect(&source);
930    let config = config::load(&source, &yui)?;
931
932    let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
933    let color = !no_color && supports_color_stdout();
934
935    // Resolve every mount.src to an absolute path so a simple
936    // `path.starts_with(&mount_src)` test can answer "claimed?".
937    //
938    //   - Iterate raw `config.mount.entry` (NOT `mount::resolve`)
939    //     so a `when=false` mount still claims its files — surfacing
940    //     them as "unmanaged" because they're inactive on this host
941    //     would be confusing. (PR #53 review.)
942    //   - Tera-render `entry.src` first so a templated path like
943    //     `"private/{{ yui.host }}/home"` claims its files on
944    //     this host rather than landing in `mount_srcs` as the
945    //     literal raw string. (PR #56 review.)
946    //   - `paths::resolve_mount_src` then applies tilde / absolute
947    //     handling so private clones outside `$DOTFILES`
948    //     participate too.
949    let mut engine = template::Engine::new();
950    let tera_ctx = template::template_context(&yui, &config.vars);
951    let mount_srcs: Vec<Utf8PathBuf> = config
952        .mount
953        .entry
954        .iter()
955        .map(|e| -> Result<Utf8PathBuf> {
956            let rendered = engine.render(e.src.as_str(), &tera_ctx)?;
957            Ok(paths::resolve_mount_src(&source, rendered.trim()))
958        })
959        .collect::<Result<_>>()?;
960
961    let mut items: Vec<Utf8PathBuf> = Vec::new();
962    let walker = paths::source_walker(&source).build();
963    for entry in walker {
964        let entry = match entry {
965            Ok(e) => e,
966            Err(_) => continue,
967        };
968        if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
969            continue;
970        }
971        let std_path = entry.path();
972        let path = match Utf8PathBuf::from_path_buf(std_path.to_path_buf()) {
973            Ok(p) => p,
974            Err(_) => continue,
975        };
976        // Filter out the repo's own meta files. These are "managed
977        // by yui itself" rather than "unmanaged orphans", so
978        // surfacing them in the report is just noise.
979        if is_repo_meta(&path, &source, &config.mount.marker_filename) {
980            continue;
981        }
982        if mount_srcs.iter().any(|m| path.starts_with(m)) {
983            continue;
984        }
985        items.push(path);
986    }
987    items.sort();
988
989    if items.is_empty() {
990        println!("  no unmanaged files under {source}");
991        return Ok(());
992    }
993
994    print_unmanaged_table(&items, &source, color);
995    println!();
996    println!("  {} unmanaged file(s)", items.len());
997    Ok(())
998}
999
1000/// True for the dotfiles repo's own scaffold files — anything yui
1001/// itself reads or writes during its own operation. Surfacing
1002/// these in `yui unmanaged` would just bury the actual orphans.
1003///
1004/// Files keyed strictly by basename anywhere in the tree:
1005///   - `.yuilink` (mount marker)
1006///   - `.yuiignore` (yui's gitignore-style filter)
1007///   - `*.tera` (template sources)
1008///
1009/// Files keyed at the repo root only:
1010///   - `.gitignore` (yui manages the rendered-files section there;
1011///     a nested `home/.config/foo/.gitignore` is a user dotfile)
1012///   - `config.toml` / `config.local.toml` / `config.*.toml` /
1013///     `config.*.example.toml` (yui's own config layering;
1014///     a nested `home/.config/myapp/config.toml` is a user dotfile)
1015fn is_repo_meta(path: &Utf8Path, source: &Utf8Path, marker_filename: &str) -> bool {
1016    let Some(name) = path.file_name() else {
1017        return false;
1018    };
1019    if name.ends_with(".tera") {
1020        return true;
1021    }
1022    if name == marker_filename || name == ".yuiignore" {
1023        return true;
1024    }
1025    let parent = path.parent().unwrap_or(Utf8Path::new(""));
1026    let at_root = parent == source;
1027    if at_root && name == ".gitignore" {
1028        return true;
1029    }
1030    if at_root && (name == "config.toml" || name == "config.local.toml") {
1031        return true;
1032    }
1033    if at_root
1034        && name.starts_with("config.")
1035        && (name.ends_with(".toml") || name.ends_with(".example.toml"))
1036    {
1037        return true;
1038    }
1039    false
1040}
1041
1042fn print_unmanaged_table(items: &[Utf8PathBuf], source: &Utf8Path, color: bool) {
1043    use owo_colors::OwoColorize as _;
1044    if color {
1045        println!("  {}", "PATH (relative to source)".dimmed());
1046    } else {
1047        println!("  PATH (relative to source)");
1048    }
1049    for p in items {
1050        let rel = p
1051            .strip_prefix(source)
1052            .map(Utf8PathBuf::from)
1053            .unwrap_or_else(|_| p.clone());
1054        if color {
1055            println!("  {}", rel.cyan());
1056        } else {
1057            println!("  {rel}");
1058        }
1059    }
1060}
1061
1062/// `yui diff [--icons MODE] [--no-color]` — for every drifted entry
1063/// (link or render), print a unified diff to stdout.
1064///
1065/// Layered on top of the same drift detection `yui status` uses
1066/// (`absorb::classify` + render dry-run), but actually emits the
1067/// content delta. InSync / Restore / RelinkOnly entries are
1068/// suppressed — they're not "drift the user can read".
1069pub fn diff(
1070    source: Option<Utf8PathBuf>,
1071    icons_override: Option<IconsMode>,
1072    no_color: bool,
1073) -> Result<()> {
1074    let source = resolve_source(source)?;
1075    let yui = YuiVars::detect(&source);
1076    let config = config::load(&source, &yui)?;
1077    let mut engine = template::Engine::new();
1078    let tera_ctx = template::template_context(&yui, &config.vars);
1079    let mounts = mount::resolve(
1080        &source,
1081        &config.mount.entry,
1082        config.mount.default_strategy,
1083        &mut engine,
1084        &tera_ctx,
1085    )?;
1086
1087    let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
1088    let color = !no_color && supports_color_stdout();
1089
1090    // Reuse classify_walk to enumerate every src→dst pair.
1091    let mut report: Vec<StatusItem> = Vec::new();
1092    let mut yuiignore = paths::YuiIgnoreStack::new();
1093    yuiignore.push_dir(&source)?;
1094    let walk_result = (|| -> Result<()> {
1095        for m in &mounts {
1096            let src_root = m.src.clone();
1097            if !src_root.is_dir() {
1098                continue;
1099            }
1100            classify_walk(
1101                &src_root,
1102                &m.dst,
1103                &config,
1104                m.strategy,
1105                &mut engine,
1106                &tera_ctx,
1107                &source,
1108                &mut yuiignore,
1109                &mut report,
1110            )?;
1111        }
1112        Ok(())
1113    })();
1114    yuiignore.pop_dir(&source);
1115    walk_result?;
1116
1117    // Render-drift surfaces too — same as cmd::status.
1118    let render_report = render::render_all(&source, &config, &yui, /* dry_run */ true)?;
1119    for rendered in &render_report.diverged {
1120        let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
1121        report.push(StatusItem {
1122            src: tera_path,
1123            dst: rendered.clone(),
1124            state: StatusState::RenderDrift,
1125        });
1126    }
1127
1128    let mut printed = 0usize;
1129    for item in &report {
1130        if !diff_worth_printing(&item.state) {
1131            continue;
1132        }
1133        let src_abs = resolve_diff_src(item, &source);
1134        print_unified_diff(
1135            &src_abs,
1136            &item.dst,
1137            &item.state,
1138            &source,
1139            &config,
1140            &yui,
1141            color,
1142        );
1143        printed += 1;
1144    }
1145
1146    if printed == 0 {
1147        println!("  no diff — every entry is in sync (or only needs a relink)");
1148    } else {
1149        println!();
1150        println!(
1151            "  {printed} entr{} with content drift",
1152            if printed == 1 { "y" } else { "ies" }
1153        );
1154    }
1155    Ok(())
1156}
1157
1158/// Resolve a `StatusItem.src` to an absolute path suitable for
1159/// reading from disk during diff rendering.
1160///
1161/// `classify_walk` stores `StatusItem.src` via
1162/// `relative_for_display(...)`, which strips the source-root prefix
1163/// for table rendering. For `Link(_)` rows we have to re-absolutize
1164/// before reading — otherwise the path resolves against the
1165/// caller's cwd and we'd read an empty / wrong file. `RenderDrift`
1166/// rows already carry an absolute `.tera` path (built from
1167/// `render_report.diverged`, which the walker yields as absolute).
1168/// (Caught in PR #53 review by coderabbitai.)
1169fn resolve_diff_src(item: &StatusItem, source: &Utf8Path) -> Utf8PathBuf {
1170    match item.state {
1171        StatusState::RenderDrift => item.src.clone(),
1172        StatusState::Link(_) => source.join(&item.src),
1173    }
1174}
1175
1176fn diff_worth_printing(state: &StatusState) -> bool {
1177    use absorb::AbsorbDecision::*;
1178    match state {
1179        StatusState::Link(InSync) => false,
1180        StatusState::Link(Restore) => false, // target missing — nothing to diff
1181        StatusState::Link(RelinkOnly) => false, // content identical, only metadata drift
1182        StatusState::Link(_) => true,
1183        StatusState::RenderDrift => true,
1184    }
1185}
1186
1187/// `src` is the .tera path for `RenderDrift` rows and the source
1188/// file/dir for `Link(_)` rows. For RenderDrift we render the
1189/// template to a string and diff that against the on-disk
1190/// rendered file — diffing the raw .tera against the rendered
1191/// output would surface Tera's `{{ }}` syntax as drift instead
1192/// of the actual content delta. (Caught in PR #53 review by
1193/// gemini-code-assist.)
1194fn print_unified_diff(
1195    src: &Utf8Path,
1196    dst: &Utf8Path,
1197    state: &StatusState,
1198    source_root: &Utf8Path,
1199    config: &Config,
1200    yui: &YuiVars,
1201    color: bool,
1202) {
1203    use owo_colors::OwoColorize as _;
1204
1205    let header = match state {
1206        StatusState::RenderDrift => format!("--- render drift: {src} (template) vs {dst}"),
1207        _ => format!("--- {src} → {dst}"),
1208    };
1209    if color {
1210        println!("{}", header.bold());
1211    } else {
1212        println!("{header}");
1213    }
1214
1215    if src.is_dir() || dst.is_dir() {
1216        println!("(directory entry — content listing skipped)");
1217        println!();
1218        return;
1219    }
1220
1221    // Source side of the diff:
1222    //   - RenderDrift → re-render the .tera in memory (otherwise
1223    //     we'd surface raw Tera syntax as drift).
1224    //   - Link(_)     → read the source file from disk.
1225    let src_content = match state {
1226        StatusState::RenderDrift => match render::render_to_string(src, source_root, config, yui) {
1227            Ok(Some(s)) => s,
1228            Ok(None) => {
1229                println!(
1230                    "(template would be skipped on this host — drift will resolve on next render)"
1231                );
1232                println!();
1233                return;
1234            }
1235            Err(e) => {
1236                println!("(error rendering template: {e})");
1237                println!();
1238                return;
1239            }
1240        },
1241        _ => match read_text_for_diff(src) {
1242            DiffSide::Text(s) => s,
1243            DiffSide::Binary => {
1244                println!("(binary file or non-UTF-8 content — diff skipped)");
1245                println!();
1246                return;
1247            }
1248        },
1249    };
1250    let dst_content = match read_text_for_diff(dst) {
1251        DiffSide::Text(s) => s,
1252        DiffSide::Binary => {
1253            println!("(binary file or non-UTF-8 content — diff skipped)");
1254            println!();
1255            return;
1256        }
1257    };
1258    print_unified_text_diff(
1259        &src_content,
1260        &dst_content,
1261        src.as_str(),
1262        dst.as_str(),
1263        color,
1264    );
1265    println!();
1266}
1267
1268/// Render a true unified diff (with `@@` hunk headers + 3-line
1269/// context windows) via `similar::TextDiff::unified_diff` and
1270/// route each line to stdout — colour the `+` / `-` / `@@` lines
1271/// when the caller asked for it. Both `yui diff` and the absorb
1272/// flow share this so the format is consistent regardless of
1273/// entry point. (PR #53 review tightened the contract from the
1274/// hand-rolled prefix loop to the standard `unified_diff`
1275/// formatter.)
1276fn print_unified_text_diff(src: &str, dst: &str, src_label: &str, dst_label: &str, color: bool) {
1277    use owo_colors::OwoColorize as _;
1278    let diff = similar::TextDiff::from_lines(src, dst);
1279    let formatted = diff.unified_diff().header(src_label, dst_label).to_string();
1280    for line in formatted.lines() {
1281        if !color {
1282            println!("{line}");
1283        } else if line.starts_with("+++") || line.starts_with("---") {
1284            println!("{}", line.dimmed());
1285        } else if line.starts_with("@@") {
1286            println!("{}", line.cyan());
1287        } else if line.starts_with('+') {
1288            println!("{}", line.green());
1289        } else if line.starts_with('-') {
1290            println!("{}", line.red());
1291        } else {
1292            println!("{line}");
1293        }
1294    }
1295}
1296
1297/// One side of a textual diff. `Binary` means the bytes weren't
1298/// valid UTF-8 (likely a binary file); the diff renderer surfaces
1299/// a one-liner instead of dumping bytes through `similar`.
1300/// Missing-file / permission errors collapse to `Text("")` so a
1301/// race during the walk doesn't bail the whole flow.
1302enum DiffSide {
1303    Text(String),
1304    Binary,
1305}
1306
1307fn read_text_for_diff(p: &Utf8Path) -> DiffSide {
1308    match std::fs::read_to_string(p) {
1309        Ok(s) => DiffSide::Text(s),
1310        Err(e) if e.kind() == std::io::ErrorKind::InvalidData => DiffSide::Binary,
1311        Err(_) => DiffSide::Text(String::new()),
1312    }
1313}
1314
1315/// Show every src→dst pair's drift state against the current host.
1316///
1317/// Walks each `[[mount.entry]]`'s source tree, honoring `.yuilink`
1318/// markers (PassThrough = single dir-level link, Override = one or more
1319/// custom dsts), classifies each pair via [`crate::absorb::classify`],
1320/// and additionally surfaces any **render drift** — rendered files
1321/// whose content has diverged from what the matching `.tera` template
1322/// would produce now (i.e. the user edited the rendered file in place
1323/// without reflecting the change back into the template).
1324///
1325/// Exits non-zero (via `anyhow::bail!`) when anything diverges, so
1326/// `yui status && …` can gate workflows on a clean tree.
1327pub fn status(
1328    source: Option<Utf8PathBuf>,
1329    icons_override: Option<IconsMode>,
1330    no_color: bool,
1331) -> Result<()> {
1332    let source = resolve_source(source)?;
1333    let yui = YuiVars::detect(&source);
1334    let config = config::load(&source, &yui)?;
1335
1336    let mut engine = template::Engine::new();
1337    let tera_ctx = template::template_context(&yui, &config.vars);
1338    let mounts = mount::resolve(
1339        &source,
1340        &config.mount.entry,
1341        config.mount.default_strategy,
1342        &mut engine,
1343        &tera_ctx,
1344    )?;
1345
1346    let icons_mode = icons_override.unwrap_or(config.ui.icons);
1347    let icons = Icons::for_mode(icons_mode);
1348    let color = !no_color && supports_color_stdout();
1349
1350    let mut report: Vec<StatusItem> = Vec::new();
1351
1352    // 1. Template drift — render in dry-run mode and surface anything
1353    //    whose rendered counterpart on disk no longer matches.
1354    let render_report = render::render_all(&source, &config, &yui, /* dry_run */ true)?;
1355    for rendered in &render_report.diverged {
1356        // `diverged` holds the rendered path; the template lives at
1357        // `<rendered>.tera`. Show the .tera as src so it's clear which
1358        // file the user needs to update.
1359        let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
1360        report.push(StatusItem {
1361            src: relative_for_display(&source, &tera_path),
1362            dst: rendered.clone(),
1363            state: StatusState::RenderDrift,
1364        });
1365    }
1366
1367    // 2. Link drift — classify each src→dst pair under every mount.
1368    // Single nested-`.yuiignore` stack threaded across all mounts.
1369    // Seed the source-root layer so root rules apply from the start.
1370    let mut yuiignore = paths::YuiIgnoreStack::new();
1371    yuiignore.push_dir(&source)?;
1372    let walk_result = (|| -> Result<()> {
1373        for m in &mounts {
1374            let src_root = m.src.clone();
1375            if !src_root.is_dir() {
1376                warn!("mount src missing: {src_root}");
1377                continue;
1378            }
1379            classify_walk(
1380                &src_root,
1381                &m.dst,
1382                &config,
1383                m.strategy,
1384                &mut engine,
1385                &tera_ctx,
1386                &source,
1387                &mut yuiignore,
1388                &mut report,
1389            )?;
1390        }
1391        Ok(())
1392    })();
1393    yuiignore.pop_dir(&source);
1394    walk_result?;
1395
1396    report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
1397
1398    print_status_table(&report, icons, color);
1399
1400    let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
1401
1402    println!();
1403    let total = report.len();
1404    let in_sync = total - drift;
1405    if drift == 0 {
1406        println!("  {total} entries · all in sync");
1407        Ok(())
1408    } else {
1409        println!("  {total} entries · {in_sync} in sync · {drift} diverged");
1410        anyhow::bail!("status: {drift} entries diverged from source")
1411    }
1412}
1413
1414#[derive(Debug)]
1415struct StatusItem {
1416    /// Path under the source tree (display only).
1417    src: Utf8PathBuf,
1418    /// Resolved target path (or rendered output path for `RenderDrift`).
1419    dst: Utf8PathBuf,
1420    state: StatusState,
1421}
1422
1423#[derive(Debug, Clone, Copy)]
1424enum StatusState {
1425    Link(absorb::AbsorbDecision),
1426    /// Rendered output diverges from current `.tera` template — user
1427    /// edited the rendered file directly without updating the template.
1428    RenderDrift,
1429}
1430
1431impl StatusState {
1432    fn is_in_sync(self) -> bool {
1433        matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
1434    }
1435}
1436
1437#[allow(clippy::too_many_arguments)]
1438fn classify_walk(
1439    src_dir: &Utf8Path,
1440    dst_dir: &Utf8Path,
1441    config: &Config,
1442    strategy: MountStrategy,
1443    engine: &mut template::Engine,
1444    tera_ctx: &TeraContext,
1445    source_root: &Utf8Path,
1446    yuiignore: &mut paths::YuiIgnoreStack,
1447    report: &mut Vec<StatusItem>,
1448) -> Result<()> {
1449    classify_walk_inner(
1450        src_dir,
1451        dst_dir,
1452        config,
1453        strategy,
1454        engine,
1455        tera_ctx,
1456        source_root,
1457        yuiignore,
1458        report,
1459        false,
1460    )
1461}
1462
1463#[allow(clippy::too_many_arguments)]
1464fn classify_walk_inner(
1465    src_dir: &Utf8Path,
1466    dst_dir: &Utf8Path,
1467    config: &Config,
1468    strategy: MountStrategy,
1469    engine: &mut template::Engine,
1470    tera_ctx: &TeraContext,
1471    source_root: &Utf8Path,
1472    yuiignore: &mut paths::YuiIgnoreStack,
1473    report: &mut Vec<StatusItem>,
1474    parent_covered: bool,
1475) -> Result<()> {
1476    if yuiignore.is_ignored(src_dir, /* is_dir */ true) {
1477        return Ok(());
1478    }
1479    // Layer this dir's .yuiignore (if any) on top before we recurse;
1480    // pop on exit so siblings don't see our subtree's rules.
1481    yuiignore.push_dir(src_dir)?;
1482    let result = classify_walk_inner_body(
1483        src_dir,
1484        dst_dir,
1485        config,
1486        strategy,
1487        engine,
1488        tera_ctx,
1489        source_root,
1490        yuiignore,
1491        report,
1492        parent_covered,
1493    );
1494    yuiignore.pop_dir(src_dir);
1495    result
1496}
1497
1498#[allow(clippy::too_many_arguments)]
1499fn classify_walk_inner_body(
1500    src_dir: &Utf8Path,
1501    dst_dir: &Utf8Path,
1502    config: &Config,
1503    strategy: MountStrategy,
1504    engine: &mut template::Engine,
1505    tera_ctx: &TeraContext,
1506    source_root: &Utf8Path,
1507    yuiignore: &mut paths::YuiIgnoreStack,
1508    report: &mut Vec<StatusItem>,
1509    parent_covered: bool,
1510) -> Result<()> {
1511    let marker_filename = &config.mount.marker_filename;
1512    let mut covered = parent_covered;
1513
1514    if strategy == MountStrategy::Marker {
1515        match marker::read_spec(src_dir, marker_filename)? {
1516            None => {}
1517            Some(MarkerSpec::PassThrough) => {
1518                let decision = absorb::classify(src_dir, dst_dir)?;
1519                report.push(StatusItem {
1520                    src: relative_for_display(source_root, src_dir),
1521                    dst: dst_dir.to_path_buf(),
1522                    state: StatusState::Link(decision),
1523                });
1524                covered = true;
1525            }
1526            Some(MarkerSpec::Explicit { links }) => {
1527                let mut emitted_dir_link = false;
1528                for link in &links {
1529                    if let Some(when) = &link.when {
1530                        if !template::eval_truthy(when, engine, tera_ctx)? {
1531                            continue;
1532                        }
1533                    }
1534                    let dst_str = engine.render(&link.dst, tera_ctx)?;
1535                    let dst = paths::expand_tilde(dst_str.trim());
1536                    if let Some(filename) = &link.src {
1537                        let file_src = src_dir.join(filename);
1538                        if !file_src.is_file() {
1539                            anyhow::bail!(
1540                                "marker at {src_dir}: [[link]] src={filename:?} \
1541                                 not found"
1542                            );
1543                        }
1544                        let decision = absorb::classify(&file_src, &dst)?;
1545                        report.push(StatusItem {
1546                            src: relative_for_display(source_root, &file_src),
1547                            dst,
1548                            state: StatusState::Link(decision),
1549                        });
1550                    } else {
1551                        let decision = absorb::classify(src_dir, &dst)?;
1552                        report.push(StatusItem {
1553                            src: relative_for_display(source_root, src_dir),
1554                            dst,
1555                            state: StatusState::Link(decision),
1556                        });
1557                        emitted_dir_link = true;
1558                    }
1559                }
1560                if emitted_dir_link {
1561                    covered = true;
1562                }
1563            }
1564        }
1565    }
1566
1567    for entry in std::fs::read_dir(src_dir)? {
1568        let entry = entry?;
1569        let name_os = entry.file_name();
1570        let Some(name) = name_os.to_str() else {
1571            continue;
1572        };
1573        if name == marker_filename || name.ends_with(".tera") {
1574            continue;
1575        }
1576        let src_path = src_dir.join(name);
1577        let dst_path = dst_dir.join(name);
1578        let ft = entry.file_type()?;
1579        if yuiignore.is_ignored(&src_path, ft.is_dir()) {
1580            continue;
1581        }
1582        if ft.is_dir() {
1583            classify_walk_inner(
1584                &src_path,
1585                &dst_path,
1586                config,
1587                strategy,
1588                engine,
1589                tera_ctx,
1590                source_root,
1591                yuiignore,
1592                report,
1593                covered,
1594            )?;
1595        } else if ft.is_file() && !covered {
1596            let decision = absorb::classify(&src_path, &dst_path)?;
1597            report.push(StatusItem {
1598                src: relative_for_display(source_root, &src_path),
1599                dst: dst_path,
1600                state: StatusState::Link(decision),
1601            });
1602        }
1603    }
1604    Ok(())
1605}
1606
1607fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
1608    p.strip_prefix(source_root)
1609        .map(Utf8PathBuf::from)
1610        .unwrap_or_else(|_| p.to_path_buf())
1611}
1612
1613fn print_status_table(items: &[StatusItem], icons: Icons, color: bool) {
1614    let src_w = items
1615        .iter()
1616        .map(|i| i.src.as_str().chars().count())
1617        .max()
1618        .unwrap_or(0)
1619        .max("SRC".len());
1620    let dst_w = items
1621        .iter()
1622        .map(|i| i.dst.as_str().chars().count())
1623        .max()
1624        .unwrap_or(0)
1625        .max("DST".len());
1626    // STATE column = icon (1ch) + space + longest label
1627    let state_label_w = items
1628        .iter()
1629        .map(|i| state_label(i.state).len())
1630        .max()
1631        .unwrap_or(0)
1632        .max("STATE".len() - 2); // "STATE" header takes 5 chars; the icon prefix accounts for 2
1633    let state_w = state_label_w + 2; // " " + label
1634
1635    print_status_header(state_w, src_w, dst_w, color);
1636    let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
1637    if color {
1638        use owo_colors::OwoColorize as _;
1639        println!("{}", sep.dimmed());
1640    } else {
1641        println!("{sep}");
1642    }
1643    for item in items {
1644        print_status_row(item, icons, state_w, src_w, dst_w, color);
1645    }
1646}
1647
1648fn state_label(s: StatusState) -> &'static str {
1649    use absorb::AbsorbDecision::*;
1650    match s {
1651        StatusState::Link(InSync) => "in-sync",
1652        StatusState::Link(RelinkOnly) => "relink",
1653        StatusState::Link(AutoAbsorb) => "drift (auto)",
1654        StatusState::Link(NeedsConfirm) => "drift (anomaly)",
1655        StatusState::Link(Restore) => "missing",
1656        StatusState::RenderDrift => "render drift",
1657    }
1658}
1659
1660fn state_icon(s: StatusState, icons: Icons) -> &'static str {
1661    use absorb::AbsorbDecision::*;
1662    match s {
1663        StatusState::Link(InSync) => icons.ok,
1664        StatusState::Link(RelinkOnly) => icons.warn,
1665        StatusState::Link(AutoAbsorb) => icons.warn,
1666        StatusState::Link(NeedsConfirm) => icons.error,
1667        StatusState::Link(Restore) => icons.info,
1668        StatusState::RenderDrift => icons.error,
1669    }
1670}
1671
1672fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
1673    use owo_colors::OwoColorize as _;
1674    // STATE is the only column with data above; "WHEN" intentionally omitted
1675    // since status only shows mounts that are already active on this host.
1676    let line = format!(
1677        "  {:<state_w$}  {:<src_w$}     {:<dst_w$}",
1678        "STATE", "SRC", "DST"
1679    );
1680    if color {
1681        println!("{}", line.bold());
1682    } else {
1683        println!("{line}");
1684    }
1685}
1686
1687fn render_status_separator(
1688    sep_ch: char,
1689    state_w: usize,
1690    src_w: usize,
1691    dst_w: usize,
1692    arrow: &str,
1693) -> String {
1694    let bar = |n: usize| sep_ch.to_string().repeat(n);
1695    format!(
1696        "  {}  {}  {}  {}",
1697        bar(state_w),
1698        bar(src_w),
1699        bar(arrow.chars().count()),
1700        bar(dst_w)
1701    )
1702}
1703
1704fn print_status_row(
1705    item: &StatusItem,
1706    icons: Icons,
1707    state_w: usize,
1708    src_w: usize,
1709    dst_w: usize,
1710    color: bool,
1711) {
1712    use owo_colors::OwoColorize as _;
1713    let icon = state_icon(item.state, icons);
1714    let label = state_label(item.state);
1715    let state_text = format!("{icon} {label}");
1716    let src_display = item.src.as_str().replace('\\', "/");
1717    let dst_display = item.dst.as_str().replace('\\', "/");
1718    let arrow = icons.arrow;
1719
1720    let cell_state = format!("{:<state_w$}", state_text);
1721    let cell_src = format!("{:<src_w$}", src_display);
1722    let cell_dst = format!("{:<dst_w$}", dst_display);
1723
1724    if !color {
1725        println!("  {cell_state}  {cell_src}  {arrow}  {cell_dst}");
1726        return;
1727    }
1728
1729    use absorb::AbsorbDecision::*;
1730    let state_colored = match item.state {
1731        StatusState::Link(InSync) => cell_state.green().to_string(),
1732        StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
1733            cell_state.yellow().to_string()
1734        }
1735        StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
1736        StatusState::Link(Restore) => cell_state.cyan().to_string(),
1737        StatusState::RenderDrift => cell_state.red().to_string(),
1738    };
1739    let src_colored = cell_src.cyan().to_string();
1740    let arrow_colored = arrow.dimmed().to_string();
1741    let dst_colored = cell_dst.dimmed().to_string();
1742    println!("  {state_colored}  {src_colored}  {arrow_colored}  {dst_colored}");
1743}
1744
1745/// Manually absorb a single target file back into source.
1746///
1747/// Used when `apply` has skipped an anomaly (`[absorb] on_anomaly = "skip"`
1748/// or non-TTY ask) but the user has decided that target is right. Bypasses
1749/// policy + git-clean checks: this is an explicit user request.
1750///
1751/// Always prints a unified diff (source vs target) to stderr first.
1752/// Without `--yes`, requires interactive y/N confirmation on a TTY,
1753/// and refuses to act off-TTY (so a CI script can't silently
1754/// rewrite source). `--dry-run` shows the diff and exits.
1755///
1756/// Walks `[[mount.entry]]` and `.yuilink` overrides to find which source
1757/// path "owns" the given target. Errors loudly if no mount claims it.
1758pub fn absorb(
1759    source: Option<Utf8PathBuf>,
1760    target: Utf8PathBuf,
1761    dry_run: bool,
1762    yes: bool,
1763) -> Result<()> {
1764    let source = resolve_source(source)?;
1765    let target = absolutize(&target)?;
1766    let yui = YuiVars::detect(&source);
1767    let config = config::load(&source, &yui)?;
1768
1769    let mut engine = template::Engine::new();
1770    let tera_ctx = template::template_context(&yui, &config.vars);
1771
1772    let src_path = match find_source_for_target(&source, &config, &target, &mut engine, &tera_ctx)?
1773    {
1774        Some(s) => s,
1775        None => anyhow::bail!(
1776            "no mount entry / .yuilink override claims target {target}; \
1777                 pass a path inside a known dst"
1778        ),
1779    };
1780
1781    info!("source for {target}: {src_path}");
1782
1783    // Show the diff before *any* action. For text files we render a
1784    // unified diff against `similar`; for dirs / binaries we just
1785    // surface a one-liner so the user knows what they're about to
1786    // overwrite without dumping garbage to the terminal.
1787    print_absorb_diff(&src_path, &target);
1788
1789    if dry_run {
1790        info!("[dry-run] would absorb {target} → {src_path}");
1791        return Ok(());
1792    }
1793
1794    if !yes {
1795        use std::io::IsTerminal;
1796        if !std::io::stdin().is_terminal() {
1797            anyhow::bail!(
1798                "manual absorb refuses to run off-TTY without --yes \
1799                 (would silently overwrite {src_path})"
1800            );
1801        }
1802        if !prompt_yes_no("absorb target into source?")? {
1803            warn!("manual absorb cancelled by user: {target}");
1804            return Ok(());
1805        }
1806    }
1807
1808    let backup_root = source.join(&config.backup.dir);
1809    let ctx = ApplyCtx {
1810        config: &config,
1811        source: &source,
1812        file_mode: resolve_file_mode(config.link.file_mode),
1813        dir_mode: resolve_dir_mode(config.link.dir_mode),
1814        backup_root: &backup_root,
1815        dry_run: false,
1816    };
1817
1818    // Manual absorb is an explicit user request — bypass `auto`,
1819    // `require_clean_git`, and `on_anomaly` policy entirely.
1820    absorb_target_into_source(&src_path, &target, &ctx)
1821}
1822
1823/// Stderr-print a unified diff between `src` (file or dir) and `dst`
1824/// using `similar`. Falls back to a one-line description when one
1825/// side is a directory or content isn't valid UTF-8 — we'd rather
1826/// say "binary file differs" than spew bytes through `similar`.
1827fn print_absorb_diff(src: &Utf8Path, dst: &Utf8Path) {
1828    eprintln!();
1829    eprintln!("--- diff (- source, + target) ---");
1830    eprintln!("  src: {src}");
1831    eprintln!("  dst: {dst}");
1832    eprintln!();
1833    if src.is_dir() || dst.is_dir() {
1834        eprintln!("(directory absorb — content listing skipped)");
1835        eprintln!();
1836        return;
1837    }
1838    let src_content = match read_text_for_diff(src) {
1839        DiffSide::Text(s) => s,
1840        DiffSide::Binary => {
1841            eprintln!("(binary file or non-UTF-8 content — diff skipped)");
1842            eprintln!();
1843            return;
1844        }
1845    };
1846    let dst_content = match read_text_for_diff(dst) {
1847        DiffSide::Text(s) => s,
1848        DiffSide::Binary => {
1849            eprintln!("(binary file or non-UTF-8 content — diff skipped)");
1850            eprintln!();
1851            return;
1852        }
1853    };
1854    let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
1855    let formatted = diff
1856        .unified_diff()
1857        .header(src.as_str(), dst.as_str())
1858        .to_string();
1859    eprint!("{formatted}");
1860    eprintln!();
1861}
1862
1863fn prompt_yes_no(question: &str) -> Result<bool> {
1864    use std::io::Write as _;
1865    eprint!("{question} [y/N]: ");
1866    std::io::stderr().flush().ok();
1867    let mut input = String::new();
1868    std::io::stdin().read_line(&mut input)?;
1869    let answer = input.trim();
1870    Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
1871}
1872
1873/// Walk mount entries + `.yuilink` Override markers to find the source
1874/// file/dir that the given target maps back to. Returns `None` when no
1875/// mount or marker claims the path.
1876fn find_source_for_target(
1877    source: &Utf8Path,
1878    config: &Config,
1879    target: &Utf8Path,
1880    engine: &mut template::Engine,
1881    tera_ctx: &TeraContext,
1882) -> Result<Option<Utf8PathBuf>> {
1883    // 1. Mount entries — render dst, see if target is inside it.
1884    for entry in &config.mount.entry {
1885        if let Some(when) = &entry.when {
1886            if !template::eval_truthy(when, engine, tera_ctx)? {
1887                continue;
1888            }
1889        }
1890        let dst_str = engine.render(&entry.dst, tera_ctx)?;
1891        let dst_root = paths::expand_tilde(dst_str.trim());
1892        if let Ok(rel) = target.strip_prefix(&dst_root) {
1893            let src_str = engine.render(entry.src.as_str(), tera_ctx)?;
1894            let candidate = paths::resolve_mount_src(source, src_str.trim()).join(rel);
1895            // Honor `.yuiignore` even on manual absorb — if you've
1896            // ignored a path, you've explicitly opted out of yui's
1897            // managing it. One-shot stack walk along the candidate's
1898            // parents picks up nested `.yuiignore` files too.
1899            if paths::is_ignored_at(source, &candidate, candidate.is_dir())? {
1900                continue;
1901            }
1902            return Ok(Some(candidate));
1903        }
1904    }
1905
1906    // 2. `.yuilink` Override markers — walk source, parse, render each
1907    //    `[[link]] dst`, see if target is the rendered dst (or nested
1908    //    inside a junction'd dir). `source_walker` skips `.yui/` and
1909    //    honours nested `.yuiignore` files automatically, so markers
1910    //    inside ignored subtrees never reach this loop.
1911    let walker = paths::source_walker(source).build();
1912    let marker_filename = &config.mount.marker_filename;
1913    for ent in walker {
1914        let ent = match ent {
1915            Ok(e) => e,
1916            Err(_) => continue,
1917        };
1918        if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
1919            continue;
1920        }
1921        if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
1922            continue;
1923        }
1924        let dir = match ent.path().parent() {
1925            Some(d) => d,
1926            None => continue,
1927        };
1928        let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
1929            Ok(p) => p,
1930            Err(_) => continue,
1931        };
1932        let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
1933            Some(s) => s,
1934            None => continue,
1935        };
1936        let MarkerSpec::Explicit { links } = spec else {
1937            continue;
1938        };
1939        for link in &links {
1940            if let Some(when) = &link.when {
1941                if !template::eval_truthy(when, engine, tera_ctx)? {
1942                    continue;
1943                }
1944            }
1945            let dst_str = engine.render(&link.dst, tera_ctx)?;
1946            let dst = paths::expand_tilde(dst_str.trim());
1947            // File-level entry: dst points at a single file, so a match
1948            // resolves directly to `<marker-dir>/<src filename>`. Mirror
1949            // the existence check that apply / status do so a missing
1950            // sibling produces the same clear message regardless of
1951            // entry point — consistent with the `marker at … src=… not
1952            // found` shape users already see from those flows.
1953            if let Some(filename) = &link.src {
1954                let file_src = dir_utf8.join(filename);
1955                if !file_src.is_file() {
1956                    anyhow::bail!(
1957                        "marker at {dir_utf8}: [[link]] src={filename:?} \
1958                         not found"
1959                    );
1960                }
1961                if target == dst {
1962                    return Ok(Some(file_src));
1963                }
1964                continue;
1965            }
1966            if target == dst {
1967                return Ok(Some(dir_utf8));
1968            }
1969            if let Ok(rel) = target.strip_prefix(&dst) {
1970                return Ok(Some(dir_utf8.join(rel)));
1971            }
1972        }
1973    }
1974
1975    Ok(None)
1976}
1977
1978pub fn doctor(
1979    source: Option<Utf8PathBuf>,
1980    icons_override: Option<IconsMode>,
1981    no_color: bool,
1982) -> Result<()> {
1983    use owo_colors::OwoColorize as _;
1984
1985    // Resolve source up-front so probes that depend on it can short-circuit
1986    // gracefully. A missing source is the single most common cause of yui
1987    // misbehaving, so we want to surface it loudly and skip the dependent
1988    // probes rather than blowing up.
1989    let resolved_source = resolve_source(source);
1990
1991    // `YuiVars::detect` reads `yui.source` from the resolved source path
1992    // (so `{{ yui.source }}` renders correctly in config templates); when
1993    // no source is detected we fall back to `.` so identity probes can
1994    // still report os/arch/user/host.
1995    let yui = match &resolved_source {
1996        Ok(s) => YuiVars::detect(s),
1997        Err(_) => YuiVars::detect(Utf8Path::new(".")),
1998    };
1999
2000    // Cache the loaded config — both the icons-override fallback and the
2001    // hooks-section probe need it. `cfg_res` keeps the original error
2002    // around so the `repo / config` probe can render a meaningful
2003    // message instead of just "not loaded".
2004    let cfg_res = match &resolved_source {
2005        Ok(s) => Some(config::load(s, &yui)),
2006        Err(_) => None,
2007    };
2008    let cfg = cfg_res.as_ref().and_then(|r| r.as_ref().ok());
2009    let icons_mode = icons_override
2010        .or_else(|| cfg.map(|c| c.ui.icons))
2011        .unwrap_or_default();
2012    let icons = Icons::for_mode(icons_mode);
2013    let color = !no_color && supports_color_stdout();
2014
2015    let mut probes: Vec<Probe> = Vec::new();
2016
2017    // ── identity ──────────────────────────────────────────────
2018    probes.push(Probe::group("identity"));
2019    probes.push(Probe::ok("os/arch", format!("{} / {}", yui.os, yui.arch)));
2020    probes.push(Probe::ok("user@host", format!("{}@{}", yui.user, yui.host)));
2021
2022    // ── repository ────────────────────────────────────────────
2023    probes.push(Probe::group("repo"));
2024    let mut have_source = false;
2025    match &resolved_source {
2026        Ok(s) => {
2027            have_source = true;
2028            probes.push(Probe::ok("source", s.to_string()));
2029            match cfg_res.as_ref().expect("cfg_res set when source is Ok") {
2030                Ok(c) => {
2031                    probes.push(Probe::ok(
2032                        "config",
2033                        format!(
2034                            "{} mount{} · {} hook{} · {} render rule{}",
2035                            c.mount.entry.len(),
2036                            plural(c.mount.entry.len()),
2037                            c.hook.len(),
2038                            plural(c.hook.len()),
2039                            c.render.rule.len(),
2040                            plural(c.render.rule.len()),
2041                        ),
2042                    ));
2043                }
2044                Err(e) => probes.push(Probe::error("config", format!("{e}"))),
2045            }
2046            // git-clean check is informational here — the actual gate is
2047            // `[absorb] require_clean_git` on apply; warn so the user
2048            // knows auto-absorb will defer if they have uncommitted work.
2049            match crate::git::is_clean(s) {
2050                Ok(true) => probes.push(Probe::ok("git", "clean")),
2051                Ok(false) => probes.push(Probe::warn(
2052                    "git",
2053                    "uncommitted changes — `[absorb] require_clean_git` will defer auto-absorb",
2054                )),
2055                Err(_) => probes.push(Probe::warn(
2056                    "git",
2057                    "no git repo (auto-absorb still works; commit history won't track drift)",
2058                )),
2059            }
2060        }
2061        Err(e) => {
2062            probes.push(Probe::error("source", format!("not found — {e}")));
2063        }
2064    }
2065
2066    // ── link / render mode ────────────────────────────────────
2067    probes.push(Probe::group("links"));
2068    if cfg!(windows) {
2069        probes.push(Probe::ok(
2070            "default mode",
2071            "files=hardlink, dirs=junction (no admin needed)",
2072        ));
2073    } else {
2074        probes.push(Probe::ok("default mode", "files=symlink, dirs=symlink"));
2075    }
2076
2077    // ── hooks ─────────────────────────────────────────────────
2078    if have_source {
2079        if let (Ok(s), Some(c)) = (&resolved_source, cfg) {
2080            probes.push(Probe::group("hooks"));
2081            if c.hook.is_empty() {
2082                probes.push(Probe::ok("hooks", "(none configured)"));
2083            } else {
2084                let mut missing = 0usize;
2085                for h in &c.hook {
2086                    if !s.join(&h.script).is_file() {
2087                        missing += 1;
2088                        probes.push(Probe::error(
2089                            format!("hook[{}]", h.name),
2090                            format!("script not found at {}", h.script),
2091                        ));
2092                    }
2093                }
2094                if missing == 0 {
2095                    probes.push(Probe::ok(
2096                        "scripts",
2097                        format!(
2098                            "{} hook{} configured, all scripts present",
2099                            c.hook.len(),
2100                            plural(c.hook.len())
2101                        ),
2102                    ));
2103                }
2104            }
2105        }
2106    }
2107
2108    // ── chezmoi cleanup hint ─────────────────────────────────
2109    if let Some(home) = paths::home_dir() {
2110        let chezmoi_src = home.join(".local/share/chezmoi");
2111        if chezmoi_src.is_dir() {
2112            probes.push(Probe::group("chezmoi"));
2113            probes.push(Probe::warn(
2114                "legacy source",
2115                format!(
2116                    "{chezmoi_src} still exists — yui doesn't use it, safe to archive once your migration has settled"
2117                ),
2118            ));
2119        }
2120    }
2121
2122    // Render
2123    println!();
2124    if color {
2125        println!("  {}", "yui doctor".bold().underline());
2126    } else {
2127        println!("  yui doctor");
2128    }
2129    println!();
2130    for probe in &probes {
2131        probe.print(&icons, color);
2132    }
2133
2134    let errors = probes.iter().filter(|p| p.is_error()).count();
2135    let warns = probes.iter().filter(|p| p.is_warn()).count();
2136    let oks = probes.iter().filter(|p| p.is_ok()).count();
2137    println!();
2138    let summary = format!("{oks} ok · {warns} warn · {errors} error");
2139    if color {
2140        if errors > 0 {
2141            println!("  {}", summary.red().bold());
2142        } else if warns > 0 {
2143            println!("  {}", summary.yellow());
2144        } else {
2145            println!("  {}", summary.green());
2146        }
2147    } else {
2148        println!("  {summary}");
2149    }
2150
2151    if errors > 0 {
2152        anyhow::bail!("doctor: {errors} probe(s) failed");
2153    }
2154    Ok(())
2155}
2156
2157#[derive(Debug)]
2158enum Probe {
2159    /// Section divider (just a heading, no severity).
2160    Group(&'static str),
2161    Ok {
2162        label: String,
2163        detail: String,
2164    },
2165    Warn {
2166        label: String,
2167        detail: String,
2168    },
2169    Error {
2170        label: String,
2171        detail: String,
2172    },
2173}
2174
2175impl Probe {
2176    fn group(label: &'static str) -> Self {
2177        Self::Group(label)
2178    }
2179    fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
2180        Self::Ok {
2181            label: label.into(),
2182            detail: detail.into(),
2183        }
2184    }
2185    fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
2186        Self::Warn {
2187            label: label.into(),
2188            detail: detail.into(),
2189        }
2190    }
2191    fn error(label: impl Into<String>, detail: impl Into<String>) -> Self {
2192        Self::Error {
2193            label: label.into(),
2194            detail: detail.into(),
2195        }
2196    }
2197    fn is_ok(&self) -> bool {
2198        matches!(self, Self::Ok { .. })
2199    }
2200    fn is_warn(&self) -> bool {
2201        matches!(self, Self::Warn { .. })
2202    }
2203    fn is_error(&self) -> bool {
2204        matches!(self, Self::Error { .. })
2205    }
2206    fn print(&self, icons: &Icons, color: bool) {
2207        use owo_colors::OwoColorize as _;
2208        match self {
2209            Self::Group(name) => {
2210                println!();
2211                if color {
2212                    println!("  {}", name.cyan().bold());
2213                } else {
2214                    println!("  {name}");
2215                }
2216            }
2217            Self::Ok { label, detail } => {
2218                let icon = icons.ok;
2219                // Pad the raw label first; styling adds invisible ANSI
2220                // bytes that `format!("{:<14}")` would count as visible
2221                // width and silently break alignment between rows.
2222                let padded = format!("{label:<14}");
2223                if color {
2224                    println!(
2225                        "    {}  {}  {}",
2226                        icon.green(),
2227                        padded.bold(),
2228                        detail.dimmed()
2229                    );
2230                } else {
2231                    println!("    {icon}  {padded}  {detail}");
2232                }
2233            }
2234            Self::Warn { label, detail } => {
2235                let icon = icons.warn;
2236                let padded = format!("{label:<14}");
2237                if color {
2238                    println!(
2239                        "    {}  {}  {}",
2240                        icon.yellow(),
2241                        padded.bold().yellow(),
2242                        detail
2243                    );
2244                } else {
2245                    println!("    {icon}  {padded}  {detail}");
2246                }
2247            }
2248            Self::Error { label, detail } => {
2249                let icon = icons.error;
2250                let padded = format!("{label:<14}");
2251                if color {
2252                    println!(
2253                        "    {}  {}  {}",
2254                        icon.red().bold(),
2255                        padded.bold().red(),
2256                        detail.red()
2257                    );
2258                } else {
2259                    println!("    {icon}  {padded}  {detail}");
2260                }
2261            }
2262        }
2263    }
2264}
2265
2266fn plural(n: usize) -> &'static str {
2267    if n == 1 { "" } else { "s" }
2268}
2269
2270/// `yui gc-backup [--older-than DUR] [--dry-run]` — prune snapshots
2271/// under `$DOTFILES/.yui/backup/`.
2272///
2273/// With no `--older-than` we run a non-destructive *survey*: walk the
2274/// backup tree, list every entry whose name carries yui's
2275/// `_<YYYYMMDD_HHMMSSfff>[.<ext>]` suffix, and print AGE / SIZE / PATH
2276/// sorted oldest-first plus a hint to pass `--older-than DUR` to
2277/// actually delete. With `--older-than DUR` (e.g. `30d`, `2w`, `12h`,
2278/// `6m`, `1y`) we delete every entry strictly older than the cutoff.
2279/// `--dry-run` previews the same set without writing.
2280///
2281/// Two design points worth flagging:
2282/// 1. *Suffix, not mtime.* `std::fs::copy` preserves source mtime on
2283///    most platforms, so a backup of an old dotfile would look
2284///    "old" by mtime even when freshly created. The suffix is the
2285///    source of truth for "when did yui take this snapshot?".
2286/// 2. *Defensive parse.* Anything in `.yui/backup/` whose name
2287///    doesn't match the suffix shape is left alone — if you dropped
2288///    a file there by hand, gc-backup isn't going to delete it.
2289pub fn gc_backup(
2290    source: Option<Utf8PathBuf>,
2291    older_than: Option<String>,
2292    dry_run: bool,
2293    icons_override: Option<IconsMode>,
2294    no_color: bool,
2295) -> Result<()> {
2296    let source = resolve_source(source)?;
2297    let yui = YuiVars::detect(&source);
2298    let config = config::load(&source, &yui)?;
2299    let backup_root = source.join(&config.backup.dir);
2300    let icons_mode = icons_override.unwrap_or(config.ui.icons);
2301    let icons = Icons::for_mode(icons_mode);
2302    let color = !no_color && supports_color_stdout();
2303
2304    if !backup_root.is_dir() {
2305        println!("  no backup tree at {backup_root}");
2306        return Ok(());
2307    }
2308
2309    let mut entries = walk_gc_backups(&backup_root)?;
2310    if entries.is_empty() {
2311        println!("  no yui-stamped backups under {backup_root}");
2312        return Ok(());
2313    }
2314    // Oldest first — that's the natural "what should I prune?" order.
2315    entries.sort_by_key(|e| e.ts);
2316    let now = jiff::Zoned::now();
2317
2318    match older_than {
2319        None => {
2320            let refs: Vec<&BackupEntry> = entries.iter().collect();
2321            print_gc_table(&refs, &backup_root, &now, icons, color);
2322            println!();
2323            println!(
2324                "  {} entries · {} total — pass --older-than DUR (e.g. 30d) to delete",
2325                entries.len(),
2326                format_bytes(entries.iter().map(|e| e.size_bytes).sum())
2327            );
2328            Ok(())
2329        }
2330        Some(dur_str) => {
2331            let span = parse_human_duration(&dur_str)?;
2332            let cutoff = now
2333                .checked_sub(span)
2334                .map_err(|e| anyhow::anyhow!("invalid duration {dur_str:?}: {e}"))?;
2335            let cutoff_dt = cutoff.datetime();
2336
2337            let total_before: u64 = entries.iter().map(|e| e.size_bytes).sum();
2338            let to_delete: Vec<&BackupEntry> =
2339                entries.iter().filter(|e| e.ts < cutoff_dt).collect();
2340
2341            if to_delete.is_empty() {
2342                println!(
2343                    "  no backups older than {dur_str} (oldest: {})",
2344                    format_age(entries[0].ts, &now)
2345                );
2346                return Ok(());
2347            }
2348
2349            print_gc_table(&to_delete, &backup_root, &now, icons, color);
2350            println!();
2351            let total_freed: u64 = to_delete.iter().map(|e| e.size_bytes).sum();
2352
2353            if dry_run {
2354                println!(
2355                    "  [dry-run] would remove {} of {} entries · would free {} of {}",
2356                    to_delete.len(),
2357                    entries.len(),
2358                    format_bytes(total_freed),
2359                    format_bytes(total_before),
2360                );
2361                return Ok(());
2362            }
2363
2364            for entry in &to_delete {
2365                match entry.kind {
2366                    BackupKind::File => std::fs::remove_file(&entry.path)?,
2367                    BackupKind::Dir => std::fs::remove_dir_all(&entry.path)?,
2368                }
2369                if let Some(parent) = entry.path.parent() {
2370                    cleanup_empty_parents(parent, &backup_root);
2371                }
2372            }
2373            println!(
2374                "  removed {} of {} entries · freed {} (was {}, now {})",
2375                to_delete.len(),
2376                entries.len(),
2377                format_bytes(total_freed),
2378                format_bytes(total_before),
2379                format_bytes(total_before - total_freed),
2380            );
2381            Ok(())
2382        }
2383    }
2384}
2385
2386#[derive(Debug)]
2387struct BackupEntry {
2388    path: Utf8PathBuf,
2389    ts: jiff::civil::DateTime,
2390    kind: BackupKind,
2391    size_bytes: u64,
2392}
2393
2394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2395enum BackupKind {
2396    File,
2397    Dir,
2398}
2399
2400/// Recursive walk that recognises directory backups as one unit
2401/// (so we don't descend into `<dirname>_<ts>/` and surface its
2402/// individual files — the whole subtree is one snapshot). Files
2403/// without a yui suffix are silently skipped.
2404fn walk_gc_backups(root: &Utf8Path) -> Result<Vec<BackupEntry>> {
2405    let mut out = Vec::new();
2406    walk_gc_backups_rec(root, &mut out)?;
2407    Ok(out)
2408}
2409
2410fn walk_gc_backups_rec(dir: &Utf8Path, out: &mut Vec<BackupEntry>) -> Result<()> {
2411    for entry in std::fs::read_dir(dir)? {
2412        let entry = entry?;
2413        let name_os = entry.file_name();
2414        let Some(name) = name_os.to_str() else {
2415            continue;
2416        };
2417        let path = dir.join(name);
2418        let ft = entry.file_type()?;
2419        if ft.is_dir() {
2420            if let Some(ts) = parse_backup_suffix(name) {
2421                let size = dir_size(&path)?;
2422                out.push(BackupEntry {
2423                    path,
2424                    ts,
2425                    kind: BackupKind::Dir,
2426                    size_bytes: size,
2427                });
2428            } else {
2429                walk_gc_backups_rec(&path, out)?;
2430            }
2431        } else if ft.is_file() {
2432            // Nested ifs (not let-chains) so the crate's MSRV
2433            // (rust-version = "1.85") stays buildable.
2434            if let Some(ts) = parse_backup_suffix(name) {
2435                let size = entry.metadata()?.len();
2436                out.push(BackupEntry {
2437                    path,
2438                    ts,
2439                    kind: BackupKind::File,
2440                    size_bytes: size,
2441                });
2442            }
2443        }
2444    }
2445    Ok(())
2446}
2447
2448fn dir_size(dir: &Utf8Path) -> Result<u64> {
2449    let mut total: u64 = 0;
2450    for entry in std::fs::read_dir(dir)? {
2451        let entry = entry?;
2452        let ft = entry.file_type()?;
2453        if ft.is_dir() {
2454            let p = match Utf8PathBuf::from_path_buf(entry.path()) {
2455                Ok(p) => p,
2456                Err(_) => continue,
2457            };
2458            total = total.saturating_add(dir_size(&p)?);
2459        } else if ft.is_file() {
2460            total = total.saturating_add(entry.metadata()?.len());
2461        }
2462    }
2463    Ok(total)
2464}
2465
2466/// Walk up from `start` toward `root`, removing any directory that
2467/// has become empty as a result of a deletion. Stops at the first
2468/// non-empty parent and never touches `root` itself.
2469fn cleanup_empty_parents(start: &Utf8Path, root: &Utf8Path) {
2470    let mut cur = start.to_path_buf();
2471    loop {
2472        if cur == *root {
2473            return;
2474        }
2475        // remove_dir succeeds only if the directory is empty.
2476        if std::fs::remove_dir(&cur).is_err() {
2477            return;
2478        }
2479        match cur.parent() {
2480            Some(p) => cur = p.to_path_buf(),
2481            None => return,
2482        }
2483    }
2484}
2485
2486/// Parse a yui backup name. Two shapes:
2487///   - `<stem>_<YYYYMMDD_HHMMSSfff>`            (dirs / dotfiles / no-ext)
2488///   - `<stem>_<YYYYMMDD_HHMMSSfff>.<ext>`      (files with extension)
2489///
2490/// Returns the timestamp on success, `None` for anything else.
2491fn parse_backup_suffix(name: &str) -> Option<jiff::civil::DateTime> {
2492    if let Some(ts) = parse_ts_at_end(name) {
2493        return Some(ts);
2494    }
2495    // Nested ifs (not let-chains) so the crate's MSRV
2496    // (rust-version = "1.85") stays buildable.
2497    if let Some((before, _ext)) = name.rsplit_once('.') {
2498        if let Some(ts) = parse_ts_at_end(before) {
2499            return Some(ts);
2500        }
2501    }
2502    None
2503}
2504
2505fn parse_ts_at_end(s: &str) -> Option<jiff::civil::DateTime> {
2506    // Need at least 1 stem char + `_` + 18-char timestamp.
2507    if s.len() < 20 {
2508        return None;
2509    }
2510    let split_at = s.len() - 19;
2511    if s.as_bytes()[split_at] != b'_' {
2512        return None;
2513    }
2514    parse_ts(&s[split_at + 1..])
2515}
2516
2517/// Parse exactly `YYYYMMDD_HHMMSSfff`.
2518fn parse_ts(s: &str) -> Option<jiff::civil::DateTime> {
2519    if s.len() != 18 || s.as_bytes()[8] != b'_' {
2520        return None;
2521    }
2522    for (i, &b) in s.as_bytes().iter().enumerate() {
2523        if i == 8 {
2524            continue;
2525        }
2526        if !b.is_ascii_digit() {
2527            return None;
2528        }
2529    }
2530    let year: i16 = s[0..4].parse().ok()?;
2531    let month: i8 = s[4..6].parse().ok()?;
2532    let day: i8 = s[6..8].parse().ok()?;
2533    let hour: i8 = s[9..11].parse().ok()?;
2534    let minute: i8 = s[11..13].parse().ok()?;
2535    let second: i8 = s[13..15].parse().ok()?;
2536    let ms: i32 = s[15..18].parse().ok()?;
2537    jiff::civil::DateTime::new(year, month, day, hour, minute, second, ms * 1_000_000).ok()
2538}
2539
2540/// Parse a duration string in the shorthand `30d`, `2w`, `12h`,
2541/// `6mo` (months), `1y`, `5m` (minutes). Whitespace around the
2542/// number is tolerated; the unit is case-insensitive.
2543///
2544/// `m` means **minutes**, `mo` means **months** — bare `m` matches
2545/// what `format_age` prints in the survey table, so a backup
2546/// shown as "5m" is pruneable as `--older-than 5m`. Months take
2547/// the explicit `mo` form. (Caught in PR #51 review.)
2548fn parse_human_duration(s: &str) -> Result<jiff::Span> {
2549    let s = s.trim();
2550    let split = s
2551        .bytes()
2552        .position(|b| b.is_ascii_alphabetic())
2553        .ok_or_else(|| anyhow::anyhow!("invalid duration {s:?}: missing unit (e.g. 30d, 2w)"))?;
2554    let n: i64 = s[..split]
2555        .trim()
2556        .parse()
2557        .map_err(|_| anyhow::anyhow!("invalid duration {s:?}: bad leading number"))?;
2558    if n < 0 {
2559        anyhow::bail!("invalid duration {s:?}: negative durations don't make sense");
2560    }
2561    let unit = s[split..].to_ascii_lowercase();
2562    let span = match unit.as_str() {
2563        "y" | "yr" | "year" | "years" => jiff::Span::new().years(n),
2564        "mo" | "month" | "months" => jiff::Span::new().months(n),
2565        "w" | "wk" | "week" | "weeks" => jiff::Span::new().weeks(n),
2566        "d" | "day" | "days" => jiff::Span::new().days(n),
2567        "h" | "hr" | "hour" | "hours" => jiff::Span::new().hours(n),
2568        "m" | "min" | "minute" | "minutes" => jiff::Span::new().minutes(n),
2569        other => {
2570            anyhow::bail!(
2571                "invalid duration {s:?}: unknown unit {other:?} \
2572                 (use y / mo / w / d / h / m)"
2573            )
2574        }
2575    };
2576    Ok(span)
2577}
2578
2579fn format_bytes(n: u64) -> String {
2580    const KIB: u64 = 1024;
2581    const MIB: u64 = KIB * 1024;
2582    const GIB: u64 = MIB * 1024;
2583    if n >= GIB {
2584        format!("{:.1} GiB", n as f64 / GIB as f64)
2585    } else if n >= MIB {
2586        format!("{:.1} MiB", n as f64 / MIB as f64)
2587    } else if n >= KIB {
2588        format!("{:.1} KiB", n as f64 / KIB as f64)
2589    } else {
2590        format!("{n} B")
2591    }
2592}
2593
2594fn format_age(ts: jiff::civil::DateTime, now: &jiff::Zoned) -> String {
2595    let Ok(ts_zoned) = ts.to_zoned(now.time_zone().clone()) else {
2596        return "?".into();
2597    };
2598    let secs = match (now - &ts_zoned).total(jiff::Unit::Second) {
2599        Ok(s) => s as i64,
2600        Err(_) => return "?".into(),
2601    };
2602    if secs < 0 {
2603        return "future".into();
2604    }
2605    if secs < 60 {
2606        format!("{secs}s")
2607    } else if secs < 3600 {
2608        format!("{}m", secs / 60)
2609    } else if secs < 86_400 {
2610        format!("{}h", secs / 3600)
2611    } else if secs < 86_400 * 30 {
2612        format!("{}d", secs / 86_400)
2613    } else if secs < 86_400 * 365 {
2614        format!("{}mo", secs / (86_400 * 30))
2615    } else {
2616        format!("{}y", secs / (86_400 * 365))
2617    }
2618}
2619
2620/// Render a borrowed slice of `BackupEntry`s as an AGE / SIZE / PATH
2621/// table. Trailing `/` on the path marks dir backups (filesystem
2622/// convention) so the kind is visible without a dedicated column.
2623/// `_icons` is currently unused but kept on the signature so future
2624/// table changes can adopt new glyphs without rippling through every
2625/// caller.
2626fn print_gc_table(
2627    entries: &[&BackupEntry],
2628    backup_root: &Utf8Path,
2629    now: &jiff::Zoned,
2630    _icons: Icons,
2631    color: bool,
2632) {
2633    use owo_colors::OwoColorize as _;
2634
2635    let rows: Vec<(String, String, String)> = entries
2636        .iter()
2637        .map(|e| {
2638            let rel = e
2639                .path
2640                .strip_prefix(backup_root)
2641                .map(Utf8PathBuf::from)
2642                .unwrap_or_else(|_| e.path.clone());
2643            let path_disp = match e.kind {
2644                BackupKind::Dir => format!("{rel}/"),
2645                BackupKind::File => rel.to_string(),
2646            };
2647            (format_age(e.ts, now), format_bytes(e.size_bytes), path_disp)
2648        })
2649        .collect();
2650
2651    let age_w = rows.iter().map(|r| r.0.len()).max().unwrap_or(3);
2652    let size_w = rows.iter().map(|r| r.1.len()).max().unwrap_or(4);
2653
2654    if color {
2655        println!(
2656            "  {:<age_w$}  {:>size_w$}  {}",
2657            "AGE".dimmed(),
2658            "SIZE".dimmed(),
2659            "PATH".dimmed(),
2660        );
2661    } else {
2662        println!("  {:<age_w$}  {:>size_w$}  PATH", "AGE", "SIZE");
2663    }
2664    for (age, size, path) in &rows {
2665        if color {
2666            println!(
2667                "  {:<age_w$}  {:>size_w$}  {}",
2668                age.yellow(),
2669                size,
2670                path.cyan(),
2671            );
2672        } else {
2673            println!("  {:<age_w$}  {:>size_w$}  {}", age, size, path);
2674        }
2675    }
2676}
2677
2678/// `yui hooks list` — show every configured hook + its last-run state.
2679pub fn hooks_list(
2680    source: Option<Utf8PathBuf>,
2681    icons_override: Option<IconsMode>,
2682    no_color: bool,
2683) -> Result<()> {
2684    let source = resolve_source(source)?;
2685    let yui = YuiVars::detect(&source);
2686    let config = config::load(&source, &yui)?;
2687    let state = hook::State::load(&source)?;
2688
2689    let icons_mode = icons_override.unwrap_or(config.ui.icons);
2690    let icons = Icons::for_mode(icons_mode);
2691    let color = !no_color && supports_color_stdout();
2692
2693    if config.hook.is_empty() {
2694        println!("(no [[hook]] entries in config)");
2695        return Ok(());
2696    }
2697
2698    // Pre-evaluate the `when` filter for every hook so the status icon
2699    // can distinguish "skipped because the OS gate is false" from
2700    // "active but never run".
2701    let mut engine = template::Engine::new();
2702    let tera_ctx = template::template_context(&yui, &config.vars);
2703    let rows: Vec<HookRow> = config
2704        .hook
2705        .iter()
2706        .map(|h| -> Result<HookRow> {
2707            // Propagate Tera errors instead of silently coercing them
2708            // to "inactive" — a syntax error in the user's `when`
2709            // expression should surface, not hide.
2710            let active = match &h.when {
2711                None => true,
2712                Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
2713            };
2714            let last_run_at = state.hooks.get(&h.name).and_then(|s| s.last_run_at.clone());
2715            Ok(HookRow {
2716                name: h.name.clone(),
2717                phase: match h.phase {
2718                    HookPhase::Pre => "pre",
2719                    HookPhase::Post => "post",
2720                },
2721                when_run: match h.when_run {
2722                    config::WhenRun::Once => "once",
2723                    config::WhenRun::Onchange => "onchange",
2724                    config::WhenRun::Every => "every",
2725                },
2726                last_run_at,
2727                when: h.when.clone(),
2728                active,
2729            })
2730        })
2731        .collect::<Result<Vec<_>>>()?;
2732
2733    print_hooks_table(&rows, icons, color);
2734
2735    let total = rows.len();
2736    let active = rows.iter().filter(|r| r.active).count();
2737    let inactive = total - active;
2738    let ran = rows.iter().filter(|r| r.last_run_at.is_some()).count();
2739    let never = total - ran;
2740    println!();
2741    println!(
2742        "  {total} hooks · {active} active · {inactive} inactive · {ran} ran · {never} never run"
2743    );
2744
2745    Ok(())
2746}
2747
2748#[derive(Debug)]
2749struct HookRow {
2750    name: String,
2751    phase: &'static str,
2752    when_run: &'static str,
2753    last_run_at: Option<String>,
2754    when: Option<String>,
2755    active: bool,
2756}
2757
2758fn print_hooks_table(rows: &[HookRow], icons: Icons, color: bool) {
2759    use owo_colors::OwoColorize as _;
2760    use std::fmt::Write as _;
2761
2762    let name_w = rows
2763        .iter()
2764        .map(|r| r.name.chars().count())
2765        .max()
2766        .unwrap_or(0)
2767        .max("NAME".len());
2768    let phase_w = rows
2769        .iter()
2770        .map(|r| r.phase.len())
2771        .max()
2772        .unwrap_or(0)
2773        .max("PHASE".len());
2774    let when_run_w = rows
2775        .iter()
2776        .map(|r| r.when_run.len())
2777        .max()
2778        .unwrap_or(0)
2779        .max("WHEN_RUN".len());
2780    let last_w = rows
2781        .iter()
2782        .map(|r| {
2783            r.last_run_at
2784                .as_deref()
2785                .map(|s| s.chars().count())
2786                .unwrap_or("(never)".len())
2787        })
2788        .max()
2789        .unwrap_or(0)
2790        .max("LAST_RUN".len());
2791    let status_w = "STATUS".len();
2792
2793    // Header
2794    let mut header = String::new();
2795    let _ = write!(
2796        &mut header,
2797        "  {:<status_w$}  {:<name_w$}  {:<phase_w$}  {:<when_run_w$}  {:<last_w$}  WHEN",
2798        "STATUS", "NAME", "PHASE", "WHEN_RUN", "LAST_RUN"
2799    );
2800    if color {
2801        println!("{}", header.bold());
2802    } else {
2803        println!("{header}");
2804    }
2805
2806    // Separator (re-uses the same sep glyph the list / status table picks).
2807    let bar = |n: usize| icons.sep.to_string().repeat(n);
2808    let sep = format!(
2809        "  {}  {}  {}  {}  {}  {}",
2810        bar(status_w),
2811        bar(name_w),
2812        bar(phase_w),
2813        bar(when_run_w),
2814        bar(last_w),
2815        bar("WHEN".len())
2816    );
2817    if color {
2818        println!("{}", sep.dimmed());
2819    } else {
2820        println!("{sep}");
2821    }
2822
2823    // Rows
2824    for r in rows {
2825        // Status icon picks one of three states. We could expand this
2826        // (✗ failed, ↻ would-rerun-via-onchange-hash) once `hooks list`
2827        // grows enough fields to justify it; today's set is enough to
2828        // make the table scannable.
2829        let (icon, ran) = match (r.active, r.last_run_at.is_some()) {
2830            (false, _) => (icons.inactive, false),
2831            (true, true) => (icons.active, true),
2832            (true, false) => (icons.info, false),
2833        };
2834        let last = r.last_run_at.as_deref().unwrap_or("(never)");
2835        let when_str = r
2836            .when
2837            .as_deref()
2838            .map(strip_braces)
2839            .unwrap_or_else(|| "(always)".to_string());
2840
2841        let cell_status = format!("{icon:<status_w$}");
2842        let cell_name = format!("{:<name_w$}", r.name);
2843        let cell_phase = format!("{:<phase_w$}", r.phase);
2844        let cell_when_run = format!("{:<when_run_w$}", r.when_run);
2845        let cell_last = format!("{last:<last_w$}");
2846
2847        if !color {
2848            println!(
2849                "  {cell_status}  {cell_name}  {cell_phase}  {cell_when_run}  {cell_last}  {when_str}"
2850            );
2851            continue;
2852        }
2853
2854        // Active+ran: green status, bold name. Active-but-never: yellow
2855        // status (the "🆕 new — apply hasn't ticked it" signal). Inactive
2856        // (when-false): dimmed across the row.
2857        if !r.active {
2858            println!(
2859                "  {}  {}  {}  {}  {}  {}",
2860                cell_status.dimmed(),
2861                cell_name.dimmed(),
2862                cell_phase.dimmed(),
2863                cell_when_run.dimmed(),
2864                cell_last.dimmed(),
2865                when_str.dimmed()
2866            );
2867        } else if ran {
2868            println!(
2869                "  {}  {}  {}  {}  {}  {}",
2870                cell_status.green(),
2871                cell_name.cyan().bold(),
2872                cell_phase.dimmed(),
2873                cell_when_run.dimmed(),
2874                cell_last.green(),
2875                when_str.dimmed()
2876            );
2877        } else {
2878            println!(
2879                "  {}  {}  {}  {}  {}  {}",
2880                cell_status.yellow(),
2881                cell_name.cyan().bold(),
2882                cell_phase.dimmed(),
2883                cell_when_run.dimmed(),
2884                cell_last.yellow(),
2885                when_str.dimmed()
2886            );
2887        }
2888    }
2889}
2890
2891/// `yui hooks run [<name>] [--force]` — run a single hook (or every
2892/// hook) on demand. `--force` bypasses the `when_run` state check;
2893/// the `when` filter (`yui.os == 'macos'` etc.) is always honored.
2894pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
2895    let source = resolve_source(source)?;
2896    let yui = YuiVars::detect(&source);
2897    let config = config::load(&source, &yui)?;
2898    let mut engine = template::Engine::new();
2899    let tera_ctx = template::template_context(&yui, &config.vars);
2900
2901    let targets: Vec<&config::HookConfig> = match &name {
2902        Some(want) => {
2903            let m = config
2904                .hook
2905                .iter()
2906                .find(|h| &h.name == want)
2907                .ok_or_else(|| {
2908                    anyhow::anyhow!(
2909                        "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
2910                    )
2911                })?;
2912            vec![m]
2913        }
2914        None => config.hook.iter().collect(),
2915    };
2916
2917    let mut state = hook::State::load(&source)?;
2918    for h in targets {
2919        let outcome = hook::run_hook(
2920            h,
2921            &source,
2922            &yui,
2923            &config.vars,
2924            &mut engine,
2925            &tera_ctx,
2926            &mut state,
2927            /* dry_run */ false,
2928            force,
2929        )?;
2930        let label = match outcome {
2931            HookOutcome::Ran => "ran",
2932            HookOutcome::SkippedOnce => "skipped (once: already ran)",
2933            HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
2934            HookOutcome::SkippedWhenFalse => "skipped (when=false)",
2935            HookOutcome::DryRun => "would run (dry-run)",
2936        };
2937        info!("hook[{}]: {label}", h.name);
2938        if outcome == HookOutcome::Ran {
2939            state.save(&source)?;
2940        }
2941    }
2942    Ok(())
2943}
2944
2945// ---------------------------------------------------------------------------
2946// internals
2947// ---------------------------------------------------------------------------
2948
2949#[allow(clippy::too_many_arguments)]
2950fn process_mount(
2951    m: &ResolvedMount,
2952    ctx: &ApplyCtx<'_>,
2953    engine: &mut template::Engine,
2954    tera_ctx: &TeraContext,
2955    yuiignore: &mut paths::YuiIgnoreStack,
2956) -> Result<()> {
2957    // `m.src` is already absolute (resolved by `mount::resolve`),
2958    // so we don't need the source-root anymore.
2959    let src_root = m.src.clone();
2960    if !src_root.is_dir() {
2961        warn!("mount src missing: {src_root}");
2962        return Ok(());
2963    }
2964    walk_and_link(
2965        &src_root, &m.dst, ctx, m.strategy, engine, tera_ctx, yuiignore, false,
2966    )
2967}
2968
2969#[allow(clippy::too_many_arguments)]
2970fn walk_and_link(
2971    src_dir: &Utf8Path,
2972    dst_dir: &Utf8Path,
2973    ctx: &ApplyCtx<'_>,
2974    strategy: MountStrategy,
2975    engine: &mut template::Engine,
2976    tera_ctx: &TeraContext,
2977    yuiignore: &mut paths::YuiIgnoreStack,
2978    parent_covered: bool,
2979) -> Result<()> {
2980    // `.yuiignore` short-circuit — entire subtrees that match are skipped
2981    // without even reading their marker / iterating their children.
2982    if yuiignore.is_ignored(src_dir, /* is_dir */ true) {
2983        return Ok(());
2984    }
2985    // Layer this dir's `.yuiignore` (if any) on top, run the body, pop
2986    // before returning so siblings don't see our subtree's rules.
2987    yuiignore.push_dir(src_dir)?;
2988    let result = walk_and_link_body(
2989        src_dir,
2990        dst_dir,
2991        ctx,
2992        strategy,
2993        engine,
2994        tera_ctx,
2995        yuiignore,
2996        parent_covered,
2997    );
2998    yuiignore.pop_dir(src_dir);
2999    result
3000}
3001
3002#[allow(clippy::too_many_arguments)]
3003fn walk_and_link_body(
3004    src_dir: &Utf8Path,
3005    dst_dir: &Utf8Path,
3006    ctx: &ApplyCtx<'_>,
3007    strategy: MountStrategy,
3008    engine: &mut template::Engine,
3009    tera_ctx: &TeraContext,
3010    yuiignore: &mut paths::YuiIgnoreStack,
3011    parent_covered: bool,
3012) -> Result<()> {
3013    let marker_filename = &ctx.config.mount.marker_filename;
3014    let mut covered = parent_covered;
3015
3016    if strategy == MountStrategy::Marker {
3017        match marker::read_spec(src_dir, marker_filename)? {
3018            None => {} // no marker — fall through to recursive walk
3019            Some(MarkerSpec::PassThrough) => {
3020                // Empty marker = junction this dir at the natural
3021                // mount-derived dst. Subsequent recursion keeps going so
3022                // descendant markers can layer on extra dsts.
3023                link_dir_with_backup(src_dir, dst_dir, ctx)?;
3024                covered = true;
3025            }
3026            Some(MarkerSpec::Explicit { links }) => {
3027                let mut emitted_dir_link = false;
3028                let mut emitted_any = false;
3029                for link in &links {
3030                    // Nested ifs (not let-chains) so the crate's MSRV
3031                    // (rust-version = "1.85") stays buildable.
3032                    if let Some(when) = &link.when {
3033                        if !template::eval_truthy(when, engine, tera_ctx)? {
3034                            continue;
3035                        }
3036                    }
3037                    let dst_str = engine.render(&link.dst, tera_ctx)?;
3038                    let dst = paths::expand_tilde(dst_str.trim());
3039                    if let Some(filename) = &link.src {
3040                        let file_src = src_dir.join(filename);
3041                        if !file_src.is_file() {
3042                            anyhow::bail!(
3043                                "marker at {src_dir}: [[link]] src={filename:?} \
3044                                 not found"
3045                            );
3046                        }
3047                        link_file_with_backup(&file_src, &dst, ctx)?;
3048                    } else {
3049                        link_dir_with_backup(src_dir, &dst, ctx)?;
3050                        emitted_dir_link = true;
3051                    }
3052                    emitted_any = true;
3053                }
3054                if !emitted_any {
3055                    // v0.6+ semantics: with no active links, the walker
3056                    // still descends and per-file defaults still apply.
3057                    // Phrase it so users don't read "skipping" as
3058                    // "subtree blocked" (the v0.5 behaviour).
3059                    info!(
3060                        "marker at {src_dir} had no active links \
3061                         — falling back to defaults"
3062                    );
3063                }
3064                if emitted_dir_link {
3065                    covered = true;
3066                }
3067            }
3068        }
3069    }
3070
3071    for entry in std::fs::read_dir(src_dir)? {
3072        let entry = entry?;
3073        let name_os = entry.file_name();
3074        let Some(name) = name_os.to_str() else {
3075            continue;
3076        };
3077        if name == marker_filename {
3078            continue;
3079        }
3080        if name.ends_with(".tera") {
3081            // Templates are handled by the render flow before linking.
3082            continue;
3083        }
3084        let src_path = src_dir.join(name);
3085        let dst_path = dst_dir.join(name);
3086        let ft = entry.file_type()?;
3087
3088        if yuiignore.is_ignored(&src_path, ft.is_dir()) {
3089            continue;
3090        }
3091
3092        if ft.is_dir() {
3093            walk_and_link(
3094                &src_path, &dst_path, ctx, strategy, engine, tera_ctx, yuiignore, covered,
3095            )?;
3096        } else if ft.is_file() {
3097            // If an ancestor (or this dir itself) created a dir-level
3098            // junction, the file is already accessible via that junction
3099            // — emitting another per-file link would just duplicate work
3100            // (and on Windows might land at a path that's already
3101            // hard-linked through the parent).
3102            if !covered {
3103                link_file_with_backup(&src_path, &dst_path, ctx)?;
3104            }
3105        }
3106    }
3107    Ok(())
3108}
3109
3110fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3111    use absorb::AbsorbDecision::*;
3112
3113    let decision = absorb::classify(src, dst)?;
3114
3115    if ctx.dry_run {
3116        info!("[dry-run] {decision:?}: {src} → {dst}");
3117        return Ok(());
3118    }
3119
3120    match decision {
3121        InSync => {
3122            // Link is intact (same inode/file-id). Nothing to do.
3123            Ok(())
3124        }
3125        Restore => {
3126            info!("link: {src} → {dst}");
3127            link::link_file(src, dst, ctx.file_mode)?;
3128            Ok(())
3129        }
3130        RelinkOnly => {
3131            // Same content, different inode (e.g. hardlink broken by an
3132            // editor's atomic save). Re-link without touching source.
3133            info!("relink: {src} → {dst}");
3134            link::unlink(dst)?;
3135            link::link_file(src, dst, ctx.file_mode)?;
3136            Ok(())
3137        }
3138        AutoAbsorb => {
3139            // Target newer + content differs: target wins, source updated.
3140            // Honor `[absorb] auto` (kill-switch) and `require_clean_git`.
3141            if !ctx.config.absorb.auto {
3142                return handle_anomaly(
3143                    src,
3144                    dst,
3145                    ctx,
3146                    "absorb.auto = false; treating divergence as anomaly",
3147                );
3148            }
3149            if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
3150                return handle_anomaly(
3151                    src,
3152                    dst,
3153                    ctx,
3154                    "source repo is dirty; deferring auto-absorb",
3155                );
3156            }
3157            absorb_target_into_source(src, dst, ctx)
3158        }
3159        NeedsConfirm => handle_anomaly(
3160            src,
3161            dst,
3162            ctx,
3163            "anomaly: source equals/newer than target but content differs",
3164        ),
3165    }
3166}
3167
3168/// Back up the source-side file, copy the target's content into source,
3169/// then re-link so the freshly-updated source is what target points at.
3170/// "Target wins" — yui's core philosophy.
3171fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3172    info!("absorb: {dst} → {src}");
3173    backup_existing(src, ctx.backup_root, /* is_dir */ false)?;
3174    std::fs::copy(dst, src)?;
3175    link::unlink(dst)?;
3176    link::link_file(src, dst, ctx.file_mode)?;
3177    Ok(())
3178}
3179
3180/// Decide what to do for an anomaly (NeedsConfirm or AutoAbsorb that was
3181/// escalated by `auto = false` / dirty git). Per `[absorb] on_anomaly`:
3182///   - `skip`  → log warning, leave target alone
3183///   - `force` → behave like AutoAbsorb (target wins)
3184///   - `ask`   → on a TTY, show diff + prompt. Off-TTY, downgrade to skip.
3185fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
3186    use crate::config::AnomalyAction::*;
3187    match ctx.config.absorb.on_anomaly {
3188        Skip => {
3189            warn!("anomaly skip: {dst} ({reason})");
3190            Ok(())
3191        }
3192        Force => {
3193            warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
3194            absorb_target_into_source(src, dst, ctx)
3195        }
3196        Ask => {
3197            use std::io::IsTerminal;
3198            if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
3199                if prompt_absorb_with_diff(src, dst, reason)? {
3200                    absorb_target_into_source(src, dst, ctx)
3201                } else {
3202                    warn!("anomaly skipped by user: {dst}");
3203                    Ok(())
3204                }
3205            } else {
3206                warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
3207                Ok(())
3208            }
3209        }
3210    }
3211}
3212
3213fn prompt_absorb_with_diff(src: &Utf8Path, dst: &Utf8Path, reason: &str) -> Result<bool> {
3214    eprintln!();
3215    eprintln!("anomaly: {reason}");
3216    print_absorb_diff(src, dst);
3217    prompt_yes_no("absorb target into source?")
3218}
3219
3220/// Resilient git-clean check: if `git` isn't available or `source` isn't
3221/// a repo, log a warning and proceed as if clean. We don't want a missing
3222/// `git` to block apply — the require_clean_git knob is a *safety net*,
3223/// not a hard prerequisite.
3224fn source_repo_is_clean(source: &Utf8Path) -> bool {
3225    match crate::git::is_clean(source) {
3226        Ok(b) => b,
3227        Err(e) => {
3228            warn!("git clean check failed at {source}: {e} — treating as clean");
3229            true
3230        }
3231    }
3232}
3233
3234fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3235    use absorb::AbsorbDecision::*;
3236    let decision = absorb::classify(src, dst)?;
3237
3238    if ctx.dry_run {
3239        info!("[dry-run] dir {decision:?}: {src} → {dst}");
3240        return Ok(());
3241    }
3242
3243    match decision {
3244        InSync => Ok(()),
3245        Restore => {
3246            info!("link dir: {src} → {dst}");
3247            link::link_dir(src, dst, ctx.dir_mode)?;
3248            Ok(())
3249        }
3250        RelinkOnly => {
3251            // For dirs the classifier doesn't currently produce
3252            // `RelinkOnly` (only InSync / NeedsConfirm), but handle it
3253            // for symmetry with the file path: contents already match,
3254            // so just swap the target for a junction to source.
3255            info!("relink dir: {src} → {dst}");
3256            remove_dir_link_or_real(dst)?;
3257            link::link_dir(src, dst, ctx.dir_mode)?;
3258            Ok(())
3259        }
3260        AutoAbsorb | NeedsConfirm => {
3261            // Reaching `link_dir_with_backup` means we're acting on a
3262            // `.yuilink` marker (or a `[[mount.entry]]` whose `src` is a
3263            // directory) — the user has explicitly opted into
3264            // "this whole subtree is target-as-truth". A dir-level
3265            // NeedsConfirm here is therefore *not* the same kind of
3266            // anomaly that file-level NeedsConfirm represents (a single
3267            // file the user edited and source got newer); it's just
3268            // "source and target dirs are different inodes" — the
3269            // marker already authorised us to merge.
3270            //
3271            // Per-file content conflicts *inside* the merge are still
3272            // a real concern (target has X, source has X with
3273            // different content). Those are surfaced from inside the
3274            // merge itself — see `merge_dir_target_into_source`'s
3275            // file-level dispatch — so the outer-dir decision falls
3276            // straight through to absorb.
3277            //
3278            // The `auto` / `require_clean_git` knobs still gate, so
3279            // turning them off restores the prompt before any
3280            // whole-dir absorb.
3281            if !ctx.config.absorb.auto {
3282                return handle_anomaly_dir(
3283                    src,
3284                    dst,
3285                    ctx,
3286                    "absorb.auto = false; treating divergence as anomaly",
3287                );
3288            }
3289            if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
3290                return handle_anomaly_dir(
3291                    src,
3292                    dst,
3293                    ctx,
3294                    "source repo is dirty; deferring auto-absorb",
3295                );
3296            }
3297            absorb_target_dir_into_source(src, dst, ctx)
3298        }
3299    }
3300}
3301
3302/// `link::unlink` with a documented fallback for the chezmoi-migration
3303/// shape: target is a real (non-link) directory packed with files. The
3304/// caller is responsible for ensuring the target's prior content is
3305/// preserved (in `.yui/backup/...` or because we just merged it into
3306/// source) before reaching here.
3307///
3308/// Anything other than the "non-empty regular dir" case — permission
3309/// denied, target gone, target now a junction or symlink — propagates
3310/// rather than being silently coerced into `remove_dir_all`.
3311fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
3312    if let Err(unlink_err) = link::unlink(dst) {
3313        let meta = std::fs::symlink_metadata(dst)
3314            .with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
3315        let ft = meta.file_type();
3316        if ft.is_dir() && !ft.is_symlink() {
3317            std::fs::remove_dir_all(dst).with_context(|| {
3318                format!(
3319                    "remove_dir_all({dst}) after link::unlink failed: \
3320                     {unlink_err}"
3321                )
3322            })?;
3323        } else {
3324            return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
3325        }
3326    }
3327    Ok(())
3328}
3329
3330/// Recursively merge target's files into source: target wins on file
3331/// conflicts, source-only files are preserved, sub-dirs are created
3332/// in source as needed. Non-regular entries (symlinks / junctions /
3333/// device files) are skipped with a warning — copying their content
3334/// is ill-defined and following them risks looping into target via
3335/// some chain back to source.
3336///
3337/// Mirrors the file-level "AutoAbsorb backs up source, copies target's
3338/// content into source before relinking" semantic for whole dirs.
3339fn merge_dir_target_into_source(
3340    target: &Utf8Path,
3341    source: &Utf8Path,
3342    ctx: &ApplyCtx<'_>,
3343) -> Result<()> {
3344    for entry in std::fs::read_dir(target)? {
3345        let entry = entry?;
3346        let name_os = entry.file_name();
3347        let Some(name) = name_os.to_str() else {
3348            continue;
3349        };
3350        let target_path = target.join(name);
3351        let source_path = source.join(name);
3352        let ft = entry.file_type()?;
3353
3354        if ft.is_dir() && !ft.is_symlink() {
3355            // Target is a real dir. If source has a non-dir entry at
3356            // the same name (regular file, symlink, junction), it
3357            // would block `create_dir_all` and the recursive merge.
3358            // Honor target-wins by clearing the conflicting source
3359            // entry first.
3360            if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
3361                let sft = src_meta.file_type();
3362                if !sft.is_dir() || sft.is_symlink() {
3363                    link::unlink(&source_path).with_context(|| {
3364                        format!("remove conflicting source entry before dir merge: {source_path}")
3365                    })?;
3366                }
3367            }
3368            if !source_path.exists() {
3369                std::fs::create_dir_all(&source_path).with_context(|| {
3370                    format!("create_dir_all({source_path}) during target→source merge")
3371                })?;
3372            }
3373            merge_dir_target_into_source(&target_path, &source_path, ctx)?;
3374        } else if ft.is_file() {
3375            // Target is a regular file. Symmetrical handling: if
3376            // source has a directory or symlink at the same name,
3377            // tear it down first so the file copy can land.
3378            if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
3379                let sft = src_meta.file_type();
3380                if sft.is_dir() && !sft.is_symlink() {
3381                    remove_dir_link_or_real(&source_path).with_context(|| {
3382                        format!("remove conflicting source dir before file merge: {source_path}")
3383                    })?;
3384                } else if sft.is_symlink() {
3385                    link::unlink(&source_path).with_context(|| {
3386                        format!(
3387                            "remove conflicting source symlink before file merge: {source_path}"
3388                        )
3389                    })?;
3390                }
3391            }
3392            if let Some(parent) = source_path.parent() {
3393                if !parent.exists() {
3394                    std::fs::create_dir_all(parent)?;
3395                }
3396            }
3397            // If both sides are now regular files at the same path, run
3398            // the file-level absorb classifier so this single overlap
3399            // is resolved against `[absorb]` policy (auto / skip /
3400            // force / ask) instead of being silently overwritten. The
3401            // dir-level marker provides consent for the *whole-tree*
3402            // merge, but a per-file content collision where the
3403            // source side is *newer* is still a legitimate anomaly
3404            // worth surfacing.
3405            //
3406            // Source-only files were already preserved by virtue of
3407            // the merge not visiting them. Target-only files (where
3408            // `source_path` doesn't exist) skip the classifier and go
3409            // straight to copy below.
3410            if source_path.is_file() {
3411                merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
3412            } else {
3413                std::fs::copy(&target_path, &source_path)
3414                    .with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
3415            }
3416        } else {
3417            warn!(
3418                "merge: skipping non-regular entry {target_path} \
3419                 (symlink / junction / special — content not copied)"
3420            );
3421        }
3422    }
3423    Ok(())
3424}
3425
3426/// Per-file conflict resolution inside the dir merge. Both
3427/// `target_path` and `source_path` exist as regular files — run the
3428/// absorb classifier on the pair and route to the matching policy:
3429///
3430/// - `InSync` / `RelinkOnly` → no-op (contents already match)
3431/// - `AutoAbsorb` (target newer + diff) → copy target → source,
3432///   target-wins per the AutoAbsorb contract.
3433/// - `NeedsConfirm` (source newer + diff, the genuine anomaly) →
3434///   `[absorb] on_anomaly` dispatch:
3435///     - `skip` → leave source alone, target's version is dropped
3436///       (after the outer junction, target ends up with source's content)
3437///     - `force` → copy target → source (target wins anyway)
3438///     - `ask` → TTY prompt with diff; downgrade to skip off-TTY
3439fn merge_resolve_file_conflict(
3440    target_path: &Utf8Path,
3441    source_path: &Utf8Path,
3442    ctx: &ApplyCtx<'_>,
3443) -> Result<()> {
3444    use absorb::AbsorbDecision::*;
3445    let decision = absorb::classify(source_path, target_path)?;
3446    match decision {
3447        InSync | RelinkOnly => Ok(()),
3448        AutoAbsorb => {
3449            std::fs::copy(target_path, source_path).with_context(|| {
3450                format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
3451            })?;
3452            Ok(())
3453        }
3454        Restore => {
3455            // `Restore` is the classifier's "target is missing" arm.
3456            // We only enter this function after the merge loop saw
3457            // `target_path` as a regular file in the read_dir
3458            // iteration, and the caller guards on `source_path.is_file()`
3459            // — both exist by construction, so this branch is
3460            // unreachable.
3461            unreachable!(
3462                "merge_resolve_file_conflict reached with both files present, \
3463                 but classify returned Restore (target {target_path} / source {source_path})"
3464            )
3465        }
3466        NeedsConfirm => {
3467            use crate::config::AnomalyAction::*;
3468            match ctx.config.absorb.on_anomaly {
3469                Skip => {
3470                    warn!(
3471                        "merge anomaly skip: {target_path} (source-newer / content drift) \
3472                         — keeping source version, target version dropped"
3473                    );
3474                    Ok(())
3475                }
3476                Force => {
3477                    warn!(
3478                        "merge anomaly force: {target_path} \
3479                         (source-newer / content drift) — overwriting source"
3480                    );
3481                    std::fs::copy(target_path, source_path)?;
3482                    Ok(())
3483                }
3484                Ask => {
3485                    use std::io::IsTerminal;
3486                    if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
3487                        if prompt_absorb_with_diff(
3488                            source_path,
3489                            target_path,
3490                            "merge: file content differs and source is newer",
3491                        )? {
3492                            std::fs::copy(target_path, source_path)?;
3493                        } else {
3494                            warn!("merge: kept source version by user choice: {source_path}");
3495                        }
3496                        Ok(())
3497                    } else {
3498                        warn!(
3499                            "merge anomaly skip (non-TTY ask mode): {target_path} \
3500                             — keeping source version"
3501                        );
3502                        Ok(())
3503                    }
3504                }
3505            }
3506        }
3507    }
3508}
3509
3510/// Back up source-side, merge target's content into source (target
3511/// wins on conflict), then replace target with a junction to source.
3512/// "Target wins" — yui's core philosophy, generalised from the file
3513/// path to whole directories so a chezmoi-style migrated `~/.config/`
3514/// keeps every file the user actually had instead of stranding most
3515/// of them in `.yui/backup/...`.
3516fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
3517    info!("absorb dir: {dst} → {src}");
3518    backup_existing(src, ctx.backup_root, /* is_dir */ true)?;
3519    merge_dir_target_into_source(dst, src, ctx)?;
3520    // Source now carries every regular file from target. Tear down the
3521    // original target dir and re-expose source via a junction.
3522    remove_dir_link_or_real(dst)?;
3523    link::link_dir(src, dst, ctx.dir_mode)?;
3524    Ok(())
3525}
3526
3527/// Dir-level counterpart to `handle_anomaly`. Same `[absorb] on_anomaly`
3528/// dispatch — `skip` warns and walks away, `force` absorbs anyway,
3529/// `ask` prompts on a TTY (downgraded to skip off-TTY).
3530fn handle_anomaly_dir(
3531    src: &Utf8Path,
3532    dst: &Utf8Path,
3533    ctx: &ApplyCtx<'_>,
3534    reason: &str,
3535) -> Result<()> {
3536    use crate::config::AnomalyAction::*;
3537    match ctx.config.absorb.on_anomaly {
3538        Skip => {
3539            warn!("anomaly skip dir: {dst} ({reason})");
3540            Ok(())
3541        }
3542        Force => {
3543            warn!(
3544                "anomaly force dir: {dst} ({reason}) \
3545                 — absorbing target into source"
3546            );
3547            absorb_target_dir_into_source(src, dst, ctx)
3548        }
3549        Ask => {
3550            use std::io::IsTerminal;
3551            if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
3552                eprintln!();
3553                eprintln!("anomaly: {dst}");
3554                eprintln!("  {reason}");
3555                eprintln!("  source: {src}");
3556                eprint!("  absorb target dir into source? (y/N) ");
3557                use std::io::{BufRead as _, Write as _};
3558                std::io::stderr().flush().ok();
3559                let mut buf = String::new();
3560                std::io::stdin().lock().read_line(&mut buf)?;
3561                let answer = buf.trim();
3562                if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
3563                    absorb_target_dir_into_source(src, dst, ctx)
3564                } else {
3565                    warn!("anomaly skipped by user: {dst}");
3566                    Ok(())
3567                }
3568            } else {
3569                warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
3570                Ok(())
3571            }
3572        }
3573    }
3574}
3575
3576fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
3577    let abs_target = absolutize(target)?;
3578    let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
3579    let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
3580    info!("backup → {bp}");
3581    if is_dir {
3582        backup::backup_dir(target, &bp)?;
3583    } else {
3584        backup::backup_file(target, &bp)?;
3585    }
3586    Ok(())
3587}
3588
3589fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
3590    if let Some(s) = source {
3591        return absolutize(&s);
3592    }
3593    if let Ok(s) = std::env::var("YUI_SOURCE") {
3594        return absolutize(Utf8Path::new(&s));
3595    }
3596    let cwd = current_dir_utf8()?;
3597    for ancestor in cwd.ancestors() {
3598        if ancestor.join("config.toml").is_file() {
3599            return Ok(ancestor.to_path_buf());
3600        }
3601    }
3602    if let Some(home) = paths::home_dir() {
3603        for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
3604            let p = home.join(c);
3605            if p.join("config.toml").is_file() {
3606                return Ok(p);
3607            }
3608        }
3609    }
3610    anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
3611}
3612
3613fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
3614    // Expand `~` first so callers can pass `--source ~/dotfiles` directly.
3615    let expanded = paths::expand_tilde(p.as_str());
3616    if expanded.is_absolute() {
3617        return Ok(expanded);
3618    }
3619    let cwd = current_dir_utf8()?;
3620    Ok(cwd.join(expanded))
3621}
3622
3623fn current_dir_utf8() -> Result<Utf8PathBuf> {
3624    let cwd = std::env::current_dir().context("getting cwd")?;
3625    Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
3626}
3627
3628// Note: `home_dir()` lives in `paths.rs` so the tilde-expansion helper and
3629// `resolve_source` share one HOME/USERPROFILE lookup.
3630
3631const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
3632
3633[vars]
3634# user-defined values; templates can reference these as {{ vars.foo }}
3635
3636# [link]
3637# file_mode = "auto"   # auto | symlink | hardlink
3638# dir_mode  = "auto"   # auto | symlink | junction
3639
3640[mount]
3641default_strategy = "marker"
3642
3643[[mount.entry]]
3644src = "home"
3645# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
3646dst = "~"
3647
3648# [[mount.entry]]
3649# src  = "appdata"
3650# dst  = "{{ env(name='APPDATA') }}"
3651# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
3652# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
3653# when = "yui.os == 'windows'"
3654"#;
3655
3656const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
3657# .yui/bin/ is intentionally tracked — it holds your hook scripts.
3658/.yui/state.json
3659/.yui/state.json.tmp
3660/.yui/backup/
3661
3662# >>> yui rendered (auto-managed, do not edit) >>>
3663# <<< yui rendered (auto-managed) <<<
3664
3665# config.local.toml is per-machine; commit a config.local.example.toml instead.
3666config.local.toml
3667"#;
3668
3669#[cfg(test)]
3670mod tests {
3671    use super::*;
3672    use tempfile::TempDir;
3673
3674    fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
3675        Utf8PathBuf::from_path_buf(p).unwrap()
3676    }
3677
3678    /// Convert a path to a TOML-string-safe form (forward slashes).
3679    fn toml_path(p: &Utf8Path) -> String {
3680        p.as_str().replace('\\', "/")
3681    }
3682
3683    #[test]
3684    fn apply_links_a_raw_file() {
3685        let tmp = TempDir::new().unwrap();
3686        let source = utf8(tmp.path().join("dotfiles"));
3687        let target = utf8(tmp.path().join("target"));
3688        std::fs::create_dir_all(source.join("home")).unwrap();
3689        std::fs::create_dir_all(&target).unwrap();
3690        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3691
3692        let cfg = format!(
3693            r#"
3694[[mount.entry]]
3695src = "home"
3696dst = "{}"
3697"#,
3698            toml_path(&target)
3699        );
3700        std::fs::write(source.join("config.toml"), cfg).unwrap();
3701
3702        apply(Some(source), false).unwrap();
3703
3704        let linked = target.join(".bashrc");
3705        assert!(linked.exists(), "expected {linked} to exist");
3706        assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
3707    }
3708
3709    #[test]
3710    fn apply_with_marker_links_whole_directory() {
3711        let tmp = TempDir::new().unwrap();
3712        let source = utf8(tmp.path().join("dotfiles"));
3713        let target = utf8(tmp.path().join("target"));
3714        let nvim_src = source.join("home/nvim");
3715        std::fs::create_dir_all(&nvim_src).unwrap();
3716        std::fs::create_dir_all(&target).unwrap();
3717        std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
3718        std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
3719        std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
3720
3721        let cfg = format!(
3722            r#"
3723[[mount.entry]]
3724src = "home"
3725dst = "{}"
3726"#,
3727            toml_path(&target)
3728        );
3729        std::fs::write(source.join("config.toml"), cfg).unwrap();
3730
3731        apply(Some(source.clone()), false).unwrap();
3732
3733        let nvim_dst = target.join("nvim");
3734        assert!(nvim_dst.exists());
3735        assert_eq!(
3736            std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
3737            "-- hi\n"
3738        );
3739        // Marker file itself shouldn't be visible as a separate link in target;
3740        // however with junction/symlink the whole dir shows up so the marker
3741        // file IS visible inside. That's fine — the marker is informational.
3742    }
3743
3744    #[test]
3745    fn apply_dry_run_does_not_write() {
3746        let tmp = TempDir::new().unwrap();
3747        let source = utf8(tmp.path().join("dotfiles"));
3748        let target = utf8(tmp.path().join("target"));
3749        std::fs::create_dir_all(source.join("home")).unwrap();
3750        std::fs::create_dir_all(&target).unwrap();
3751        std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
3752
3753        let cfg = format!(
3754            r#"
3755[[mount.entry]]
3756src = "home"
3757dst = "{}"
3758"#,
3759            toml_path(&target)
3760        );
3761        std::fs::write(source.join("config.toml"), cfg).unwrap();
3762
3763        apply(Some(source), true).unwrap();
3764
3765        assert!(!target.join(".bashrc").exists());
3766    }
3767
3768    #[test]
3769    fn apply_renders_templates_then_links_rendered_outputs() {
3770        let tmp = TempDir::new().unwrap();
3771        let source = utf8(tmp.path().join("dotfiles"));
3772        let target = utf8(tmp.path().join("target"));
3773        std::fs::create_dir_all(source.join("home")).unwrap();
3774        std::fs::create_dir_all(&target).unwrap();
3775        std::fs::write(
3776            source.join("home/.gitconfig.tera"),
3777            "[user]\n  os = {{ yui.os }}\n",
3778        )
3779        .unwrap();
3780        std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
3781
3782        let cfg = format!(
3783            r#"
3784[[mount.entry]]
3785src = "home"
3786dst = "{}"
3787"#,
3788            toml_path(&target)
3789        );
3790        std::fs::write(source.join("config.toml"), cfg).unwrap();
3791
3792        apply(Some(source.clone()), false).unwrap();
3793
3794        // Raw file: linked.
3795        assert!(target.join(".bashrc").exists());
3796        // Template's rendered output: written to source then linked.
3797        assert!(source.join("home/.gitconfig").exists());
3798        assert!(target.join(".gitconfig").exists());
3799        // The .tera file itself is never linked into target.
3800        assert!(!target.join(".gitconfig.tera").exists());
3801        // Rendered file content carries the yui.os substitution.
3802        let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
3803        assert!(linked.contains("os = "));
3804    }
3805
3806    #[test]
3807    fn apply_marker_override_links_to_custom_dst() {
3808        let tmp = TempDir::new().unwrap();
3809        let source = utf8(tmp.path().join("dotfiles"));
3810        let target_a = utf8(tmp.path().join("target_a"));
3811        let target_b = utf8(tmp.path().join("target_b"));
3812        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3813        std::fs::create_dir_all(&target_a).unwrap();
3814        std::fs::create_dir_all(&target_b).unwrap();
3815        std::fs::write(
3816            source.join("home/.config/nvim/init.lua"),
3817            "-- nvim config\n",
3818        )
3819        .unwrap();
3820
3821        // Marker tells yui to ignore the parent mount's dst for this dir
3822        // and link it to two custom places (the second only if condition matches).
3823        std::fs::write(
3824            source.join("home/.config/nvim/.yuilink"),
3825            format!(
3826                r#"
3827[[link]]
3828dst = "{}/nvim"
3829
3830[[link]]
3831dst = "{}/nvim"
3832when = "{{{{ yui.os == '{}' }}}}"
3833"#,
3834                toml_path(&target_a),
3835                toml_path(&target_b),
3836                std::env::consts::OS
3837            ),
3838        )
3839        .unwrap();
3840
3841        let parent_target = utf8(tmp.path().join("parent_target"));
3842        std::fs::create_dir_all(&parent_target).unwrap();
3843        let cfg = format!(
3844            r#"
3845[[mount.entry]]
3846src = "home"
3847dst = "{}"
3848"#,
3849            toml_path(&parent_target)
3850        );
3851        std::fs::write(source.join("config.toml"), cfg).unwrap();
3852
3853        apply(Some(source.clone()), false).unwrap();
3854
3855        // Both override targets received the link (the second's when matches OS).
3856        assert!(
3857            target_a.join("nvim/init.lua").exists(),
3858            "target_a/nvim/init.lua should be reachable through the link"
3859        );
3860        assert!(
3861            target_b.join("nvim/init.lua").exists(),
3862            "target_b/nvim/init.lua should be reachable through the link"
3863        );
3864        // Parent mount did NOT also link this dir (it would have appeared at
3865        // parent_target/.config/nvim — the marker claims the dir).
3866        assert!(
3867            !parent_target.join(".config/nvim").exists(),
3868            "parent mount should have skipped the marker-claimed sub-dir"
3869        );
3870    }
3871
3872    #[test]
3873    fn apply_marker_inactive_link_falls_through_to_default() {
3874        // v0.6+ semantics: a marker that has only inactive links no
3875        // longer suppresses the parent mount's natural placement. The
3876        // walker keeps descending so per-file defaults still apply.
3877        // (Use `.yuiignore` to actually exclude a subtree.)
3878        let tmp = TempDir::new().unwrap();
3879        let source = utf8(tmp.path().join("dotfiles"));
3880        let target_inactive = utf8(tmp.path().join("inactive"));
3881        let parent_target = utf8(tmp.path().join("parent"));
3882        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3883        std::fs::create_dir_all(&parent_target).unwrap();
3884        std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
3885
3886        // when=false on every link → marker has no active links.
3887        std::fs::write(
3888            source.join("home/.config/nvim/.yuilink"),
3889            format!(
3890                r#"
3891[[link]]
3892dst = "{}/nvim"
3893when = "{{{{ yui.os == 'no-such-os' }}}}"
3894"#,
3895                toml_path(&target_inactive)
3896            ),
3897        )
3898        .unwrap();
3899
3900        let cfg = format!(
3901            r#"
3902[[mount.entry]]
3903src = "home"
3904dst = "{}"
3905"#,
3906            toml_path(&parent_target)
3907        );
3908        std::fs::write(source.join("config.toml"), cfg).unwrap();
3909
3910        apply(Some(source.clone()), false).unwrap();
3911
3912        // Inactive marker target untouched.
3913        assert!(!target_inactive.join("nvim").exists());
3914        // Parent mount's natural placement IS produced — the marker had
3915        // no active dir-level link to claim coverage with.
3916        assert!(parent_target.join(".config/nvim/init.lua").exists());
3917    }
3918
3919    #[test]
3920    fn list_shows_mount_entries_and_marker_overrides() {
3921        let tmp = TempDir::new().unwrap();
3922        let source = utf8(tmp.path().join("dotfiles"));
3923        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3924        std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
3925        std::fs::write(
3926            source.join("home/.config/nvim/.yuilink"),
3927            r#"
3928[[link]]
3929dst = "/custom/nvim"
3930"#,
3931        )
3932        .unwrap();
3933        std::fs::write(
3934            source.join("config.toml"),
3935            r#"
3936[[mount.entry]]
3937src = "home"
3938dst = "/h"
3939"#,
3940        )
3941        .unwrap();
3942
3943        // Just verify it runs without error — output format is covered by
3944        // unit-level helpers below.
3945        list(Some(source), false, None, true).unwrap();
3946    }
3947
3948    #[test]
3949    fn status_reports_in_sync_after_apply() {
3950        let tmp = TempDir::new().unwrap();
3951        let source = utf8(tmp.path().join("dotfiles"));
3952        let target = utf8(tmp.path().join("target"));
3953        std::fs::create_dir_all(source.join("home")).unwrap();
3954        std::fs::create_dir_all(&target).unwrap();
3955        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3956        let cfg = format!(
3957            r#"
3958[[mount.entry]]
3959src = "home"
3960dst = "{}"
3961"#,
3962            toml_path(&target)
3963        );
3964        std::fs::write(source.join("config.toml"), cfg).unwrap();
3965        // First link the target so the link is intact.
3966        apply(Some(source.clone()), false).unwrap();
3967        // status should succeed (everything in-sync).
3968        status(Some(source), None, true).unwrap();
3969    }
3970
3971    #[test]
3972    fn status_reports_template_drift() {
3973        let tmp = TempDir::new().unwrap();
3974        let source = utf8(tmp.path().join("dotfiles"));
3975        let target = utf8(tmp.path().join("target"));
3976        std::fs::create_dir_all(source.join("home")).unwrap();
3977        std::fs::create_dir_all(&target).unwrap();
3978        // Template would render to "fresh" but the rendered file on disk
3979        // says "stale" — simulating a manual edit not reflected back.
3980        std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
3981        std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
3982
3983        let cfg = format!(
3984            r#"
3985[[mount.entry]]
3986src = "home"
3987dst = "{}"
3988"#,
3989            toml_path(&target)
3990        );
3991        std::fs::write(source.join("config.toml"), cfg).unwrap();
3992
3993        let err = status(Some(source), None, true).unwrap_err();
3994        assert!(format!("{err}").contains("diverged"));
3995    }
3996
3997    #[test]
3998    fn status_fails_when_target_missing() {
3999        let tmp = TempDir::new().unwrap();
4000        let source = utf8(tmp.path().join("dotfiles"));
4001        let target = utf8(tmp.path().join("target"));
4002        std::fs::create_dir_all(source.join("home")).unwrap();
4003        std::fs::create_dir_all(&target).unwrap();
4004        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4005        let cfg = format!(
4006            r#"
4007[[mount.entry]]
4008src = "home"
4009dst = "{}"
4010"#,
4011            toml_path(&target)
4012        );
4013        std::fs::write(source.join("config.toml"), cfg).unwrap();
4014        // No apply yet — target/.bashrc doesn't exist.
4015        let err = status(Some(source), None, true).unwrap_err();
4016        assert!(format!("{err}").contains("diverged"));
4017    }
4018
4019    #[test]
4020    fn strip_braces_removes_outer_template_braces() {
4021        assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
4022        assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
4023        assert_eq!(strip_braces("  {{x}}  "), "x");
4024    }
4025
4026    #[test]
4027    fn apply_aborts_on_render_drift() {
4028        let tmp = TempDir::new().unwrap();
4029        let source = utf8(tmp.path().join("dotfiles"));
4030        let target = utf8(tmp.path().join("target"));
4031        std::fs::create_dir_all(source.join("home")).unwrap();
4032        std::fs::create_dir_all(&target).unwrap();
4033        std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
4034        std::fs::write(source.join("home/foo"), "manually edited").unwrap();
4035
4036        let cfg = format!(
4037            r#"
4038[[mount.entry]]
4039src = "home"
4040dst = "{}"
4041"#,
4042            toml_path(&target)
4043        );
4044        std::fs::write(source.join("config.toml"), cfg).unwrap();
4045
4046        let err = apply(Some(source.clone()), false).unwrap_err();
4047        assert!(format!("{err}").contains("drift"));
4048        // Existing rendered file untouched.
4049        assert_eq!(
4050            std::fs::read_to_string(source.join("home/foo")).unwrap(),
4051            "manually edited"
4052        );
4053        // Linking aborted — target empty.
4054        assert!(!target.join("foo").exists());
4055    }
4056
4057    #[test]
4058    fn init_creates_skeleton_when_dir_empty() {
4059        let tmp = TempDir::new().unwrap();
4060        let dir = utf8(tmp.path().join("new_dotfiles"));
4061        init(Some(dir.clone()), false).unwrap();
4062        assert!(dir.join("config.toml").is_file());
4063        assert!(dir.join(".gitignore").is_file());
4064    }
4065
4066    #[test]
4067    fn init_refuses_to_overwrite_existing_config() {
4068        let tmp = TempDir::new().unwrap();
4069        let dir = utf8(tmp.path().join("dotfiles"));
4070        std::fs::create_dir_all(&dir).unwrap();
4071        std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
4072        let err = init(Some(dir), false).unwrap_err();
4073        assert!(format!("{err}").contains("already exists"));
4074    }
4075
4076    /// `init` is now in charge of the `.yui/` state / backup ignore
4077    /// lines, even on a re-run against an existing repo. Pre-fix it
4078    /// silently left a half-populated `.gitignore` alone if the user
4079    /// didn't have the entries in place; now it appends the missing
4080    /// ones idempotently.
4081    #[test]
4082    fn init_appends_missing_gitignore_entries_into_existing_file() {
4083        let tmp = TempDir::new().unwrap();
4084        let dir = utf8(tmp.path().join("dotfiles"));
4085        std::fs::create_dir_all(&dir).unwrap();
4086        // Existing .gitignore that DOESN'T yet have any yui entries.
4087        let user_gitignore = "# user entries\n*.swp\nnode_modules/\n";
4088        std::fs::write(dir.join(".gitignore"), user_gitignore).unwrap();
4089
4090        init(Some(dir.clone()), false).unwrap();
4091
4092        let body = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
4093        // The user's existing lines survive untouched.
4094        assert!(body.contains("*.swp"));
4095        assert!(body.contains("node_modules/"));
4096        // Each yui-required line was appended.
4097        assert!(body.contains("/.yui/state.json"));
4098        assert!(body.contains("/.yui/backup/"));
4099        assert!(body.contains("config.local.toml"));
4100        // Re-running init on the already-fixed-up file is a no-op.
4101        let before_rerun = body.clone();
4102        // `init` would normally bail on an existing config; remove it so
4103        // the second call doesn't trip that guard.
4104        std::fs::remove_file(dir.join("config.toml")).unwrap();
4105        init(Some(dir.clone()), false).unwrap();
4106        let after_rerun = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
4107        assert_eq!(
4108            before_rerun, after_rerun,
4109            "init must be idempotent when the gitignore already has every yui entry"
4110        );
4111    }
4112
4113    /// `init --git-hooks` against an *existing* repo (config.toml
4114    /// already there) skips the scaffold and just installs the hooks.
4115    /// Pre-fix this combo bailed with "config.toml already exists",
4116    /// which forced users with a populated dotfiles repo to delete
4117    /// their config before they could opt into the render-drift hooks.
4118    #[test]
4119    fn init_with_git_hooks_installs_into_existing_repo() {
4120        let tmp = TempDir::new().unwrap();
4121        let dir = utf8(tmp.path().join("dotfiles"));
4122        std::fs::create_dir_all(&dir).unwrap();
4123        let st = std::process::Command::new("git")
4124            .args(["init", "-q"])
4125            .current_dir(dir.as_std_path())
4126            .status()
4127            .expect("git init");
4128        if !st.success() {
4129            return;
4130        }
4131        // Pre-existing user config — init should NOT overwrite it.
4132        let user_config = "# user already wrote this\n";
4133        std::fs::write(dir.join("config.toml"), user_config).unwrap();
4134
4135        // hooks-only invocation: succeeds, leaves config alone.
4136        init(Some(dir.clone()), /* git_hooks */ true).unwrap();
4137
4138        assert_eq!(
4139            std::fs::read_to_string(dir.join("config.toml")).unwrap(),
4140            user_config
4141        );
4142        assert!(dir.join(".git/hooks/pre-commit").is_file());
4143        assert!(dir.join(".git/hooks/pre-push").is_file());
4144    }
4145
4146    /// `init --git-hooks` writes pre-commit / pre-push that run the
4147    /// render-drift check against `.git/hooks/`. We need a real git
4148    /// repo for `git rev-parse --git-path hooks` to point at, so
4149    /// prepare one before calling init.
4150    #[test]
4151    fn init_with_git_hooks_writes_pre_commit_and_pre_push() {
4152        let tmp = TempDir::new().unwrap();
4153        let dir = utf8(tmp.path().join("dotfiles"));
4154        std::fs::create_dir_all(&dir).unwrap();
4155        // Bootstrap a git repo at `dir`.
4156        let st = std::process::Command::new("git")
4157            .args(["init", "-q"])
4158            .current_dir(dir.as_std_path())
4159            .status()
4160            .expect("git init");
4161        if !st.success() {
4162            // Skip if git isn't on PATH on this CI runner.
4163            eprintln!("skipping: git not available");
4164            return;
4165        }
4166        init(Some(dir.clone()), /* git_hooks */ true).unwrap();
4167
4168        let pre_commit = dir.join(".git/hooks/pre-commit");
4169        let pre_push = dir.join(".git/hooks/pre-push");
4170        assert!(pre_commit.is_file(), "pre-commit hook should be written");
4171        assert!(pre_push.is_file(), "pre-push hook should be written");
4172
4173        let body = std::fs::read_to_string(&pre_commit).unwrap();
4174        assert!(
4175            body.contains("yui render --check"),
4176            "pre-commit hook should call `yui render --check`, got: {body}"
4177        );
4178    }
4179
4180    /// `init --git-hooks` against a non-git directory must fail with a
4181    /// clear message instead of silently doing nothing — the user
4182    /// asked for hooks and we couldn't deliver.
4183    #[test]
4184    fn init_with_git_hooks_errors_outside_a_git_repo() {
4185        let tmp = TempDir::new().unwrap();
4186        let dir = utf8(tmp.path().join("not-a-repo"));
4187        std::fs::create_dir_all(&dir).unwrap();
4188        let err = init(Some(dir), /* git_hooks */ true).unwrap_err();
4189        let msg = format!("{err:#}");
4190        assert!(
4191            msg.contains("git repo") || msg.contains("git rev-parse"),
4192            "expected error to mention the git issue, got: {msg}"
4193        );
4194    }
4195
4196    /// Pre-existing hooks are not silently overwritten — yui leaves
4197    /// the user's prior file alone (warns) and writes the missing one.
4198    #[test]
4199    fn init_with_git_hooks_does_not_clobber_existing_hooks() {
4200        let tmp = TempDir::new().unwrap();
4201        let dir = utf8(tmp.path().join("dotfiles"));
4202        std::fs::create_dir_all(&dir).unwrap();
4203        let st = std::process::Command::new("git")
4204            .args(["init", "-q"])
4205            .current_dir(dir.as_std_path())
4206            .status()
4207            .expect("git init");
4208        if !st.success() {
4209            return;
4210        }
4211        let hooks = dir.join(".git/hooks");
4212        std::fs::create_dir_all(&hooks).unwrap();
4213        std::fs::write(hooks.join("pre-commit"), "#! /bin/sh\nexit 0\n").unwrap();
4214
4215        init(Some(dir.clone()), true).unwrap();
4216
4217        // Existing pre-commit untouched, pre-push freshly written.
4218        let pc = std::fs::read_to_string(hooks.join("pre-commit")).unwrap();
4219        assert!(
4220            !pc.contains("yui render --check"),
4221            "existing pre-commit must not be overwritten"
4222        );
4223        let pp = std::fs::read_to_string(hooks.join("pre-push")).unwrap();
4224        assert!(
4225            pp.contains("yui render --check"),
4226            "missing pre-push should be written: {pp}"
4227        );
4228    }
4229
4230    /// Build a minimal `apply`-able dotfiles tree for absorb tests.
4231    /// Returns (source_dir, target_dir).
4232    fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
4233        let source = utf8(tmp.path().join("dotfiles"));
4234        let target = utf8(tmp.path().join("target"));
4235        std::fs::create_dir_all(source.join("home")).unwrap();
4236        std::fs::create_dir_all(&target).unwrap();
4237        let cfg = format!(
4238            r#"
4239[[mount.entry]]
4240src = "home"
4241dst = "{}"
4242"#,
4243            toml_path(&target)
4244        );
4245        std::fs::write(source.join("config.toml"), cfg).unwrap();
4246        (source, target)
4247    }
4248
4249    fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
4250        std::fs::write(path, body).unwrap();
4251        let f = std::fs::OpenOptions::new()
4252            .write(true)
4253            .open(path)
4254            .expect("open writable");
4255        f.set_modified(when).expect("set_modified");
4256    }
4257
4258    #[test]
4259    fn apply_target_newer_absorbs_target_into_source() {
4260        // Target has the user's edit and is mtime-newer than source —
4261        // classifier returns `AutoAbsorb`. yui's "target-as-truth"
4262        // philosophy: target wins, source is updated and backed up.
4263        let tmp = TempDir::new().unwrap();
4264        let (source, target) = setup_minimal_dotfiles(&tmp);
4265
4266        let now = std::time::SystemTime::now();
4267        let past = now - std::time::Duration::from_secs(120);
4268        write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
4269        // Pre-existing target with user's edit, NEWER mtime.
4270        write_with_mtime(&target.join(".bashrc"), "user's edit", now);
4271
4272        apply(Some(source.clone()), false).unwrap();
4273
4274        // Target's content survives — that's the whole point.
4275        assert_eq!(
4276            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4277            "user's edit"
4278        );
4279        // Source has been updated to match target.
4280        assert_eq!(
4281            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4282            "user's edit"
4283        );
4284        // Source's previous content lives under .yui/backup.
4285        let backup_root = source.join(".yui/backup");
4286        let mut found_old = false;
4287        for entry in walkdir(&backup_root) {
4288            if let Ok(s) = std::fs::read_to_string(&entry) {
4289                if s == "default from repo" {
4290                    found_old = true;
4291                    break;
4292                }
4293            }
4294        }
4295        assert!(found_old, "expected backup containing 'default from repo'");
4296    }
4297
4298    #[test]
4299    fn apply_in_sync_target_is_a_no_op() {
4300        // After an initial `apply`, running `apply` again classifies as
4301        // `InSync` and does nothing.
4302        let tmp = TempDir::new().unwrap();
4303        let (source, target) = setup_minimal_dotfiles(&tmp);
4304        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
4305        apply(Some(source.clone()), false).unwrap();
4306        let backup_root = source.join(".yui/backup");
4307        let backup_count_after_first = walkdir(&backup_root).len();
4308
4309        // Second apply — nothing should change.
4310        apply(Some(source.clone()), false).unwrap();
4311        assert_eq!(
4312            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4313            "echo hi\n"
4314        );
4315        let backup_count_after_second = walkdir(&backup_root).len();
4316        assert_eq!(
4317            backup_count_after_first, backup_count_after_second,
4318            "second apply on an in-sync tree should not produce backups"
4319        );
4320    }
4321
4322    #[test]
4323    fn apply_skip_policy_leaves_anomaly_alone() {
4324        // Source newer than target + content differs = NeedsConfirm.
4325        // With on_anomaly = "skip", target stays untouched.
4326        let tmp = TempDir::new().unwrap();
4327        let source = utf8(tmp.path().join("dotfiles"));
4328        let target = utf8(tmp.path().join("target"));
4329        std::fs::create_dir_all(source.join("home")).unwrap();
4330        std::fs::create_dir_all(&target).unwrap();
4331        let cfg = format!(
4332            r#"
4333[absorb]
4334on_anomaly = "skip"
4335
4336[[mount.entry]]
4337src = "home"
4338dst = "{}"
4339"#,
4340            toml_path(&target)
4341        );
4342        std::fs::write(source.join("config.toml"), cfg).unwrap();
4343
4344        let now = std::time::SystemTime::now();
4345        let past = now - std::time::Duration::from_secs(120);
4346        write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4347        write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4348
4349        apply(Some(source.clone()), false).unwrap();
4350
4351        // Target untouched (skip policy honored).
4352        assert_eq!(
4353            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4354            "user's edit (older)"
4355        );
4356        // Source untouched too.
4357        assert_eq!(
4358            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4359            "fresh from upstream"
4360        );
4361    }
4362
4363    #[test]
4364    fn apply_force_policy_absorbs_anomaly_anyway() {
4365        // Same anomaly setup, but on_anomaly = "force" → target wins.
4366        let tmp = TempDir::new().unwrap();
4367        let source = utf8(tmp.path().join("dotfiles"));
4368        let target = utf8(tmp.path().join("target"));
4369        std::fs::create_dir_all(source.join("home")).unwrap();
4370        std::fs::create_dir_all(&target).unwrap();
4371        let cfg = format!(
4372            r#"
4373[absorb]
4374on_anomaly = "force"
4375
4376[[mount.entry]]
4377src = "home"
4378dst = "{}"
4379"#,
4380            toml_path(&target)
4381        );
4382        std::fs::write(source.join("config.toml"), cfg).unwrap();
4383
4384        let now = std::time::SystemTime::now();
4385        let past = now - std::time::Duration::from_secs(120);
4386        write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
4387        write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
4388
4389        apply(Some(source.clone()), false).unwrap();
4390
4391        // Target wins despite being mtime-older — force policy.
4392        assert_eq!(
4393            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
4394            "user's edit (older)"
4395        );
4396        assert_eq!(
4397            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4398            "user's edit (older)"
4399        );
4400    }
4401
4402    /// Regression for the Windows-error-145 bug: a `home/.config/.yuilink`
4403    /// (PassThrough) marker pointing at a non-empty regular `~/.config`
4404    /// directory (the typical chezmoi-migrated state, where every file
4405    /// inside is an individual hardlink) used to fail the absorb with
4406    /// `Directory not empty` because `link::unlink` refuses to recurse.
4407    /// After backup we now `remove_dir_all` as a fallback.
4408    ///
4409    /// v0.7+: also exercises the target-wins merge — target's
4410    /// `config.toml` overwrites source's, target's `state.json` lands
4411    /// in source (target was the source of truth), and source-only
4412    /// scaffolding (`.yuilink`) survives the absorb.
4413    #[test]
4414    fn apply_absorbs_non_empty_target_dir_target_wins() {
4415        let tmp = TempDir::new().unwrap();
4416        let source = utf8(tmp.path().join("dotfiles"));
4417        let target = utf8(tmp.path().join("target"));
4418        std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
4419        std::fs::create_dir_all(target.join(".config/app")).unwrap();
4420        // Marker that says "junction this dir at the parent mount's dst"
4421        // — same shape as a typical home/.config/.yuilink.
4422        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4423        std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
4424        // Source-only scaffolding that the absorb must preserve.
4425        std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
4426        // Pre-existing non-empty regular dir at the target — chezmoi /
4427        // any per-file dotfiles flow leaves things in this shape.
4428        std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
4429        std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
4430
4431        let cfg = format!(
4432            r#"
4433[absorb]
4434on_anomaly = "force"
4435
4436[[mount.entry]]
4437src = "home"
4438dst = "{}"
4439"#,
4440            toml_path(&target)
4441        );
4442        std::fs::write(source.join("config.toml"), cfg).unwrap();
4443
4444        // Used to bail with `unlink: ... Directory not empty` here.
4445        apply(Some(source.clone()), false).unwrap();
4446
4447        // Target wins on the conflicting file.
4448        assert_eq!(
4449            std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
4450            "target side"
4451        );
4452        // Target-only file is now reachable via the junction.
4453        assert_eq!(
4454            std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
4455            "{}"
4456        );
4457        // Source's pre-merge state was backed up before being overwritten,
4458        // so the original "src side" / `.yuilink` survive in `.yui/backup/`.
4459        let backup_root = source.join(".yui/backup");
4460        let mut backup_files: Vec<String> = Vec::new();
4461        for entry in walkdir(&backup_root) {
4462            if let Some(n) = entry.file_name() {
4463                backup_files.push(n.to_string());
4464            }
4465        }
4466        assert!(
4467            backup_files.iter().any(|f| f == "config.toml"),
4468            "expected source's config.toml to land in the backup tree, got {backup_files:?}"
4469        );
4470        // Source-only scaffolding survives the merge.
4471        assert!(
4472            source.join("home/.config/app/source-only.toml").exists(),
4473            "source-only file should survive a target-wins merge"
4474        );
4475        // Source picked up target-only state.json via the merge.
4476        assert!(
4477            source.join("home/.config/app/state.json").exists(),
4478            "target-only state.json should be merged into source"
4479        );
4480    }
4481
4482    /// v0.7+: `home/.config/.yuilink` is the user's explicit
4483    /// "this whole subtree is target-as-truth" declaration. A
4484    /// dir-level NeedsConfirm at the marker root is therefore not a
4485    /// real anomaly — the marker is consent. Default `[absorb]` (ask
4486    /// + require_clean_git) should still absorb, no prompt.
4487    #[test]
4488    fn marker_dir_absorbs_with_default_ask_policy() {
4489        let tmp = TempDir::new().unwrap();
4490        let source = utf8(tmp.path().join("dotfiles"));
4491        let target = utf8(tmp.path().join("target"));
4492        std::fs::create_dir_all(source.join("home/.config")).unwrap();
4493        std::fs::create_dir_all(target.join(".config/gh")).unwrap();
4494        // Marker — user opts the whole .config dir into target-as-truth.
4495        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4496        // gh exists only on the target side (no entry in source).
4497        std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\n").unwrap();
4498
4499        // Default [absorb] (no override) — `on_anomaly = "ask"`,
4500        // `auto = true`, `require_clean_git = true`. Pre-v0.7 this
4501        // would have been routed through the ask prompt at dir level.
4502        let cfg = format!(
4503            r#"
4504[[mount.entry]]
4505src = "home"
4506dst = "{}"
4507"#,
4508            toml_path(&target)
4509        );
4510        std::fs::write(source.join("config.toml"), cfg).unwrap();
4511
4512        // Even with default `ask`, the marker-rooted absorb proceeds.
4513        // Test would hang on a stdin prompt if dir-level still treated
4514        // this as an anomaly.
4515        apply(Some(source.clone()), false).unwrap();
4516
4517        // Target-only file is now reachable through the junction and
4518        // recorded in source.
4519        assert!(target.join(".config/gh/hosts.yml").exists());
4520        assert!(source.join("home/.config/gh/hosts.yml").exists());
4521    }
4522
4523    /// File↔dir collisions during merge. Honor target-wins: if source
4524    /// has a regular file at a path where target has a dir, the file
4525    /// gets removed and the dir is created. Symmetrical for the
4526    /// inverse case. Without the conflict-clearing the merge would
4527    /// fail with `not a directory` / `path exists` deep in the recursion.
4528    #[test]
4529    fn merge_handles_file_vs_dir_collisions_target_wins() {
4530        let tmp = TempDir::new().unwrap();
4531        let source = utf8(tmp.path().join("dotfiles"));
4532        let target = utf8(tmp.path().join("target"));
4533        std::fs::create_dir_all(source.join("home/.config/foo")).unwrap();
4534        std::fs::create_dir_all(target.join(".config")).unwrap();
4535        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4536
4537        // Conflict A: source has `foo` as dir, target has `foo` as file.
4538        std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
4539        std::fs::write(target.join(".config/foo"), "target file body").unwrap();
4540        // Conflict B: source has `bar` as file, target has `bar` as dir.
4541        std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
4542        std::fs::create_dir_all(target.join(".config/bar")).unwrap();
4543        std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
4544
4545        let cfg = format!(
4546            r#"
4547[absorb]
4548on_anomaly = "force"
4549
4550[[mount.entry]]
4551src = "home"
4552dst = "{}"
4553"#,
4554            toml_path(&target)
4555        );
4556        std::fs::write(source.join("config.toml"), cfg).unwrap();
4557        apply(Some(source.clone()), false).unwrap();
4558
4559        // After absorb the target's view (which equals source via
4560        // junction) carries target's shapes:
4561        // `foo` is a regular file
4562        let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
4563        assert!(foo_meta.file_type().is_file(), "foo should be a file");
4564        assert_eq!(
4565            std::fs::read_to_string(target.join(".config/foo")).unwrap(),
4566            "target file body"
4567        );
4568        // `bar` is a directory with the nested file
4569        let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
4570        assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
4571        assert_eq!(
4572            std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
4573            "target nested"
4574        );
4575    }
4576
4577    /// Per-file conflict in dir merge — target newer + content
4578    /// differs → AutoAbsorb. Target wins automatically without
4579    /// touching `[absorb] on_anomaly`.
4580    #[test]
4581    fn merge_per_file_target_newer_auto_absorbs() {
4582        let tmp = TempDir::new().unwrap();
4583        let source = utf8(tmp.path().join("dotfiles"));
4584        let target = utf8(tmp.path().join("target"));
4585        std::fs::create_dir_all(source.join("home/.config")).unwrap();
4586        std::fs::create_dir_all(target.join(".config")).unwrap();
4587        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4588
4589        // Source has the older copy, target has the newer edit.
4590        let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4591        write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
4592        std::fs::write(target.join(".config/app.toml"), "user's live edit").unwrap();
4593
4594        // Default `ask` policy — should NOT prompt because the
4595        // classifier returns AutoAbsorb (target newer + diff), which
4596        // bypasses `on_anomaly` entirely.
4597        let cfg = format!(
4598            r#"
4599[[mount.entry]]
4600src = "home"
4601dst = "{}"
4602"#,
4603            toml_path(&target)
4604        );
4605        std::fs::write(source.join("config.toml"), cfg).unwrap();
4606        apply(Some(source.clone()), false).unwrap();
4607
4608        // Target wins.
4609        assert_eq!(
4610            std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4611            "user's live edit"
4612        );
4613    }
4614
4615    /// Per-file conflict — source newer + content differs +
4616    /// `on_anomaly = "skip"` → keep source's version. After the outer
4617    /// junction, target ends up with source's content (so target's
4618    /// file is effectively dropped, matching the file-level `skip`
4619    /// semantic).
4620    #[test]
4621    fn merge_per_file_source_newer_skip_keeps_source() {
4622        let tmp = TempDir::new().unwrap();
4623        let source = utf8(tmp.path().join("dotfiles"));
4624        let target = utf8(tmp.path().join("target"));
4625        std::fs::create_dir_all(source.join("home/.config")).unwrap();
4626        std::fs::create_dir_all(target.join(".config")).unwrap();
4627        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4628
4629        // Target has the older copy, source has the newer edit.
4630        let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4631        write_with_mtime(&target.join(".config/app.toml"), "old target", past);
4632        std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
4633
4634        let cfg = format!(
4635            r#"
4636[absorb]
4637on_anomaly = "skip"
4638
4639[[mount.entry]]
4640src = "home"
4641dst = "{}"
4642"#,
4643            toml_path(&target)
4644        );
4645        std::fs::write(source.join("config.toml"), cfg).unwrap();
4646        apply(Some(source.clone()), false).unwrap();
4647
4648        // Source kept — target now reads source's version through the
4649        // junction (so target's old text is dropped).
4650        assert_eq!(
4651            std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4652            "fresh source"
4653        );
4654    }
4655
4656    /// Per-file conflict — source newer + content differs +
4657    /// `on_anomaly = "force"` → target wins anyway.
4658    #[test]
4659    fn merge_per_file_source_newer_force_overwrites_source() {
4660        let tmp = TempDir::new().unwrap();
4661        let source = utf8(tmp.path().join("dotfiles"));
4662        let target = utf8(tmp.path().join("target"));
4663        std::fs::create_dir_all(source.join("home/.config")).unwrap();
4664        std::fs::create_dir_all(target.join(".config")).unwrap();
4665        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4666
4667        let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
4668        write_with_mtime(&target.join(".config/app.toml"), "old target", past);
4669        std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
4670
4671        let cfg = format!(
4672            r#"
4673[absorb]
4674on_anomaly = "force"
4675
4676[[mount.entry]]
4677src = "home"
4678dst = "{}"
4679"#,
4680            toml_path(&target)
4681        );
4682        std::fs::write(source.join("config.toml"), cfg).unwrap();
4683        apply(Some(source.clone()), false).unwrap();
4684
4685        // Target overrides source despite being mtime-older.
4686        assert_eq!(
4687            std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4688            "old target"
4689        );
4690    }
4691
4692    /// Per-file conflict — bytes match → no-op. The merge classifies
4693    /// this as RelinkOnly and skips the copy entirely (saves a lot of
4694    /// I/O when migrating big chezmoi repos where source and target
4695    /// have already shared inodes).
4696    #[test]
4697    fn merge_per_file_identical_content_is_noop() {
4698        let tmp = TempDir::new().unwrap();
4699        let source = utf8(tmp.path().join("dotfiles"));
4700        let target = utf8(tmp.path().join("target"));
4701        std::fs::create_dir_all(source.join("home/.config")).unwrap();
4702        std::fs::create_dir_all(target.join(".config")).unwrap();
4703        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
4704        std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
4705        std::fs::write(target.join(".config/app.toml"), "same").unwrap();
4706
4707        // Default policy — bytes match, classifier returns RelinkOnly,
4708        // merge skips the copy. Apply must succeed without prompting.
4709        let cfg = format!(
4710            r#"
4711[[mount.entry]]
4712src = "home"
4713dst = "{}"
4714"#,
4715            toml_path(&target)
4716        );
4717        std::fs::write(source.join("config.toml"), cfg).unwrap();
4718        apply(Some(source.clone()), false).unwrap();
4719
4720        assert_eq!(
4721            std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
4722            "same"
4723        );
4724    }
4725
4726    #[test]
4727    fn manual_absorb_command_pulls_target_into_source() {
4728        // Manual `yui absorb <target>` bypasses policy + git checks.
4729        let tmp = TempDir::new().unwrap();
4730        let source = utf8(tmp.path().join("dotfiles"));
4731        let target = utf8(tmp.path().join("target"));
4732        std::fs::create_dir_all(source.join("home")).unwrap();
4733        std::fs::create_dir_all(&target).unwrap();
4734        // on_anomaly = "skip" so passive `apply` would NOT touch this.
4735        let cfg = format!(
4736            r#"
4737[absorb]
4738on_anomaly = "skip"
4739
4740[[mount.entry]]
4741src = "home"
4742dst = "{}"
4743"#,
4744            toml_path(&target)
4745        );
4746        std::fs::write(source.join("config.toml"), cfg).unwrap();
4747        std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
4748        std::fs::write(source.join("home/.bashrc"), "default").unwrap();
4749
4750        // Run absorb directly on the target — `--yes` skips the
4751        // interactive prompt the manual flow normally requires.
4752        absorb(
4753            Some(source.clone()),
4754            target.join(".bashrc"),
4755            /* dry_run */ false,
4756            /* yes */ true,
4757        )
4758        .unwrap();
4759
4760        // Source picked up target's content (manual absorb is forceful).
4761        assert_eq!(
4762            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4763            "user picked this"
4764        );
4765    }
4766
4767    #[test]
4768    fn manual_absorb_errors_when_target_outside_known_mounts() {
4769        let tmp = TempDir::new().unwrap();
4770        let (source, _target) = setup_minimal_dotfiles(&tmp);
4771        std::fs::write(source.join("home/.bashrc"), "x").unwrap();
4772        let stranger = utf8(tmp.path().join("not-managed/foo"));
4773        std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
4774        std::fs::write(&stranger, "not yui's").unwrap();
4775        let err = absorb(Some(source), stranger, false, /* yes */ true).unwrap_err();
4776        assert!(format!("{err}").contains("no mount entry"));
4777    }
4778
4779    #[test]
4780    fn yuiignore_excludes_file_from_linking() {
4781        let tmp = TempDir::new().unwrap();
4782        let (source, target) = setup_minimal_dotfiles(&tmp);
4783        std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
4784        std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
4785        // Exclude `lock.json` files anywhere under source.
4786        std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
4787        apply(Some(source.clone()), false).unwrap();
4788        assert!(target.join(".bashrc").exists());
4789        assert!(
4790            !target.join("lock.json").exists(),
4791            "yuiignore should keep lock.json out of target"
4792        );
4793    }
4794
4795    #[test]
4796    fn yuiignore_excludes_directory_subtree() {
4797        let tmp = TempDir::new().unwrap();
4798        let (source, target) = setup_minimal_dotfiles(&tmp);
4799        std::fs::create_dir_all(source.join("home/cache")).unwrap();
4800        std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
4801        std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
4802        std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
4803        // Trailing slash → match dirs only; entire subtree skipped.
4804        std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
4805        apply(Some(source.clone()), false).unwrap();
4806        assert!(target.join(".bashrc").exists());
4807        assert!(
4808            !target.join("cache").exists(),
4809            "yuiignore'd subtree should not appear in target"
4810        );
4811    }
4812
4813    #[test]
4814    fn yuiignore_negation_re_includes_file() {
4815        let tmp = TempDir::new().unwrap();
4816        let (source, target) = setup_minimal_dotfiles(&tmp);
4817        std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
4818        std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
4819        // Ignore all .cache files except keep.cache.
4820        std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
4821        apply(Some(source.clone()), false).unwrap();
4822        assert!(target.join("keep.cache").exists());
4823        assert!(!target.join("drop.cache").exists());
4824    }
4825
4826    /// Issue #47: a `.yuiignore` placed in a nested subdirectory must
4827    /// scope its rules to that subtree, just like `.gitignore`.
4828    /// `home/inner/.yuiignore` excluding `secret*` should drop
4829    /// `home/inner/secret.txt` but leave `home/secret.txt` alone.
4830    #[test]
4831    fn nested_yuiignore_only_affects_its_subtree() {
4832        let tmp = TempDir::new().unwrap();
4833        let (source, target) = setup_minimal_dotfiles(&tmp);
4834        std::fs::create_dir_all(source.join("home/inner")).unwrap();
4835        std::fs::write(source.join("home/secret.txt"), "outer keep").unwrap();
4836        std::fs::write(source.join("home/inner/secret.txt"), "inner drop").unwrap();
4837        std::fs::write(source.join("home/inner/keep.txt"), "inner keep").unwrap();
4838        // Nested ignore — affects only `home/inner/`.
4839        std::fs::write(source.join("home/inner/.yuiignore"), "secret*\n").unwrap();
4840        apply(Some(source.clone()), false).unwrap();
4841        assert!(
4842            target.join("secret.txt").exists(),
4843            "outer secret.txt is outside the nested .yuiignore scope"
4844        );
4845        assert!(target.join("inner/keep.txt").exists());
4846        assert!(
4847            !target.join("inner/secret.txt").exists(),
4848            "inner secret.txt should be excluded by the nested .yuiignore"
4849        );
4850    }
4851
4852    /// A nested `.yuiignore` can re-include (via `!negation`) a file
4853    /// the root ignore had excluded — gitignore's last-rule-wins
4854    /// semantics, scoped per-subtree.
4855    #[test]
4856    fn nested_yuiignore_negation_overrides_root_rule() {
4857        let tmp = TempDir::new().unwrap();
4858        let (source, target) = setup_minimal_dotfiles(&tmp);
4859        std::fs::create_dir_all(source.join("home/keepers")).unwrap();
4860        std::fs::write(source.join("home/drop.lock"), "outer drop").unwrap();
4861        std::fs::write(source.join("home/keepers/wanted.lock"), "inner keep").unwrap();
4862        std::fs::write(source.join(".yuiignore"), "*.lock\n").unwrap();
4863        // Re-include `*.lock` only inside keepers/.
4864        std::fs::write(source.join("home/keepers/.yuiignore"), "!*.lock\n").unwrap();
4865        apply(Some(source.clone()), false).unwrap();
4866        assert!(
4867            !target.join("drop.lock").exists(),
4868            "root rule still drops outer .lock file"
4869        );
4870        assert!(
4871            target.join("keepers/wanted.lock").exists(),
4872            "nested negation re-includes .lock under keepers/"
4873        );
4874    }
4875
4876    /// `yui status` walk uses the same nested-`.yuiignore` semantics:
4877    /// a nested ignore scoped to one subtree must NOT make a sibling
4878    /// subtree's identical filename look ignored.
4879    #[test]
4880    fn nested_yuiignore_status_walk_scoped() {
4881        let tmp = TempDir::new().unwrap();
4882        let (source, _target) = setup_minimal_dotfiles(&tmp);
4883        std::fs::create_dir_all(source.join("home/a")).unwrap();
4884        std::fs::create_dir_all(source.join("home/b")).unwrap();
4885        std::fs::write(source.join("home/a/foo.txt"), "a-foo").unwrap();
4886        std::fs::write(source.join("home/b/foo.txt"), "b-foo").unwrap();
4887        // Only `home/a/` ignores foo.txt.
4888        std::fs::write(source.join("home/a/.yuiignore"), "foo.txt\n").unwrap();
4889        apply(Some(source.clone()), false).unwrap();
4890        // status should not error; walk completes despite the nested rule.
4891        let res = status(Some(source), None, /* no_color */ true);
4892        assert!(res.is_ok() || matches!(&res, Err(e) if format!("{e}").contains("diverged")));
4893    }
4894
4895    #[test]
4896    fn yuiignore_skips_template_in_render() {
4897        let tmp = TempDir::new().unwrap();
4898        let source = utf8(tmp.path().join("dotfiles"));
4899        let target = utf8(tmp.path().join("target"));
4900        std::fs::create_dir_all(source.join("home")).unwrap();
4901        std::fs::create_dir_all(&target).unwrap();
4902        std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
4903        std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
4904        let cfg = format!(
4905            r#"
4906[[mount.entry]]
4907src = "home"
4908dst = "{}"
4909"#,
4910            toml_path(&target)
4911        );
4912        std::fs::write(source.join("config.toml"), cfg).unwrap();
4913        apply(Some(source.clone()), false).unwrap();
4914        // Neither the template nor the rendered output linked.
4915        assert!(!source.join("home/note").exists());
4916        assert!(!target.join("note").exists());
4917        assert!(!target.join("note.tera").exists());
4918    }
4919
4920    // -----------------------------------------------------------------
4921    // secrets (age) end-to-end
4922    // -----------------------------------------------------------------
4923
4924    /// `yui apply` decrypts every `*.age` to its sibling and the
4925    /// sibling lands in target as a regular file. The plaintext is
4926    /// also added to the managed `.gitignore` section so it doesn't
4927    /// get committed.
4928    #[test]
4929    fn apply_decrypts_age_files_to_sibling_and_links() {
4930        let tmp = TempDir::new().unwrap();
4931        let source = utf8(tmp.path().join("dotfiles"));
4932        let target = utf8(tmp.path().join("target"));
4933        std::fs::create_dir_all(source.join("home/.ssh")).unwrap();
4934        std::fs::create_dir_all(&target).unwrap();
4935
4936        // 1. Generate a keypair, write identity file inside the test
4937        //    sandbox so we don't touch the user's real `~/.config/yui/`.
4938        let identity_path = utf8(tmp.path().join("age.txt"));
4939        let (secret, public) = secret::generate_x25519_keypair();
4940        std::fs::write(&identity_path, format!("{secret}\n")).unwrap();
4941
4942        // 2. Encrypt a fake private key into source as `.age`.
4943        let recipient = secret::parse_recipient(&public).unwrap();
4944        let cipher = secret::encrypt(b"-- super secret key --\n", &[recipient]).unwrap();
4945        std::fs::write(source.join("home/.ssh/id_ed25519.age"), &cipher).unwrap();
4946
4947        // 3. config.toml: mount + secrets pointing at the test identity.
4948        let cfg = format!(
4949            r#"
4950[[mount.entry]]
4951src = "home"
4952dst = "{}"
4953
4954[secrets]
4955identity = "{}"
4956recipients = ["{}"]
4957"#,
4958            toml_path(&target),
4959            toml_path(&identity_path),
4960            public
4961        );
4962        std::fs::write(source.join("config.toml"), cfg).unwrap();
4963
4964        apply(Some(source.clone()), false).unwrap();
4965
4966        // Plaintext sibling appeared.
4967        assert!(source.join("home/.ssh/id_ed25519").exists());
4968        // Target got the linked file with decrypted content.
4969        let target_bytes = std::fs::read(target.join(".ssh/id_ed25519")).unwrap();
4970        assert_eq!(target_bytes, b"-- super secret key --\n");
4971        // Plaintext path is in the managed .gitignore section.
4972        let gi = std::fs::read_to_string(source.join(".gitignore")).unwrap();
4973        assert!(
4974            gi.contains("home/.ssh/id_ed25519"),
4975            ".gitignore should list the decrypted plaintext sibling: {gi}"
4976        );
4977        // The .age ciphertext is the canonical, NOT in the managed list.
4978        // (It's expected to be committed normally.)
4979    }
4980
4981    /// `yui apply` bails when the on-disk plaintext sibling has
4982    /// drifted from the canonical `.age`. Mirrors render-drift
4983    /// semantics: the user must run `yui secret encrypt` to roll
4984    /// the change back into ciphertext before re-running apply.
4985    #[test]
4986    fn apply_bails_on_secret_drift() {
4987        let tmp = TempDir::new().unwrap();
4988        let source = utf8(tmp.path().join("dotfiles"));
4989        let target = utf8(tmp.path().join("target"));
4990        std::fs::create_dir_all(source.join("home")).unwrap();
4991        std::fs::create_dir_all(&target).unwrap();
4992
4993        let identity_path = utf8(tmp.path().join("age.txt"));
4994        let (secret_key, public) = secret::generate_x25519_keypair();
4995        std::fs::write(&identity_path, format!("{secret_key}\n")).unwrap();
4996
4997        let recipient = secret::parse_recipient(&public).unwrap();
4998        let cipher = secret::encrypt(b"v1 content\n", &[recipient]).unwrap();
4999        std::fs::write(source.join("home/secret.age"), &cipher).unwrap();
5000        // Drifted sibling: plaintext exists but doesn't match the .age content.
5001        std::fs::write(source.join("home/secret"), "edited locally\n").unwrap();
5002
5003        let cfg = format!(
5004            r#"
5005[[mount.entry]]
5006src = "home"
5007dst = "{}"
5008
5009[secrets]
5010identity = "{}"
5011recipients = ["{}"]
5012"#,
5013            toml_path(&target),
5014            toml_path(&identity_path),
5015            public
5016        );
5017        std::fs::write(source.join("config.toml"), cfg).unwrap();
5018
5019        let err = apply(Some(source.clone()), false).unwrap_err();
5020        assert!(
5021            format!("{err:#}").contains("secret drift"),
5022            "expected secret drift error, got: {err:#}"
5023        );
5024    }
5025
5026    // -- append_recipient_to_local (PR #57 review: toml_edit) --
5027
5028    #[test]
5029    fn append_recipient_creates_secrets_table_when_missing() {
5030        let result =
5031            append_recipient_to_local("", "host alice", "age1abcrecipientpublickey").unwrap();
5032        // Round-trip parse — must be valid TOML.
5033        let parsed: toml::Table = toml::from_str(&result).unwrap();
5034        let secrets = parsed.get("secrets").and_then(|v| v.as_table()).unwrap();
5035        let recipients = secrets
5036            .get("recipients")
5037            .and_then(|v| v.as_array())
5038            .unwrap();
5039        assert_eq!(recipients.len(), 1);
5040        assert_eq!(recipients[0].as_str(), Some("age1abcrecipientpublickey"));
5041    }
5042
5043    #[test]
5044    fn append_recipient_preserves_existing_other_tables() {
5045        // Crude string-pasting used to put a new recipient in the
5046        // wrong place when other tables followed `[secrets]`.
5047        // toml_edit handles arbitrary table ordering.
5048        let existing = r#"
5049[vars]
5050greet = "hi"
5051
5052[secrets]
5053recipients = ["age1machine_a"]
5054
5055[ui]
5056icons = "ascii"
5057"#;
5058        let result = append_recipient_to_local(existing, "host b", "age1machine_b").unwrap();
5059        let parsed: toml::Table = toml::from_str(&result).unwrap();
5060        // All three tables still there.
5061        assert!(parsed.get("vars").is_some());
5062        assert!(parsed.get("secrets").is_some());
5063        assert!(parsed.get("ui").is_some());
5064        // Both recipients in the array.
5065        let recipients = parsed["secrets"]["recipients"].as_array().unwrap();
5066        assert_eq!(recipients.len(), 2);
5067        let pubs: Vec<&str> = recipients.iter().filter_map(|v| v.as_str()).collect();
5068        assert!(pubs.contains(&"age1machine_a"));
5069        assert!(pubs.contains(&"age1machine_b"));
5070    }
5071
5072    #[test]
5073    fn append_recipient_is_idempotent_on_duplicate() {
5074        let existing = r#"[secrets]
5075recipients = ["age1same"]
5076"#;
5077        let result = append_recipient_to_local(existing, "anyone", "age1same").unwrap();
5078        let parsed: toml::Table = toml::from_str(&result).unwrap();
5079        let recipients = parsed["secrets"]["recipients"].as_array().unwrap();
5080        assert_eq!(recipients.len(), 1, "duplicate must not be appended twice");
5081    }
5082
5083    #[test]
5084    fn append_recipient_creates_recipients_array_when_secrets_table_empty() {
5085        // `[secrets]` exists but no recipients yet (e.g. user hand-
5086        // initialised a different field first).
5087        let existing = r#"[secrets]
5088identity = "~/.config/yui/age.txt"
5089"#;
5090        let result = append_recipient_to_local(existing, "h", "age1new").unwrap();
5091        let parsed: toml::Table = toml::from_str(&result).unwrap();
5092        let secrets = parsed["secrets"].as_table().unwrap();
5093        assert_eq!(
5094            secrets["identity"].as_str(),
5095            Some("~/.config/yui/age.txt"),
5096            "existing identity field must survive"
5097        );
5098        let recipients = secrets["recipients"].as_array().unwrap();
5099        assert_eq!(recipients.len(), 1);
5100        assert_eq!(recipients[0].as_str(), Some("age1new"));
5101    }
5102
5103    /// Secrets feature is opt-in: an empty `[secrets] recipients`
5104    /// list keeps `decrypt_all` a no-op so existing repos behave
5105    /// exactly as before this PR.
5106    #[test]
5107    fn apply_without_recipients_skips_secret_walker() {
5108        let tmp = TempDir::new().unwrap();
5109        let (source, _target) = setup_minimal_dotfiles(&tmp);
5110        // No `[secrets]` block at all.
5111        std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5112        // A stray `.age` file with no recipients configured: walker
5113        // shouldn't even open it (no identity loaded → no decrypt
5114        // attempt → no error).
5115        std::fs::write(source.join("home/some.junk.age"), b"not actually a cipher").unwrap();
5116        apply(Some(source.clone()), false).unwrap();
5117    }
5118
5119    /// v0.6+: parent `.yuilink` doesn't stop the walker. A parent
5120    /// marker can junction the whole dir, AND a child marker can layer
5121    /// on extra dsts (e.g. an OS-specific alternate location).
5122    #[test]
5123    fn nested_marker_accumulates_extra_dst() {
5124        let tmp = TempDir::new().unwrap();
5125        let source = utf8(tmp.path().join("dotfiles"));
5126        let parent_target = utf8(tmp.path().join("home"));
5127        let extra_target = utf8(tmp.path().join("extra"));
5128        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
5129        std::fs::create_dir_all(&parent_target).unwrap();
5130        std::fs::create_dir_all(&extra_target).unwrap();
5131        std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
5132
5133        // Parent: junction the whole .config dir to <home>/.config.
5134        std::fs::write(
5135            source.join("home/.config/.yuilink"),
5136            format!(
5137                r#"
5138[[link]]
5139dst = "{}/.config"
5140"#,
5141                toml_path(&parent_target)
5142            ),
5143        )
5144        .unwrap();
5145        // Child: ALSO junction nvim/ to an extra path, but only on the
5146        // running OS (so the test exercises an active link).
5147        std::fs::write(
5148            source.join("home/.config/nvim/.yuilink"),
5149            format!(
5150                r#"
5151[[link]]
5152dst = "{}/nvim"
5153when = "{{{{ yui.os == '{}' }}}}"
5154"#,
5155                toml_path(&extra_target),
5156                std::env::consts::OS
5157            ),
5158        )
5159        .unwrap();
5160
5161        let cfg = format!(
5162            r#"
5163[[mount.entry]]
5164src = "home"
5165dst = "{}"
5166"#,
5167            toml_path(&parent_target)
5168        );
5169        std::fs::write(source.join("config.toml"), cfg).unwrap();
5170
5171        apply(Some(source.clone()), false).unwrap();
5172
5173        // Both links are present: parent's whole-.config junction reaches
5174        // init.lua, and the child marker added an additional path.
5175        assert!(parent_target.join(".config/nvim/init.lua").exists());
5176        assert!(extra_target.join("nvim/init.lua").exists());
5177    }
5178
5179    /// v0.6+: `[[link]] src = "<filename>"` links a single sibling file
5180    /// to a custom dst, leaving the rest of the dir to default
5181    /// behaviour. Useful for paths like the PowerShell profile that
5182    /// have to live in a non-`~/.config` location on Windows.
5183    #[test]
5184    fn marker_file_link_targets_specific_file() {
5185        let tmp = TempDir::new().unwrap();
5186        let source = utf8(tmp.path().join("dotfiles"));
5187        let parent_target = utf8(tmp.path().join("home"));
5188        let docs_target = utf8(tmp.path().join("docs"));
5189        std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
5190        std::fs::create_dir_all(&parent_target).unwrap();
5191        std::fs::create_dir_all(&docs_target).unwrap();
5192        std::fs::write(
5193            source.join("home/.config/powershell/profile.ps1"),
5194            "# profile\n",
5195        )
5196        .unwrap();
5197        std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
5198
5199        // File-level entry only — no dir-level [[link]], so the dir
5200        // itself still falls through to the default mount placement.
5201        std::fs::write(
5202            source.join("home/.config/powershell/.yuilink"),
5203            format!(
5204                r#"
5205[[link]]
5206src = "profile.ps1"
5207dst = "{}/Microsoft.PowerShell_profile.ps1"
5208"#,
5209                toml_path(&docs_target)
5210            ),
5211        )
5212        .unwrap();
5213
5214        let cfg = format!(
5215            r#"
5216[[mount.entry]]
5217src = "home"
5218dst = "{}"
5219"#,
5220            toml_path(&parent_target)
5221        );
5222        std::fs::write(source.join("config.toml"), cfg).unwrap();
5223
5224        apply(Some(source.clone()), false).unwrap();
5225
5226        // File-level target gets the link.
5227        assert!(
5228            docs_target
5229                .join("Microsoft.PowerShell_profile.ps1")
5230                .exists()
5231        );
5232        // Default per-file placement still happens for ALL files in the
5233        // dir (the marker had no dir-level [[link]] to claim coverage).
5234        assert!(
5235            parent_target
5236                .join(".config/powershell/profile.ps1")
5237                .exists()
5238        );
5239        assert!(parent_target.join(".config/powershell/extra.txt").exists());
5240    }
5241
5242    /// File-level [[link]] errors clearly when src points at a missing
5243    /// file — config bug, not a silent skip.
5244    #[test]
5245    fn marker_file_link_missing_src_errors() {
5246        let tmp = TempDir::new().unwrap();
5247        let source = utf8(tmp.path().join("dotfiles"));
5248        let parent_target = utf8(tmp.path().join("home"));
5249        let docs_target = utf8(tmp.path().join("docs"));
5250        std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
5251        std::fs::create_dir_all(&parent_target).unwrap();
5252        std::fs::create_dir_all(&docs_target).unwrap();
5253
5254        std::fs::write(
5255            source.join("home/.config/powershell/.yuilink"),
5256            format!(
5257                r#"
5258[[link]]
5259src = "missing.ps1"
5260dst = "{}/profile.ps1"
5261"#,
5262                toml_path(&docs_target)
5263            ),
5264        )
5265        .unwrap();
5266
5267        let cfg = format!(
5268            r#"
5269[[mount.entry]]
5270src = "home"
5271dst = "{}"
5272"#,
5273            toml_path(&parent_target)
5274        );
5275        std::fs::write(source.join("config.toml"), cfg).unwrap();
5276
5277        let err = apply(Some(source.clone()), false).unwrap_err();
5278        assert!(format!("{err:#}").contains("missing.ps1"));
5279    }
5280
5281    // -----------------------------------------------------------------
5282    // unmanaged
5283    // -----------------------------------------------------------------
5284
5285    /// `yui unmanaged` lists files in the source tree that no
5286    /// `[[mount.entry]]` claims. Should NOT include the repo's own
5287    /// scaffold (`config.toml`, `.gitignore`, `.yuilink`, `.tera`
5288    /// templates) — those are managed-by-yui-itself.
5289    #[test]
5290    fn unmanaged_finds_files_outside_any_mount() {
5291        let tmp = TempDir::new().unwrap();
5292        let (source, _target) = setup_minimal_dotfiles(&tmp);
5293        // Mount-claimed file (under `home/` per setup_minimal_dotfiles).
5294        std::fs::write(source.join("home/.bashrc"), "x").unwrap();
5295        // Truly unmanaged file at repo root.
5296        std::fs::write(source.join("orphan.txt"), "y").unwrap();
5297        std::fs::create_dir_all(source.join("notes")).unwrap();
5298        std::fs::write(source.join("notes/scratch.md"), "z").unwrap();
5299
5300        // unmanaged() should succeed and not touch anything.
5301        unmanaged(Some(source.clone()), None, /* no_color */ true).unwrap();
5302
5303        // Verify the helper itself classifies correctly without printing.
5304        let yui = YuiVars::detect(&source);
5305        let cfg = config::load(&source, &yui).unwrap();
5306        let mount_srcs: Vec<Utf8PathBuf> = cfg
5307            .mount
5308            .entry
5309            .iter()
5310            .map(|m| source.join(&m.src))
5311            .collect();
5312        let walker = paths::source_walker(&source).build();
5313        let mut unmanaged_paths = Vec::new();
5314        for entry in walker.flatten() {
5315            if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
5316                continue;
5317            }
5318            let p = match Utf8PathBuf::from_path_buf(entry.path().to_path_buf()) {
5319                Ok(p) => p,
5320                Err(_) => continue,
5321            };
5322            if is_repo_meta(&p, &source, &cfg.mount.marker_filename) {
5323                continue;
5324            }
5325            if mount_srcs.iter().any(|m| p.starts_with(m)) {
5326                continue;
5327            }
5328            unmanaged_paths.push(p);
5329        }
5330        let names: Vec<String> = unmanaged_paths
5331            .iter()
5332            .filter_map(|p| p.file_name().map(String::from))
5333            .collect();
5334        assert!(names.contains(&"orphan.txt".into()));
5335        assert!(names.contains(&"scratch.md".into()));
5336        assert!(!names.contains(&".bashrc".into()), "mount-claimed file");
5337        assert!(!names.contains(&"config.toml".into()), "repo meta");
5338    }
5339
5340    #[test]
5341    fn is_repo_meta_recognises_yui_scaffold() {
5342        let source = Utf8Path::new("/dot");
5343        // Repo-root config layering — yui-owned.
5344        assert!(is_repo_meta(
5345            Utf8Path::new("/dot/config.toml"),
5346            source,
5347            ".yuilink",
5348        ));
5349        assert!(is_repo_meta(
5350            Utf8Path::new("/dot/config.local.toml"),
5351            source,
5352            ".yuilink",
5353        ));
5354        assert!(is_repo_meta(
5355            Utf8Path::new("/dot/config.linux.toml"),
5356            source,
5357            ".yuilink",
5358        ));
5359        assert!(is_repo_meta(
5360            Utf8Path::new("/dot/config.local.example.toml"),
5361            source,
5362            ".yuilink",
5363        ));
5364        // Repo-root .gitignore — yui manages its rendered-files section.
5365        assert!(is_repo_meta(
5366            Utf8Path::new("/dot/.gitignore"),
5367            source,
5368            ".yuilink",
5369        ));
5370        // Marker / yuiignore / *.tera — anywhere in the tree.
5371        assert!(is_repo_meta(
5372            Utf8Path::new("/dot/home/.config/foo/.yuilink"),
5373            source,
5374            ".yuilink",
5375        ));
5376        assert!(is_repo_meta(
5377            Utf8Path::new("/dot/home/.gitconfig.tera"),
5378            source,
5379            ".yuilink",
5380        ));
5381        // Nested config.toml is a user dotfile, NOT yui's config.
5382        assert!(!is_repo_meta(
5383            Utf8Path::new("/dot/home/.config/myapp/config.toml"),
5384            source,
5385            ".yuilink",
5386        ));
5387        // Nested .gitignore is a user dotfile too — only the
5388        // repo-root one is yui-managed. (PR #53 review caught
5389        // the original code marking every .gitignore as meta.)
5390        assert!(!is_repo_meta(
5391            Utf8Path::new("/dot/home/.config/git/.gitignore"),
5392            source,
5393            ".yuilink",
5394        ));
5395    }
5396
5397    /// `unmanaged` must NOT report files under a mount entry that's
5398    /// inactive on the current host (e.g. `home_macos/foo` when on
5399    /// Linux). The raw `config.mount.entry` list — not
5400    /// `mount::resolve` which filters by `when` — claims those
5401    /// files. (PR #53 review caught the original code using
5402    /// `mount::resolve`.)
5403    #[test]
5404    fn unmanaged_respects_inactive_mount_entries() {
5405        let tmp = TempDir::new().unwrap();
5406        let source = utf8(tmp.path().join("dotfiles"));
5407        let target = utf8(tmp.path().join("target"));
5408        std::fs::create_dir_all(source.join("home_active")).unwrap();
5409        std::fs::create_dir_all(source.join("home_other_os")).unwrap();
5410        std::fs::create_dir_all(&target).unwrap();
5411        std::fs::write(source.join("home_active/.bashrc"), "active").unwrap();
5412        std::fs::write(source.join("home_other_os/.bashrc"), "inactive").unwrap();
5413        // One mount active, one with a `when` that's always false.
5414        let cfg = format!(
5415            r#"
5416[[mount.entry]]
5417src = "home_active"
5418dst = "{target}"
5419
5420[[mount.entry]]
5421src = "home_other_os"
5422dst = "{target}"
5423when = "yui.os == 'definitely_not_a_real_os'"
5424"#,
5425            target = toml_path(&target)
5426        );
5427        std::fs::write(source.join("config.toml"), cfg).unwrap();
5428
5429        // Replicate unmanaged()'s classification logic and verify the
5430        // `home_other_os/.bashrc` file is NOT listed (because the
5431        // when=false mount entry still owns it on principle).
5432        let yui = YuiVars::detect(&source);
5433        let cfg = config::load(&source, &yui).unwrap();
5434        let mount_srcs: Vec<Utf8PathBuf> = cfg
5435            .mount
5436            .entry
5437            .iter()
5438            .map(|m| source.join(&m.src))
5439            .collect();
5440        let inactive_file = source.join("home_other_os/.bashrc");
5441        let claimed = mount_srcs.iter().any(|m| inactive_file.starts_with(m));
5442        assert!(
5443            claimed,
5444            "raw config.mount.entry should claim files even under inactive mounts"
5445        );
5446    }
5447
5448    // -----------------------------------------------------------------
5449    // diff
5450    // -----------------------------------------------------------------
5451
5452    #[test]
5453    fn diff_shows_drift_skips_in_sync() {
5454        let tmp = TempDir::new().unwrap();
5455        let (source, target) = setup_minimal_dotfiles(&tmp);
5456        std::fs::write(source.join("home/.bashrc"), "first\nsecond\n").unwrap();
5457        // Sync once.
5458        apply(Some(source.clone()), false).unwrap();
5459        // Edit target — break the link, create content drift.
5460        std::fs::remove_file(target.join(".bashrc")).unwrap();
5461        std::fs::write(target.join(".bashrc"), "first\nEDITED\n").unwrap();
5462
5463        // diff() should run without bailing — the drift is what it
5464        // exists to surface.
5465        diff(Some(source.clone()), None, /* no_color */ true).unwrap();
5466    }
5467
5468    /// `read_text_for_diff` distinguishes binary (invalid UTF-8)
5469    /// from text and from missing — so `print_unified_diff` /
5470    /// `print_absorb_diff` can short-circuit instead of dumping
5471    /// bytes through `similar`. (PR #53 review.)
5472    #[test]
5473    fn read_text_for_diff_classifies_correctly() {
5474        let tmp = TempDir::new().unwrap();
5475        let root = utf8(tmp.path().to_path_buf());
5476        // Plain UTF-8.
5477        let txt = root.join("a.txt");
5478        std::fs::write(&txt, "hello\n").unwrap();
5479        match read_text_for_diff(&txt) {
5480            DiffSide::Text(s) => assert_eq!(s, "hello\n"),
5481            DiffSide::Binary => panic!("text file misclassified as binary"),
5482        }
5483        // Invalid UTF-8 bytes.
5484        let bin = root.join("b.bin");
5485        std::fs::write(&bin, [0xff, 0xfe, 0x00, 0xff]).unwrap();
5486        assert!(matches!(read_text_for_diff(&bin), DiffSide::Binary));
5487        // Missing file collapses to empty Text — graceful for races.
5488        let missing = root.join("missing.txt");
5489        match read_text_for_diff(&missing) {
5490            DiffSide::Text(s) => assert!(s.is_empty()),
5491            DiffSide::Binary => panic!("missing file misclassified as binary"),
5492        }
5493    }
5494
5495    /// `yui diff` for a render-drifted template must diff the
5496    /// **rendered output** against the on-disk file, not the raw
5497    /// `.tera` source — otherwise Tera's `{{ }}` syntax shows up
5498    /// as drift. The fix exposes `render::render_to_string` for
5499    /// `print_unified_diff` to compute the expected content.
5500    /// (PR #53 review caught this.)
5501    #[test]
5502    fn diff_render_drift_uses_rendered_output_not_raw_template() {
5503        let tmp = TempDir::new().unwrap();
5504        let (source, _target) = setup_minimal_dotfiles(&tmp);
5505        // Template renders `os = linux` (or whatever the host is);
5506        // the on-disk rendered file is stale ("os = ancient").
5507        std::fs::write(source.join("home/note.tera"), "os = {{ yui.os }}\n").unwrap();
5508        std::fs::write(source.join("home/note"), "os = ancient\n").unwrap();
5509        // The renderer should produce the expected new content.
5510        let yui = YuiVars::detect(&source);
5511        let cfg = config::load(&source, &yui).unwrap();
5512        let rendered =
5513            render::render_to_string(&source.join("home/note.tera"), &source, &cfg, &yui)
5514                .unwrap()
5515                .expect("template should render on this host");
5516        assert!(rendered.starts_with("os = "));
5517        assert!(
5518            !rendered.contains("{{"),
5519            "rendered output must not contain raw Tera tags"
5520        );
5521    }
5522
5523    /// Regression for the path-resolution bug coderabbitai flagged
5524    /// on PR #53: `StatusItem.src` is a *relative-for-display*
5525    /// path, so reading it directly during diff rendering would
5526    /// resolve against the caller's cwd — empty file, wrong file,
5527    /// or NotFound. `resolve_diff_src` re-absolutizes against the
5528    /// source root for `Link(_)` rows, leaves `RenderDrift` rows
5529    /// alone (those already carry absolute `.tera` paths).
5530    #[test]
5531    fn resolve_diff_src_absolutizes_link_rows() {
5532        let source = Utf8Path::new("/dot");
5533        let link_item = StatusItem {
5534            src: Utf8PathBuf::from("home/.bashrc"),
5535            dst: Utf8PathBuf::from("/h/u/.bashrc"),
5536            state: StatusState::Link(absorb::AbsorbDecision::AutoAbsorb),
5537        };
5538        assert_eq!(
5539            resolve_diff_src(&link_item, source),
5540            Utf8PathBuf::from("/dot/home/.bashrc"),
5541        );
5542        let render_item = StatusItem {
5543            src: Utf8PathBuf::from("/dot/home/foo.tera"),
5544            dst: Utf8PathBuf::from("/dot/home/foo"),
5545            state: StatusState::RenderDrift,
5546        };
5547        assert_eq!(
5548            resolve_diff_src(&render_item, source),
5549            Utf8PathBuf::from("/dot/home/foo.tera"),
5550        );
5551    }
5552
5553    #[test]
5554    fn diff_classifier_skips_uninteresting_states() {
5555        use absorb::AbsorbDecision::*;
5556        // Neither InSync nor Restore nor RelinkOnly is worth diffing.
5557        assert!(!diff_worth_printing(&StatusState::Link(InSync)));
5558        assert!(!diff_worth_printing(&StatusState::Link(Restore)));
5559        assert!(!diff_worth_printing(&StatusState::Link(RelinkOnly)));
5560        // Anything content-divergent is.
5561        assert!(diff_worth_printing(&StatusState::Link(AutoAbsorb)));
5562        assert!(diff_worth_printing(&StatusState::Link(NeedsConfirm)));
5563        assert!(diff_worth_printing(&StatusState::RenderDrift));
5564    }
5565
5566    // -----------------------------------------------------------------
5567    // update
5568    // -----------------------------------------------------------------
5569
5570    /// `yui update` bails out early on a dirty source tree before
5571    /// even shelling out to `git pull`. Easiest way to provoke that
5572    /// is on a fresh untracked file in a git repo, but git itself
5573    /// isn't always available in the test sandbox — fall back to
5574    /// only asserting the path that DOES run cleanly: a non-repo
5575    /// directory yields a clear `git: ...` error from is_clean.
5576    #[test]
5577    fn update_errors_when_source_is_not_a_git_repo() {
5578        let tmp = TempDir::new().unwrap();
5579        let source = utf8(tmp.path().join("dotfiles"));
5580        std::fs::create_dir_all(&source).unwrap();
5581        std::fs::write(source.join("config.toml"), "").unwrap();
5582        // No `.git` here — is_clean should bail.
5583        let err = update(Some(source), false).unwrap_err();
5584        let msg = format!("{err:#}");
5585        assert!(
5586            msg.contains("not a git repository")
5587                || msg.contains("uncommitted")
5588                || msg.contains("git"),
5589            "unexpected error: {msg}",
5590        );
5591    }
5592
5593    fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
5594        let mut out = Vec::new();
5595        let mut stack = vec![root.to_path_buf()];
5596        while let Some(dir) = stack.pop() {
5597            let Ok(entries) = std::fs::read_dir(&dir) else {
5598                continue;
5599            };
5600            for e in entries.flatten() {
5601                let p = utf8(e.path());
5602                if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
5603                    stack.push(p);
5604                } else {
5605                    out.push(p);
5606                }
5607            }
5608        }
5609        out
5610    }
5611
5612    // -----------------------------------------------------------------
5613    // gc-backup
5614    // -----------------------------------------------------------------
5615
5616    #[test]
5617    fn parse_backup_suffix_recognises_file_with_extension() {
5618        let dt = parse_backup_suffix("foo_20260429_143022123.yml").unwrap();
5619        assert_eq!(dt.year(), 2026);
5620        assert_eq!(dt.month(), 4);
5621        assert_eq!(dt.day(), 29);
5622        assert_eq!(dt.hour(), 14);
5623        assert_eq!(dt.minute(), 30);
5624        assert_eq!(dt.second(), 22);
5625    }
5626
5627    #[test]
5628    fn parse_backup_suffix_recognises_dotfile_no_extension() {
5629        let dt = parse_backup_suffix(".gitconfig_20260429_143022123").unwrap();
5630        assert_eq!(dt.year(), 2026);
5631    }
5632
5633    #[test]
5634    fn parse_backup_suffix_recognises_directory_form() {
5635        let dt = parse_backup_suffix("nvim_20260429_143022123").unwrap();
5636        assert_eq!(dt.day(), 29);
5637    }
5638
5639    #[test]
5640    fn parse_backup_suffix_recognises_multi_dot_filename() {
5641        // archive.tar.gz_<ts>.gz round-trips back through the rsplit-on-dot fallback.
5642        let dt = parse_backup_suffix("archive.tar.gz_20260429_143022123.gz").unwrap();
5643        assert_eq!(dt.month(), 4);
5644    }
5645
5646    #[test]
5647    fn parse_backup_suffix_rejects_non_yui_names() {
5648        assert!(parse_backup_suffix("README.md").is_none());
5649        assert!(parse_backup_suffix("notes_2026.txt").is_none());
5650        assert!(parse_backup_suffix("almost_20260429_14302212").is_none()); // 17 digits
5651        assert!(parse_backup_suffix("almost_20260429-143022123").is_none()); // wrong sep
5652        // Bare timestamp with no stem is rejected (defensive — yui never produces these).
5653        assert!(parse_backup_suffix("_20260429_143022123").is_none());
5654    }
5655
5656    #[test]
5657    fn parse_human_duration_basic_units() {
5658        let s = parse_human_duration("30d").unwrap();
5659        assert_eq!(s.get_days(), 30);
5660        let s = parse_human_duration("2w").unwrap();
5661        assert_eq!(s.get_weeks(), 2);
5662        let s = parse_human_duration("12h").unwrap();
5663        assert_eq!(s.get_hours(), 12);
5664        // `m` is minutes (matches what `format_age` prints), `mo` is months.
5665        let s = parse_human_duration("5m").unwrap();
5666        assert_eq!(s.get_minutes(), 5);
5667        let s = parse_human_duration("6mo").unwrap();
5668        assert_eq!(s.get_months(), 6);
5669        let s = parse_human_duration("1y").unwrap();
5670        assert_eq!(s.get_years(), 1);
5671    }
5672
5673    #[test]
5674    fn parse_human_duration_case_insensitive_and_whitespace() {
5675        let s = parse_human_duration("  90D  ").unwrap();
5676        assert_eq!(s.get_days(), 90);
5677        let s = parse_human_duration("3WEEKS").unwrap();
5678        assert_eq!(s.get_weeks(), 3);
5679    }
5680
5681    #[test]
5682    fn parse_human_duration_rejects_garbage() {
5683        assert!(parse_human_duration("").is_err());
5684        assert!(parse_human_duration("d30").is_err());
5685        assert!(parse_human_duration("30").is_err()); // no unit
5686        assert!(parse_human_duration("30x").is_err()); // unknown unit
5687        assert!(parse_human_duration("-1d").is_err()); // negative
5688    }
5689
5690    /// Plant a real-shaped backup tree and confirm `walk_gc_backups`
5691    /// finds both files and dir-snapshots, treats dirs as one unit
5692    /// (no descent), and ignores anything without yui's suffix.
5693    #[test]
5694    fn walk_gc_backups_collects_files_and_dir_snapshots() {
5695        let tmp = TempDir::new().unwrap();
5696        let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
5697        std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
5698        // File-style backup.
5699        std::fs::write(
5700            root.join("C/Users/u/.config/foo_20260429_143022123.yml"),
5701            "old yml",
5702        )
5703        .unwrap();
5704        // Dir-style backup with internal files (must not be surfaced individually).
5705        std::fs::create_dir_all(root.join("C/Users/u/nvim_20260101_000000000/lua")).unwrap();
5706        std::fs::write(
5707            root.join("C/Users/u/nvim_20260101_000000000/init.lua"),
5708            "ok",
5709        )
5710        .unwrap();
5711        std::fs::write(
5712            root.join("C/Users/u/nvim_20260101_000000000/lua/x.lua"),
5713            "kk",
5714        )
5715        .unwrap();
5716        // User-dropped file with no yui suffix — must stay out of the survey.
5717        std::fs::write(root.join("C/Users/u/.config/README.md"), "user note").unwrap();
5718
5719        let entries = walk_gc_backups(&root).unwrap();
5720        assert_eq!(entries.len(), 2, "two backup roots, not three");
5721        let kinds: Vec<_> = entries.iter().map(|e| e.kind).collect();
5722        assert!(kinds.contains(&BackupKind::File));
5723        assert!(kinds.contains(&BackupKind::Dir));
5724        // Dir-size aggregates contents.
5725        let dir_entry = entries.iter().find(|e| e.kind == BackupKind::Dir).unwrap();
5726        assert!(dir_entry.size_bytes >= 4); // "ok" + "kk"
5727    }
5728
5729    #[test]
5730    fn cleanup_empty_parents_stops_at_root_and_at_non_empty() {
5731        let tmp = TempDir::new().unwrap();
5732        let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
5733        std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
5734        std::fs::write(root.join("C/Users/u/sibling_keep"), "x").unwrap();
5735
5736        // Pretend we just deleted everything under .config/, the parent
5737        // is now empty and walks up — but Users/ has `sibling_keep` so
5738        // we must stop there. .yui/backup itself must never be removed.
5739        cleanup_empty_parents(&root.join("C/Users/u/.config"), &root);
5740
5741        assert!(!root.join("C/Users/u/.config").exists(), "empty leaf gone");
5742        assert!(root.join("C/Users/u").exists(), "stops at non-empty parent");
5743        assert!(root.exists(), "backup root preserved");
5744    }
5745
5746    /// Survey mode (no `--older-than`) lists everything and deletes nothing.
5747    #[test]
5748    fn gc_backup_survey_keeps_all_entries() {
5749        let tmp = TempDir::new().unwrap();
5750        let source = utf8(tmp.path().join("dotfiles"));
5751        std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
5752        std::fs::write(source.join("config.toml"), "").unwrap();
5753        let backup = source.join(".yui/backup");
5754        std::fs::write(backup.join("a_20260101_000000000.txt"), "old").unwrap();
5755        std::fs::write(backup.join("b_20260415_120000000.txt"), "fresh").unwrap();
5756
5757        gc_backup(Some(source.clone()), None, false, None, true).unwrap();
5758
5759        // Both still present.
5760        assert!(backup.join("a_20260101_000000000.txt").exists());
5761        assert!(backup.join("b_20260415_120000000.txt").exists());
5762    }
5763
5764    /// Prune mode deletes entries strictly older than the cutoff and
5765    /// leaves newer ones plus user-dropped files alone.
5766    #[test]
5767    fn gc_backup_prune_removes_old_files_only() {
5768        let tmp = TempDir::new().unwrap();
5769        let source = utf8(tmp.path().join("dotfiles"));
5770        std::fs::create_dir_all(source.join(".yui/backup/sub")).unwrap();
5771        std::fs::write(source.join("config.toml"), "").unwrap();
5772        let backup = source.join(".yui/backup");
5773
5774        // Far-past file (will be older than 30d unless this test runs in 2026-01).
5775        std::fs::write(backup.join("sub/old_20200101_000000000.txt"), "old").unwrap();
5776        // Tomorrow → ts > now → never older than any positive cutoff.
5777        let tomorrow = jiff::Zoned::now()
5778            .checked_add(jiff::Span::new().days(1))
5779            .unwrap();
5780        let bdt = jiff::fmt::strtime::BrokenDownTime::from(&tomorrow);
5781        let future_ts = bdt.to_string("%Y%m%d_%H%M%S%3f").unwrap();
5782        std::fs::write(backup.join(format!("fresh_{future_ts}.txt")), "fresh").unwrap();
5783        // User-dropped file — not in yui shape.
5784        std::fs::write(backup.join("notes.md"), "mine").unwrap();
5785
5786        gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
5787
5788        assert!(!backup.join("sub/old_20200101_000000000.txt").exists());
5789        // Empty parent dir got cleaned up too.
5790        assert!(!backup.join("sub").exists(), "empty parent removed");
5791        // Backup root itself is preserved even after losing children.
5792        assert!(backup.exists());
5793        assert!(backup.join(format!("fresh_{future_ts}.txt")).exists());
5794        assert!(backup.join("notes.md").exists(), "user file untouched");
5795    }
5796
5797    /// `--dry-run` prints the same set but mutates nothing.
5798    #[test]
5799    fn gc_backup_dry_run_does_not_delete() {
5800        let tmp = TempDir::new().unwrap();
5801        let source = utf8(tmp.path().join("dotfiles"));
5802        std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
5803        std::fs::write(source.join("config.toml"), "").unwrap();
5804        let backup = source.join(".yui/backup");
5805        std::fs::write(backup.join("old_20200101_000000000.txt"), "old").unwrap();
5806
5807        gc_backup(Some(source.clone()), Some("30d".into()), true, None, true).unwrap();
5808
5809        assert!(
5810            backup.join("old_20200101_000000000.txt").exists(),
5811            "dry-run keeps everything in place"
5812        );
5813    }
5814
5815    /// Dir-snapshots are removed wholesale (no per-file judgment) and
5816    /// the now-empty mirror parents collapse up to (but not past) the
5817    /// backup root.
5818    #[test]
5819    fn gc_backup_prune_handles_directory_snapshot() {
5820        let tmp = TempDir::new().unwrap();
5821        let source = utf8(tmp.path().join("dotfiles"));
5822        std::fs::create_dir_all(source.join(".yui/backup/mirror/u")).unwrap();
5823        std::fs::write(source.join("config.toml"), "").unwrap();
5824        let backup = source.join(".yui/backup");
5825        let snap = backup.join("mirror/u/nvim_20200101_000000000");
5826        std::fs::create_dir_all(snap.join("lua")).unwrap();
5827        std::fs::write(snap.join("init.lua"), "x").unwrap();
5828        std::fs::write(snap.join("lua/y.lua"), "y").unwrap();
5829
5830        gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
5831
5832        assert!(!snap.exists(), "dir snapshot removed wholesale");
5833        assert!(!backup.join("mirror").exists(), "empty mirror chain pruned");
5834        assert!(backup.exists(), "backup root preserved");
5835    }
5836}