Skip to main content

yui/
cmd.rs

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