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