Skip to main content

yui/
cmd.rs

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