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