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