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