Skip to main content

yui/
cmd.rs

1//! Command implementations.
2//!
3//! Each `Command` variant in `cli.rs` calls one of these. Currently
4//! implemented: `apply`, `init`, `doctor`. The rest are `todo!()`.
5
6use std::fmt::Write as _;
7
8use anyhow::{Context as _, Result};
9use camino::{Utf8Path, Utf8PathBuf};
10use tera::Context as TeraContext;
11use tracing::{info, warn};
12
13use crate::config::{self, Config, HookPhase, IconsMode, MountStrategy};
14use crate::hook::{self, HookOutcome};
15use crate::icons::Icons;
16use crate::link::{self, EffectiveDirMode, EffectiveFileMode, resolve_dir_mode, resolve_file_mode};
17use crate::marker::{self, MarkerSpec};
18use crate::mount::{self, ResolvedMount};
19use crate::render::{self, RenderReport};
20use crate::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    // 1. Render templates first so the link walk picks up rendered files.
216    let render_report = render::render_all(&source, &config, &yui, dry_run)?;
217    log_render_report(&render_report);
218    if render_report.has_drift() {
219        anyhow::bail!(
220            "render drift detected ({} file(s)); reflect target edits back into the .tera before re-running apply",
221            render_report.diverged.len()
222        );
223    }
224
225    // 2. Resolve mounts and link.
226    let mounts = mount::resolve(
227        &config.mount.entry,
228        config.mount.default_strategy,
229        &mut engine,
230        &tera_ctx,
231    )?;
232
233    let backup_root = source.join(&config.backup.dir);
234    let ctx = ApplyCtx {
235        config: &config,
236        source: &source,
237        file_mode: resolve_file_mode(config.link.file_mode),
238        dir_mode: resolve_dir_mode(config.link.dir_mode),
239        backup_root: &backup_root,
240        dry_run,
241    };
242
243    info!("source: {source}");
244    info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
245    if dry_run {
246        info!("dry-run: nothing will be written");
247    }
248
249    // Nested `.yuiignore` stack — push on dir entry, pop on exit.
250    // Seed with the source-root layer so root-level rules apply from
251    // the start without `walk_and_link` having to special-case it.
252    let mut yuiignore = paths::YuiIgnoreStack::new();
253    yuiignore.push_dir(&source)?;
254    let walk_result = (|| -> Result<()> {
255        for m in &mounts {
256            info!("mount: {} → {}", m.src, m.dst);
257            process_mount(&source, m, &ctx, &mut engine, &tera_ctx, &mut yuiignore)?;
258        }
259        Ok(())
260    })();
261    yuiignore.pop_dir(&source);
262    walk_result?;
263
264    // 3. Post-apply hooks (after every link is in place).
265    hook::run_phase(
266        &config,
267        &source,
268        &yui,
269        &mut engine,
270        &tera_ctx,
271        HookPhase::Post,
272        dry_run,
273    )?;
274    Ok(())
275}
276
277fn log_render_report(r: &RenderReport) {
278    if !r.written.is_empty() {
279        info!("rendered {} new file(s)", r.written.len());
280    }
281    if !r.unchanged.is_empty() {
282        info!("rendered {} file(s) unchanged", r.unchanged.len());
283    }
284    if !r.skipped_when_false.is_empty() {
285        info!(
286            "skipped {} template(s) (when=false)",
287            r.skipped_when_false.len()
288        );
289    }
290    for d in &r.diverged {
291        warn!("rendered file diverged from template: {d}");
292    }
293}
294
295/// Bundle of immutable settings threaded through the apply walk.
296///
297/// `.yuiignore` rules are not in here — they need a `&mut` stack
298/// (push on dir entry, pop on dir exit) which doesn't compose with
299/// `ApplyCtx` being shared by `&`. The stack is plumbed through
300/// `walk_and_link` as its own parameter instead.
301struct ApplyCtx<'a> {
302    config: &'a Config,
303    /// Source repo root — needed for git-clean checks during absorb.
304    source: &'a Utf8Path,
305    file_mode: EffectiveFileMode,
306    dir_mode: EffectiveDirMode,
307    backup_root: &'a Utf8Path,
308    dry_run: bool,
309}
310
311/// Show the resolved src→dst mappings for the current source repo.
312///
313/// By default only entries whose `when` matches the current host are shown
314/// (`active`). With `--all`, inactive entries are included with a dim row
315/// and the `when` condition that excluded them.
316pub fn list(
317    source: Option<Utf8PathBuf>,
318    all: bool,
319    icons_override: Option<IconsMode>,
320    no_color: bool,
321) -> Result<()> {
322    let source = resolve_source(source)?;
323    let yui = YuiVars::detect(&source);
324    let config = config::load(&source, &yui)?;
325
326    let icons_mode = icons_override.unwrap_or(config.ui.icons);
327    let icons = Icons::for_mode(icons_mode);
328    let color = !no_color && supports_color_stdout();
329
330    let items = collect_list_items(&source, &config, &yui)?;
331    let displayed: Vec<&ListItem> = if all {
332        items.iter().collect()
333    } else {
334        items.iter().filter(|i| i.active).collect()
335    };
336
337    print_list_table(&displayed, icons, color);
338
339    let total = items.len();
340    let active = items.iter().filter(|i| i.active).count();
341    let inactive = total - active;
342    println!();
343    if all {
344        println!("  {total} entries · {active} active · {inactive} inactive");
345    } else {
346        println!(
347            "  {} of {} entries shown ({} inactive hidden — use --all)",
348            active, total, inactive
349        );
350    }
351    Ok(())
352}
353
354#[derive(Debug)]
355struct ListItem {
356    src: Utf8PathBuf,
357    dst: String,
358    when: Option<String>,
359    active: bool,
360}
361
362fn collect_list_items(source: &Utf8Path, config: &Config, yui: &YuiVars) -> Result<Vec<ListItem>> {
363    let mut engine = template::Engine::new();
364    let tera_ctx = template::template_context(yui, &config.vars);
365    let mut items = Vec::new();
366
367    // 1. config.toml [[mount.entry]] entries
368    for entry in &config.mount.entry {
369        let active = match &entry.when {
370            None => true,
371            Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
372        };
373        let dst = engine
374            .render(&entry.dst, &tera_ctx)
375            .map(|s| paths::expand_tilde(s.trim()).to_string())
376            .unwrap_or_else(|_| entry.dst.clone());
377        items.push(ListItem {
378            src: entry.src.clone(),
379            dst,
380            when: entry.when.clone(),
381            active,
382        });
383    }
384
385    // 2. .yuilink overrides under source
386    let walker = paths::source_walker(source).build();
387    let marker_filename = &config.mount.marker_filename;
388    for entry in walker {
389        let entry = match entry {
390            Ok(e) => e,
391            Err(_) => continue,
392        };
393        if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
394            continue;
395        }
396        if entry.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
397            continue;
398        }
399        let dir = match entry.path().parent() {
400            Some(d) => d,
401            None => continue,
402        };
403        let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
404            Ok(p) => p,
405            Err(_) => continue,
406        };
407        // .yuiignore filtering happens in `source_walker` via
408        // `add_custom_ignore_filename` — markers under ignored
409        // subtrees never reach here.
410        let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
411            Some(s) => s,
412            None => continue,
413        };
414        let MarkerSpec::Explicit { links } = spec else {
415            continue; // PassThrough markers are already implied by mount entry
416        };
417        let rel = dir_utf8
418            .strip_prefix(source)
419            .map(Utf8PathBuf::from)
420            .unwrap_or(dir_utf8);
421        for link in &links {
422            let active = match &link.when {
423                None => true,
424                Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
425            };
426            let dst = engine
427                .render(&link.dst, &tera_ctx)
428                .map(|s| paths::expand_tilde(s.trim()).to_string())
429                .unwrap_or_else(|_| link.dst.clone());
430            // File-level entry (`[[link]] src = "<filename>"`) targets a
431            // single file inside the marker dir; show that file path
432            // instead of the bare dir so `yui list` makes the scope
433            // obvious at a glance.
434            let src_display = match &link.src {
435                Some(filename) => rel.join(filename),
436                None => rel.clone(),
437            };
438            items.push(ListItem {
439                src: src_display,
440                dst,
441                when: link.when.clone(),
442                active,
443            });
444        }
445    }
446
447    items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
448    Ok(items)
449}
450
451fn supports_color_stdout() -> bool {
452    use std::io::IsTerminal;
453    std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
454}
455
456fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
457    let src_w = items
458        .iter()
459        .map(|i| i.src.as_str().chars().count())
460        .max()
461        .unwrap_or(0)
462        .max("SRC".len());
463    let dst_w = items
464        .iter()
465        .map(|i| i.dst.chars().count())
466        .max()
467        .unwrap_or(0)
468        .max("DST".len());
469
470    let status_w = "STATUS".len();
471    let arrow_w = icons.arrow.chars().count();
472
473    // Header
474    print_header(status_w, src_w, arrow_w, dst_w, color);
475
476    // Separator
477    let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
478    if color {
479        use owo_colors::OwoColorize as _;
480        println!("{}", sep.dimmed());
481    } else {
482        println!("{sep}");
483    }
484
485    // Rows
486    for item in items {
487        print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
488    }
489}
490
491fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
492    use owo_colors::OwoColorize as _;
493    let mut line = String::new();
494    let _ = write!(
495        &mut line,
496        "  {:<status_w$}  {:<src_w$}  {:<arrow_w$}  {:<dst_w$}  WHEN",
497        "STATUS", "SRC", "", "DST"
498    );
499    if color {
500        println!("{}", line.bold());
501    } else {
502        println!("{line}");
503    }
504}
505
506fn render_separator(
507    sep_ch: char,
508    status_w: usize,
509    src_w: usize,
510    arrow_w: usize,
511    dst_w: usize,
512) -> String {
513    let bar = |n: usize| sep_ch.to_string().repeat(n);
514    format!(
515        "  {}  {}  {}  {}  {}",
516        bar(status_w),
517        bar(src_w),
518        bar(arrow_w),
519        bar(dst_w),
520        bar("WHEN".len())
521    )
522}
523
524fn print_row(
525    item: &ListItem,
526    icons: Icons,
527    status_w: usize,
528    src_w: usize,
529    arrow_w: usize,
530    dst_w: usize,
531    color: bool,
532) {
533    use owo_colors::OwoColorize as _;
534    let status = if item.active {
535        icons.active
536    } else {
537        icons.inactive
538    };
539    let when_str = item
540        .when
541        .as_deref()
542        .map(strip_braces)
543        .unwrap_or_else(|| "(always)".to_string());
544
545    // Normalize backslashes to forward slashes for cross-platform display.
546    let src_display = item.src.as_str().replace('\\', "/");
547    let src = src_display.as_str();
548    let dst = &item.dst;
549    let arrow = icons.arrow;
550
551    // Pad each cell to its column width FIRST, then apply color. Doing it
552    // the other way round lets ANSI escape codes count as printable chars
553    // in `format!("{:<w$}")`, which silently breaks alignment when colors
554    // are enabled (caught in PR #11 review).
555    let cell_status = format!("{:<status_w$}", status);
556    let cell_src = format!("{:<src_w$}", src);
557    let cell_arrow = format!("{:<arrow_w$}", arrow);
558    let cell_dst = format!("{:<dst_w$}", dst);
559
560    if !color {
561        println!("  {cell_status}  {cell_src}  {cell_arrow}  {cell_dst}  {when_str}");
562        return;
563    }
564
565    if item.active {
566        println!(
567            "  {}  {}  {}  {}  {}",
568            cell_status.green(),
569            cell_src.cyan(),
570            cell_arrow.dimmed(),
571            cell_dst.green(),
572            when_str.dimmed()
573        );
574    } else {
575        println!(
576            "  {}  {}  {}  {}  {}",
577            cell_status.red().dimmed(),
578            cell_src.dimmed(),
579            cell_arrow.dimmed(),
580            cell_dst.dimmed(),
581            when_str.dimmed()
582        );
583    }
584}
585
586/// Strip the outer `{{ ... }}` Tera braces from a `when` expression for
587/// display purposes (shorter line, easier to read at a glance).
588fn strip_braces(expr: &str) -> String {
589    let trimmed = expr.trim();
590    if let Some(inner) = trimmed
591        .strip_prefix("{{")
592        .and_then(|s| s.strip_suffix("}}"))
593    {
594        inner.trim().to_string()
595    } else {
596        trimmed.to_string()
597    }
598}
599
600pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
601    let source = resolve_source(source)?;
602    let yui = YuiVars::detect(&source);
603    let config = config::load(&source, &yui)?;
604    // --check is a stricter dry-run: never writes, exits non-zero on drift.
605    let report = render::render_all(&source, &config, &yui, dry_run || check)?;
606    log_render_report(&report);
607    if check && report.has_drift() {
608        anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
609    }
610    Ok(())
611}
612
613pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
614    // For now `link` and `apply` do the same thing (no render/absorb yet).
615    apply(source, dry_run)
616}
617
618pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
619    let _source = resolve_source(source)?;
620    if paths_arg.is_empty() {
621        anyhow::bail!("yui unlink: provide at least one target path");
622    }
623    for p in paths_arg {
624        let abs = absolutize(&p)?;
625        info!("unlink: {abs}");
626        link::unlink(&abs)?;
627    }
628    Ok(())
629}
630
631/// Show every src→dst pair's drift state against the current host.
632///
633/// Walks each `[[mount.entry]]`'s source tree, honoring `.yuilink`
634/// markers (PassThrough = single dir-level link, Override = one or more
635/// custom dsts), classifies each pair via [`crate::absorb::classify`],
636/// and additionally surfaces any **render drift** — rendered files
637/// whose content has diverged from what the matching `.tera` template
638/// would produce now (i.e. the user edited the rendered file in place
639/// without reflecting the change back into the template).
640///
641/// Exits non-zero (via `anyhow::bail!`) when anything diverges, so
642/// `yui status && …` can gate workflows on a clean tree.
643pub fn status(
644    source: Option<Utf8PathBuf>,
645    icons_override: Option<IconsMode>,
646    no_color: bool,
647) -> Result<()> {
648    let source = resolve_source(source)?;
649    let yui = YuiVars::detect(&source);
650    let config = config::load(&source, &yui)?;
651
652    let mut engine = template::Engine::new();
653    let tera_ctx = template::template_context(&yui, &config.vars);
654    let mounts = mount::resolve(
655        &config.mount.entry,
656        config.mount.default_strategy,
657        &mut engine,
658        &tera_ctx,
659    )?;
660
661    let icons_mode = icons_override.unwrap_or(config.ui.icons);
662    let icons = Icons::for_mode(icons_mode);
663    let color = !no_color && supports_color_stdout();
664
665    let mut report: Vec<StatusItem> = Vec::new();
666
667    // 1. Template drift — render in dry-run mode and surface anything
668    //    whose rendered counterpart on disk no longer matches.
669    let render_report = render::render_all(&source, &config, &yui, /* dry_run */ true)?;
670    for rendered in &render_report.diverged {
671        // `diverged` holds the rendered path; the template lives at
672        // `<rendered>.tera`. Show the .tera as src so it's clear which
673        // file the user needs to update.
674        let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
675        report.push(StatusItem {
676            src: relative_for_display(&source, &tera_path),
677            dst: rendered.clone(),
678            state: StatusState::RenderDrift,
679        });
680    }
681
682    // 2. Link drift — classify each src→dst pair under every mount.
683    // Single nested-`.yuiignore` stack threaded across all mounts.
684    // Seed the source-root layer so root rules apply from the start.
685    let mut yuiignore = paths::YuiIgnoreStack::new();
686    yuiignore.push_dir(&source)?;
687    let walk_result = (|| -> Result<()> {
688        for m in &mounts {
689            let src_root = source.join(&m.src);
690            if !src_root.is_dir() {
691                warn!("mount src missing: {src_root}");
692                continue;
693            }
694            classify_walk(
695                &src_root,
696                &m.dst,
697                &config,
698                m.strategy,
699                &mut engine,
700                &tera_ctx,
701                &source,
702                &mut yuiignore,
703                &mut report,
704            )?;
705        }
706        Ok(())
707    })();
708    yuiignore.pop_dir(&source);
709    walk_result?;
710
711    report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
712
713    print_status_table(&report, icons, color);
714
715    let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
716
717    println!();
718    let total = report.len();
719    let in_sync = total - drift;
720    if drift == 0 {
721        println!("  {total} entries · all in sync");
722        Ok(())
723    } else {
724        println!("  {total} entries · {in_sync} in sync · {drift} diverged");
725        anyhow::bail!("status: {drift} entries diverged from source")
726    }
727}
728
729#[derive(Debug)]
730struct StatusItem {
731    /// Path under the source tree (display only).
732    src: Utf8PathBuf,
733    /// Resolved target path (or rendered output path for `RenderDrift`).
734    dst: Utf8PathBuf,
735    state: StatusState,
736}
737
738#[derive(Debug, Clone, Copy)]
739enum StatusState {
740    Link(absorb::AbsorbDecision),
741    /// Rendered output diverges from current `.tera` template — user
742    /// edited the rendered file directly without updating the template.
743    RenderDrift,
744}
745
746impl StatusState {
747    fn is_in_sync(self) -> bool {
748        matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
749    }
750}
751
752#[allow(clippy::too_many_arguments)]
753fn classify_walk(
754    src_dir: &Utf8Path,
755    dst_dir: &Utf8Path,
756    config: &Config,
757    strategy: MountStrategy,
758    engine: &mut template::Engine,
759    tera_ctx: &TeraContext,
760    source_root: &Utf8Path,
761    yuiignore: &mut paths::YuiIgnoreStack,
762    report: &mut Vec<StatusItem>,
763) -> Result<()> {
764    classify_walk_inner(
765        src_dir,
766        dst_dir,
767        config,
768        strategy,
769        engine,
770        tera_ctx,
771        source_root,
772        yuiignore,
773        report,
774        false,
775    )
776}
777
778#[allow(clippy::too_many_arguments)]
779fn classify_walk_inner(
780    src_dir: &Utf8Path,
781    dst_dir: &Utf8Path,
782    config: &Config,
783    strategy: MountStrategy,
784    engine: &mut template::Engine,
785    tera_ctx: &TeraContext,
786    source_root: &Utf8Path,
787    yuiignore: &mut paths::YuiIgnoreStack,
788    report: &mut Vec<StatusItem>,
789    parent_covered: bool,
790) -> Result<()> {
791    if yuiignore.is_ignored(src_dir, /* is_dir */ true) {
792        return Ok(());
793    }
794    // Layer this dir's .yuiignore (if any) on top before we recurse;
795    // pop on exit so siblings don't see our subtree's rules.
796    yuiignore.push_dir(src_dir)?;
797    let result = classify_walk_inner_body(
798        src_dir,
799        dst_dir,
800        config,
801        strategy,
802        engine,
803        tera_ctx,
804        source_root,
805        yuiignore,
806        report,
807        parent_covered,
808    );
809    yuiignore.pop_dir(src_dir);
810    result
811}
812
813#[allow(clippy::too_many_arguments)]
814fn classify_walk_inner_body(
815    src_dir: &Utf8Path,
816    dst_dir: &Utf8Path,
817    config: &Config,
818    strategy: MountStrategy,
819    engine: &mut template::Engine,
820    tera_ctx: &TeraContext,
821    source_root: &Utf8Path,
822    yuiignore: &mut paths::YuiIgnoreStack,
823    report: &mut Vec<StatusItem>,
824    parent_covered: bool,
825) -> Result<()> {
826    let marker_filename = &config.mount.marker_filename;
827    let mut covered = parent_covered;
828
829    if strategy == MountStrategy::Marker {
830        match marker::read_spec(src_dir, marker_filename)? {
831            None => {}
832            Some(MarkerSpec::PassThrough) => {
833                let decision = absorb::classify(src_dir, dst_dir)?;
834                report.push(StatusItem {
835                    src: relative_for_display(source_root, src_dir),
836                    dst: dst_dir.to_path_buf(),
837                    state: StatusState::Link(decision),
838                });
839                covered = true;
840            }
841            Some(MarkerSpec::Explicit { links }) => {
842                let mut emitted_dir_link = false;
843                for link in &links {
844                    if let Some(when) = &link.when {
845                        if !template::eval_truthy(when, engine, tera_ctx)? {
846                            continue;
847                        }
848                    }
849                    let dst_str = engine.render(&link.dst, tera_ctx)?;
850                    let dst = paths::expand_tilde(dst_str.trim());
851                    if let Some(filename) = &link.src {
852                        let file_src = src_dir.join(filename);
853                        if !file_src.is_file() {
854                            anyhow::bail!(
855                                "marker at {src_dir}: [[link]] src={filename:?} \
856                                 not found"
857                            );
858                        }
859                        let decision = absorb::classify(&file_src, &dst)?;
860                        report.push(StatusItem {
861                            src: relative_for_display(source_root, &file_src),
862                            dst,
863                            state: StatusState::Link(decision),
864                        });
865                    } else {
866                        let decision = absorb::classify(src_dir, &dst)?;
867                        report.push(StatusItem {
868                            src: relative_for_display(source_root, src_dir),
869                            dst,
870                            state: StatusState::Link(decision),
871                        });
872                        emitted_dir_link = true;
873                    }
874                }
875                if emitted_dir_link {
876                    covered = true;
877                }
878            }
879        }
880    }
881
882    for entry in std::fs::read_dir(src_dir)? {
883        let entry = entry?;
884        let name_os = entry.file_name();
885        let Some(name) = name_os.to_str() else {
886            continue;
887        };
888        if name == marker_filename || name.ends_with(".tera") {
889            continue;
890        }
891        let src_path = src_dir.join(name);
892        let dst_path = dst_dir.join(name);
893        let ft = entry.file_type()?;
894        if yuiignore.is_ignored(&src_path, ft.is_dir()) {
895            continue;
896        }
897        if ft.is_dir() {
898            classify_walk_inner(
899                &src_path,
900                &dst_path,
901                config,
902                strategy,
903                engine,
904                tera_ctx,
905                source_root,
906                yuiignore,
907                report,
908                covered,
909            )?;
910        } else if ft.is_file() && !covered {
911            let decision = absorb::classify(&src_path, &dst_path)?;
912            report.push(StatusItem {
913                src: relative_for_display(source_root, &src_path),
914                dst: dst_path,
915                state: StatusState::Link(decision),
916            });
917        }
918    }
919    Ok(())
920}
921
922fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
923    p.strip_prefix(source_root)
924        .map(Utf8PathBuf::from)
925        .unwrap_or_else(|_| p.to_path_buf())
926}
927
928fn print_status_table(items: &[StatusItem], icons: Icons, color: bool) {
929    let src_w = items
930        .iter()
931        .map(|i| i.src.as_str().chars().count())
932        .max()
933        .unwrap_or(0)
934        .max("SRC".len());
935    let dst_w = items
936        .iter()
937        .map(|i| i.dst.as_str().chars().count())
938        .max()
939        .unwrap_or(0)
940        .max("DST".len());
941    // STATE column = icon (1ch) + space + longest label
942    let state_label_w = items
943        .iter()
944        .map(|i| state_label(i.state).len())
945        .max()
946        .unwrap_or(0)
947        .max("STATE".len() - 2); // "STATE" header takes 5 chars; the icon prefix accounts for 2
948    let state_w = state_label_w + 2; // " " + label
949
950    print_status_header(state_w, src_w, dst_w, color);
951    let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
952    if color {
953        use owo_colors::OwoColorize as _;
954        println!("{}", sep.dimmed());
955    } else {
956        println!("{sep}");
957    }
958    for item in items {
959        print_status_row(item, icons, state_w, src_w, dst_w, color);
960    }
961}
962
963fn state_label(s: StatusState) -> &'static str {
964    use absorb::AbsorbDecision::*;
965    match s {
966        StatusState::Link(InSync) => "in-sync",
967        StatusState::Link(RelinkOnly) => "relink",
968        StatusState::Link(AutoAbsorb) => "drift (auto)",
969        StatusState::Link(NeedsConfirm) => "drift (anomaly)",
970        StatusState::Link(Restore) => "missing",
971        StatusState::RenderDrift => "render drift",
972    }
973}
974
975fn state_icon(s: StatusState, icons: Icons) -> &'static str {
976    use absorb::AbsorbDecision::*;
977    match s {
978        StatusState::Link(InSync) => icons.ok,
979        StatusState::Link(RelinkOnly) => icons.warn,
980        StatusState::Link(AutoAbsorb) => icons.warn,
981        StatusState::Link(NeedsConfirm) => icons.error,
982        StatusState::Link(Restore) => icons.info,
983        StatusState::RenderDrift => icons.error,
984    }
985}
986
987fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
988    use owo_colors::OwoColorize as _;
989    // STATE is the only column with data above; "WHEN" intentionally omitted
990    // since status only shows mounts that are already active on this host.
991    let line = format!(
992        "  {:<state_w$}  {:<src_w$}     {:<dst_w$}",
993        "STATE", "SRC", "DST"
994    );
995    if color {
996        println!("{}", line.bold());
997    } else {
998        println!("{line}");
999    }
1000}
1001
1002fn render_status_separator(
1003    sep_ch: char,
1004    state_w: usize,
1005    src_w: usize,
1006    dst_w: usize,
1007    arrow: &str,
1008) -> String {
1009    let bar = |n: usize| sep_ch.to_string().repeat(n);
1010    format!(
1011        "  {}  {}  {}  {}",
1012        bar(state_w),
1013        bar(src_w),
1014        bar(arrow.chars().count()),
1015        bar(dst_w)
1016    )
1017}
1018
1019fn print_status_row(
1020    item: &StatusItem,
1021    icons: Icons,
1022    state_w: usize,
1023    src_w: usize,
1024    dst_w: usize,
1025    color: bool,
1026) {
1027    use owo_colors::OwoColorize as _;
1028    let icon = state_icon(item.state, icons);
1029    let label = state_label(item.state);
1030    let state_text = format!("{icon} {label}");
1031    let src_display = item.src.as_str().replace('\\', "/");
1032    let dst_display = item.dst.as_str().replace('\\', "/");
1033    let arrow = icons.arrow;
1034
1035    let cell_state = format!("{:<state_w$}", state_text);
1036    let cell_src = format!("{:<src_w$}", src_display);
1037    let cell_dst = format!("{:<dst_w$}", dst_display);
1038
1039    if !color {
1040        println!("  {cell_state}  {cell_src}  {arrow}  {cell_dst}");
1041        return;
1042    }
1043
1044    use absorb::AbsorbDecision::*;
1045    let state_colored = match item.state {
1046        StatusState::Link(InSync) => cell_state.green().to_string(),
1047        StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
1048            cell_state.yellow().to_string()
1049        }
1050        StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
1051        StatusState::Link(Restore) => cell_state.cyan().to_string(),
1052        StatusState::RenderDrift => cell_state.red().to_string(),
1053    };
1054    let src_colored = cell_src.cyan().to_string();
1055    let arrow_colored = arrow.dimmed().to_string();
1056    let dst_colored = cell_dst.dimmed().to_string();
1057    println!("  {state_colored}  {src_colored}  {arrow_colored}  {dst_colored}");
1058}
1059
1060/// Manually absorb a single target file back into source.
1061///
1062/// Used when `apply` has skipped an anomaly (`[absorb] on_anomaly = "skip"`
1063/// or non-TTY ask) but the user has decided that target is right. Bypasses
1064/// policy + git-clean checks: this is an explicit user request.
1065///
1066/// Walks `[[mount.entry]]` and `.yuilink` overrides to find which source
1067/// path "owns" the given target. Errors loudly if no mount claims it.
1068pub fn absorb(source: Option<Utf8PathBuf>, target: Utf8PathBuf, dry_run: bool) -> Result<()> {
1069    let source = resolve_source(source)?;
1070    let target = absolutize(&target)?;
1071    let yui = YuiVars::detect(&source);
1072    let config = config::load(&source, &yui)?;
1073
1074    let mut engine = template::Engine::new();
1075    let tera_ctx = template::template_context(&yui, &config.vars);
1076
1077    let src_path = match find_source_for_target(&source, &config, &target, &mut engine, &tera_ctx)?
1078    {
1079        Some(s) => s,
1080        None => anyhow::bail!(
1081            "no mount entry / .yuilink override claims target {target}; \
1082                 pass a path inside a known dst"
1083        ),
1084    };
1085
1086    info!("source for {target}: {src_path}");
1087
1088    if dry_run {
1089        info!("[dry-run] would absorb {target} → {src_path}");
1090        return Ok(());
1091    }
1092
1093    let backup_root = source.join(&config.backup.dir);
1094    let ctx = ApplyCtx {
1095        config: &config,
1096        source: &source,
1097        file_mode: resolve_file_mode(config.link.file_mode),
1098        dir_mode: resolve_dir_mode(config.link.dir_mode),
1099        backup_root: &backup_root,
1100        dry_run: false,
1101    };
1102
1103    // Manual absorb is an explicit user request — bypass `auto`,
1104    // `require_clean_git`, and `on_anomaly` policy entirely.
1105    absorb_target_into_source(&src_path, &target, &ctx)
1106}
1107
1108/// Walk mount entries + `.yuilink` Override markers to find the source
1109/// file/dir that the given target maps back to. Returns `None` when no
1110/// mount or marker claims the path.
1111fn find_source_for_target(
1112    source: &Utf8Path,
1113    config: &Config,
1114    target: &Utf8Path,
1115    engine: &mut template::Engine,
1116    tera_ctx: &TeraContext,
1117) -> Result<Option<Utf8PathBuf>> {
1118    // 1. Mount entries — render dst, see if target is inside it.
1119    for entry in &config.mount.entry {
1120        if let Some(when) = &entry.when {
1121            if !template::eval_truthy(when, engine, tera_ctx)? {
1122                continue;
1123            }
1124        }
1125        let dst_str = engine.render(&entry.dst, tera_ctx)?;
1126        let dst_root = paths::expand_tilde(dst_str.trim());
1127        if let Ok(rel) = target.strip_prefix(&dst_root) {
1128            let candidate = source.join(&entry.src).join(rel);
1129            // Honor `.yuiignore` even on manual absorb — if you've
1130            // ignored a path, you've explicitly opted out of yui's
1131            // managing it. One-shot stack walk along the candidate's
1132            // parents picks up nested `.yuiignore` files too.
1133            if paths::is_ignored_at(source, &candidate, candidate.is_dir())? {
1134                continue;
1135            }
1136            return Ok(Some(candidate));
1137        }
1138    }
1139
1140    // 2. `.yuilink` Override markers — walk source, parse, render each
1141    //    `[[link]] dst`, see if target is the rendered dst (or nested
1142    //    inside a junction'd dir). `source_walker` skips `.yui/` and
1143    //    honours nested `.yuiignore` files automatically, so markers
1144    //    inside ignored subtrees never reach this loop.
1145    let walker = paths::source_walker(source).build();
1146    let marker_filename = &config.mount.marker_filename;
1147    for ent in walker {
1148        let ent = match ent {
1149            Ok(e) => e,
1150            Err(_) => continue,
1151        };
1152        if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
1153            continue;
1154        }
1155        if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
1156            continue;
1157        }
1158        let dir = match ent.path().parent() {
1159            Some(d) => d,
1160            None => continue,
1161        };
1162        let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
1163            Ok(p) => p,
1164            Err(_) => continue,
1165        };
1166        let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
1167            Some(s) => s,
1168            None => continue,
1169        };
1170        let MarkerSpec::Explicit { links } = spec else {
1171            continue;
1172        };
1173        for link in &links {
1174            if let Some(when) = &link.when {
1175                if !template::eval_truthy(when, engine, tera_ctx)? {
1176                    continue;
1177                }
1178            }
1179            let dst_str = engine.render(&link.dst, tera_ctx)?;
1180            let dst = paths::expand_tilde(dst_str.trim());
1181            // File-level entry: dst points at a single file, so a match
1182            // resolves directly to `<marker-dir>/<src filename>`. Mirror
1183            // the existence check that apply / status do so a missing
1184            // sibling produces the same clear message regardless of
1185            // entry point — consistent with the `marker at … src=… not
1186            // found` shape users already see from those flows.
1187            if let Some(filename) = &link.src {
1188                let file_src = dir_utf8.join(filename);
1189                if !file_src.is_file() {
1190                    anyhow::bail!(
1191                        "marker at {dir_utf8}: [[link]] src={filename:?} \
1192                         not found"
1193                    );
1194                }
1195                if target == dst {
1196                    return Ok(Some(file_src));
1197                }
1198                continue;
1199            }
1200            if target == dst {
1201                return Ok(Some(dir_utf8));
1202            }
1203            if let Ok(rel) = target.strip_prefix(&dst) {
1204                return Ok(Some(dir_utf8.join(rel)));
1205            }
1206        }
1207    }
1208
1209    Ok(None)
1210}
1211
1212pub fn doctor(
1213    source: Option<Utf8PathBuf>,
1214    icons_override: Option<IconsMode>,
1215    no_color: bool,
1216) -> Result<()> {
1217    use owo_colors::OwoColorize as _;
1218
1219    // Resolve source up-front so probes that depend on it can short-circuit
1220    // gracefully. A missing source is the single most common cause of yui
1221    // misbehaving, so we want to surface it loudly and skip the dependent
1222    // probes rather than blowing up.
1223    let resolved_source = resolve_source(source);
1224
1225    // `YuiVars::detect` reads `yui.source` from the resolved source path
1226    // (so `{{ yui.source }}` renders correctly in config templates); when
1227    // no source is detected we fall back to `.` so identity probes can
1228    // still report os/arch/user/host.
1229    let yui = match &resolved_source {
1230        Ok(s) => YuiVars::detect(s),
1231        Err(_) => YuiVars::detect(Utf8Path::new(".")),
1232    };
1233
1234    // Cache the loaded config — both the icons-override fallback and the
1235    // hooks-section probe need it. `cfg_res` keeps the original error
1236    // around so the `repo / config` probe can render a meaningful
1237    // message instead of just "not loaded".
1238    let cfg_res = match &resolved_source {
1239        Ok(s) => Some(config::load(s, &yui)),
1240        Err(_) => None,
1241    };
1242    let cfg = cfg_res.as_ref().and_then(|r| r.as_ref().ok());
1243    let icons_mode = icons_override
1244        .or_else(|| cfg.map(|c| c.ui.icons))
1245        .unwrap_or_default();
1246    let icons = Icons::for_mode(icons_mode);
1247    let color = !no_color && supports_color_stdout();
1248
1249    let mut probes: Vec<Probe> = Vec::new();
1250
1251    // ── identity ──────────────────────────────────────────────
1252    probes.push(Probe::group("identity"));
1253    probes.push(Probe::ok("os/arch", format!("{} / {}", yui.os, yui.arch)));
1254    probes.push(Probe::ok("user@host", format!("{}@{}", yui.user, yui.host)));
1255
1256    // ── repository ────────────────────────────────────────────
1257    probes.push(Probe::group("repo"));
1258    let mut have_source = false;
1259    match &resolved_source {
1260        Ok(s) => {
1261            have_source = true;
1262            probes.push(Probe::ok("source", s.to_string()));
1263            match cfg_res.as_ref().expect("cfg_res set when source is Ok") {
1264                Ok(c) => {
1265                    probes.push(Probe::ok(
1266                        "config",
1267                        format!(
1268                            "{} mount{} · {} hook{} · {} render rule{}",
1269                            c.mount.entry.len(),
1270                            plural(c.mount.entry.len()),
1271                            c.hook.len(),
1272                            plural(c.hook.len()),
1273                            c.render.rule.len(),
1274                            plural(c.render.rule.len()),
1275                        ),
1276                    ));
1277                }
1278                Err(e) => probes.push(Probe::error("config", format!("{e}"))),
1279            }
1280            // git-clean check is informational here — the actual gate is
1281            // `[absorb] require_clean_git` on apply; warn so the user
1282            // knows auto-absorb will defer if they have uncommitted work.
1283            match crate::git::is_clean(s) {
1284                Ok(true) => probes.push(Probe::ok("git", "clean")),
1285                Ok(false) => probes.push(Probe::warn(
1286                    "git",
1287                    "uncommitted changes — `[absorb] require_clean_git` will defer auto-absorb",
1288                )),
1289                Err(_) => probes.push(Probe::warn(
1290                    "git",
1291                    "no git repo (auto-absorb still works; commit history won't track drift)",
1292                )),
1293            }
1294        }
1295        Err(e) => {
1296            probes.push(Probe::error("source", format!("not found — {e}")));
1297        }
1298    }
1299
1300    // ── link / render mode ────────────────────────────────────
1301    probes.push(Probe::group("links"));
1302    if cfg!(windows) {
1303        probes.push(Probe::ok(
1304            "default mode",
1305            "files=hardlink, dirs=junction (no admin needed)",
1306        ));
1307    } else {
1308        probes.push(Probe::ok("default mode", "files=symlink, dirs=symlink"));
1309    }
1310
1311    // ── hooks ─────────────────────────────────────────────────
1312    if have_source {
1313        if let (Ok(s), Some(c)) = (&resolved_source, cfg) {
1314            probes.push(Probe::group("hooks"));
1315            if c.hook.is_empty() {
1316                probes.push(Probe::ok("hooks", "(none configured)"));
1317            } else {
1318                let mut missing = 0usize;
1319                for h in &c.hook {
1320                    if !s.join(&h.script).is_file() {
1321                        missing += 1;
1322                        probes.push(Probe::error(
1323                            format!("hook[{}]", h.name),
1324                            format!("script not found at {}", h.script),
1325                        ));
1326                    }
1327                }
1328                if missing == 0 {
1329                    probes.push(Probe::ok(
1330                        "scripts",
1331                        format!(
1332                            "{} hook{} configured, all scripts present",
1333                            c.hook.len(),
1334                            plural(c.hook.len())
1335                        ),
1336                    ));
1337                }
1338            }
1339        }
1340    }
1341
1342    // ── chezmoi cleanup hint ─────────────────────────────────
1343    if let Some(home) = paths::home_dir() {
1344        let chezmoi_src = home.join(".local/share/chezmoi");
1345        if chezmoi_src.is_dir() {
1346            probes.push(Probe::group("chezmoi"));
1347            probes.push(Probe::warn(
1348                "legacy source",
1349                format!(
1350                    "{chezmoi_src} still exists — yui doesn't use it, safe to archive once your migration has settled"
1351                ),
1352            ));
1353        }
1354    }
1355
1356    // Render
1357    println!();
1358    if color {
1359        println!("  {}", "yui doctor".bold().underline());
1360    } else {
1361        println!("  yui doctor");
1362    }
1363    println!();
1364    for probe in &probes {
1365        probe.print(&icons, color);
1366    }
1367
1368    let errors = probes.iter().filter(|p| p.is_error()).count();
1369    let warns = probes.iter().filter(|p| p.is_warn()).count();
1370    let oks = probes.iter().filter(|p| p.is_ok()).count();
1371    println!();
1372    let summary = format!("{oks} ok · {warns} warn · {errors} error");
1373    if color {
1374        if errors > 0 {
1375            println!("  {}", summary.red().bold());
1376        } else if warns > 0 {
1377            println!("  {}", summary.yellow());
1378        } else {
1379            println!("  {}", summary.green());
1380        }
1381    } else {
1382        println!("  {summary}");
1383    }
1384
1385    if errors > 0 {
1386        anyhow::bail!("doctor: {errors} probe(s) failed");
1387    }
1388    Ok(())
1389}
1390
1391#[derive(Debug)]
1392enum Probe {
1393    /// Section divider (just a heading, no severity).
1394    Group(&'static str),
1395    Ok {
1396        label: String,
1397        detail: String,
1398    },
1399    Warn {
1400        label: String,
1401        detail: String,
1402    },
1403    Error {
1404        label: String,
1405        detail: String,
1406    },
1407}
1408
1409impl Probe {
1410    fn group(label: &'static str) -> Self {
1411        Self::Group(label)
1412    }
1413    fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
1414        Self::Ok {
1415            label: label.into(),
1416            detail: detail.into(),
1417        }
1418    }
1419    fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
1420        Self::Warn {
1421            label: label.into(),
1422            detail: detail.into(),
1423        }
1424    }
1425    fn error(label: impl Into<String>, detail: impl Into<String>) -> Self {
1426        Self::Error {
1427            label: label.into(),
1428            detail: detail.into(),
1429        }
1430    }
1431    fn is_ok(&self) -> bool {
1432        matches!(self, Self::Ok { .. })
1433    }
1434    fn is_warn(&self) -> bool {
1435        matches!(self, Self::Warn { .. })
1436    }
1437    fn is_error(&self) -> bool {
1438        matches!(self, Self::Error { .. })
1439    }
1440    fn print(&self, icons: &Icons, color: bool) {
1441        use owo_colors::OwoColorize as _;
1442        match self {
1443            Self::Group(name) => {
1444                println!();
1445                if color {
1446                    println!("  {}", name.cyan().bold());
1447                } else {
1448                    println!("  {name}");
1449                }
1450            }
1451            Self::Ok { label, detail } => {
1452                let icon = icons.ok;
1453                // Pad the raw label first; styling adds invisible ANSI
1454                // bytes that `format!("{:<14}")` would count as visible
1455                // width and silently break alignment between rows.
1456                let padded = format!("{label:<14}");
1457                if color {
1458                    println!(
1459                        "    {}  {}  {}",
1460                        icon.green(),
1461                        padded.bold(),
1462                        detail.dimmed()
1463                    );
1464                } else {
1465                    println!("    {icon}  {padded}  {detail}");
1466                }
1467            }
1468            Self::Warn { label, detail } => {
1469                let icon = icons.warn;
1470                let padded = format!("{label:<14}");
1471                if color {
1472                    println!(
1473                        "    {}  {}  {}",
1474                        icon.yellow(),
1475                        padded.bold().yellow(),
1476                        detail
1477                    );
1478                } else {
1479                    println!("    {icon}  {padded}  {detail}");
1480                }
1481            }
1482            Self::Error { label, detail } => {
1483                let icon = icons.error;
1484                let padded = format!("{label:<14}");
1485                if color {
1486                    println!(
1487                        "    {}  {}  {}",
1488                        icon.red().bold(),
1489                        padded.bold().red(),
1490                        detail.red()
1491                    );
1492                } else {
1493                    println!("    {icon}  {padded}  {detail}");
1494                }
1495            }
1496        }
1497    }
1498}
1499
1500fn plural(n: usize) -> &'static str {
1501    if n == 1 { "" } else { "s" }
1502}
1503
1504/// `yui gc-backup [--older-than DUR] [--dry-run]` — prune snapshots
1505/// under `$DOTFILES/.yui/backup/`.
1506///
1507/// With no `--older-than` we run a non-destructive *survey*: walk the
1508/// backup tree, list every entry whose name carries yui's
1509/// `_<YYYYMMDD_HHMMSSfff>[.<ext>]` suffix, and print AGE / SIZE / PATH
1510/// sorted oldest-first plus a hint to pass `--older-than DUR` to
1511/// actually delete. With `--older-than DUR` (e.g. `30d`, `2w`, `12h`,
1512/// `6m`, `1y`) we delete every entry strictly older than the cutoff.
1513/// `--dry-run` previews the same set without writing.
1514///
1515/// Two design points worth flagging:
1516/// 1. *Suffix, not mtime.* `std::fs::copy` preserves source mtime on
1517///    most platforms, so a backup of an old dotfile would look
1518///    "old" by mtime even when freshly created. The suffix is the
1519///    source of truth for "when did yui take this snapshot?".
1520/// 2. *Defensive parse.* Anything in `.yui/backup/` whose name
1521///    doesn't match the suffix shape is left alone — if you dropped
1522///    a file there by hand, gc-backup isn't going to delete it.
1523pub fn gc_backup(
1524    source: Option<Utf8PathBuf>,
1525    older_than: Option<String>,
1526    dry_run: bool,
1527    icons_override: Option<IconsMode>,
1528    no_color: bool,
1529) -> Result<()> {
1530    let source = resolve_source(source)?;
1531    let yui = YuiVars::detect(&source);
1532    let config = config::load(&source, &yui)?;
1533    let backup_root = source.join(&config.backup.dir);
1534    let icons_mode = icons_override.unwrap_or(config.ui.icons);
1535    let icons = Icons::for_mode(icons_mode);
1536    let color = !no_color && supports_color_stdout();
1537
1538    if !backup_root.is_dir() {
1539        println!("  no backup tree at {backup_root}");
1540        return Ok(());
1541    }
1542
1543    let mut entries = walk_gc_backups(&backup_root)?;
1544    if entries.is_empty() {
1545        println!("  no yui-stamped backups under {backup_root}");
1546        return Ok(());
1547    }
1548    // Oldest first — that's the natural "what should I prune?" order.
1549    entries.sort_by_key(|e| e.ts);
1550    let now = jiff::Zoned::now();
1551
1552    match older_than {
1553        None => {
1554            let refs: Vec<&BackupEntry> = entries.iter().collect();
1555            print_gc_table(&refs, &backup_root, &now, icons, color);
1556            println!();
1557            println!(
1558                "  {} entries · {} total — pass --older-than DUR (e.g. 30d) to delete",
1559                entries.len(),
1560                format_bytes(entries.iter().map(|e| e.size_bytes).sum())
1561            );
1562            Ok(())
1563        }
1564        Some(dur_str) => {
1565            let span = parse_human_duration(&dur_str)?;
1566            let cutoff = now
1567                .checked_sub(span)
1568                .map_err(|e| anyhow::anyhow!("invalid duration {dur_str:?}: {e}"))?;
1569            let cutoff_dt = cutoff.datetime();
1570
1571            let total_before: u64 = entries.iter().map(|e| e.size_bytes).sum();
1572            let to_delete: Vec<&BackupEntry> =
1573                entries.iter().filter(|e| e.ts < cutoff_dt).collect();
1574
1575            if to_delete.is_empty() {
1576                println!(
1577                    "  no backups older than {dur_str} (oldest: {})",
1578                    format_age(entries[0].ts, &now)
1579                );
1580                return Ok(());
1581            }
1582
1583            print_gc_table(&to_delete, &backup_root, &now, icons, color);
1584            println!();
1585            let total_freed: u64 = to_delete.iter().map(|e| e.size_bytes).sum();
1586
1587            if dry_run {
1588                println!(
1589                    "  [dry-run] would remove {} of {} entries · would free {} of {}",
1590                    to_delete.len(),
1591                    entries.len(),
1592                    format_bytes(total_freed),
1593                    format_bytes(total_before),
1594                );
1595                return Ok(());
1596            }
1597
1598            for entry in &to_delete {
1599                match entry.kind {
1600                    BackupKind::File => std::fs::remove_file(&entry.path)?,
1601                    BackupKind::Dir => std::fs::remove_dir_all(&entry.path)?,
1602                }
1603                if let Some(parent) = entry.path.parent() {
1604                    cleanup_empty_parents(parent, &backup_root);
1605                }
1606            }
1607            println!(
1608                "  removed {} of {} entries · freed {} (was {}, now {})",
1609                to_delete.len(),
1610                entries.len(),
1611                format_bytes(total_freed),
1612                format_bytes(total_before),
1613                format_bytes(total_before - total_freed),
1614            );
1615            Ok(())
1616        }
1617    }
1618}
1619
1620#[derive(Debug)]
1621struct BackupEntry {
1622    path: Utf8PathBuf,
1623    ts: jiff::civil::DateTime,
1624    kind: BackupKind,
1625    size_bytes: u64,
1626}
1627
1628#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1629enum BackupKind {
1630    File,
1631    Dir,
1632}
1633
1634/// Recursive walk that recognises directory backups as one unit
1635/// (so we don't descend into `<dirname>_<ts>/` and surface its
1636/// individual files — the whole subtree is one snapshot). Files
1637/// without a yui suffix are silently skipped.
1638fn walk_gc_backups(root: &Utf8Path) -> Result<Vec<BackupEntry>> {
1639    let mut out = Vec::new();
1640    walk_gc_backups_rec(root, &mut out)?;
1641    Ok(out)
1642}
1643
1644fn walk_gc_backups_rec(dir: &Utf8Path, out: &mut Vec<BackupEntry>) -> Result<()> {
1645    for entry in std::fs::read_dir(dir)? {
1646        let entry = entry?;
1647        let name_os = entry.file_name();
1648        let Some(name) = name_os.to_str() else {
1649            continue;
1650        };
1651        let path = dir.join(name);
1652        let ft = entry.file_type()?;
1653        if ft.is_dir() {
1654            if let Some(ts) = parse_backup_suffix(name) {
1655                let size = dir_size(&path)?;
1656                out.push(BackupEntry {
1657                    path,
1658                    ts,
1659                    kind: BackupKind::Dir,
1660                    size_bytes: size,
1661                });
1662            } else {
1663                walk_gc_backups_rec(&path, out)?;
1664            }
1665        } else if ft.is_file() {
1666            // Nested ifs (not let-chains) so the crate's MSRV
1667            // (rust-version = "1.85") stays buildable.
1668            if let Some(ts) = parse_backup_suffix(name) {
1669                let size = entry.metadata()?.len();
1670                out.push(BackupEntry {
1671                    path,
1672                    ts,
1673                    kind: BackupKind::File,
1674                    size_bytes: size,
1675                });
1676            }
1677        }
1678    }
1679    Ok(())
1680}
1681
1682fn dir_size(dir: &Utf8Path) -> Result<u64> {
1683    let mut total: u64 = 0;
1684    for entry in std::fs::read_dir(dir)? {
1685        let entry = entry?;
1686        let ft = entry.file_type()?;
1687        if ft.is_dir() {
1688            let p = match Utf8PathBuf::from_path_buf(entry.path()) {
1689                Ok(p) => p,
1690                Err(_) => continue,
1691            };
1692            total = total.saturating_add(dir_size(&p)?);
1693        } else if ft.is_file() {
1694            total = total.saturating_add(entry.metadata()?.len());
1695        }
1696    }
1697    Ok(total)
1698}
1699
1700/// Walk up from `start` toward `root`, removing any directory that
1701/// has become empty as a result of a deletion. Stops at the first
1702/// non-empty parent and never touches `root` itself.
1703fn cleanup_empty_parents(start: &Utf8Path, root: &Utf8Path) {
1704    let mut cur = start.to_path_buf();
1705    loop {
1706        if cur == *root {
1707            return;
1708        }
1709        // remove_dir succeeds only if the directory is empty.
1710        if std::fs::remove_dir(&cur).is_err() {
1711            return;
1712        }
1713        match cur.parent() {
1714            Some(p) => cur = p.to_path_buf(),
1715            None => return,
1716        }
1717    }
1718}
1719
1720/// Parse a yui backup name. Two shapes:
1721///   - `<stem>_<YYYYMMDD_HHMMSSfff>`            (dirs / dotfiles / no-ext)
1722///   - `<stem>_<YYYYMMDD_HHMMSSfff>.<ext>`      (files with extension)
1723///
1724/// Returns the timestamp on success, `None` for anything else.
1725fn parse_backup_suffix(name: &str) -> Option<jiff::civil::DateTime> {
1726    if let Some(ts) = parse_ts_at_end(name) {
1727        return Some(ts);
1728    }
1729    // Nested ifs (not let-chains) so the crate's MSRV
1730    // (rust-version = "1.85") stays buildable.
1731    if let Some((before, _ext)) = name.rsplit_once('.') {
1732        if let Some(ts) = parse_ts_at_end(before) {
1733            return Some(ts);
1734        }
1735    }
1736    None
1737}
1738
1739fn parse_ts_at_end(s: &str) -> Option<jiff::civil::DateTime> {
1740    // Need at least 1 stem char + `_` + 18-char timestamp.
1741    if s.len() < 20 {
1742        return None;
1743    }
1744    let split_at = s.len() - 19;
1745    if s.as_bytes()[split_at] != b'_' {
1746        return None;
1747    }
1748    parse_ts(&s[split_at + 1..])
1749}
1750
1751/// Parse exactly `YYYYMMDD_HHMMSSfff`.
1752fn parse_ts(s: &str) -> Option<jiff::civil::DateTime> {
1753    if s.len() != 18 || s.as_bytes()[8] != b'_' {
1754        return None;
1755    }
1756    for (i, &b) in s.as_bytes().iter().enumerate() {
1757        if i == 8 {
1758            continue;
1759        }
1760        if !b.is_ascii_digit() {
1761            return None;
1762        }
1763    }
1764    let year: i16 = s[0..4].parse().ok()?;
1765    let month: i8 = s[4..6].parse().ok()?;
1766    let day: i8 = s[6..8].parse().ok()?;
1767    let hour: i8 = s[9..11].parse().ok()?;
1768    let minute: i8 = s[11..13].parse().ok()?;
1769    let second: i8 = s[13..15].parse().ok()?;
1770    let ms: i32 = s[15..18].parse().ok()?;
1771    jiff::civil::DateTime::new(year, month, day, hour, minute, second, ms * 1_000_000).ok()
1772}
1773
1774/// Parse a duration string in the shorthand `30d`, `2w`, `12h`,
1775/// `6mo` (months), `1y`, `5m` (minutes). Whitespace around the
1776/// number is tolerated; the unit is case-insensitive.
1777///
1778/// `m` means **minutes**, `mo` means **months** — bare `m` matches
1779/// what `format_age` prints in the survey table, so a backup
1780/// shown as "5m" is pruneable as `--older-than 5m`. Months take
1781/// the explicit `mo` form. (Caught in PR #51 review.)
1782fn parse_human_duration(s: &str) -> Result<jiff::Span> {
1783    let s = s.trim();
1784    let split = s
1785        .bytes()
1786        .position(|b| b.is_ascii_alphabetic())
1787        .ok_or_else(|| anyhow::anyhow!("invalid duration {s:?}: missing unit (e.g. 30d, 2w)"))?;
1788    let n: i64 = s[..split]
1789        .trim()
1790        .parse()
1791        .map_err(|_| anyhow::anyhow!("invalid duration {s:?}: bad leading number"))?;
1792    if n < 0 {
1793        anyhow::bail!("invalid duration {s:?}: negative durations don't make sense");
1794    }
1795    let unit = s[split..].to_ascii_lowercase();
1796    let span = match unit.as_str() {
1797        "y" | "yr" | "year" | "years" => jiff::Span::new().years(n),
1798        "mo" | "month" | "months" => jiff::Span::new().months(n),
1799        "w" | "wk" | "week" | "weeks" => jiff::Span::new().weeks(n),
1800        "d" | "day" | "days" => jiff::Span::new().days(n),
1801        "h" | "hr" | "hour" | "hours" => jiff::Span::new().hours(n),
1802        "m" | "min" | "minute" | "minutes" => jiff::Span::new().minutes(n),
1803        other => {
1804            anyhow::bail!(
1805                "invalid duration {s:?}: unknown unit {other:?} \
1806                 (use y / mo / w / d / h / m)"
1807            )
1808        }
1809    };
1810    Ok(span)
1811}
1812
1813fn format_bytes(n: u64) -> String {
1814    const KIB: u64 = 1024;
1815    const MIB: u64 = KIB * 1024;
1816    const GIB: u64 = MIB * 1024;
1817    if n >= GIB {
1818        format!("{:.1} GiB", n as f64 / GIB as f64)
1819    } else if n >= MIB {
1820        format!("{:.1} MiB", n as f64 / MIB as f64)
1821    } else if n >= KIB {
1822        format!("{:.1} KiB", n as f64 / KIB as f64)
1823    } else {
1824        format!("{n} B")
1825    }
1826}
1827
1828fn format_age(ts: jiff::civil::DateTime, now: &jiff::Zoned) -> String {
1829    let Ok(ts_zoned) = ts.to_zoned(now.time_zone().clone()) else {
1830        return "?".into();
1831    };
1832    let secs = match (now - &ts_zoned).total(jiff::Unit::Second) {
1833        Ok(s) => s as i64,
1834        Err(_) => return "?".into(),
1835    };
1836    if secs < 0 {
1837        return "future".into();
1838    }
1839    if secs < 60 {
1840        format!("{secs}s")
1841    } else if secs < 3600 {
1842        format!("{}m", secs / 60)
1843    } else if secs < 86_400 {
1844        format!("{}h", secs / 3600)
1845    } else if secs < 86_400 * 30 {
1846        format!("{}d", secs / 86_400)
1847    } else if secs < 86_400 * 365 {
1848        format!("{}mo", secs / (86_400 * 30))
1849    } else {
1850        format!("{}y", secs / (86_400 * 365))
1851    }
1852}
1853
1854/// Render a borrowed slice of `BackupEntry`s as an AGE / SIZE / PATH
1855/// table. Trailing `/` on the path marks dir backups (filesystem
1856/// convention) so the kind is visible without a dedicated column.
1857/// `_icons` is currently unused but kept on the signature so future
1858/// table changes can adopt new glyphs without rippling through every
1859/// caller.
1860fn print_gc_table(
1861    entries: &[&BackupEntry],
1862    backup_root: &Utf8Path,
1863    now: &jiff::Zoned,
1864    _icons: Icons,
1865    color: bool,
1866) {
1867    use owo_colors::OwoColorize as _;
1868
1869    let rows: Vec<(String, String, String)> = entries
1870        .iter()
1871        .map(|e| {
1872            let rel = e
1873                .path
1874                .strip_prefix(backup_root)
1875                .map(Utf8PathBuf::from)
1876                .unwrap_or_else(|_| e.path.clone());
1877            let path_disp = match e.kind {
1878                BackupKind::Dir => format!("{rel}/"),
1879                BackupKind::File => rel.to_string(),
1880            };
1881            (format_age(e.ts, now), format_bytes(e.size_bytes), path_disp)
1882        })
1883        .collect();
1884
1885    let age_w = rows.iter().map(|r| r.0.len()).max().unwrap_or(3);
1886    let size_w = rows.iter().map(|r| r.1.len()).max().unwrap_or(4);
1887
1888    if color {
1889        println!(
1890            "  {:<age_w$}  {:>size_w$}  {}",
1891            "AGE".dimmed(),
1892            "SIZE".dimmed(),
1893            "PATH".dimmed(),
1894        );
1895    } else {
1896        println!("  {:<age_w$}  {:>size_w$}  PATH", "AGE", "SIZE");
1897    }
1898    for (age, size, path) in &rows {
1899        if color {
1900            println!(
1901                "  {:<age_w$}  {:>size_w$}  {}",
1902                age.yellow(),
1903                size,
1904                path.cyan(),
1905            );
1906        } else {
1907            println!("  {:<age_w$}  {:>size_w$}  {}", age, size, path);
1908        }
1909    }
1910}
1911
1912/// `yui hooks list` — show every configured hook + its last-run state.
1913pub fn hooks_list(
1914    source: Option<Utf8PathBuf>,
1915    icons_override: Option<IconsMode>,
1916    no_color: bool,
1917) -> Result<()> {
1918    let source = resolve_source(source)?;
1919    let yui = YuiVars::detect(&source);
1920    let config = config::load(&source, &yui)?;
1921    let state = hook::State::load(&source)?;
1922
1923    let icons_mode = icons_override.unwrap_or(config.ui.icons);
1924    let icons = Icons::for_mode(icons_mode);
1925    let color = !no_color && supports_color_stdout();
1926
1927    if config.hook.is_empty() {
1928        println!("(no [[hook]] entries in config)");
1929        return Ok(());
1930    }
1931
1932    // Pre-evaluate the `when` filter for every hook so the status icon
1933    // can distinguish "skipped because the OS gate is false" from
1934    // "active but never run".
1935    let mut engine = template::Engine::new();
1936    let tera_ctx = template::template_context(&yui, &config.vars);
1937    let rows: Vec<HookRow> = config
1938        .hook
1939        .iter()
1940        .map(|h| -> Result<HookRow> {
1941            // Propagate Tera errors instead of silently coercing them
1942            // to "inactive" — a syntax error in the user's `when`
1943            // expression should surface, not hide.
1944            let active = match &h.when {
1945                None => true,
1946                Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
1947            };
1948            let last_run_at = state.hooks.get(&h.name).and_then(|s| s.last_run_at.clone());
1949            Ok(HookRow {
1950                name: h.name.clone(),
1951                phase: match h.phase {
1952                    HookPhase::Pre => "pre",
1953                    HookPhase::Post => "post",
1954                },
1955                when_run: match h.when_run {
1956                    config::WhenRun::Once => "once",
1957                    config::WhenRun::Onchange => "onchange",
1958                    config::WhenRun::Every => "every",
1959                },
1960                last_run_at,
1961                when: h.when.clone(),
1962                active,
1963            })
1964        })
1965        .collect::<Result<Vec<_>>>()?;
1966
1967    print_hooks_table(&rows, icons, color);
1968
1969    let total = rows.len();
1970    let active = rows.iter().filter(|r| r.active).count();
1971    let inactive = total - active;
1972    let ran = rows.iter().filter(|r| r.last_run_at.is_some()).count();
1973    let never = total - ran;
1974    println!();
1975    println!(
1976        "  {total} hooks · {active} active · {inactive} inactive · {ran} ran · {never} never run"
1977    );
1978
1979    Ok(())
1980}
1981
1982#[derive(Debug)]
1983struct HookRow {
1984    name: String,
1985    phase: &'static str,
1986    when_run: &'static str,
1987    last_run_at: Option<String>,
1988    when: Option<String>,
1989    active: bool,
1990}
1991
1992fn print_hooks_table(rows: &[HookRow], icons: Icons, color: bool) {
1993    use owo_colors::OwoColorize as _;
1994    use std::fmt::Write as _;
1995
1996    let name_w = rows
1997        .iter()
1998        .map(|r| r.name.chars().count())
1999        .max()
2000        .unwrap_or(0)
2001        .max("NAME".len());
2002    let phase_w = rows
2003        .iter()
2004        .map(|r| r.phase.len())
2005        .max()
2006        .unwrap_or(0)
2007        .max("PHASE".len());
2008    let when_run_w = rows
2009        .iter()
2010        .map(|r| r.when_run.len())
2011        .max()
2012        .unwrap_or(0)
2013        .max("WHEN_RUN".len());
2014    let last_w = rows
2015        .iter()
2016        .map(|r| {
2017            r.last_run_at
2018                .as_deref()
2019                .map(|s| s.chars().count())
2020                .unwrap_or("(never)".len())
2021        })
2022        .max()
2023        .unwrap_or(0)
2024        .max("LAST_RUN".len());
2025    let status_w = "STATUS".len();
2026
2027    // Header
2028    let mut header = String::new();
2029    let _ = write!(
2030        &mut header,
2031        "  {:<status_w$}  {:<name_w$}  {:<phase_w$}  {:<when_run_w$}  {:<last_w$}  WHEN",
2032        "STATUS", "NAME", "PHASE", "WHEN_RUN", "LAST_RUN"
2033    );
2034    if color {
2035        println!("{}", header.bold());
2036    } else {
2037        println!("{header}");
2038    }
2039
2040    // Separator (re-uses the same sep glyph the list / status table picks).
2041    let bar = |n: usize| icons.sep.to_string().repeat(n);
2042    let sep = format!(
2043        "  {}  {}  {}  {}  {}  {}",
2044        bar(status_w),
2045        bar(name_w),
2046        bar(phase_w),
2047        bar(when_run_w),
2048        bar(last_w),
2049        bar("WHEN".len())
2050    );
2051    if color {
2052        println!("{}", sep.dimmed());
2053    } else {
2054        println!("{sep}");
2055    }
2056
2057    // Rows
2058    for r in rows {
2059        // Status icon picks one of three states. We could expand this
2060        // (✗ failed, ↻ would-rerun-via-onchange-hash) once `hooks list`
2061        // grows enough fields to justify it; today's set is enough to
2062        // make the table scannable.
2063        let (icon, ran) = match (r.active, r.last_run_at.is_some()) {
2064            (false, _) => (icons.inactive, false),
2065            (true, true) => (icons.active, true),
2066            (true, false) => (icons.info, false),
2067        };
2068        let last = r.last_run_at.as_deref().unwrap_or("(never)");
2069        let when_str = r
2070            .when
2071            .as_deref()
2072            .map(strip_braces)
2073            .unwrap_or_else(|| "(always)".to_string());
2074
2075        let cell_status = format!("{icon:<status_w$}");
2076        let cell_name = format!("{:<name_w$}", r.name);
2077        let cell_phase = format!("{:<phase_w$}", r.phase);
2078        let cell_when_run = format!("{:<when_run_w$}", r.when_run);
2079        let cell_last = format!("{last:<last_w$}");
2080
2081        if !color {
2082            println!(
2083                "  {cell_status}  {cell_name}  {cell_phase}  {cell_when_run}  {cell_last}  {when_str}"
2084            );
2085            continue;
2086        }
2087
2088        // Active+ran: green status, bold name. Active-but-never: yellow
2089        // status (the "🆕 new — apply hasn't ticked it" signal). Inactive
2090        // (when-false): dimmed across the row.
2091        if !r.active {
2092            println!(
2093                "  {}  {}  {}  {}  {}  {}",
2094                cell_status.dimmed(),
2095                cell_name.dimmed(),
2096                cell_phase.dimmed(),
2097                cell_when_run.dimmed(),
2098                cell_last.dimmed(),
2099                when_str.dimmed()
2100            );
2101        } else if ran {
2102            println!(
2103                "  {}  {}  {}  {}  {}  {}",
2104                cell_status.green(),
2105                cell_name.cyan().bold(),
2106                cell_phase.dimmed(),
2107                cell_when_run.dimmed(),
2108                cell_last.green(),
2109                when_str.dimmed()
2110            );
2111        } else {
2112            println!(
2113                "  {}  {}  {}  {}  {}  {}",
2114                cell_status.yellow(),
2115                cell_name.cyan().bold(),
2116                cell_phase.dimmed(),
2117                cell_when_run.dimmed(),
2118                cell_last.yellow(),
2119                when_str.dimmed()
2120            );
2121        }
2122    }
2123}
2124
2125/// `yui hooks run [<name>] [--force]` — run a single hook (or every
2126/// hook) on demand. `--force` bypasses the `when_run` state check;
2127/// the `when` filter (`yui.os == 'macos'` etc.) is always honored.
2128pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
2129    let source = resolve_source(source)?;
2130    let yui = YuiVars::detect(&source);
2131    let config = config::load(&source, &yui)?;
2132    let mut engine = template::Engine::new();
2133    let tera_ctx = template::template_context(&yui, &config.vars);
2134
2135    let targets: Vec<&config::HookConfig> = match &name {
2136        Some(want) => {
2137            let m = config
2138                .hook
2139                .iter()
2140                .find(|h| &h.name == want)
2141                .ok_or_else(|| {
2142                    anyhow::anyhow!(
2143                        "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
2144                    )
2145                })?;
2146            vec![m]
2147        }
2148        None => config.hook.iter().collect(),
2149    };
2150
2151    let mut state = hook::State::load(&source)?;
2152    for h in targets {
2153        let outcome = hook::run_hook(
2154            h,
2155            &source,
2156            &yui,
2157            &config.vars,
2158            &mut engine,
2159            &tera_ctx,
2160            &mut state,
2161            /* dry_run */ false,
2162            force,
2163        )?;
2164        let label = match outcome {
2165            HookOutcome::Ran => "ran",
2166            HookOutcome::SkippedOnce => "skipped (once: already ran)",
2167            HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
2168            HookOutcome::SkippedWhenFalse => "skipped (when=false)",
2169            HookOutcome::DryRun => "would run (dry-run)",
2170        };
2171        info!("hook[{}]: {label}", h.name);
2172        if outcome == HookOutcome::Ran {
2173            state.save(&source)?;
2174        }
2175    }
2176    Ok(())
2177}
2178
2179// ---------------------------------------------------------------------------
2180// internals
2181// ---------------------------------------------------------------------------
2182
2183#[allow(clippy::too_many_arguments)]
2184fn process_mount(
2185    source: &Utf8Path,
2186    m: &ResolvedMount,
2187    ctx: &ApplyCtx<'_>,
2188    engine: &mut template::Engine,
2189    tera_ctx: &TeraContext,
2190    yuiignore: &mut paths::YuiIgnoreStack,
2191) -> Result<()> {
2192    let src_root = source.join(&m.src);
2193    if !src_root.is_dir() {
2194        warn!("mount src missing: {src_root}");
2195        return Ok(());
2196    }
2197    walk_and_link(
2198        &src_root, &m.dst, ctx, m.strategy, engine, tera_ctx, yuiignore, false,
2199    )
2200}
2201
2202#[allow(clippy::too_many_arguments)]
2203fn walk_and_link(
2204    src_dir: &Utf8Path,
2205    dst_dir: &Utf8Path,
2206    ctx: &ApplyCtx<'_>,
2207    strategy: MountStrategy,
2208    engine: &mut template::Engine,
2209    tera_ctx: &TeraContext,
2210    yuiignore: &mut paths::YuiIgnoreStack,
2211    parent_covered: bool,
2212) -> Result<()> {
2213    // `.yuiignore` short-circuit — entire subtrees that match are skipped
2214    // without even reading their marker / iterating their children.
2215    if yuiignore.is_ignored(src_dir, /* is_dir */ true) {
2216        return Ok(());
2217    }
2218    // Layer this dir's `.yuiignore` (if any) on top, run the body, pop
2219    // before returning so siblings don't see our subtree's rules.
2220    yuiignore.push_dir(src_dir)?;
2221    let result = walk_and_link_body(
2222        src_dir,
2223        dst_dir,
2224        ctx,
2225        strategy,
2226        engine,
2227        tera_ctx,
2228        yuiignore,
2229        parent_covered,
2230    );
2231    yuiignore.pop_dir(src_dir);
2232    result
2233}
2234
2235#[allow(clippy::too_many_arguments)]
2236fn walk_and_link_body(
2237    src_dir: &Utf8Path,
2238    dst_dir: &Utf8Path,
2239    ctx: &ApplyCtx<'_>,
2240    strategy: MountStrategy,
2241    engine: &mut template::Engine,
2242    tera_ctx: &TeraContext,
2243    yuiignore: &mut paths::YuiIgnoreStack,
2244    parent_covered: bool,
2245) -> Result<()> {
2246    let marker_filename = &ctx.config.mount.marker_filename;
2247    let mut covered = parent_covered;
2248
2249    if strategy == MountStrategy::Marker {
2250        match marker::read_spec(src_dir, marker_filename)? {
2251            None => {} // no marker — fall through to recursive walk
2252            Some(MarkerSpec::PassThrough) => {
2253                // Empty marker = junction this dir at the natural
2254                // mount-derived dst. Subsequent recursion keeps going so
2255                // descendant markers can layer on extra dsts.
2256                link_dir_with_backup(src_dir, dst_dir, ctx)?;
2257                covered = true;
2258            }
2259            Some(MarkerSpec::Explicit { links }) => {
2260                let mut emitted_dir_link = false;
2261                let mut emitted_any = false;
2262                for link in &links {
2263                    // Nested ifs (not let-chains) so the crate's MSRV
2264                    // (rust-version = "1.85") stays buildable.
2265                    if let Some(when) = &link.when {
2266                        if !template::eval_truthy(when, engine, tera_ctx)? {
2267                            continue;
2268                        }
2269                    }
2270                    let dst_str = engine.render(&link.dst, tera_ctx)?;
2271                    let dst = paths::expand_tilde(dst_str.trim());
2272                    if let Some(filename) = &link.src {
2273                        let file_src = src_dir.join(filename);
2274                        if !file_src.is_file() {
2275                            anyhow::bail!(
2276                                "marker at {src_dir}: [[link]] src={filename:?} \
2277                                 not found"
2278                            );
2279                        }
2280                        link_file_with_backup(&file_src, &dst, ctx)?;
2281                    } else {
2282                        link_dir_with_backup(src_dir, &dst, ctx)?;
2283                        emitted_dir_link = true;
2284                    }
2285                    emitted_any = true;
2286                }
2287                if !emitted_any {
2288                    // v0.6+ semantics: with no active links, the walker
2289                    // still descends and per-file defaults still apply.
2290                    // Phrase it so users don't read "skipping" as
2291                    // "subtree blocked" (the v0.5 behaviour).
2292                    info!(
2293                        "marker at {src_dir} had no active links \
2294                         — falling back to defaults"
2295                    );
2296                }
2297                if emitted_dir_link {
2298                    covered = true;
2299                }
2300            }
2301        }
2302    }
2303
2304    for entry in std::fs::read_dir(src_dir)? {
2305        let entry = entry?;
2306        let name_os = entry.file_name();
2307        let Some(name) = name_os.to_str() else {
2308            continue;
2309        };
2310        if name == marker_filename {
2311            continue;
2312        }
2313        if name.ends_with(".tera") {
2314            // Templates are handled by the render flow before linking.
2315            continue;
2316        }
2317        let src_path = src_dir.join(name);
2318        let dst_path = dst_dir.join(name);
2319        let ft = entry.file_type()?;
2320
2321        if yuiignore.is_ignored(&src_path, ft.is_dir()) {
2322            continue;
2323        }
2324
2325        if ft.is_dir() {
2326            walk_and_link(
2327                &src_path, &dst_path, ctx, strategy, engine, tera_ctx, yuiignore, covered,
2328            )?;
2329        } else if ft.is_file() {
2330            // If an ancestor (or this dir itself) created a dir-level
2331            // junction, the file is already accessible via that junction
2332            // — emitting another per-file link would just duplicate work
2333            // (and on Windows might land at a path that's already
2334            // hard-linked through the parent).
2335            if !covered {
2336                link_file_with_backup(&src_path, &dst_path, ctx)?;
2337            }
2338        }
2339    }
2340    Ok(())
2341}
2342
2343fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2344    use absorb::AbsorbDecision::*;
2345
2346    let decision = absorb::classify(src, dst)?;
2347
2348    if ctx.dry_run {
2349        info!("[dry-run] {decision:?}: {src} → {dst}");
2350        return Ok(());
2351    }
2352
2353    match decision {
2354        InSync => {
2355            // Link is intact (same inode/file-id). Nothing to do.
2356            Ok(())
2357        }
2358        Restore => {
2359            info!("link: {src} → {dst}");
2360            link::link_file(src, dst, ctx.file_mode)?;
2361            Ok(())
2362        }
2363        RelinkOnly => {
2364            // Same content, different inode (e.g. hardlink broken by an
2365            // editor's atomic save). Re-link without touching source.
2366            info!("relink: {src} → {dst}");
2367            link::unlink(dst)?;
2368            link::link_file(src, dst, ctx.file_mode)?;
2369            Ok(())
2370        }
2371        AutoAbsorb => {
2372            // Target newer + content differs: target wins, source updated.
2373            // Honor `[absorb] auto` (kill-switch) and `require_clean_git`.
2374            if !ctx.config.absorb.auto {
2375                return handle_anomaly(
2376                    src,
2377                    dst,
2378                    ctx,
2379                    "absorb.auto = false; treating divergence as anomaly",
2380                );
2381            }
2382            if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
2383                return handle_anomaly(
2384                    src,
2385                    dst,
2386                    ctx,
2387                    "source repo is dirty; deferring auto-absorb",
2388                );
2389            }
2390            absorb_target_into_source(src, dst, ctx)
2391        }
2392        NeedsConfirm => handle_anomaly(
2393            src,
2394            dst,
2395            ctx,
2396            "anomaly: source equals/newer than target but content differs",
2397        ),
2398    }
2399}
2400
2401/// Back up the source-side file, copy the target's content into source,
2402/// then re-link so the freshly-updated source is what target points at.
2403/// "Target wins" — yui's core philosophy.
2404fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2405    info!("absorb: {dst} → {src}");
2406    backup_existing(src, ctx.backup_root, /* is_dir */ false)?;
2407    std::fs::copy(dst, src)?;
2408    link::unlink(dst)?;
2409    link::link_file(src, dst, ctx.file_mode)?;
2410    Ok(())
2411}
2412
2413/// Decide what to do for an anomaly (NeedsConfirm or AutoAbsorb that was
2414/// escalated by `auto = false` / dirty git). Per `[absorb] on_anomaly`:
2415///   - `skip`  → log warning, leave target alone
2416///   - `force` → behave like AutoAbsorb (target wins)
2417///   - `ask`   → on a TTY, show diff + prompt. Off-TTY, downgrade to skip.
2418fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
2419    use crate::config::AnomalyAction::*;
2420    match ctx.config.absorb.on_anomaly {
2421        Skip => {
2422            warn!("anomaly skip: {dst} ({reason})");
2423            Ok(())
2424        }
2425        Force => {
2426            warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
2427            absorb_target_into_source(src, dst, ctx)
2428        }
2429        Ask => {
2430            use std::io::IsTerminal;
2431            if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
2432                if prompt_absorb_with_diff(src, dst, reason)? {
2433                    absorb_target_into_source(src, dst, ctx)
2434                } else {
2435                    warn!("anomaly skipped by user: {dst}");
2436                    Ok(())
2437                }
2438            } else {
2439                warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
2440                Ok(())
2441            }
2442        }
2443    }
2444}
2445
2446fn prompt_absorb_with_diff(src: &Utf8Path, dst: &Utf8Path, reason: &str) -> Result<bool> {
2447    use std::io::Write as _;
2448    let src_content = std::fs::read_to_string(src).unwrap_or_default();
2449    let dst_content = std::fs::read_to_string(dst).unwrap_or_default();
2450    eprintln!();
2451    eprintln!("anomaly: {reason}");
2452    eprintln!("  src: {src}");
2453    eprintln!("  dst: {dst}");
2454    eprintln!();
2455    eprintln!("--- diff (- source, + target) ---");
2456    let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
2457    for change in diff.iter_all_changes() {
2458        let sign = match change.tag() {
2459            similar::ChangeTag::Delete => "-",
2460            similar::ChangeTag::Insert => "+",
2461            similar::ChangeTag::Equal => " ",
2462        };
2463        eprint!("{sign}{change}");
2464    }
2465    eprintln!();
2466    eprint!("absorb target into source? [y/N]: ");
2467    // Flush stderr (where the prompt was written) — flushing stdout was a
2468    // bug; on a buffered stderr (rare but possible) the prompt would be
2469    // hidden until after the user typed something. Caught in PR #15
2470    // review (gemini-code-assist).
2471    std::io::stderr().flush().ok();
2472    let mut input = String::new();
2473    std::io::stdin().read_line(&mut input)?;
2474    let answer = input.trim();
2475    Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
2476}
2477
2478/// Resilient git-clean check: if `git` isn't available or `source` isn't
2479/// a repo, log a warning and proceed as if clean. We don't want a missing
2480/// `git` to block apply — the require_clean_git knob is a *safety net*,
2481/// not a hard prerequisite.
2482fn source_repo_is_clean(source: &Utf8Path) -> bool {
2483    match crate::git::is_clean(source) {
2484        Ok(b) => b,
2485        Err(e) => {
2486            warn!("git clean check failed at {source}: {e} — treating as clean");
2487            true
2488        }
2489    }
2490}
2491
2492fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2493    use absorb::AbsorbDecision::*;
2494    let decision = absorb::classify(src, dst)?;
2495
2496    if ctx.dry_run {
2497        info!("[dry-run] dir {decision:?}: {src} → {dst}");
2498        return Ok(());
2499    }
2500
2501    match decision {
2502        InSync => Ok(()),
2503        Restore => {
2504            info!("link dir: {src} → {dst}");
2505            link::link_dir(src, dst, ctx.dir_mode)?;
2506            Ok(())
2507        }
2508        RelinkOnly => {
2509            // For dirs the classifier doesn't currently produce
2510            // `RelinkOnly` (only InSync / NeedsConfirm), but handle it
2511            // for symmetry with the file path: contents already match,
2512            // so just swap the target for a junction to source.
2513            info!("relink dir: {src} → {dst}");
2514            remove_dir_link_or_real(dst)?;
2515            link::link_dir(src, dst, ctx.dir_mode)?;
2516            Ok(())
2517        }
2518        AutoAbsorb | NeedsConfirm => {
2519            // Reaching `link_dir_with_backup` means we're acting on a
2520            // `.yuilink` marker (or a `[[mount.entry]]` whose `src` is a
2521            // directory) — the user has explicitly opted into
2522            // "this whole subtree is target-as-truth". A dir-level
2523            // NeedsConfirm here is therefore *not* the same kind of
2524            // anomaly that file-level NeedsConfirm represents (a single
2525            // file the user edited and source got newer); it's just
2526            // "source and target dirs are different inodes" — the
2527            // marker already authorised us to merge.
2528            //
2529            // Per-file content conflicts *inside* the merge are still
2530            // a real concern (target has X, source has X with
2531            // different content). Those are surfaced from inside the
2532            // merge itself — see `merge_dir_target_into_source`'s
2533            // file-level dispatch — so the outer-dir decision falls
2534            // straight through to absorb.
2535            //
2536            // The `auto` / `require_clean_git` knobs still gate, so
2537            // turning them off restores the prompt before any
2538            // whole-dir absorb.
2539            if !ctx.config.absorb.auto {
2540                return handle_anomaly_dir(
2541                    src,
2542                    dst,
2543                    ctx,
2544                    "absorb.auto = false; treating divergence as anomaly",
2545                );
2546            }
2547            if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
2548                return handle_anomaly_dir(
2549                    src,
2550                    dst,
2551                    ctx,
2552                    "source repo is dirty; deferring auto-absorb",
2553                );
2554            }
2555            absorb_target_dir_into_source(src, dst, ctx)
2556        }
2557    }
2558}
2559
2560/// `link::unlink` with a documented fallback for the chezmoi-migration
2561/// shape: target is a real (non-link) directory packed with files. The
2562/// caller is responsible for ensuring the target's prior content is
2563/// preserved (in `.yui/backup/...` or because we just merged it into
2564/// source) before reaching here.
2565///
2566/// Anything other than the "non-empty regular dir" case — permission
2567/// denied, target gone, target now a junction or symlink — propagates
2568/// rather than being silently coerced into `remove_dir_all`.
2569fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
2570    if let Err(unlink_err) = link::unlink(dst) {
2571        let meta = std::fs::symlink_metadata(dst)
2572            .with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
2573        let ft = meta.file_type();
2574        if ft.is_dir() && !ft.is_symlink() {
2575            std::fs::remove_dir_all(dst).with_context(|| {
2576                format!(
2577                    "remove_dir_all({dst}) after link::unlink failed: \
2578                     {unlink_err}"
2579                )
2580            })?;
2581        } else {
2582            return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
2583        }
2584    }
2585    Ok(())
2586}
2587
2588/// Recursively merge target's files into source: target wins on file
2589/// conflicts, source-only files are preserved, sub-dirs are created
2590/// in source as needed. Non-regular entries (symlinks / junctions /
2591/// device files) are skipped with a warning — copying their content
2592/// is ill-defined and following them risks looping into target via
2593/// some chain back to source.
2594///
2595/// Mirrors the file-level "AutoAbsorb backs up source, copies target's
2596/// content into source before relinking" semantic for whole dirs.
2597fn merge_dir_target_into_source(
2598    target: &Utf8Path,
2599    source: &Utf8Path,
2600    ctx: &ApplyCtx<'_>,
2601) -> Result<()> {
2602    for entry in std::fs::read_dir(target)? {
2603        let entry = entry?;
2604        let name_os = entry.file_name();
2605        let Some(name) = name_os.to_str() else {
2606            continue;
2607        };
2608        let target_path = target.join(name);
2609        let source_path = source.join(name);
2610        let ft = entry.file_type()?;
2611
2612        if ft.is_dir() && !ft.is_symlink() {
2613            // Target is a real dir. If source has a non-dir entry at
2614            // the same name (regular file, symlink, junction), it
2615            // would block `create_dir_all` and the recursive merge.
2616            // Honor target-wins by clearing the conflicting source
2617            // entry first.
2618            if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
2619                let sft = src_meta.file_type();
2620                if !sft.is_dir() || sft.is_symlink() {
2621                    link::unlink(&source_path).with_context(|| {
2622                        format!("remove conflicting source entry before dir merge: {source_path}")
2623                    })?;
2624                }
2625            }
2626            if !source_path.exists() {
2627                std::fs::create_dir_all(&source_path).with_context(|| {
2628                    format!("create_dir_all({source_path}) during target→source merge")
2629                })?;
2630            }
2631            merge_dir_target_into_source(&target_path, &source_path, ctx)?;
2632        } else if ft.is_file() {
2633            // Target is a regular file. Symmetrical handling: if
2634            // source has a directory or symlink at the same name,
2635            // tear it down first so the file copy can land.
2636            if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
2637                let sft = src_meta.file_type();
2638                if sft.is_dir() && !sft.is_symlink() {
2639                    remove_dir_link_or_real(&source_path).with_context(|| {
2640                        format!("remove conflicting source dir before file merge: {source_path}")
2641                    })?;
2642                } else if sft.is_symlink() {
2643                    link::unlink(&source_path).with_context(|| {
2644                        format!(
2645                            "remove conflicting source symlink before file merge: {source_path}"
2646                        )
2647                    })?;
2648                }
2649            }
2650            if let Some(parent) = source_path.parent() {
2651                if !parent.exists() {
2652                    std::fs::create_dir_all(parent)?;
2653                }
2654            }
2655            // If both sides are now regular files at the same path, run
2656            // the file-level absorb classifier so this single overlap
2657            // is resolved against `[absorb]` policy (auto / skip /
2658            // force / ask) instead of being silently overwritten. The
2659            // dir-level marker provides consent for the *whole-tree*
2660            // merge, but a per-file content collision where the
2661            // source side is *newer* is still a legitimate anomaly
2662            // worth surfacing.
2663            //
2664            // Source-only files were already preserved by virtue of
2665            // the merge not visiting them. Target-only files (where
2666            // `source_path` doesn't exist) skip the classifier and go
2667            // straight to copy below.
2668            if source_path.is_file() {
2669                merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
2670            } else {
2671                std::fs::copy(&target_path, &source_path)
2672                    .with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
2673            }
2674        } else {
2675            warn!(
2676                "merge: skipping non-regular entry {target_path} \
2677                 (symlink / junction / special — content not copied)"
2678            );
2679        }
2680    }
2681    Ok(())
2682}
2683
2684/// Per-file conflict resolution inside the dir merge. Both
2685/// `target_path` and `source_path` exist as regular files — run the
2686/// absorb classifier on the pair and route to the matching policy:
2687///
2688/// - `InSync` / `RelinkOnly` → no-op (contents already match)
2689/// - `AutoAbsorb` (target newer + diff) → copy target → source,
2690///   target-wins per the AutoAbsorb contract.
2691/// - `NeedsConfirm` (source newer + diff, the genuine anomaly) →
2692///   `[absorb] on_anomaly` dispatch:
2693///     - `skip` → leave source alone, target's version is dropped
2694///       (after the outer junction, target ends up with source's content)
2695///     - `force` → copy target → source (target wins anyway)
2696///     - `ask` → TTY prompt with diff; downgrade to skip off-TTY
2697fn merge_resolve_file_conflict(
2698    target_path: &Utf8Path,
2699    source_path: &Utf8Path,
2700    ctx: &ApplyCtx<'_>,
2701) -> Result<()> {
2702    use absorb::AbsorbDecision::*;
2703    let decision = absorb::classify(source_path, target_path)?;
2704    match decision {
2705        InSync | RelinkOnly => Ok(()),
2706        AutoAbsorb => {
2707            std::fs::copy(target_path, source_path).with_context(|| {
2708                format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
2709            })?;
2710            Ok(())
2711        }
2712        Restore => {
2713            // `Restore` is the classifier's "target is missing" arm.
2714            // We only enter this function after the merge loop saw
2715            // `target_path` as a regular file in the read_dir
2716            // iteration, and the caller guards on `source_path.is_file()`
2717            // — both exist by construction, so this branch is
2718            // unreachable.
2719            unreachable!(
2720                "merge_resolve_file_conflict reached with both files present, \
2721                 but classify returned Restore (target {target_path} / source {source_path})"
2722            )
2723        }
2724        NeedsConfirm => {
2725            use crate::config::AnomalyAction::*;
2726            match ctx.config.absorb.on_anomaly {
2727                Skip => {
2728                    warn!(
2729                        "merge anomaly skip: {target_path} (source-newer / content drift) \
2730                         — keeping source version, target version dropped"
2731                    );
2732                    Ok(())
2733                }
2734                Force => {
2735                    warn!(
2736                        "merge anomaly force: {target_path} \
2737                         (source-newer / content drift) — overwriting source"
2738                    );
2739                    std::fs::copy(target_path, source_path)?;
2740                    Ok(())
2741                }
2742                Ask => {
2743                    use std::io::IsTerminal;
2744                    if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
2745                        if prompt_absorb_with_diff(
2746                            source_path,
2747                            target_path,
2748                            "merge: file content differs and source is newer",
2749                        )? {
2750                            std::fs::copy(target_path, source_path)?;
2751                        } else {
2752                            warn!("merge: kept source version by user choice: {source_path}");
2753                        }
2754                        Ok(())
2755                    } else {
2756                        warn!(
2757                            "merge anomaly skip (non-TTY ask mode): {target_path} \
2758                             — keeping source version"
2759                        );
2760                        Ok(())
2761                    }
2762                }
2763            }
2764        }
2765    }
2766}
2767
2768/// Back up source-side, merge target's content into source (target
2769/// wins on conflict), then replace target with a junction to source.
2770/// "Target wins" — yui's core philosophy, generalised from the file
2771/// path to whole directories so a chezmoi-style migrated `~/.config/`
2772/// keeps every file the user actually had instead of stranding most
2773/// of them in `.yui/backup/...`.
2774fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2775    info!("absorb dir: {dst} → {src}");
2776    backup_existing(src, ctx.backup_root, /* is_dir */ true)?;
2777    merge_dir_target_into_source(dst, src, ctx)?;
2778    // Source now carries every regular file from target. Tear down the
2779    // original target dir and re-expose source via a junction.
2780    remove_dir_link_or_real(dst)?;
2781    link::link_dir(src, dst, ctx.dir_mode)?;
2782    Ok(())
2783}
2784
2785/// Dir-level counterpart to `handle_anomaly`. Same `[absorb] on_anomaly`
2786/// dispatch — `skip` warns and walks away, `force` absorbs anyway,
2787/// `ask` prompts on a TTY (downgraded to skip off-TTY).
2788fn handle_anomaly_dir(
2789    src: &Utf8Path,
2790    dst: &Utf8Path,
2791    ctx: &ApplyCtx<'_>,
2792    reason: &str,
2793) -> Result<()> {
2794    use crate::config::AnomalyAction::*;
2795    match ctx.config.absorb.on_anomaly {
2796        Skip => {
2797            warn!("anomaly skip dir: {dst} ({reason})");
2798            Ok(())
2799        }
2800        Force => {
2801            warn!(
2802                "anomaly force dir: {dst} ({reason}) \
2803                 — absorbing target into source"
2804            );
2805            absorb_target_dir_into_source(src, dst, ctx)
2806        }
2807        Ask => {
2808            use std::io::IsTerminal;
2809            if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
2810                eprintln!();
2811                eprintln!("anomaly: {dst}");
2812                eprintln!("  {reason}");
2813                eprintln!("  source: {src}");
2814                eprint!("  absorb target dir into source? (y/N) ");
2815                use std::io::{BufRead as _, Write as _};
2816                std::io::stderr().flush().ok();
2817                let mut buf = String::new();
2818                std::io::stdin().lock().read_line(&mut buf)?;
2819                let answer = buf.trim();
2820                if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
2821                    absorb_target_dir_into_source(src, dst, ctx)
2822                } else {
2823                    warn!("anomaly skipped by user: {dst}");
2824                    Ok(())
2825                }
2826            } else {
2827                warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
2828                Ok(())
2829            }
2830        }
2831    }
2832}
2833
2834fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
2835    let abs_target = absolutize(target)?;
2836    let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
2837    let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
2838    info!("backup → {bp}");
2839    if is_dir {
2840        backup::backup_dir(target, &bp)?;
2841    } else {
2842        backup::backup_file(target, &bp)?;
2843    }
2844    Ok(())
2845}
2846
2847fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
2848    if let Some(s) = source {
2849        return absolutize(&s);
2850    }
2851    if let Ok(s) = std::env::var("YUI_SOURCE") {
2852        return absolutize(Utf8Path::new(&s));
2853    }
2854    let cwd = current_dir_utf8()?;
2855    for ancestor in cwd.ancestors() {
2856        if ancestor.join("config.toml").is_file() {
2857            return Ok(ancestor.to_path_buf());
2858        }
2859    }
2860    if let Some(home) = paths::home_dir() {
2861        for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
2862            let p = home.join(c);
2863            if p.join("config.toml").is_file() {
2864                return Ok(p);
2865            }
2866        }
2867    }
2868    anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
2869}
2870
2871fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
2872    // Expand `~` first so callers can pass `--source ~/dotfiles` directly.
2873    let expanded = paths::expand_tilde(p.as_str());
2874    if expanded.is_absolute() {
2875        return Ok(expanded);
2876    }
2877    let cwd = current_dir_utf8()?;
2878    Ok(cwd.join(expanded))
2879}
2880
2881fn current_dir_utf8() -> Result<Utf8PathBuf> {
2882    let cwd = std::env::current_dir().context("getting cwd")?;
2883    Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
2884}
2885
2886// Note: `home_dir()` lives in `paths.rs` so the tilde-expansion helper and
2887// `resolve_source` share one HOME/USERPROFILE lookup.
2888
2889const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
2890
2891[vars]
2892# user-defined values; templates can reference these as {{ vars.foo }}
2893
2894# [link]
2895# file_mode = "auto"   # auto | symlink | hardlink
2896# dir_mode  = "auto"   # auto | symlink | junction
2897
2898[mount]
2899default_strategy = "marker"
2900
2901[[mount.entry]]
2902src = "home"
2903# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
2904dst = "~"
2905
2906# [[mount.entry]]
2907# src  = "appdata"
2908# dst  = "{{ env(name='APPDATA') }}"
2909# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
2910# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
2911# when = "yui.os == 'windows'"
2912"#;
2913
2914const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
2915# .yui/bin/ is intentionally tracked — it holds your hook scripts.
2916/.yui/state.json
2917/.yui/state.json.tmp
2918/.yui/backup/
2919
2920# >>> yui rendered (auto-managed, do not edit) >>>
2921# <<< yui rendered (auto-managed) <<<
2922
2923# config.local.toml is per-machine; commit a config.local.example.toml instead.
2924config.local.toml
2925"#;
2926
2927#[cfg(test)]
2928mod tests {
2929    use super::*;
2930    use tempfile::TempDir;
2931
2932    fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
2933        Utf8PathBuf::from_path_buf(p).unwrap()
2934    }
2935
2936    /// Convert a path to a TOML-string-safe form (forward slashes).
2937    fn toml_path(p: &Utf8Path) -> String {
2938        p.as_str().replace('\\', "/")
2939    }
2940
2941    #[test]
2942    fn apply_links_a_raw_file() {
2943        let tmp = TempDir::new().unwrap();
2944        let source = utf8(tmp.path().join("dotfiles"));
2945        let target = utf8(tmp.path().join("target"));
2946        std::fs::create_dir_all(source.join("home")).unwrap();
2947        std::fs::create_dir_all(&target).unwrap();
2948        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2949
2950        let cfg = format!(
2951            r#"
2952[[mount.entry]]
2953src = "home"
2954dst = "{}"
2955"#,
2956            toml_path(&target)
2957        );
2958        std::fs::write(source.join("config.toml"), cfg).unwrap();
2959
2960        apply(Some(source), false).unwrap();
2961
2962        let linked = target.join(".bashrc");
2963        assert!(linked.exists(), "expected {linked} to exist");
2964        assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
2965    }
2966
2967    #[test]
2968    fn apply_with_marker_links_whole_directory() {
2969        let tmp = TempDir::new().unwrap();
2970        let source = utf8(tmp.path().join("dotfiles"));
2971        let target = utf8(tmp.path().join("target"));
2972        let nvim_src = source.join("home/nvim");
2973        std::fs::create_dir_all(&nvim_src).unwrap();
2974        std::fs::create_dir_all(&target).unwrap();
2975        std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
2976        std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
2977        std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
2978
2979        let cfg = format!(
2980            r#"
2981[[mount.entry]]
2982src = "home"
2983dst = "{}"
2984"#,
2985            toml_path(&target)
2986        );
2987        std::fs::write(source.join("config.toml"), cfg).unwrap();
2988
2989        apply(Some(source.clone()), false).unwrap();
2990
2991        let nvim_dst = target.join("nvim");
2992        assert!(nvim_dst.exists());
2993        assert_eq!(
2994            std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
2995            "-- hi\n"
2996        );
2997        // Marker file itself shouldn't be visible as a separate link in target;
2998        // however with junction/symlink the whole dir shows up so the marker
2999        // file IS visible inside. That's fine — the marker is informational.
3000    }
3001
3002    #[test]
3003    fn apply_dry_run_does_not_write() {
3004        let tmp = TempDir::new().unwrap();
3005        let source = utf8(tmp.path().join("dotfiles"));
3006        let target = utf8(tmp.path().join("target"));
3007        std::fs::create_dir_all(source.join("home")).unwrap();
3008        std::fs::create_dir_all(&target).unwrap();
3009        std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
3010
3011        let cfg = format!(
3012            r#"
3013[[mount.entry]]
3014src = "home"
3015dst = "{}"
3016"#,
3017            toml_path(&target)
3018        );
3019        std::fs::write(source.join("config.toml"), cfg).unwrap();
3020
3021        apply(Some(source), true).unwrap();
3022
3023        assert!(!target.join(".bashrc").exists());
3024    }
3025
3026    #[test]
3027    fn apply_renders_templates_then_links_rendered_outputs() {
3028        let tmp = TempDir::new().unwrap();
3029        let source = utf8(tmp.path().join("dotfiles"));
3030        let target = utf8(tmp.path().join("target"));
3031        std::fs::create_dir_all(source.join("home")).unwrap();
3032        std::fs::create_dir_all(&target).unwrap();
3033        std::fs::write(
3034            source.join("home/.gitconfig.tera"),
3035            "[user]\n  os = {{ yui.os }}\n",
3036        )
3037        .unwrap();
3038        std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
3039
3040        let cfg = format!(
3041            r#"
3042[[mount.entry]]
3043src = "home"
3044dst = "{}"
3045"#,
3046            toml_path(&target)
3047        );
3048        std::fs::write(source.join("config.toml"), cfg).unwrap();
3049
3050        apply(Some(source.clone()), false).unwrap();
3051
3052        // Raw file: linked.
3053        assert!(target.join(".bashrc").exists());
3054        // Template's rendered output: written to source then linked.
3055        assert!(source.join("home/.gitconfig").exists());
3056        assert!(target.join(".gitconfig").exists());
3057        // The .tera file itself is never linked into target.
3058        assert!(!target.join(".gitconfig.tera").exists());
3059        // Rendered file content carries the yui.os substitution.
3060        let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
3061        assert!(linked.contains("os = "));
3062    }
3063
3064    #[test]
3065    fn apply_marker_override_links_to_custom_dst() {
3066        let tmp = TempDir::new().unwrap();
3067        let source = utf8(tmp.path().join("dotfiles"));
3068        let target_a = utf8(tmp.path().join("target_a"));
3069        let target_b = utf8(tmp.path().join("target_b"));
3070        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3071        std::fs::create_dir_all(&target_a).unwrap();
3072        std::fs::create_dir_all(&target_b).unwrap();
3073        std::fs::write(
3074            source.join("home/.config/nvim/init.lua"),
3075            "-- nvim config\n",
3076        )
3077        .unwrap();
3078
3079        // Marker tells yui to ignore the parent mount's dst for this dir
3080        // and link it to two custom places (the second only if condition matches).
3081        std::fs::write(
3082            source.join("home/.config/nvim/.yuilink"),
3083            format!(
3084                r#"
3085[[link]]
3086dst = "{}/nvim"
3087
3088[[link]]
3089dst = "{}/nvim"
3090when = "{{{{ yui.os == '{}' }}}}"
3091"#,
3092                toml_path(&target_a),
3093                toml_path(&target_b),
3094                std::env::consts::OS
3095            ),
3096        )
3097        .unwrap();
3098
3099        let parent_target = utf8(tmp.path().join("parent_target"));
3100        std::fs::create_dir_all(&parent_target).unwrap();
3101        let cfg = format!(
3102            r#"
3103[[mount.entry]]
3104src = "home"
3105dst = "{}"
3106"#,
3107            toml_path(&parent_target)
3108        );
3109        std::fs::write(source.join("config.toml"), cfg).unwrap();
3110
3111        apply(Some(source.clone()), false).unwrap();
3112
3113        // Both override targets received the link (the second's when matches OS).
3114        assert!(
3115            target_a.join("nvim/init.lua").exists(),
3116            "target_a/nvim/init.lua should be reachable through the link"
3117        );
3118        assert!(
3119            target_b.join("nvim/init.lua").exists(),
3120            "target_b/nvim/init.lua should be reachable through the link"
3121        );
3122        // Parent mount did NOT also link this dir (it would have appeared at
3123        // parent_target/.config/nvim — the marker claims the dir).
3124        assert!(
3125            !parent_target.join(".config/nvim").exists(),
3126            "parent mount should have skipped the marker-claimed sub-dir"
3127        );
3128    }
3129
3130    #[test]
3131    fn apply_marker_inactive_link_falls_through_to_default() {
3132        // v0.6+ semantics: a marker that has only inactive links no
3133        // longer suppresses the parent mount's natural placement. The
3134        // walker keeps descending so per-file defaults still apply.
3135        // (Use `.yuiignore` to actually exclude a subtree.)
3136        let tmp = TempDir::new().unwrap();
3137        let source = utf8(tmp.path().join("dotfiles"));
3138        let target_inactive = utf8(tmp.path().join("inactive"));
3139        let parent_target = utf8(tmp.path().join("parent"));
3140        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3141        std::fs::create_dir_all(&parent_target).unwrap();
3142        std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
3143
3144        // when=false on every link → marker has no active links.
3145        std::fs::write(
3146            source.join("home/.config/nvim/.yuilink"),
3147            format!(
3148                r#"
3149[[link]]
3150dst = "{}/nvim"
3151when = "{{{{ yui.os == 'no-such-os' }}}}"
3152"#,
3153                toml_path(&target_inactive)
3154            ),
3155        )
3156        .unwrap();
3157
3158        let cfg = format!(
3159            r#"
3160[[mount.entry]]
3161src = "home"
3162dst = "{}"
3163"#,
3164            toml_path(&parent_target)
3165        );
3166        std::fs::write(source.join("config.toml"), cfg).unwrap();
3167
3168        apply(Some(source.clone()), false).unwrap();
3169
3170        // Inactive marker target untouched.
3171        assert!(!target_inactive.join("nvim").exists());
3172        // Parent mount's natural placement IS produced — the marker had
3173        // no active dir-level link to claim coverage with.
3174        assert!(parent_target.join(".config/nvim/init.lua").exists());
3175    }
3176
3177    #[test]
3178    fn list_shows_mount_entries_and_marker_overrides() {
3179        let tmp = TempDir::new().unwrap();
3180        let source = utf8(tmp.path().join("dotfiles"));
3181        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3182        std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
3183        std::fs::write(
3184            source.join("home/.config/nvim/.yuilink"),
3185            r#"
3186[[link]]
3187dst = "/custom/nvim"
3188"#,
3189        )
3190        .unwrap();
3191        std::fs::write(
3192            source.join("config.toml"),
3193            r#"
3194[[mount.entry]]
3195src = "home"
3196dst = "/h"
3197"#,
3198        )
3199        .unwrap();
3200
3201        // Just verify it runs without error — output format is covered by
3202        // unit-level helpers below.
3203        list(Some(source), false, None, true).unwrap();
3204    }
3205
3206    #[test]
3207    fn status_reports_in_sync_after_apply() {
3208        let tmp = TempDir::new().unwrap();
3209        let source = utf8(tmp.path().join("dotfiles"));
3210        let target = utf8(tmp.path().join("target"));
3211        std::fs::create_dir_all(source.join("home")).unwrap();
3212        std::fs::create_dir_all(&target).unwrap();
3213        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3214        let cfg = format!(
3215            r#"
3216[[mount.entry]]
3217src = "home"
3218dst = "{}"
3219"#,
3220            toml_path(&target)
3221        );
3222        std::fs::write(source.join("config.toml"), cfg).unwrap();
3223        // First link the target so the link is intact.
3224        apply(Some(source.clone()), false).unwrap();
3225        // status should succeed (everything in-sync).
3226        status(Some(source), None, true).unwrap();
3227    }
3228
3229    #[test]
3230    fn status_reports_template_drift() {
3231        let tmp = TempDir::new().unwrap();
3232        let source = utf8(tmp.path().join("dotfiles"));
3233        let target = utf8(tmp.path().join("target"));
3234        std::fs::create_dir_all(source.join("home")).unwrap();
3235        std::fs::create_dir_all(&target).unwrap();
3236        // Template would render to "fresh" but the rendered file on disk
3237        // says "stale" — simulating a manual edit not reflected back.
3238        std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
3239        std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
3240
3241        let cfg = format!(
3242            r#"
3243[[mount.entry]]
3244src = "home"
3245dst = "{}"
3246"#,
3247            toml_path(&target)
3248        );
3249        std::fs::write(source.join("config.toml"), cfg).unwrap();
3250
3251        let err = status(Some(source), None, true).unwrap_err();
3252        assert!(format!("{err}").contains("diverged"));
3253    }
3254
3255    #[test]
3256    fn status_fails_when_target_missing() {
3257        let tmp = TempDir::new().unwrap();
3258        let source = utf8(tmp.path().join("dotfiles"));
3259        let target = utf8(tmp.path().join("target"));
3260        std::fs::create_dir_all(source.join("home")).unwrap();
3261        std::fs::create_dir_all(&target).unwrap();
3262        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3263        let cfg = format!(
3264            r#"
3265[[mount.entry]]
3266src = "home"
3267dst = "{}"
3268"#,
3269            toml_path(&target)
3270        );
3271        std::fs::write(source.join("config.toml"), cfg).unwrap();
3272        // No apply yet — target/.bashrc doesn't exist.
3273        let err = status(Some(source), None, true).unwrap_err();
3274        assert!(format!("{err}").contains("diverged"));
3275    }
3276
3277    #[test]
3278    fn strip_braces_removes_outer_template_braces() {
3279        assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
3280        assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
3281        assert_eq!(strip_braces("  {{x}}  "), "x");
3282    }
3283
3284    #[test]
3285    fn apply_aborts_on_render_drift() {
3286        let tmp = TempDir::new().unwrap();
3287        let source = utf8(tmp.path().join("dotfiles"));
3288        let target = utf8(tmp.path().join("target"));
3289        std::fs::create_dir_all(source.join("home")).unwrap();
3290        std::fs::create_dir_all(&target).unwrap();
3291        std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
3292        std::fs::write(source.join("home/foo"), "manually edited").unwrap();
3293
3294        let cfg = format!(
3295            r#"
3296[[mount.entry]]
3297src = "home"
3298dst = "{}"
3299"#,
3300            toml_path(&target)
3301        );
3302        std::fs::write(source.join("config.toml"), cfg).unwrap();
3303
3304        let err = apply(Some(source.clone()), false).unwrap_err();
3305        assert!(format!("{err}").contains("drift"));
3306        // Existing rendered file untouched.
3307        assert_eq!(
3308            std::fs::read_to_string(source.join("home/foo")).unwrap(),
3309            "manually edited"
3310        );
3311        // Linking aborted — target empty.
3312        assert!(!target.join("foo").exists());
3313    }
3314
3315    #[test]
3316    fn init_creates_skeleton_when_dir_empty() {
3317        let tmp = TempDir::new().unwrap();
3318        let dir = utf8(tmp.path().join("new_dotfiles"));
3319        init(Some(dir.clone()), false).unwrap();
3320        assert!(dir.join("config.toml").is_file());
3321        assert!(dir.join(".gitignore").is_file());
3322    }
3323
3324    #[test]
3325    fn init_refuses_to_overwrite_existing_config() {
3326        let tmp = TempDir::new().unwrap();
3327        let dir = utf8(tmp.path().join("dotfiles"));
3328        std::fs::create_dir_all(&dir).unwrap();
3329        std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
3330        let err = init(Some(dir), false).unwrap_err();
3331        assert!(format!("{err}").contains("already exists"));
3332    }
3333
3334    /// `init` is now in charge of the `.yui/` state / backup ignore
3335    /// lines, even on a re-run against an existing repo. Pre-fix it
3336    /// silently left a half-populated `.gitignore` alone if the user
3337    /// didn't have the entries in place; now it appends the missing
3338    /// ones idempotently.
3339    #[test]
3340    fn init_appends_missing_gitignore_entries_into_existing_file() {
3341        let tmp = TempDir::new().unwrap();
3342        let dir = utf8(tmp.path().join("dotfiles"));
3343        std::fs::create_dir_all(&dir).unwrap();
3344        // Existing .gitignore that DOESN'T yet have any yui entries.
3345        let user_gitignore = "# user entries\n*.swp\nnode_modules/\n";
3346        std::fs::write(dir.join(".gitignore"), user_gitignore).unwrap();
3347
3348        init(Some(dir.clone()), false).unwrap();
3349
3350        let body = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
3351        // The user's existing lines survive untouched.
3352        assert!(body.contains("*.swp"));
3353        assert!(body.contains("node_modules/"));
3354        // Each yui-required line was appended.
3355        assert!(body.contains("/.yui/state.json"));
3356        assert!(body.contains("/.yui/backup/"));
3357        assert!(body.contains("config.local.toml"));
3358        // Re-running init on the already-fixed-up file is a no-op.
3359        let before_rerun = body.clone();
3360        // `init` would normally bail on an existing config; remove it so
3361        // the second call doesn't trip that guard.
3362        std::fs::remove_file(dir.join("config.toml")).unwrap();
3363        init(Some(dir.clone()), false).unwrap();
3364        let after_rerun = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
3365        assert_eq!(
3366            before_rerun, after_rerun,
3367            "init must be idempotent when the gitignore already has every yui entry"
3368        );
3369    }
3370
3371    /// `init --git-hooks` against an *existing* repo (config.toml
3372    /// already there) skips the scaffold and just installs the hooks.
3373    /// Pre-fix this combo bailed with "config.toml already exists",
3374    /// which forced users with a populated dotfiles repo to delete
3375    /// their config before they could opt into the render-drift hooks.
3376    #[test]
3377    fn init_with_git_hooks_installs_into_existing_repo() {
3378        let tmp = TempDir::new().unwrap();
3379        let dir = utf8(tmp.path().join("dotfiles"));
3380        std::fs::create_dir_all(&dir).unwrap();
3381        let st = std::process::Command::new("git")
3382            .args(["init", "-q"])
3383            .current_dir(dir.as_std_path())
3384            .status()
3385            .expect("git init");
3386        if !st.success() {
3387            return;
3388        }
3389        // Pre-existing user config — init should NOT overwrite it.
3390        let user_config = "# user already wrote this\n";
3391        std::fs::write(dir.join("config.toml"), user_config).unwrap();
3392
3393        // hooks-only invocation: succeeds, leaves config alone.
3394        init(Some(dir.clone()), /* git_hooks */ true).unwrap();
3395
3396        assert_eq!(
3397            std::fs::read_to_string(dir.join("config.toml")).unwrap(),
3398            user_config
3399        );
3400        assert!(dir.join(".git/hooks/pre-commit").is_file());
3401        assert!(dir.join(".git/hooks/pre-push").is_file());
3402    }
3403
3404    /// `init --git-hooks` writes pre-commit / pre-push that run the
3405    /// render-drift check against `.git/hooks/`. We need a real git
3406    /// repo for `git rev-parse --git-path hooks` to point at, so
3407    /// prepare one before calling init.
3408    #[test]
3409    fn init_with_git_hooks_writes_pre_commit_and_pre_push() {
3410        let tmp = TempDir::new().unwrap();
3411        let dir = utf8(tmp.path().join("dotfiles"));
3412        std::fs::create_dir_all(&dir).unwrap();
3413        // Bootstrap a git repo at `dir`.
3414        let st = std::process::Command::new("git")
3415            .args(["init", "-q"])
3416            .current_dir(dir.as_std_path())
3417            .status()
3418            .expect("git init");
3419        if !st.success() {
3420            // Skip if git isn't on PATH on this CI runner.
3421            eprintln!("skipping: git not available");
3422            return;
3423        }
3424        init(Some(dir.clone()), /* git_hooks */ true).unwrap();
3425
3426        let pre_commit = dir.join(".git/hooks/pre-commit");
3427        let pre_push = dir.join(".git/hooks/pre-push");
3428        assert!(pre_commit.is_file(), "pre-commit hook should be written");
3429        assert!(pre_push.is_file(), "pre-push hook should be written");
3430
3431        let body = std::fs::read_to_string(&pre_commit).unwrap();
3432        assert!(
3433            body.contains("yui render --check"),
3434            "pre-commit hook should call `yui render --check`, got: {body}"
3435        );
3436    }
3437
3438    /// `init --git-hooks` against a non-git directory must fail with a
3439    /// clear message instead of silently doing nothing — the user
3440    /// asked for hooks and we couldn't deliver.
3441    #[test]
3442    fn init_with_git_hooks_errors_outside_a_git_repo() {
3443        let tmp = TempDir::new().unwrap();
3444        let dir = utf8(tmp.path().join("not-a-repo"));
3445        std::fs::create_dir_all(&dir).unwrap();
3446        let err = init(Some(dir), /* git_hooks */ true).unwrap_err();
3447        let msg = format!("{err:#}");
3448        assert!(
3449            msg.contains("git repo") || msg.contains("git rev-parse"),
3450            "expected error to mention the git issue, got: {msg}"
3451        );
3452    }
3453
3454    /// Pre-existing hooks are not silently overwritten — yui leaves
3455    /// the user's prior file alone (warns) and writes the missing one.
3456    #[test]
3457    fn init_with_git_hooks_does_not_clobber_existing_hooks() {
3458        let tmp = TempDir::new().unwrap();
3459        let dir = utf8(tmp.path().join("dotfiles"));
3460        std::fs::create_dir_all(&dir).unwrap();
3461        let st = std::process::Command::new("git")
3462            .args(["init", "-q"])
3463            .current_dir(dir.as_std_path())
3464            .status()
3465            .expect("git init");
3466        if !st.success() {
3467            return;
3468        }
3469        let hooks = dir.join(".git/hooks");
3470        std::fs::create_dir_all(&hooks).unwrap();
3471        std::fs::write(hooks.join("pre-commit"), "#! /bin/sh\nexit 0\n").unwrap();
3472
3473        init(Some(dir.clone()), true).unwrap();
3474
3475        // Existing pre-commit untouched, pre-push freshly written.
3476        let pc = std::fs::read_to_string(hooks.join("pre-commit")).unwrap();
3477        assert!(
3478            !pc.contains("yui render --check"),
3479            "existing pre-commit must not be overwritten"
3480        );
3481        let pp = std::fs::read_to_string(hooks.join("pre-push")).unwrap();
3482        assert!(
3483            pp.contains("yui render --check"),
3484            "missing pre-push should be written: {pp}"
3485        );
3486    }
3487
3488    /// Build a minimal `apply`-able dotfiles tree for absorb tests.
3489    /// Returns (source_dir, target_dir).
3490    fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
3491        let source = utf8(tmp.path().join("dotfiles"));
3492        let target = utf8(tmp.path().join("target"));
3493        std::fs::create_dir_all(source.join("home")).unwrap();
3494        std::fs::create_dir_all(&target).unwrap();
3495        let cfg = format!(
3496            r#"
3497[[mount.entry]]
3498src = "home"
3499dst = "{}"
3500"#,
3501            toml_path(&target)
3502        );
3503        std::fs::write(source.join("config.toml"), cfg).unwrap();
3504        (source, target)
3505    }
3506
3507    fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
3508        std::fs::write(path, body).unwrap();
3509        let f = std::fs::OpenOptions::new()
3510            .write(true)
3511            .open(path)
3512            .expect("open writable");
3513        f.set_modified(when).expect("set_modified");
3514    }
3515
3516    #[test]
3517    fn apply_target_newer_absorbs_target_into_source() {
3518        // Target has the user's edit and is mtime-newer than source —
3519        // classifier returns `AutoAbsorb`. yui's "target-as-truth"
3520        // philosophy: target wins, source is updated and backed up.
3521        let tmp = TempDir::new().unwrap();
3522        let (source, target) = setup_minimal_dotfiles(&tmp);
3523
3524        let now = std::time::SystemTime::now();
3525        let past = now - std::time::Duration::from_secs(120);
3526        write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
3527        // Pre-existing target with user's edit, NEWER mtime.
3528        write_with_mtime(&target.join(".bashrc"), "user's edit", now);
3529
3530        apply(Some(source.clone()), false).unwrap();
3531
3532        // Target's content survives — that's the whole point.
3533        assert_eq!(
3534            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3535            "user's edit"
3536        );
3537        // Source has been updated to match target.
3538        assert_eq!(
3539            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3540            "user's edit"
3541        );
3542        // Source's previous content lives under .yui/backup.
3543        let backup_root = source.join(".yui/backup");
3544        let mut found_old = false;
3545        for entry in walkdir(&backup_root) {
3546            if let Ok(s) = std::fs::read_to_string(&entry) {
3547                if s == "default from repo" {
3548                    found_old = true;
3549                    break;
3550                }
3551            }
3552        }
3553        assert!(found_old, "expected backup containing 'default from repo'");
3554    }
3555
3556    #[test]
3557    fn apply_in_sync_target_is_a_no_op() {
3558        // After an initial `apply`, running `apply` again classifies as
3559        // `InSync` and does nothing.
3560        let tmp = TempDir::new().unwrap();
3561        let (source, target) = setup_minimal_dotfiles(&tmp);
3562        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3563        apply(Some(source.clone()), false).unwrap();
3564        let backup_root = source.join(".yui/backup");
3565        let backup_count_after_first = walkdir(&backup_root).len();
3566
3567        // Second apply — nothing should change.
3568        apply(Some(source.clone()), false).unwrap();
3569        assert_eq!(
3570            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3571            "echo hi\n"
3572        );
3573        let backup_count_after_second = walkdir(&backup_root).len();
3574        assert_eq!(
3575            backup_count_after_first, backup_count_after_second,
3576            "second apply on an in-sync tree should not produce backups"
3577        );
3578    }
3579
3580    #[test]
3581    fn apply_skip_policy_leaves_anomaly_alone() {
3582        // Source newer than target + content differs = NeedsConfirm.
3583        // With on_anomaly = "skip", target stays untouched.
3584        let tmp = TempDir::new().unwrap();
3585        let source = utf8(tmp.path().join("dotfiles"));
3586        let target = utf8(tmp.path().join("target"));
3587        std::fs::create_dir_all(source.join("home")).unwrap();
3588        std::fs::create_dir_all(&target).unwrap();
3589        let cfg = format!(
3590            r#"
3591[absorb]
3592on_anomaly = "skip"
3593
3594[[mount.entry]]
3595src = "home"
3596dst = "{}"
3597"#,
3598            toml_path(&target)
3599        );
3600        std::fs::write(source.join("config.toml"), cfg).unwrap();
3601
3602        let now = std::time::SystemTime::now();
3603        let past = now - std::time::Duration::from_secs(120);
3604        write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
3605        write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
3606
3607        apply(Some(source.clone()), false).unwrap();
3608
3609        // Target untouched (skip policy honored).
3610        assert_eq!(
3611            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3612            "user's edit (older)"
3613        );
3614        // Source untouched too.
3615        assert_eq!(
3616            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3617            "fresh from upstream"
3618        );
3619    }
3620
3621    #[test]
3622    fn apply_force_policy_absorbs_anomaly_anyway() {
3623        // Same anomaly setup, but on_anomaly = "force" → target wins.
3624        let tmp = TempDir::new().unwrap();
3625        let source = utf8(tmp.path().join("dotfiles"));
3626        let target = utf8(tmp.path().join("target"));
3627        std::fs::create_dir_all(source.join("home")).unwrap();
3628        std::fs::create_dir_all(&target).unwrap();
3629        let cfg = format!(
3630            r#"
3631[absorb]
3632on_anomaly = "force"
3633
3634[[mount.entry]]
3635src = "home"
3636dst = "{}"
3637"#,
3638            toml_path(&target)
3639        );
3640        std::fs::write(source.join("config.toml"), cfg).unwrap();
3641
3642        let now = std::time::SystemTime::now();
3643        let past = now - std::time::Duration::from_secs(120);
3644        write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
3645        write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
3646
3647        apply(Some(source.clone()), false).unwrap();
3648
3649        // Target wins despite being mtime-older — force policy.
3650        assert_eq!(
3651            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3652            "user's edit (older)"
3653        );
3654        assert_eq!(
3655            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3656            "user's edit (older)"
3657        );
3658    }
3659
3660    /// Regression for the Windows-error-145 bug: a `home/.config/.yuilink`
3661    /// (PassThrough) marker pointing at a non-empty regular `~/.config`
3662    /// directory (the typical chezmoi-migrated state, where every file
3663    /// inside is an individual hardlink) used to fail the absorb with
3664    /// `Directory not empty` because `link::unlink` refuses to recurse.
3665    /// After backup we now `remove_dir_all` as a fallback.
3666    ///
3667    /// v0.7+: also exercises the target-wins merge — target's
3668    /// `config.toml` overwrites source's, target's `state.json` lands
3669    /// in source (target was the source of truth), and source-only
3670    /// scaffolding (`.yuilink`) survives the absorb.
3671    #[test]
3672    fn apply_absorbs_non_empty_target_dir_target_wins() {
3673        let tmp = TempDir::new().unwrap();
3674        let source = utf8(tmp.path().join("dotfiles"));
3675        let target = utf8(tmp.path().join("target"));
3676        std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
3677        std::fs::create_dir_all(target.join(".config/app")).unwrap();
3678        // Marker that says "junction this dir at the parent mount's dst"
3679        // — same shape as a typical home/.config/.yuilink.
3680        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3681        std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
3682        // Source-only scaffolding that the absorb must preserve.
3683        std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
3684        // Pre-existing non-empty regular dir at the target — chezmoi /
3685        // any per-file dotfiles flow leaves things in this shape.
3686        std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
3687        std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
3688
3689        let cfg = format!(
3690            r#"
3691[absorb]
3692on_anomaly = "force"
3693
3694[[mount.entry]]
3695src = "home"
3696dst = "{}"
3697"#,
3698            toml_path(&target)
3699        );
3700        std::fs::write(source.join("config.toml"), cfg).unwrap();
3701
3702        // Used to bail with `unlink: ... Directory not empty` here.
3703        apply(Some(source.clone()), false).unwrap();
3704
3705        // Target wins on the conflicting file.
3706        assert_eq!(
3707            std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
3708            "target side"
3709        );
3710        // Target-only file is now reachable via the junction.
3711        assert_eq!(
3712            std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
3713            "{}"
3714        );
3715        // Source's pre-merge state was backed up before being overwritten,
3716        // so the original "src side" / `.yuilink` survive in `.yui/backup/`.
3717        let backup_root = source.join(".yui/backup");
3718        let mut backup_files: Vec<String> = Vec::new();
3719        for entry in walkdir(&backup_root) {
3720            if let Some(n) = entry.file_name() {
3721                backup_files.push(n.to_string());
3722            }
3723        }
3724        assert!(
3725            backup_files.iter().any(|f| f == "config.toml"),
3726            "expected source's config.toml to land in the backup tree, got {backup_files:?}"
3727        );
3728        // Source-only scaffolding survives the merge.
3729        assert!(
3730            source.join("home/.config/app/source-only.toml").exists(),
3731            "source-only file should survive a target-wins merge"
3732        );
3733        // Source picked up target-only state.json via the merge.
3734        assert!(
3735            source.join("home/.config/app/state.json").exists(),
3736            "target-only state.json should be merged into source"
3737        );
3738    }
3739
3740    /// v0.7+: `home/.config/.yuilink` is the user's explicit
3741    /// "this whole subtree is target-as-truth" declaration. A
3742    /// dir-level NeedsConfirm at the marker root is therefore not a
3743    /// real anomaly — the marker is consent. Default `[absorb]` (ask
3744    /// + require_clean_git) should still absorb, no prompt.
3745    #[test]
3746    fn marker_dir_absorbs_with_default_ask_policy() {
3747        let tmp = TempDir::new().unwrap();
3748        let source = utf8(tmp.path().join("dotfiles"));
3749        let target = utf8(tmp.path().join("target"));
3750        std::fs::create_dir_all(source.join("home/.config")).unwrap();
3751        std::fs::create_dir_all(target.join(".config/gh")).unwrap();
3752        // Marker — user opts the whole .config dir into target-as-truth.
3753        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3754        // gh exists only on the target side (no entry in source).
3755        std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\n").unwrap();
3756
3757        // Default [absorb] (no override) — `on_anomaly = "ask"`,
3758        // `auto = true`, `require_clean_git = true`. Pre-v0.7 this
3759        // would have been routed through the ask prompt at dir level.
3760        let cfg = format!(
3761            r#"
3762[[mount.entry]]
3763src = "home"
3764dst = "{}"
3765"#,
3766            toml_path(&target)
3767        );
3768        std::fs::write(source.join("config.toml"), cfg).unwrap();
3769
3770        // Even with default `ask`, the marker-rooted absorb proceeds.
3771        // Test would hang on a stdin prompt if dir-level still treated
3772        // this as an anomaly.
3773        apply(Some(source.clone()), false).unwrap();
3774
3775        // Target-only file is now reachable through the junction and
3776        // recorded in source.
3777        assert!(target.join(".config/gh/hosts.yml").exists());
3778        assert!(source.join("home/.config/gh/hosts.yml").exists());
3779    }
3780
3781    /// File↔dir collisions during merge. Honor target-wins: if source
3782    /// has a regular file at a path where target has a dir, the file
3783    /// gets removed and the dir is created. Symmetrical for the
3784    /// inverse case. Without the conflict-clearing the merge would
3785    /// fail with `not a directory` / `path exists` deep in the recursion.
3786    #[test]
3787    fn merge_handles_file_vs_dir_collisions_target_wins() {
3788        let tmp = TempDir::new().unwrap();
3789        let source = utf8(tmp.path().join("dotfiles"));
3790        let target = utf8(tmp.path().join("target"));
3791        std::fs::create_dir_all(source.join("home/.config/foo")).unwrap();
3792        std::fs::create_dir_all(target.join(".config")).unwrap();
3793        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3794
3795        // Conflict A: source has `foo` as dir, target has `foo` as file.
3796        std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
3797        std::fs::write(target.join(".config/foo"), "target file body").unwrap();
3798        // Conflict B: source has `bar` as file, target has `bar` as dir.
3799        std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
3800        std::fs::create_dir_all(target.join(".config/bar")).unwrap();
3801        std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
3802
3803        let cfg = format!(
3804            r#"
3805[absorb]
3806on_anomaly = "force"
3807
3808[[mount.entry]]
3809src = "home"
3810dst = "{}"
3811"#,
3812            toml_path(&target)
3813        );
3814        std::fs::write(source.join("config.toml"), cfg).unwrap();
3815        apply(Some(source.clone()), false).unwrap();
3816
3817        // After absorb the target's view (which equals source via
3818        // junction) carries target's shapes:
3819        // `foo` is a regular file
3820        let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
3821        assert!(foo_meta.file_type().is_file(), "foo should be a file");
3822        assert_eq!(
3823            std::fs::read_to_string(target.join(".config/foo")).unwrap(),
3824            "target file body"
3825        );
3826        // `bar` is a directory with the nested file
3827        let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
3828        assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
3829        assert_eq!(
3830            std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
3831            "target nested"
3832        );
3833    }
3834
3835    /// Per-file conflict in dir merge — target newer + content
3836    /// differs → AutoAbsorb. Target wins automatically without
3837    /// touching `[absorb] on_anomaly`.
3838    #[test]
3839    fn merge_per_file_target_newer_auto_absorbs() {
3840        let tmp = TempDir::new().unwrap();
3841        let source = utf8(tmp.path().join("dotfiles"));
3842        let target = utf8(tmp.path().join("target"));
3843        std::fs::create_dir_all(source.join("home/.config")).unwrap();
3844        std::fs::create_dir_all(target.join(".config")).unwrap();
3845        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3846
3847        // Source has the older copy, target has the newer edit.
3848        let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3849        write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
3850        std::fs::write(target.join(".config/app.toml"), "user's live edit").unwrap();
3851
3852        // Default `ask` policy — should NOT prompt because the
3853        // classifier returns AutoAbsorb (target newer + diff), which
3854        // bypasses `on_anomaly` entirely.
3855        let cfg = format!(
3856            r#"
3857[[mount.entry]]
3858src = "home"
3859dst = "{}"
3860"#,
3861            toml_path(&target)
3862        );
3863        std::fs::write(source.join("config.toml"), cfg).unwrap();
3864        apply(Some(source.clone()), false).unwrap();
3865
3866        // Target wins.
3867        assert_eq!(
3868            std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3869            "user's live edit"
3870        );
3871    }
3872
3873    /// Per-file conflict — source newer + content differs +
3874    /// `on_anomaly = "skip"` → keep source's version. After the outer
3875    /// junction, target ends up with source's content (so target's
3876    /// file is effectively dropped, matching the file-level `skip`
3877    /// semantic).
3878    #[test]
3879    fn merge_per_file_source_newer_skip_keeps_source() {
3880        let tmp = TempDir::new().unwrap();
3881        let source = utf8(tmp.path().join("dotfiles"));
3882        let target = utf8(tmp.path().join("target"));
3883        std::fs::create_dir_all(source.join("home/.config")).unwrap();
3884        std::fs::create_dir_all(target.join(".config")).unwrap();
3885        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3886
3887        // Target has the older copy, source has the newer edit.
3888        let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3889        write_with_mtime(&target.join(".config/app.toml"), "old target", past);
3890        std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
3891
3892        let cfg = format!(
3893            r#"
3894[absorb]
3895on_anomaly = "skip"
3896
3897[[mount.entry]]
3898src = "home"
3899dst = "{}"
3900"#,
3901            toml_path(&target)
3902        );
3903        std::fs::write(source.join("config.toml"), cfg).unwrap();
3904        apply(Some(source.clone()), false).unwrap();
3905
3906        // Source kept — target now reads source's version through the
3907        // junction (so target's old text is dropped).
3908        assert_eq!(
3909            std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3910            "fresh source"
3911        );
3912    }
3913
3914    /// Per-file conflict — source newer + content differs +
3915    /// `on_anomaly = "force"` → target wins anyway.
3916    #[test]
3917    fn merge_per_file_source_newer_force_overwrites_source() {
3918        let tmp = TempDir::new().unwrap();
3919        let source = utf8(tmp.path().join("dotfiles"));
3920        let target = utf8(tmp.path().join("target"));
3921        std::fs::create_dir_all(source.join("home/.config")).unwrap();
3922        std::fs::create_dir_all(target.join(".config")).unwrap();
3923        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3924
3925        let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3926        write_with_mtime(&target.join(".config/app.toml"), "old target", past);
3927        std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
3928
3929        let cfg = format!(
3930            r#"
3931[absorb]
3932on_anomaly = "force"
3933
3934[[mount.entry]]
3935src = "home"
3936dst = "{}"
3937"#,
3938            toml_path(&target)
3939        );
3940        std::fs::write(source.join("config.toml"), cfg).unwrap();
3941        apply(Some(source.clone()), false).unwrap();
3942
3943        // Target overrides source despite being mtime-older.
3944        assert_eq!(
3945            std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3946            "old target"
3947        );
3948    }
3949
3950    /// Per-file conflict — bytes match → no-op. The merge classifies
3951    /// this as RelinkOnly and skips the copy entirely (saves a lot of
3952    /// I/O when migrating big chezmoi repos where source and target
3953    /// have already shared inodes).
3954    #[test]
3955    fn merge_per_file_identical_content_is_noop() {
3956        let tmp = TempDir::new().unwrap();
3957        let source = utf8(tmp.path().join("dotfiles"));
3958        let target = utf8(tmp.path().join("target"));
3959        std::fs::create_dir_all(source.join("home/.config")).unwrap();
3960        std::fs::create_dir_all(target.join(".config")).unwrap();
3961        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3962        std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
3963        std::fs::write(target.join(".config/app.toml"), "same").unwrap();
3964
3965        // Default policy — bytes match, classifier returns RelinkOnly,
3966        // merge skips the copy. Apply must succeed without prompting.
3967        let cfg = format!(
3968            r#"
3969[[mount.entry]]
3970src = "home"
3971dst = "{}"
3972"#,
3973            toml_path(&target)
3974        );
3975        std::fs::write(source.join("config.toml"), cfg).unwrap();
3976        apply(Some(source.clone()), false).unwrap();
3977
3978        assert_eq!(
3979            std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3980            "same"
3981        );
3982    }
3983
3984    #[test]
3985    fn manual_absorb_command_pulls_target_into_source() {
3986        // Manual `yui absorb <target>` bypasses policy + git checks.
3987        let tmp = TempDir::new().unwrap();
3988        let source = utf8(tmp.path().join("dotfiles"));
3989        let target = utf8(tmp.path().join("target"));
3990        std::fs::create_dir_all(source.join("home")).unwrap();
3991        std::fs::create_dir_all(&target).unwrap();
3992        // on_anomaly = "skip" so passive `apply` would NOT touch this.
3993        let cfg = format!(
3994            r#"
3995[absorb]
3996on_anomaly = "skip"
3997
3998[[mount.entry]]
3999src = "home"
4000dst = "{}"
4001"#,
4002            toml_path(&target)
4003        );
4004        std::fs::write(source.join("config.toml"), cfg).unwrap();
4005        std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
4006        std::fs::write(source.join("home/.bashrc"), "default").unwrap();
4007
4008        // Run absorb directly on the target.
4009        absorb(
4010            Some(source.clone()),
4011            target.join(".bashrc"),
4012            /* dry_run */ false,
4013        )
4014        .unwrap();
4015
4016        // Source picked up target's content (manual absorb is forceful).
4017        assert_eq!(
4018            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
4019            "user picked this"
4020        );
4021    }
4022
4023    #[test]
4024    fn manual_absorb_errors_when_target_outside_known_mounts() {
4025        let tmp = TempDir::new().unwrap();
4026        let (source, _target) = setup_minimal_dotfiles(&tmp);
4027        std::fs::write(source.join("home/.bashrc"), "x").unwrap();
4028        let stranger = utf8(tmp.path().join("not-managed/foo"));
4029        std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
4030        std::fs::write(&stranger, "not yui's").unwrap();
4031        let err = absorb(Some(source), stranger, false).unwrap_err();
4032        assert!(format!("{err}").contains("no mount entry"));
4033    }
4034
4035    #[test]
4036    fn yuiignore_excludes_file_from_linking() {
4037        let tmp = TempDir::new().unwrap();
4038        let (source, target) = setup_minimal_dotfiles(&tmp);
4039        std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
4040        std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
4041        // Exclude `lock.json` files anywhere under source.
4042        std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
4043        apply(Some(source.clone()), false).unwrap();
4044        assert!(target.join(".bashrc").exists());
4045        assert!(
4046            !target.join("lock.json").exists(),
4047            "yuiignore should keep lock.json out of target"
4048        );
4049    }
4050
4051    #[test]
4052    fn yuiignore_excludes_directory_subtree() {
4053        let tmp = TempDir::new().unwrap();
4054        let (source, target) = setup_minimal_dotfiles(&tmp);
4055        std::fs::create_dir_all(source.join("home/cache")).unwrap();
4056        std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
4057        std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
4058        std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
4059        // Trailing slash → match dirs only; entire subtree skipped.
4060        std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
4061        apply(Some(source.clone()), false).unwrap();
4062        assert!(target.join(".bashrc").exists());
4063        assert!(
4064            !target.join("cache").exists(),
4065            "yuiignore'd subtree should not appear in target"
4066        );
4067    }
4068
4069    #[test]
4070    fn yuiignore_negation_re_includes_file() {
4071        let tmp = TempDir::new().unwrap();
4072        let (source, target) = setup_minimal_dotfiles(&tmp);
4073        std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
4074        std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
4075        // Ignore all .cache files except keep.cache.
4076        std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
4077        apply(Some(source.clone()), false).unwrap();
4078        assert!(target.join("keep.cache").exists());
4079        assert!(!target.join("drop.cache").exists());
4080    }
4081
4082    /// Issue #47: a `.yuiignore` placed in a nested subdirectory must
4083    /// scope its rules to that subtree, just like `.gitignore`.
4084    /// `home/inner/.yuiignore` excluding `secret*` should drop
4085    /// `home/inner/secret.txt` but leave `home/secret.txt` alone.
4086    #[test]
4087    fn nested_yuiignore_only_affects_its_subtree() {
4088        let tmp = TempDir::new().unwrap();
4089        let (source, target) = setup_minimal_dotfiles(&tmp);
4090        std::fs::create_dir_all(source.join("home/inner")).unwrap();
4091        std::fs::write(source.join("home/secret.txt"), "outer keep").unwrap();
4092        std::fs::write(source.join("home/inner/secret.txt"), "inner drop").unwrap();
4093        std::fs::write(source.join("home/inner/keep.txt"), "inner keep").unwrap();
4094        // Nested ignore — affects only `home/inner/`.
4095        std::fs::write(source.join("home/inner/.yuiignore"), "secret*\n").unwrap();
4096        apply(Some(source.clone()), false).unwrap();
4097        assert!(
4098            target.join("secret.txt").exists(),
4099            "outer secret.txt is outside the nested .yuiignore scope"
4100        );
4101        assert!(target.join("inner/keep.txt").exists());
4102        assert!(
4103            !target.join("inner/secret.txt").exists(),
4104            "inner secret.txt should be excluded by the nested .yuiignore"
4105        );
4106    }
4107
4108    /// A nested `.yuiignore` can re-include (via `!negation`) a file
4109    /// the root ignore had excluded — gitignore's last-rule-wins
4110    /// semantics, scoped per-subtree.
4111    #[test]
4112    fn nested_yuiignore_negation_overrides_root_rule() {
4113        let tmp = TempDir::new().unwrap();
4114        let (source, target) = setup_minimal_dotfiles(&tmp);
4115        std::fs::create_dir_all(source.join("home/keepers")).unwrap();
4116        std::fs::write(source.join("home/drop.lock"), "outer drop").unwrap();
4117        std::fs::write(source.join("home/keepers/wanted.lock"), "inner keep").unwrap();
4118        std::fs::write(source.join(".yuiignore"), "*.lock\n").unwrap();
4119        // Re-include `*.lock` only inside keepers/.
4120        std::fs::write(source.join("home/keepers/.yuiignore"), "!*.lock\n").unwrap();
4121        apply(Some(source.clone()), false).unwrap();
4122        assert!(
4123            !target.join("drop.lock").exists(),
4124            "root rule still drops outer .lock file"
4125        );
4126        assert!(
4127            target.join("keepers/wanted.lock").exists(),
4128            "nested negation re-includes .lock under keepers/"
4129        );
4130    }
4131
4132    /// `yui status` walk uses the same nested-`.yuiignore` semantics:
4133    /// a nested ignore scoped to one subtree must NOT make a sibling
4134    /// subtree's identical filename look ignored.
4135    #[test]
4136    fn nested_yuiignore_status_walk_scoped() {
4137        let tmp = TempDir::new().unwrap();
4138        let (source, _target) = setup_minimal_dotfiles(&tmp);
4139        std::fs::create_dir_all(source.join("home/a")).unwrap();
4140        std::fs::create_dir_all(source.join("home/b")).unwrap();
4141        std::fs::write(source.join("home/a/foo.txt"), "a-foo").unwrap();
4142        std::fs::write(source.join("home/b/foo.txt"), "b-foo").unwrap();
4143        // Only `home/a/` ignores foo.txt.
4144        std::fs::write(source.join("home/a/.yuiignore"), "foo.txt\n").unwrap();
4145        apply(Some(source.clone()), false).unwrap();
4146        // status should not error; walk completes despite the nested rule.
4147        let res = status(Some(source), None, /* no_color */ true);
4148        assert!(res.is_ok() || matches!(&res, Err(e) if format!("{e}").contains("diverged")));
4149    }
4150
4151    #[test]
4152    fn yuiignore_skips_template_in_render() {
4153        let tmp = TempDir::new().unwrap();
4154        let source = utf8(tmp.path().join("dotfiles"));
4155        let target = utf8(tmp.path().join("target"));
4156        std::fs::create_dir_all(source.join("home")).unwrap();
4157        std::fs::create_dir_all(&target).unwrap();
4158        std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
4159        std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
4160        let cfg = format!(
4161            r#"
4162[[mount.entry]]
4163src = "home"
4164dst = "{}"
4165"#,
4166            toml_path(&target)
4167        );
4168        std::fs::write(source.join("config.toml"), cfg).unwrap();
4169        apply(Some(source.clone()), false).unwrap();
4170        // Neither the template nor the rendered output linked.
4171        assert!(!source.join("home/note").exists());
4172        assert!(!target.join("note").exists());
4173        assert!(!target.join("note.tera").exists());
4174    }
4175
4176    /// v0.6+: parent `.yuilink` doesn't stop the walker. A parent
4177    /// marker can junction the whole dir, AND a child marker can layer
4178    /// on extra dsts (e.g. an OS-specific alternate location).
4179    #[test]
4180    fn nested_marker_accumulates_extra_dst() {
4181        let tmp = TempDir::new().unwrap();
4182        let source = utf8(tmp.path().join("dotfiles"));
4183        let parent_target = utf8(tmp.path().join("home"));
4184        let extra_target = utf8(tmp.path().join("extra"));
4185        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
4186        std::fs::create_dir_all(&parent_target).unwrap();
4187        std::fs::create_dir_all(&extra_target).unwrap();
4188        std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
4189
4190        // Parent: junction the whole .config dir to <home>/.config.
4191        std::fs::write(
4192            source.join("home/.config/.yuilink"),
4193            format!(
4194                r#"
4195[[link]]
4196dst = "{}/.config"
4197"#,
4198                toml_path(&parent_target)
4199            ),
4200        )
4201        .unwrap();
4202        // Child: ALSO junction nvim/ to an extra path, but only on the
4203        // running OS (so the test exercises an active link).
4204        std::fs::write(
4205            source.join("home/.config/nvim/.yuilink"),
4206            format!(
4207                r#"
4208[[link]]
4209dst = "{}/nvim"
4210when = "{{{{ yui.os == '{}' }}}}"
4211"#,
4212                toml_path(&extra_target),
4213                std::env::consts::OS
4214            ),
4215        )
4216        .unwrap();
4217
4218        let cfg = format!(
4219            r#"
4220[[mount.entry]]
4221src = "home"
4222dst = "{}"
4223"#,
4224            toml_path(&parent_target)
4225        );
4226        std::fs::write(source.join("config.toml"), cfg).unwrap();
4227
4228        apply(Some(source.clone()), false).unwrap();
4229
4230        // Both links are present: parent's whole-.config junction reaches
4231        // init.lua, and the child marker added an additional path.
4232        assert!(parent_target.join(".config/nvim/init.lua").exists());
4233        assert!(extra_target.join("nvim/init.lua").exists());
4234    }
4235
4236    /// v0.6+: `[[link]] src = "<filename>"` links a single sibling file
4237    /// to a custom dst, leaving the rest of the dir to default
4238    /// behaviour. Useful for paths like the PowerShell profile that
4239    /// have to live in a non-`~/.config` location on Windows.
4240    #[test]
4241    fn marker_file_link_targets_specific_file() {
4242        let tmp = TempDir::new().unwrap();
4243        let source = utf8(tmp.path().join("dotfiles"));
4244        let parent_target = utf8(tmp.path().join("home"));
4245        let docs_target = utf8(tmp.path().join("docs"));
4246        std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
4247        std::fs::create_dir_all(&parent_target).unwrap();
4248        std::fs::create_dir_all(&docs_target).unwrap();
4249        std::fs::write(
4250            source.join("home/.config/powershell/profile.ps1"),
4251            "# profile\n",
4252        )
4253        .unwrap();
4254        std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
4255
4256        // File-level entry only — no dir-level [[link]], so the dir
4257        // itself still falls through to the default mount placement.
4258        std::fs::write(
4259            source.join("home/.config/powershell/.yuilink"),
4260            format!(
4261                r#"
4262[[link]]
4263src = "profile.ps1"
4264dst = "{}/Microsoft.PowerShell_profile.ps1"
4265"#,
4266                toml_path(&docs_target)
4267            ),
4268        )
4269        .unwrap();
4270
4271        let cfg = format!(
4272            r#"
4273[[mount.entry]]
4274src = "home"
4275dst = "{}"
4276"#,
4277            toml_path(&parent_target)
4278        );
4279        std::fs::write(source.join("config.toml"), cfg).unwrap();
4280
4281        apply(Some(source.clone()), false).unwrap();
4282
4283        // File-level target gets the link.
4284        assert!(
4285            docs_target
4286                .join("Microsoft.PowerShell_profile.ps1")
4287                .exists()
4288        );
4289        // Default per-file placement still happens for ALL files in the
4290        // dir (the marker had no dir-level [[link]] to claim coverage).
4291        assert!(
4292            parent_target
4293                .join(".config/powershell/profile.ps1")
4294                .exists()
4295        );
4296        assert!(parent_target.join(".config/powershell/extra.txt").exists());
4297    }
4298
4299    /// File-level [[link]] errors clearly when src points at a missing
4300    /// file — config bug, not a silent skip.
4301    #[test]
4302    fn marker_file_link_missing_src_errors() {
4303        let tmp = TempDir::new().unwrap();
4304        let source = utf8(tmp.path().join("dotfiles"));
4305        let parent_target = utf8(tmp.path().join("home"));
4306        let docs_target = utf8(tmp.path().join("docs"));
4307        std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
4308        std::fs::create_dir_all(&parent_target).unwrap();
4309        std::fs::create_dir_all(&docs_target).unwrap();
4310
4311        std::fs::write(
4312            source.join("home/.config/powershell/.yuilink"),
4313            format!(
4314                r#"
4315[[link]]
4316src = "missing.ps1"
4317dst = "{}/profile.ps1"
4318"#,
4319                toml_path(&docs_target)
4320            ),
4321        )
4322        .unwrap();
4323
4324        let cfg = format!(
4325            r#"
4326[[mount.entry]]
4327src = "home"
4328dst = "{}"
4329"#,
4330            toml_path(&parent_target)
4331        );
4332        std::fs::write(source.join("config.toml"), cfg).unwrap();
4333
4334        let err = apply(Some(source.clone()), false).unwrap_err();
4335        assert!(format!("{err:#}").contains("missing.ps1"));
4336    }
4337
4338    fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
4339        let mut out = Vec::new();
4340        let mut stack = vec![root.to_path_buf()];
4341        while let Some(dir) = stack.pop() {
4342            let Ok(entries) = std::fs::read_dir(&dir) else {
4343                continue;
4344            };
4345            for e in entries.flatten() {
4346                let p = utf8(e.path());
4347                if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
4348                    stack.push(p);
4349                } else {
4350                    out.push(p);
4351                }
4352            }
4353        }
4354        out
4355    }
4356
4357    // -----------------------------------------------------------------
4358    // gc-backup
4359    // -----------------------------------------------------------------
4360
4361    #[test]
4362    fn parse_backup_suffix_recognises_file_with_extension() {
4363        let dt = parse_backup_suffix("foo_20260429_143022123.yml").unwrap();
4364        assert_eq!(dt.year(), 2026);
4365        assert_eq!(dt.month(), 4);
4366        assert_eq!(dt.day(), 29);
4367        assert_eq!(dt.hour(), 14);
4368        assert_eq!(dt.minute(), 30);
4369        assert_eq!(dt.second(), 22);
4370    }
4371
4372    #[test]
4373    fn parse_backup_suffix_recognises_dotfile_no_extension() {
4374        let dt = parse_backup_suffix(".gitconfig_20260429_143022123").unwrap();
4375        assert_eq!(dt.year(), 2026);
4376    }
4377
4378    #[test]
4379    fn parse_backup_suffix_recognises_directory_form() {
4380        let dt = parse_backup_suffix("nvim_20260429_143022123").unwrap();
4381        assert_eq!(dt.day(), 29);
4382    }
4383
4384    #[test]
4385    fn parse_backup_suffix_recognises_multi_dot_filename() {
4386        // archive.tar.gz_<ts>.gz round-trips back through the rsplit-on-dot fallback.
4387        let dt = parse_backup_suffix("archive.tar.gz_20260429_143022123.gz").unwrap();
4388        assert_eq!(dt.month(), 4);
4389    }
4390
4391    #[test]
4392    fn parse_backup_suffix_rejects_non_yui_names() {
4393        assert!(parse_backup_suffix("README.md").is_none());
4394        assert!(parse_backup_suffix("notes_2026.txt").is_none());
4395        assert!(parse_backup_suffix("almost_20260429_14302212").is_none()); // 17 digits
4396        assert!(parse_backup_suffix("almost_20260429-143022123").is_none()); // wrong sep
4397        // Bare timestamp with no stem is rejected (defensive — yui never produces these).
4398        assert!(parse_backup_suffix("_20260429_143022123").is_none());
4399    }
4400
4401    #[test]
4402    fn parse_human_duration_basic_units() {
4403        let s = parse_human_duration("30d").unwrap();
4404        assert_eq!(s.get_days(), 30);
4405        let s = parse_human_duration("2w").unwrap();
4406        assert_eq!(s.get_weeks(), 2);
4407        let s = parse_human_duration("12h").unwrap();
4408        assert_eq!(s.get_hours(), 12);
4409        // `m` is minutes (matches what `format_age` prints), `mo` is months.
4410        let s = parse_human_duration("5m").unwrap();
4411        assert_eq!(s.get_minutes(), 5);
4412        let s = parse_human_duration("6mo").unwrap();
4413        assert_eq!(s.get_months(), 6);
4414        let s = parse_human_duration("1y").unwrap();
4415        assert_eq!(s.get_years(), 1);
4416    }
4417
4418    #[test]
4419    fn parse_human_duration_case_insensitive_and_whitespace() {
4420        let s = parse_human_duration("  90D  ").unwrap();
4421        assert_eq!(s.get_days(), 90);
4422        let s = parse_human_duration("3WEEKS").unwrap();
4423        assert_eq!(s.get_weeks(), 3);
4424    }
4425
4426    #[test]
4427    fn parse_human_duration_rejects_garbage() {
4428        assert!(parse_human_duration("").is_err());
4429        assert!(parse_human_duration("d30").is_err());
4430        assert!(parse_human_duration("30").is_err()); // no unit
4431        assert!(parse_human_duration("30x").is_err()); // unknown unit
4432        assert!(parse_human_duration("-1d").is_err()); // negative
4433    }
4434
4435    /// Plant a real-shaped backup tree and confirm `walk_gc_backups`
4436    /// finds both files and dir-snapshots, treats dirs as one unit
4437    /// (no descent), and ignores anything without yui's suffix.
4438    #[test]
4439    fn walk_gc_backups_collects_files_and_dir_snapshots() {
4440        let tmp = TempDir::new().unwrap();
4441        let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
4442        std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
4443        // File-style backup.
4444        std::fs::write(
4445            root.join("C/Users/u/.config/foo_20260429_143022123.yml"),
4446            "old yml",
4447        )
4448        .unwrap();
4449        // Dir-style backup with internal files (must not be surfaced individually).
4450        std::fs::create_dir_all(root.join("C/Users/u/nvim_20260101_000000000/lua")).unwrap();
4451        std::fs::write(
4452            root.join("C/Users/u/nvim_20260101_000000000/init.lua"),
4453            "ok",
4454        )
4455        .unwrap();
4456        std::fs::write(
4457            root.join("C/Users/u/nvim_20260101_000000000/lua/x.lua"),
4458            "kk",
4459        )
4460        .unwrap();
4461        // User-dropped file with no yui suffix — must stay out of the survey.
4462        std::fs::write(root.join("C/Users/u/.config/README.md"), "user note").unwrap();
4463
4464        let entries = walk_gc_backups(&root).unwrap();
4465        assert_eq!(entries.len(), 2, "two backup roots, not three");
4466        let kinds: Vec<_> = entries.iter().map(|e| e.kind).collect();
4467        assert!(kinds.contains(&BackupKind::File));
4468        assert!(kinds.contains(&BackupKind::Dir));
4469        // Dir-size aggregates contents.
4470        let dir_entry = entries.iter().find(|e| e.kind == BackupKind::Dir).unwrap();
4471        assert!(dir_entry.size_bytes >= 4); // "ok" + "kk"
4472    }
4473
4474    #[test]
4475    fn cleanup_empty_parents_stops_at_root_and_at_non_empty() {
4476        let tmp = TempDir::new().unwrap();
4477        let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
4478        std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
4479        std::fs::write(root.join("C/Users/u/sibling_keep"), "x").unwrap();
4480
4481        // Pretend we just deleted everything under .config/, the parent
4482        // is now empty and walks up — but Users/ has `sibling_keep` so
4483        // we must stop there. .yui/backup itself must never be removed.
4484        cleanup_empty_parents(&root.join("C/Users/u/.config"), &root);
4485
4486        assert!(!root.join("C/Users/u/.config").exists(), "empty leaf gone");
4487        assert!(root.join("C/Users/u").exists(), "stops at non-empty parent");
4488        assert!(root.exists(), "backup root preserved");
4489    }
4490
4491    /// Survey mode (no `--older-than`) lists everything and deletes nothing.
4492    #[test]
4493    fn gc_backup_survey_keeps_all_entries() {
4494        let tmp = TempDir::new().unwrap();
4495        let source = utf8(tmp.path().join("dotfiles"));
4496        std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
4497        std::fs::write(source.join("config.toml"), "").unwrap();
4498        let backup = source.join(".yui/backup");
4499        std::fs::write(backup.join("a_20260101_000000000.txt"), "old").unwrap();
4500        std::fs::write(backup.join("b_20260415_120000000.txt"), "fresh").unwrap();
4501
4502        gc_backup(Some(source.clone()), None, false, None, true).unwrap();
4503
4504        // Both still present.
4505        assert!(backup.join("a_20260101_000000000.txt").exists());
4506        assert!(backup.join("b_20260415_120000000.txt").exists());
4507    }
4508
4509    /// Prune mode deletes entries strictly older than the cutoff and
4510    /// leaves newer ones plus user-dropped files alone.
4511    #[test]
4512    fn gc_backup_prune_removes_old_files_only() {
4513        let tmp = TempDir::new().unwrap();
4514        let source = utf8(tmp.path().join("dotfiles"));
4515        std::fs::create_dir_all(source.join(".yui/backup/sub")).unwrap();
4516        std::fs::write(source.join("config.toml"), "").unwrap();
4517        let backup = source.join(".yui/backup");
4518
4519        // Far-past file (will be older than 30d unless this test runs in 2026-01).
4520        std::fs::write(backup.join("sub/old_20200101_000000000.txt"), "old").unwrap();
4521        // Tomorrow → ts > now → never older than any positive cutoff.
4522        let tomorrow = jiff::Zoned::now()
4523            .checked_add(jiff::Span::new().days(1))
4524            .unwrap();
4525        let bdt = jiff::fmt::strtime::BrokenDownTime::from(&tomorrow);
4526        let future_ts = bdt.to_string("%Y%m%d_%H%M%S%3f").unwrap();
4527        std::fs::write(backup.join(format!("fresh_{future_ts}.txt")), "fresh").unwrap();
4528        // User-dropped file — not in yui shape.
4529        std::fs::write(backup.join("notes.md"), "mine").unwrap();
4530
4531        gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
4532
4533        assert!(!backup.join("sub/old_20200101_000000000.txt").exists());
4534        // Empty parent dir got cleaned up too.
4535        assert!(!backup.join("sub").exists(), "empty parent removed");
4536        // Backup root itself is preserved even after losing children.
4537        assert!(backup.exists());
4538        assert!(backup.join(format!("fresh_{future_ts}.txt")).exists());
4539        assert!(backup.join("notes.md").exists(), "user file untouched");
4540    }
4541
4542    /// `--dry-run` prints the same set but mutates nothing.
4543    #[test]
4544    fn gc_backup_dry_run_does_not_delete() {
4545        let tmp = TempDir::new().unwrap();
4546        let source = utf8(tmp.path().join("dotfiles"));
4547        std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
4548        std::fs::write(source.join("config.toml"), "").unwrap();
4549        let backup = source.join(".yui/backup");
4550        std::fs::write(backup.join("old_20200101_000000000.txt"), "old").unwrap();
4551
4552        gc_backup(Some(source.clone()), Some("30d".into()), true, None, true).unwrap();
4553
4554        assert!(
4555            backup.join("old_20200101_000000000.txt").exists(),
4556            "dry-run keeps everything in place"
4557        );
4558    }
4559
4560    /// Dir-snapshots are removed wholesale (no per-file judgment) and
4561    /// the now-empty mirror parents collapse up to (but not past) the
4562    /// backup root.
4563    #[test]
4564    fn gc_backup_prune_handles_directory_snapshot() {
4565        let tmp = TempDir::new().unwrap();
4566        let source = utf8(tmp.path().join("dotfiles"));
4567        std::fs::create_dir_all(source.join(".yui/backup/mirror/u")).unwrap();
4568        std::fs::write(source.join("config.toml"), "").unwrap();
4569        let backup = source.join(".yui/backup");
4570        let snap = backup.join("mirror/u/nvim_20200101_000000000");
4571        std::fs::create_dir_all(snap.join("lua")).unwrap();
4572        std::fs::write(snap.join("init.lua"), "x").unwrap();
4573        std::fs::write(snap.join("lua/y.lua"), "y").unwrap();
4574
4575        gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
4576
4577        assert!(!snap.exists(), "dir snapshot removed wholesale");
4578        assert!(!backup.join("mirror").exists(), "empty mirror chain pruned");
4579        assert!(backup.exists(), "backup root preserved");
4580    }
4581}