Skip to main content

yui/
cmd.rs

1//! Command implementations.
2//!
3//! Each `Command` variant in `cli.rs` calls one of these. Currently
4//! implemented: `apply`, `init`, `doctor`. The rest are `todo!()`.
5
6use std::fmt::Write as _;
7
8use anyhow::{Context as _, Result};
9use camino::{Utf8Path, Utf8PathBuf};
10use tera::Context as TeraContext;
11use tracing::{info, warn};
12
13use crate::config::{self, Config, HookPhase, IconsMode, MountStrategy};
14use crate::hook::{self, HookOutcome};
15use crate::icons::Icons;
16use crate::link::{self, EffectiveDirMode, EffectiveFileMode, resolve_dir_mode, resolve_file_mode};
17use crate::marker::{self, MarkerSpec};
18use crate::mount::{self, ResolvedMount};
19use crate::render::{self, RenderReport};
20use crate::template;
21use crate::vars::YuiVars;
22use crate::{absorb, backup, paths};
23
24// NOTE: `owo_colors::OwoColorize` is intentionally NOT imported at module
25// scope — its blanket impl shadows inherent methods of unrelated types
26// (e.g. `ignore::WalkBuilder::hidden(bool)` collides with
27// `OwoColorize::hidden(&self)`). Each print function imports the trait
28// locally with `use owo_colors::OwoColorize as _;`.
29
30pub fn init(source: Option<Utf8PathBuf>, git_hooks: bool) -> Result<()> {
31    let dir = match source {
32        Some(s) => absolutize(&s)?,
33        None => current_dir_utf8()?,
34    };
35    std::fs::create_dir_all(&dir)?;
36    let config_path = dir.join("config.toml");
37    let scaffolded = if !config_path.exists() {
38        std::fs::write(&config_path, SKELETON_CONFIG)?;
39        info!("initialized yui source repo at {dir}");
40        info!("created: {config_path}");
41        true
42    } else if git_hooks {
43        // Existing repo + hooks-only invocation: just install the
44        // hooks. Don't bail like we used to — a user who already has
45        // a populated dotfiles repo shouldn't need to delete
46        // config.toml to opt into the render-drift hooks.
47        info!(
48            "config.toml already exists at {config_path} \
49             — skipping scaffold, installing git hooks only"
50        );
51        false
52    } else {
53        anyhow::bail!("config.toml already exists at {config_path}");
54    };
55
56    // .gitignore upkeep is `init`'s responsibility — running it
57    // again on an existing repo (e.g. for a hooks-only install)
58    // should still backfill the yui-required ignore lines if the
59    // .gitignore has drifted. The rendered-template section is
60    // separately maintained by `apply`'s render flow, so we only
61    // touch the state / backup / config.local entries here.
62    ensure_gitignore_yui_entries(&dir)?;
63
64    if git_hooks {
65        install_git_hooks(&dir)?;
66    }
67    if scaffolded {
68        info!("next: edit config.toml, then run `yui apply`");
69    }
70    Ok(())
71}
72
73/// .gitignore lines yui needs every dotfiles repo to carry. Anything
74/// the render flow auto-manages (the `# >>> yui rendered ... <<<`
75/// section) lives there; what `init` owns is the per-machine state +
76/// backup pile + the `config.local.toml` carve-out.
77const YUI_REQUIRED_GITIGNORE: &[&str] = &[
78    "/.yui/state.json",
79    "/.yui/state.json.tmp",
80    "/.yui/backup/",
81    "config.local.toml",
82];
83
84/// Ensure each `YUI_REQUIRED_GITIGNORE` line is present in the repo's
85/// `.gitignore`. Creates the file with the full skeleton when it's
86/// missing entirely, and appends only the missing entries (in a
87/// labelled section) when it already exists. Idempotent — re-running
88/// `init` is a no-op once the entries are in place.
89fn ensure_gitignore_yui_entries(dir: &Utf8Path) -> Result<()> {
90    let path = dir.join(".gitignore");
91    if !path.exists() {
92        std::fs::write(&path, SKELETON_GITIGNORE)?;
93        info!("created: {path}");
94        return Ok(());
95    }
96    let existing = std::fs::read_to_string(&path)?;
97    let missing: Vec<&str> = YUI_REQUIRED_GITIGNORE
98        .iter()
99        .copied()
100        .filter(|entry| !existing.lines().any(|line| line.trim() == *entry))
101        .collect();
102    if missing.is_empty() {
103        return Ok(());
104    }
105    let mut next = existing;
106    if !next.is_empty() && !next.ends_with('\n') {
107        next.push('\n');
108    }
109    if !next.is_empty() {
110        next.push('\n');
111    }
112    next.push_str("# yui per-machine state and backups (added by `yui init`).\n");
113    for entry in &missing {
114        next.push_str(entry);
115        next.push('\n');
116    }
117    std::fs::write(&path, next)?;
118    info!(
119        "updated .gitignore: appended {} yui entr{} ({})",
120        missing.len(),
121        if missing.len() == 1 { "y" } else { "ies" },
122        missing.join(", ")
123    );
124    Ok(())
125}
126
127/// Install yui's render-drift hooks into the source repo's
128/// `.git/hooks/`. Both pre-commit and pre-push run `yui render --check`
129/// — pre-commit catches the easy case (you forgot to `apply` before
130/// committing), pre-push is the safety net that catches anything a
131/// bypassed pre-commit (or a `git commit --no-verify`) let slip
132/// through.
133///
134/// Asks git for the hooks directory via `rev-parse --git-path hooks`
135/// so `core.hooksPath` (configured globally or per-repo to redirect
136/// hooks elsewhere) is honoured, and worktrees / bare repos / GIT_DIR
137/// overrides come along for the ride. Refuses to overwrite existing
138/// hooks — the user has to delete them first if they want yui to
139/// manage that slot.
140fn install_git_hooks(source: &Utf8Path) -> Result<()> {
141    let out = std::process::Command::new("git")
142        .args(["rev-parse", "--git-path", "hooks"])
143        .current_dir(source.as_std_path())
144        .output()
145        .with_context(|| format!("git rev-parse --git-path hooks in {source}"))?;
146    if !out.status.success() {
147        let stderr = String::from_utf8_lossy(&out.stderr);
148        anyhow::bail!(
149            "--git-hooks: {source} doesn't look like a git repo \
150             (run `git init` first). git: {}",
151            stderr.trim()
152        );
153    }
154    let raw = String::from_utf8(out.stdout)?;
155    let hooks_dir = {
156        let p = Utf8PathBuf::from(raw.trim());
157        if p.is_absolute() { p } else { source.join(p) }
158    };
159    std::fs::create_dir_all(&hooks_dir).with_context(|| format!("mkdir -p {hooks_dir}"))?;
160
161    for (name, body) in [("pre-commit", PRE_COMMIT_HOOK), ("pre-push", PRE_PUSH_HOOK)] {
162        let path = hooks_dir.join(name);
163        if path.exists() {
164            warn!("--git-hooks: {path} already exists — leaving it alone");
165            continue;
166        }
167        std::fs::write(&path, body).with_context(|| format!("write hook {path}"))?;
168        #[cfg(unix)]
169        {
170            use std::os::unix::fs::PermissionsExt;
171            let mut perms = std::fs::metadata(&path)?.permissions();
172            perms.set_mode(0o755);
173            std::fs::set_permissions(&path, perms)?;
174        }
175        info!("installed: {path}");
176    }
177    Ok(())
178}
179
180const PRE_COMMIT_HOOK: &str = r#"#!/bin/sh
181# Installed by `yui init --git-hooks`.
182# Reject the commit if any `*.tera` template would render to something
183# that diverges from the rendered output staged alongside it. Run
184# `yui apply` (or `yui render`) to refresh and re-commit.
185exec yui render --check
186"#;
187
188const PRE_PUSH_HOOK: &str = r#"#!/bin/sh
189# Installed by `yui init --git-hooks`.
190# Same render-drift check as pre-commit, mirrored on push so a
191# `--no-verify` commit doesn't sneak diverged state to the remote.
192exec yui render --check
193"#;
194
195pub fn apply(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
196    let source = resolve_source(source)?;
197    let yui = YuiVars::detect(&source);
198    let config = config::load(&source, &yui)?;
199    // Load `.yuiignore` once and thread through render + walk so the
200    // matcher isn't re-built per-flow.
201    let yuiignore = paths::load_yuiignore(&source)?;
202
203    let mut engine = template::Engine::new();
204    let tera_ctx = template::template_context(&yui, &config.vars);
205
206    // 0. Pre-apply hooks (before render / link). Bail on hook failure so
207    //    apply doesn't proceed past a broken bootstrap.
208    hook::run_phase(
209        &config,
210        &source,
211        &yui,
212        &mut engine,
213        &tera_ctx,
214        HookPhase::Pre,
215        dry_run,
216    )?;
217
218    // 1. Render templates first so the link walk picks up rendered files.
219    let render_report = render::render_all(&source, &config, &yui, &yuiignore, dry_run)?;
220    log_render_report(&render_report);
221    if render_report.has_drift() {
222        anyhow::bail!(
223            "render drift detected ({} file(s)); reflect target edits back into the .tera before re-running apply",
224            render_report.diverged.len()
225        );
226    }
227
228    // 2. Resolve mounts and link.
229    let mounts = mount::resolve(
230        &config.mount.entry,
231        config.mount.default_strategy,
232        &mut engine,
233        &tera_ctx,
234    )?;
235
236    let backup_root = source.join(&config.backup.dir);
237    let ctx = ApplyCtx {
238        config: &config,
239        source: &source,
240        yuiignore: &yuiignore,
241        file_mode: resolve_file_mode(config.link.file_mode),
242        dir_mode: resolve_dir_mode(config.link.dir_mode),
243        backup_root: &backup_root,
244        dry_run,
245    };
246
247    info!("source: {source}");
248    info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
249    if dry_run {
250        info!("dry-run: nothing will be written");
251    }
252
253    for m in &mounts {
254        info!("mount: {} → {}", m.src, m.dst);
255        process_mount(&source, m, &ctx, &mut engine, &tera_ctx)?;
256    }
257
258    // 3. Post-apply hooks (after every link is in place).
259    hook::run_phase(
260        &config,
261        &source,
262        &yui,
263        &mut engine,
264        &tera_ctx,
265        HookPhase::Post,
266        dry_run,
267    )?;
268    Ok(())
269}
270
271fn log_render_report(r: &RenderReport) {
272    if !r.written.is_empty() {
273        info!("rendered {} new file(s)", r.written.len());
274    }
275    if !r.unchanged.is_empty() {
276        info!("rendered {} file(s) unchanged", r.unchanged.len());
277    }
278    if !r.skipped_when_false.is_empty() {
279        info!(
280            "skipped {} template(s) (when=false)",
281            r.skipped_when_false.len()
282        );
283    }
284    for d in &r.diverged {
285        warn!("rendered file diverged from template: {d}");
286    }
287}
288
289/// Bundle of immutable settings threaded through the apply walk.
290struct ApplyCtx<'a> {
291    config: &'a Config,
292    /// Source repo root — needed for git-clean checks during absorb and
293    /// for resolving paths inside `is_ignored` against `.yuiignore`.
294    source: &'a Utf8Path,
295    /// Patterns from `$source/.yuiignore`. Empty matcher when the file
296    /// is absent.
297    yuiignore: &'a ignore::gitignore::Gitignore,
298    file_mode: EffectiveFileMode,
299    dir_mode: EffectiveDirMode,
300    backup_root: &'a Utf8Path,
301    dry_run: bool,
302}
303
304/// Show the resolved src→dst mappings for the current source repo.
305///
306/// By default only entries whose `when` matches the current host are shown
307/// (`active`). With `--all`, inactive entries are included with a dim row
308/// and the `when` condition that excluded them.
309pub fn list(
310    source: Option<Utf8PathBuf>,
311    all: bool,
312    icons_override: Option<IconsMode>,
313    no_color: bool,
314) -> Result<()> {
315    let source = resolve_source(source)?;
316    let yui = YuiVars::detect(&source);
317    let config = config::load(&source, &yui)?;
318
319    let icons_mode = icons_override.unwrap_or(config.ui.icons);
320    let icons = Icons::for_mode(icons_mode);
321    let color = !no_color && supports_color_stdout();
322
323    let items = collect_list_items(&source, &config, &yui)?;
324    let displayed: Vec<&ListItem> = if all {
325        items.iter().collect()
326    } else {
327        items.iter().filter(|i| i.active).collect()
328    };
329
330    print_list_table(&displayed, icons, color);
331
332    let total = items.len();
333    let active = items.iter().filter(|i| i.active).count();
334    let inactive = total - active;
335    println!();
336    if all {
337        println!("  {total} entries · {active} active · {inactive} inactive");
338    } else {
339        println!(
340            "  {} of {} entries shown ({} inactive hidden — use --all)",
341            active, total, inactive
342        );
343    }
344    Ok(())
345}
346
347#[derive(Debug)]
348struct ListItem {
349    src: Utf8PathBuf,
350    dst: String,
351    when: Option<String>,
352    active: bool,
353}
354
355fn collect_list_items(source: &Utf8Path, config: &Config, yui: &YuiVars) -> Result<Vec<ListItem>> {
356    let mut engine = template::Engine::new();
357    let tera_ctx = template::template_context(yui, &config.vars);
358    let yuiignore = paths::load_yuiignore(source)?;
359    let mut items = Vec::new();
360
361    // 1. config.toml [[mount.entry]] entries
362    for entry in &config.mount.entry {
363        let active = match &entry.when {
364            None => true,
365            Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
366        };
367        let dst = engine
368            .render(&entry.dst, &tera_ctx)
369            .map(|s| paths::expand_tilde(s.trim()).to_string())
370            .unwrap_or_else(|_| entry.dst.clone());
371        items.push(ListItem {
372            src: entry.src.clone(),
373            dst,
374            when: entry.when.clone(),
375            active,
376        });
377    }
378
379    // 2. .yuilink overrides under source
380    let walker = paths::source_walker(source).build();
381    let marker_filename = &config.mount.marker_filename;
382    for entry in walker {
383        let entry = match entry {
384            Ok(e) => e,
385            Err(_) => continue,
386        };
387        if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
388            continue;
389        }
390        if entry.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
391            continue;
392        }
393        let dir = match entry.path().parent() {
394            Some(d) => d,
395            None => continue,
396        };
397        let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
398            Ok(p) => p,
399            Err(_) => continue,
400        };
401        // .yuiignore filter — markers inside ignored subtrees are skipped.
402        if paths::is_ignored(&yuiignore, source, &dir_utf8, true) {
403            continue;
404        }
405        let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
406            Some(s) => s,
407            None => continue,
408        };
409        let MarkerSpec::Explicit { links } = spec else {
410            continue; // PassThrough markers are already implied by mount entry
411        };
412        let rel = dir_utf8
413            .strip_prefix(source)
414            .map(Utf8PathBuf::from)
415            .unwrap_or(dir_utf8);
416        for link in &links {
417            let active = match &link.when {
418                None => true,
419                Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
420            };
421            let dst = engine
422                .render(&link.dst, &tera_ctx)
423                .map(|s| paths::expand_tilde(s.trim()).to_string())
424                .unwrap_or_else(|_| link.dst.clone());
425            // File-level entry (`[[link]] src = "<filename>"`) targets a
426            // single file inside the marker dir; show that file path
427            // instead of the bare dir so `yui list` makes the scope
428            // obvious at a glance.
429            let src_display = match &link.src {
430                Some(filename) => rel.join(filename),
431                None => rel.clone(),
432            };
433            items.push(ListItem {
434                src: src_display,
435                dst,
436                when: link.when.clone(),
437                active,
438            });
439        }
440    }
441
442    items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
443    Ok(items)
444}
445
446fn supports_color_stdout() -> bool {
447    use std::io::IsTerminal;
448    std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
449}
450
451fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
452    let src_w = items
453        .iter()
454        .map(|i| i.src.as_str().chars().count())
455        .max()
456        .unwrap_or(0)
457        .max("SRC".len());
458    let dst_w = items
459        .iter()
460        .map(|i| i.dst.chars().count())
461        .max()
462        .unwrap_or(0)
463        .max("DST".len());
464
465    let status_w = "STATUS".len();
466    let arrow_w = icons.arrow.chars().count();
467
468    // Header
469    print_header(status_w, src_w, arrow_w, dst_w, color);
470
471    // Separator
472    let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
473    if color {
474        use owo_colors::OwoColorize as _;
475        println!("{}", sep.dimmed());
476    } else {
477        println!("{sep}");
478    }
479
480    // Rows
481    for item in items {
482        print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
483    }
484}
485
486fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
487    use owo_colors::OwoColorize as _;
488    let mut line = String::new();
489    let _ = write!(
490        &mut line,
491        "  {:<status_w$}  {:<src_w$}  {:<arrow_w$}  {:<dst_w$}  WHEN",
492        "STATUS", "SRC", "", "DST"
493    );
494    if color {
495        println!("{}", line.bold());
496    } else {
497        println!("{line}");
498    }
499}
500
501fn render_separator(
502    sep_ch: char,
503    status_w: usize,
504    src_w: usize,
505    arrow_w: usize,
506    dst_w: usize,
507) -> String {
508    let bar = |n: usize| sep_ch.to_string().repeat(n);
509    format!(
510        "  {}  {}  {}  {}  {}",
511        bar(status_w),
512        bar(src_w),
513        bar(arrow_w),
514        bar(dst_w),
515        bar("WHEN".len())
516    )
517}
518
519fn print_row(
520    item: &ListItem,
521    icons: Icons,
522    status_w: usize,
523    src_w: usize,
524    arrow_w: usize,
525    dst_w: usize,
526    color: bool,
527) {
528    use owo_colors::OwoColorize as _;
529    let status = if item.active {
530        icons.active
531    } else {
532        icons.inactive
533    };
534    let when_str = item
535        .when
536        .as_deref()
537        .map(strip_braces)
538        .unwrap_or_else(|| "(always)".to_string());
539
540    // Normalize backslashes to forward slashes for cross-platform display.
541    let src_display = item.src.as_str().replace('\\', "/");
542    let src = src_display.as_str();
543    let dst = &item.dst;
544    let arrow = icons.arrow;
545
546    // Pad each cell to its column width FIRST, then apply color. Doing it
547    // the other way round lets ANSI escape codes count as printable chars
548    // in `format!("{:<w$}")`, which silently breaks alignment when colors
549    // are enabled (caught in PR #11 review).
550    let cell_status = format!("{:<status_w$}", status);
551    let cell_src = format!("{:<src_w$}", src);
552    let cell_arrow = format!("{:<arrow_w$}", arrow);
553    let cell_dst = format!("{:<dst_w$}", dst);
554
555    if !color {
556        println!("  {cell_status}  {cell_src}  {cell_arrow}  {cell_dst}  {when_str}");
557        return;
558    }
559
560    if item.active {
561        println!(
562            "  {}  {}  {}  {}  {}",
563            cell_status.green(),
564            cell_src.cyan(),
565            cell_arrow.dimmed(),
566            cell_dst.green(),
567            when_str.dimmed()
568        );
569    } else {
570        println!(
571            "  {}  {}  {}  {}  {}",
572            cell_status.red().dimmed(),
573            cell_src.dimmed(),
574            cell_arrow.dimmed(),
575            cell_dst.dimmed(),
576            when_str.dimmed()
577        );
578    }
579}
580
581/// Strip the outer `{{ ... }}` Tera braces from a `when` expression for
582/// display purposes (shorter line, easier to read at a glance).
583fn strip_braces(expr: &str) -> String {
584    let trimmed = expr.trim();
585    if let Some(inner) = trimmed
586        .strip_prefix("{{")
587        .and_then(|s| s.strip_suffix("}}"))
588    {
589        inner.trim().to_string()
590    } else {
591        trimmed.to_string()
592    }
593}
594
595pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
596    let source = resolve_source(source)?;
597    let yui = YuiVars::detect(&source);
598    let config = config::load(&source, &yui)?;
599    let yuiignore = paths::load_yuiignore(&source)?;
600    // --check is a stricter dry-run: never writes, exits non-zero on drift.
601    let report = render::render_all(&source, &config, &yui, &yuiignore, dry_run || check)?;
602    log_render_report(&report);
603    if check && report.has_drift() {
604        anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
605    }
606    Ok(())
607}
608
609pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
610    // For now `link` and `apply` do the same thing (no render/absorb yet).
611    apply(source, dry_run)
612}
613
614pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
615    let _source = resolve_source(source)?;
616    if paths_arg.is_empty() {
617        anyhow::bail!("yui unlink: provide at least one target path");
618    }
619    for p in paths_arg {
620        let abs = absolutize(&p)?;
621        info!("unlink: {abs}");
622        link::unlink(&abs)?;
623    }
624    Ok(())
625}
626
627/// Show every src→dst pair's drift state against the current host.
628///
629/// Walks each `[[mount.entry]]`'s source tree, honoring `.yuilink`
630/// markers (PassThrough = single dir-level link, Override = one or more
631/// custom dsts), classifies each pair via [`crate::absorb::classify`],
632/// and additionally surfaces any **render drift** — rendered files
633/// whose content has diverged from what the matching `.tera` template
634/// would produce now (i.e. the user edited the rendered file in place
635/// without reflecting the change back into the template).
636///
637/// Exits non-zero (via `anyhow::bail!`) when anything diverges, so
638/// `yui status && …` can gate workflows on a clean tree.
639pub fn status(
640    source: Option<Utf8PathBuf>,
641    icons_override: Option<IconsMode>,
642    no_color: bool,
643) -> Result<()> {
644    let source = resolve_source(source)?;
645    let yui = YuiVars::detect(&source);
646    let config = config::load(&source, &yui)?;
647
648    let mut engine = template::Engine::new();
649    let tera_ctx = template::template_context(&yui, &config.vars);
650    let mounts = mount::resolve(
651        &config.mount.entry,
652        config.mount.default_strategy,
653        &mut engine,
654        &tera_ctx,
655    )?;
656
657    let icons_mode = icons_override.unwrap_or(config.ui.icons);
658    let icons = Icons::for_mode(icons_mode);
659    let color = !no_color && supports_color_stdout();
660
661    let mut report: Vec<StatusItem> = Vec::new();
662    // Load `.yuiignore` once and reuse for both render-drift detection
663    // and the link-drift walk below.
664    let yuiignore = paths::load_yuiignore(&source)?;
665
666    // 1. Template drift — render in dry-run mode and surface anything
667    //    whose rendered counterpart on disk no longer matches.
668    let render_report =
669        render::render_all(&source, &config, &yui, &yuiignore, /* dry_run */ true)?;
670    for rendered in &render_report.diverged {
671        // `diverged` holds the rendered path; the template lives at
672        // `<rendered>.tera`. Show the .tera as src so it's clear which
673        // file the user needs to update.
674        let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
675        report.push(StatusItem {
676            src: relative_for_display(&source, &tera_path),
677            dst: rendered.clone(),
678            state: StatusState::RenderDrift,
679        });
680    }
681
682    // 2. Link drift — classify each src→dst pair under every mount.
683    for m in &mounts {
684        let src_root = source.join(&m.src);
685        if !src_root.is_dir() {
686            warn!("mount src missing: {src_root}");
687            continue;
688        }
689        classify_walk(
690            &src_root,
691            &m.dst,
692            &config,
693            m.strategy,
694            &mut engine,
695            &tera_ctx,
696            &source,
697            &yuiignore,
698            &mut report,
699        )?;
700    }
701
702    report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
703
704    print_status_table(&report, icons, color);
705
706    let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
707
708    println!();
709    let total = report.len();
710    let in_sync = total - drift;
711    if drift == 0 {
712        println!("  {total} entries · all in sync");
713        Ok(())
714    } else {
715        println!("  {total} entries · {in_sync} in sync · {drift} diverged");
716        anyhow::bail!("status: {drift} entries diverged from source")
717    }
718}
719
720#[derive(Debug)]
721struct StatusItem {
722    /// Path under the source tree (display only).
723    src: Utf8PathBuf,
724    /// Resolved target path (or rendered output path for `RenderDrift`).
725    dst: Utf8PathBuf,
726    state: StatusState,
727}
728
729#[derive(Debug, Clone, Copy)]
730enum StatusState {
731    Link(absorb::AbsorbDecision),
732    /// Rendered output diverges from current `.tera` template — user
733    /// edited the rendered file directly without updating the template.
734    RenderDrift,
735}
736
737impl StatusState {
738    fn is_in_sync(self) -> bool {
739        matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
740    }
741}
742
743#[allow(clippy::too_many_arguments)]
744fn classify_walk(
745    src_dir: &Utf8Path,
746    dst_dir: &Utf8Path,
747    config: &Config,
748    strategy: MountStrategy,
749    engine: &mut template::Engine,
750    tera_ctx: &TeraContext,
751    source_root: &Utf8Path,
752    yuiignore: &ignore::gitignore::Gitignore,
753    report: &mut Vec<StatusItem>,
754) -> Result<()> {
755    classify_walk_inner(
756        src_dir,
757        dst_dir,
758        config,
759        strategy,
760        engine,
761        tera_ctx,
762        source_root,
763        yuiignore,
764        report,
765        false,
766    )
767}
768
769#[allow(clippy::too_many_arguments)]
770fn classify_walk_inner(
771    src_dir: &Utf8Path,
772    dst_dir: &Utf8Path,
773    config: &Config,
774    strategy: MountStrategy,
775    engine: &mut template::Engine,
776    tera_ctx: &TeraContext,
777    source_root: &Utf8Path,
778    yuiignore: &ignore::gitignore::Gitignore,
779    report: &mut Vec<StatusItem>,
780    parent_covered: bool,
781) -> Result<()> {
782    if paths::is_ignored(yuiignore, source_root, src_dir, /* is_dir */ true) {
783        return Ok(());
784    }
785
786    let marker_filename = &config.mount.marker_filename;
787    let mut covered = parent_covered;
788
789    if strategy == MountStrategy::Marker {
790        match marker::read_spec(src_dir, marker_filename)? {
791            None => {}
792            Some(MarkerSpec::PassThrough) => {
793                let decision = absorb::classify(src_dir, dst_dir)?;
794                report.push(StatusItem {
795                    src: relative_for_display(source_root, src_dir),
796                    dst: dst_dir.to_path_buf(),
797                    state: StatusState::Link(decision),
798                });
799                covered = true;
800            }
801            Some(MarkerSpec::Explicit { links }) => {
802                let mut emitted_dir_link = false;
803                for link in &links {
804                    if let Some(when) = &link.when {
805                        if !template::eval_truthy(when, engine, tera_ctx)? {
806                            continue;
807                        }
808                    }
809                    let dst_str = engine.render(&link.dst, tera_ctx)?;
810                    let dst = paths::expand_tilde(dst_str.trim());
811                    if let Some(filename) = &link.src {
812                        let file_src = src_dir.join(filename);
813                        if !file_src.is_file() {
814                            anyhow::bail!(
815                                "marker at {src_dir}: [[link]] src={filename:?} \
816                                 not found"
817                            );
818                        }
819                        let decision = absorb::classify(&file_src, &dst)?;
820                        report.push(StatusItem {
821                            src: relative_for_display(source_root, &file_src),
822                            dst,
823                            state: StatusState::Link(decision),
824                        });
825                    } else {
826                        let decision = absorb::classify(src_dir, &dst)?;
827                        report.push(StatusItem {
828                            src: relative_for_display(source_root, src_dir),
829                            dst,
830                            state: StatusState::Link(decision),
831                        });
832                        emitted_dir_link = true;
833                    }
834                }
835                if emitted_dir_link {
836                    covered = true;
837                }
838            }
839        }
840    }
841
842    for entry in std::fs::read_dir(src_dir)? {
843        let entry = entry?;
844        let name_os = entry.file_name();
845        let Some(name) = name_os.to_str() else {
846            continue;
847        };
848        if name == marker_filename || name.ends_with(".tera") {
849            continue;
850        }
851        let src_path = src_dir.join(name);
852        let dst_path = dst_dir.join(name);
853        let ft = entry.file_type()?;
854        if paths::is_ignored(yuiignore, source_root, &src_path, ft.is_dir()) {
855            continue;
856        }
857        if ft.is_dir() {
858            classify_walk_inner(
859                &src_path,
860                &dst_path,
861                config,
862                strategy,
863                engine,
864                tera_ctx,
865                source_root,
866                yuiignore,
867                report,
868                covered,
869            )?;
870        } else if ft.is_file() && !covered {
871            let decision = absorb::classify(&src_path, &dst_path)?;
872            report.push(StatusItem {
873                src: relative_for_display(source_root, &src_path),
874                dst: dst_path,
875                state: StatusState::Link(decision),
876            });
877        }
878    }
879    Ok(())
880}
881
882fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
883    p.strip_prefix(source_root)
884        .map(Utf8PathBuf::from)
885        .unwrap_or_else(|_| p.to_path_buf())
886}
887
888fn print_status_table(items: &[StatusItem], icons: Icons, color: bool) {
889    let src_w = items
890        .iter()
891        .map(|i| i.src.as_str().chars().count())
892        .max()
893        .unwrap_or(0)
894        .max("SRC".len());
895    let dst_w = items
896        .iter()
897        .map(|i| i.dst.as_str().chars().count())
898        .max()
899        .unwrap_or(0)
900        .max("DST".len());
901    // STATE column = icon (1ch) + space + longest label
902    let state_label_w = items
903        .iter()
904        .map(|i| state_label(i.state).len())
905        .max()
906        .unwrap_or(0)
907        .max("STATE".len() - 2); // "STATE" header takes 5 chars; the icon prefix accounts for 2
908    let state_w = state_label_w + 2; // " " + label
909
910    print_status_header(state_w, src_w, dst_w, color);
911    let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
912    if color {
913        use owo_colors::OwoColorize as _;
914        println!("{}", sep.dimmed());
915    } else {
916        println!("{sep}");
917    }
918    for item in items {
919        print_status_row(item, icons, state_w, src_w, dst_w, color);
920    }
921}
922
923fn state_label(s: StatusState) -> &'static str {
924    use absorb::AbsorbDecision::*;
925    match s {
926        StatusState::Link(InSync) => "in-sync",
927        StatusState::Link(RelinkOnly) => "relink",
928        StatusState::Link(AutoAbsorb) => "drift (auto)",
929        StatusState::Link(NeedsConfirm) => "drift (anomaly)",
930        StatusState::Link(Restore) => "missing",
931        StatusState::RenderDrift => "render drift",
932    }
933}
934
935fn state_icon(s: StatusState, icons: Icons) -> &'static str {
936    use absorb::AbsorbDecision::*;
937    match s {
938        StatusState::Link(InSync) => icons.ok,
939        StatusState::Link(RelinkOnly) => icons.warn,
940        StatusState::Link(AutoAbsorb) => icons.warn,
941        StatusState::Link(NeedsConfirm) => icons.error,
942        StatusState::Link(Restore) => icons.info,
943        StatusState::RenderDrift => icons.error,
944    }
945}
946
947fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
948    use owo_colors::OwoColorize as _;
949    // STATE is the only column with data above; "WHEN" intentionally omitted
950    // since status only shows mounts that are already active on this host.
951    let line = format!(
952        "  {:<state_w$}  {:<src_w$}     {:<dst_w$}",
953        "STATE", "SRC", "DST"
954    );
955    if color {
956        println!("{}", line.bold());
957    } else {
958        println!("{line}");
959    }
960}
961
962fn render_status_separator(
963    sep_ch: char,
964    state_w: usize,
965    src_w: usize,
966    dst_w: usize,
967    arrow: &str,
968) -> String {
969    let bar = |n: usize| sep_ch.to_string().repeat(n);
970    format!(
971        "  {}  {}  {}  {}",
972        bar(state_w),
973        bar(src_w),
974        bar(arrow.chars().count()),
975        bar(dst_w)
976    )
977}
978
979fn print_status_row(
980    item: &StatusItem,
981    icons: Icons,
982    state_w: usize,
983    src_w: usize,
984    dst_w: usize,
985    color: bool,
986) {
987    use owo_colors::OwoColorize as _;
988    let icon = state_icon(item.state, icons);
989    let label = state_label(item.state);
990    let state_text = format!("{icon} {label}");
991    let src_display = item.src.as_str().replace('\\', "/");
992    let dst_display = item.dst.as_str().replace('\\', "/");
993    let arrow = icons.arrow;
994
995    let cell_state = format!("{:<state_w$}", state_text);
996    let cell_src = format!("{:<src_w$}", src_display);
997    let cell_dst = format!("{:<dst_w$}", dst_display);
998
999    if !color {
1000        println!("  {cell_state}  {cell_src}  {arrow}  {cell_dst}");
1001        return;
1002    }
1003
1004    use absorb::AbsorbDecision::*;
1005    let state_colored = match item.state {
1006        StatusState::Link(InSync) => cell_state.green().to_string(),
1007        StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
1008            cell_state.yellow().to_string()
1009        }
1010        StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
1011        StatusState::Link(Restore) => cell_state.cyan().to_string(),
1012        StatusState::RenderDrift => cell_state.red().to_string(),
1013    };
1014    let src_colored = cell_src.cyan().to_string();
1015    let arrow_colored = arrow.dimmed().to_string();
1016    let dst_colored = cell_dst.dimmed().to_string();
1017    println!("  {state_colored}  {src_colored}  {arrow_colored}  {dst_colored}");
1018}
1019
1020/// Manually absorb a single target file back into source.
1021///
1022/// Used when `apply` has skipped an anomaly (`[absorb] on_anomaly = "skip"`
1023/// or non-TTY ask) but the user has decided that target is right. Bypasses
1024/// policy + git-clean checks: this is an explicit user request.
1025///
1026/// Walks `[[mount.entry]]` and `.yuilink` overrides to find which source
1027/// path "owns" the given target. Errors loudly if no mount claims it.
1028pub fn absorb(source: Option<Utf8PathBuf>, target: Utf8PathBuf, dry_run: bool) -> Result<()> {
1029    let source = resolve_source(source)?;
1030    let target = absolutize(&target)?;
1031    let yui = YuiVars::detect(&source);
1032    let config = config::load(&source, &yui)?;
1033
1034    let mut engine = template::Engine::new();
1035    let tera_ctx = template::template_context(&yui, &config.vars);
1036    // Single load — the matcher is shared with both find_source_for_target
1037    // and the eventual ApplyCtx below.
1038    let yuiignore = paths::load_yuiignore(&source)?;
1039
1040    let src_path = match find_source_for_target(
1041        &source,
1042        &config,
1043        &target,
1044        &mut engine,
1045        &tera_ctx,
1046        &yuiignore,
1047    )? {
1048        Some(s) => s,
1049        None => anyhow::bail!(
1050            "no mount entry / .yuilink override claims target {target}; \
1051                 pass a path inside a known dst"
1052        ),
1053    };
1054
1055    info!("source for {target}: {src_path}");
1056
1057    if dry_run {
1058        info!("[dry-run] would absorb {target} → {src_path}");
1059        return Ok(());
1060    }
1061
1062    let backup_root = source.join(&config.backup.dir);
1063    let ctx = ApplyCtx {
1064        config: &config,
1065        source: &source,
1066        yuiignore: &yuiignore,
1067        file_mode: resolve_file_mode(config.link.file_mode),
1068        dir_mode: resolve_dir_mode(config.link.dir_mode),
1069        backup_root: &backup_root,
1070        dry_run: false,
1071    };
1072
1073    // Manual absorb is an explicit user request — bypass `auto`,
1074    // `require_clean_git`, and `on_anomaly` policy entirely.
1075    absorb_target_into_source(&src_path, &target, &ctx)
1076}
1077
1078/// Walk mount entries + `.yuilink` Override markers to find the source
1079/// file/dir that the given target maps back to. Returns `None` when no
1080/// mount or marker claims the path.
1081fn find_source_for_target(
1082    source: &Utf8Path,
1083    config: &Config,
1084    target: &Utf8Path,
1085    engine: &mut template::Engine,
1086    tera_ctx: &TeraContext,
1087    yuiignore: &ignore::gitignore::Gitignore,
1088) -> Result<Option<Utf8PathBuf>> {
1089    // 1. Mount entries — render dst, see if target is inside it.
1090    for entry in &config.mount.entry {
1091        if let Some(when) = &entry.when {
1092            if !template::eval_truthy(when, engine, tera_ctx)? {
1093                continue;
1094            }
1095        }
1096        let dst_str = engine.render(&entry.dst, tera_ctx)?;
1097        let dst_root = paths::expand_tilde(dst_str.trim());
1098        if let Ok(rel) = target.strip_prefix(&dst_root) {
1099            let candidate = source.join(&entry.src).join(rel);
1100            // Honor `.yuiignore` even on manual absorb — if you've
1101            // ignored a path, you've explicitly opted out of yui's
1102            // managing it.
1103            if paths::is_ignored(yuiignore, source, &candidate, candidate.is_dir()) {
1104                continue;
1105            }
1106            return Ok(Some(candidate));
1107        }
1108    }
1109
1110    // 2. `.yuilink` Override markers — walk source, parse, render each
1111    //    `[[link]] dst`, see if target is the rendered dst (or nested
1112    //    inside a junction'd dir). Skips `.yui/` (backup mirrors etc.).
1113    let walker = paths::source_walker(source).build();
1114    let marker_filename = &config.mount.marker_filename;
1115    for ent in walker {
1116        let ent = match ent {
1117            Ok(e) => e,
1118            Err(_) => continue,
1119        };
1120        if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
1121            continue;
1122        }
1123        if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
1124            continue;
1125        }
1126        let dir = match ent.path().parent() {
1127            Some(d) => d,
1128            None => continue,
1129        };
1130        let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
1131            Ok(p) => p,
1132            Err(_) => continue,
1133        };
1134        if paths::is_ignored(yuiignore, source, &dir_utf8, true) {
1135            continue;
1136        }
1137        let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
1138            Some(s) => s,
1139            None => continue,
1140        };
1141        let MarkerSpec::Explicit { links } = spec else {
1142            continue;
1143        };
1144        for link in &links {
1145            if let Some(when) = &link.when {
1146                if !template::eval_truthy(when, engine, tera_ctx)? {
1147                    continue;
1148                }
1149            }
1150            let dst_str = engine.render(&link.dst, tera_ctx)?;
1151            let dst = paths::expand_tilde(dst_str.trim());
1152            // File-level entry: dst points at a single file, so a match
1153            // resolves directly to `<marker-dir>/<src filename>`. Mirror
1154            // the existence check that apply / status do so a missing
1155            // sibling produces the same clear message regardless of
1156            // entry point — consistent with the `marker at … src=… not
1157            // found` shape users already see from those flows.
1158            if let Some(filename) = &link.src {
1159                let file_src = dir_utf8.join(filename);
1160                if !file_src.is_file() {
1161                    anyhow::bail!(
1162                        "marker at {dir_utf8}: [[link]] src={filename:?} \
1163                         not found"
1164                    );
1165                }
1166                if target == dst {
1167                    return Ok(Some(file_src));
1168                }
1169                continue;
1170            }
1171            if target == dst {
1172                return Ok(Some(dir_utf8));
1173            }
1174            if let Ok(rel) = target.strip_prefix(&dst) {
1175                return Ok(Some(dir_utf8.join(rel)));
1176            }
1177        }
1178    }
1179
1180    Ok(None)
1181}
1182
1183pub fn doctor(
1184    source: Option<Utf8PathBuf>,
1185    icons_override: Option<IconsMode>,
1186    no_color: bool,
1187) -> Result<()> {
1188    use owo_colors::OwoColorize as _;
1189
1190    // Resolve source up-front so probes that depend on it can short-circuit
1191    // gracefully. A missing source is the single most common cause of yui
1192    // misbehaving, so we want to surface it loudly and skip the dependent
1193    // probes rather than blowing up.
1194    let resolved_source = resolve_source(source);
1195
1196    // `YuiVars::detect` reads `yui.source` from the resolved source path
1197    // (so `{{ yui.source }}` renders correctly in config templates); when
1198    // no source is detected we fall back to `.` so identity probes can
1199    // still report os/arch/user/host.
1200    let yui = match &resolved_source {
1201        Ok(s) => YuiVars::detect(s),
1202        Err(_) => YuiVars::detect(Utf8Path::new(".")),
1203    };
1204
1205    // Cache the loaded config — both the icons-override fallback and the
1206    // hooks-section probe need it. `cfg_res` keeps the original error
1207    // around so the `repo / config` probe can render a meaningful
1208    // message instead of just "not loaded".
1209    let cfg_res = match &resolved_source {
1210        Ok(s) => Some(config::load(s, &yui)),
1211        Err(_) => None,
1212    };
1213    let cfg = cfg_res.as_ref().and_then(|r| r.as_ref().ok());
1214    let icons_mode = icons_override
1215        .or_else(|| cfg.map(|c| c.ui.icons))
1216        .unwrap_or_default();
1217    let icons = Icons::for_mode(icons_mode);
1218    let color = !no_color && supports_color_stdout();
1219
1220    let mut probes: Vec<Probe> = Vec::new();
1221
1222    // ── identity ──────────────────────────────────────────────
1223    probes.push(Probe::group("identity"));
1224    probes.push(Probe::ok("os/arch", format!("{} / {}", yui.os, yui.arch)));
1225    probes.push(Probe::ok("user@host", format!("{}@{}", yui.user, yui.host)));
1226
1227    // ── repository ────────────────────────────────────────────
1228    probes.push(Probe::group("repo"));
1229    let mut have_source = false;
1230    match &resolved_source {
1231        Ok(s) => {
1232            have_source = true;
1233            probes.push(Probe::ok("source", s.to_string()));
1234            match cfg_res.as_ref().expect("cfg_res set when source is Ok") {
1235                Ok(c) => {
1236                    probes.push(Probe::ok(
1237                        "config",
1238                        format!(
1239                            "{} mount{} · {} hook{} · {} render rule{}",
1240                            c.mount.entry.len(),
1241                            plural(c.mount.entry.len()),
1242                            c.hook.len(),
1243                            plural(c.hook.len()),
1244                            c.render.rule.len(),
1245                            plural(c.render.rule.len()),
1246                        ),
1247                    ));
1248                }
1249                Err(e) => probes.push(Probe::error("config", format!("{e}"))),
1250            }
1251            // git-clean check is informational here — the actual gate is
1252            // `[absorb] require_clean_git` on apply; warn so the user
1253            // knows auto-absorb will defer if they have uncommitted work.
1254            match crate::git::is_clean(s) {
1255                Ok(true) => probes.push(Probe::ok("git", "clean")),
1256                Ok(false) => probes.push(Probe::warn(
1257                    "git",
1258                    "uncommitted changes — `[absorb] require_clean_git` will defer auto-absorb",
1259                )),
1260                Err(_) => probes.push(Probe::warn(
1261                    "git",
1262                    "no git repo (auto-absorb still works; commit history won't track drift)",
1263                )),
1264            }
1265        }
1266        Err(e) => {
1267            probes.push(Probe::error("source", format!("not found — {e}")));
1268        }
1269    }
1270
1271    // ── link / render mode ────────────────────────────────────
1272    probes.push(Probe::group("links"));
1273    if cfg!(windows) {
1274        probes.push(Probe::ok(
1275            "default mode",
1276            "files=hardlink, dirs=junction (no admin needed)",
1277        ));
1278    } else {
1279        probes.push(Probe::ok("default mode", "files=symlink, dirs=symlink"));
1280    }
1281
1282    // ── hooks ─────────────────────────────────────────────────
1283    if have_source {
1284        if let (Ok(s), Some(c)) = (&resolved_source, cfg) {
1285            probes.push(Probe::group("hooks"));
1286            if c.hook.is_empty() {
1287                probes.push(Probe::ok("hooks", "(none configured)"));
1288            } else {
1289                let mut missing = 0usize;
1290                for h in &c.hook {
1291                    if !s.join(&h.script).is_file() {
1292                        missing += 1;
1293                        probes.push(Probe::error(
1294                            format!("hook[{}]", h.name),
1295                            format!("script not found at {}", h.script),
1296                        ));
1297                    }
1298                }
1299                if missing == 0 {
1300                    probes.push(Probe::ok(
1301                        "scripts",
1302                        format!(
1303                            "{} hook{} configured, all scripts present",
1304                            c.hook.len(),
1305                            plural(c.hook.len())
1306                        ),
1307                    ));
1308                }
1309            }
1310        }
1311    }
1312
1313    // ── chezmoi cleanup hint ─────────────────────────────────
1314    if let Some(home) = paths::home_dir() {
1315        let chezmoi_src = home.join(".local/share/chezmoi");
1316        if chezmoi_src.is_dir() {
1317            probes.push(Probe::group("chezmoi"));
1318            probes.push(Probe::warn(
1319                "legacy source",
1320                format!(
1321                    "{chezmoi_src} still exists — yui doesn't use it, safe to archive once your migration has settled"
1322                ),
1323            ));
1324        }
1325    }
1326
1327    // Render
1328    println!();
1329    if color {
1330        println!("  {}", "yui doctor".bold().underline());
1331    } else {
1332        println!("  yui doctor");
1333    }
1334    println!();
1335    for probe in &probes {
1336        probe.print(&icons, color);
1337    }
1338
1339    let errors = probes.iter().filter(|p| p.is_error()).count();
1340    let warns = probes.iter().filter(|p| p.is_warn()).count();
1341    let oks = probes.iter().filter(|p| p.is_ok()).count();
1342    println!();
1343    let summary = format!("{oks} ok · {warns} warn · {errors} error");
1344    if color {
1345        if errors > 0 {
1346            println!("  {}", summary.red().bold());
1347        } else if warns > 0 {
1348            println!("  {}", summary.yellow());
1349        } else {
1350            println!("  {}", summary.green());
1351        }
1352    } else {
1353        println!("  {summary}");
1354    }
1355
1356    if errors > 0 {
1357        anyhow::bail!("doctor: {errors} probe(s) failed");
1358    }
1359    Ok(())
1360}
1361
1362#[derive(Debug)]
1363enum Probe {
1364    /// Section divider (just a heading, no severity).
1365    Group(&'static str),
1366    Ok {
1367        label: String,
1368        detail: String,
1369    },
1370    Warn {
1371        label: String,
1372        detail: String,
1373    },
1374    Error {
1375        label: String,
1376        detail: String,
1377    },
1378}
1379
1380impl Probe {
1381    fn group(label: &'static str) -> Self {
1382        Self::Group(label)
1383    }
1384    fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
1385        Self::Ok {
1386            label: label.into(),
1387            detail: detail.into(),
1388        }
1389    }
1390    fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
1391        Self::Warn {
1392            label: label.into(),
1393            detail: detail.into(),
1394        }
1395    }
1396    fn error(label: impl Into<String>, detail: impl Into<String>) -> Self {
1397        Self::Error {
1398            label: label.into(),
1399            detail: detail.into(),
1400        }
1401    }
1402    fn is_ok(&self) -> bool {
1403        matches!(self, Self::Ok { .. })
1404    }
1405    fn is_warn(&self) -> bool {
1406        matches!(self, Self::Warn { .. })
1407    }
1408    fn is_error(&self) -> bool {
1409        matches!(self, Self::Error { .. })
1410    }
1411    fn print(&self, icons: &Icons, color: bool) {
1412        use owo_colors::OwoColorize as _;
1413        match self {
1414            Self::Group(name) => {
1415                println!();
1416                if color {
1417                    println!("  {}", name.cyan().bold());
1418                } else {
1419                    println!("  {name}");
1420                }
1421            }
1422            Self::Ok { label, detail } => {
1423                let icon = icons.ok;
1424                // Pad the raw label first; styling adds invisible ANSI
1425                // bytes that `format!("{:<14}")` would count as visible
1426                // width and silently break alignment between rows.
1427                let padded = format!("{label:<14}");
1428                if color {
1429                    println!(
1430                        "    {}  {}  {}",
1431                        icon.green(),
1432                        padded.bold(),
1433                        detail.dimmed()
1434                    );
1435                } else {
1436                    println!("    {icon}  {padded}  {detail}");
1437                }
1438            }
1439            Self::Warn { label, detail } => {
1440                let icon = icons.warn;
1441                let padded = format!("{label:<14}");
1442                if color {
1443                    println!(
1444                        "    {}  {}  {}",
1445                        icon.yellow(),
1446                        padded.bold().yellow(),
1447                        detail
1448                    );
1449                } else {
1450                    println!("    {icon}  {padded}  {detail}");
1451                }
1452            }
1453            Self::Error { label, detail } => {
1454                let icon = icons.error;
1455                let padded = format!("{label:<14}");
1456                if color {
1457                    println!(
1458                        "    {}  {}  {}",
1459                        icon.red().bold(),
1460                        padded.bold().red(),
1461                        detail.red()
1462                    );
1463                } else {
1464                    println!("    {icon}  {padded}  {detail}");
1465                }
1466            }
1467        }
1468    }
1469}
1470
1471fn plural(n: usize) -> &'static str {
1472    if n == 1 { "" } else { "s" }
1473}
1474
1475pub fn gc_backup(_source: Option<Utf8PathBuf>, _older_than: Option<String>) -> Result<()> {
1476    todo!("yui gc-backup — clean up old backups")
1477}
1478
1479/// `yui hooks list` — show every configured hook + its last-run state.
1480pub fn hooks_list(
1481    source: Option<Utf8PathBuf>,
1482    icons_override: Option<IconsMode>,
1483    no_color: bool,
1484) -> Result<()> {
1485    let source = resolve_source(source)?;
1486    let yui = YuiVars::detect(&source);
1487    let config = config::load(&source, &yui)?;
1488    let state = hook::State::load(&source)?;
1489
1490    let icons_mode = icons_override.unwrap_or(config.ui.icons);
1491    let icons = Icons::for_mode(icons_mode);
1492    let color = !no_color && supports_color_stdout();
1493
1494    if config.hook.is_empty() {
1495        println!("(no [[hook]] entries in config)");
1496        return Ok(());
1497    }
1498
1499    // Pre-evaluate the `when` filter for every hook so the status icon
1500    // can distinguish "skipped because the OS gate is false" from
1501    // "active but never run".
1502    let mut engine = template::Engine::new();
1503    let tera_ctx = template::template_context(&yui, &config.vars);
1504    let rows: Vec<HookRow> = config
1505        .hook
1506        .iter()
1507        .map(|h| -> Result<HookRow> {
1508            // Propagate Tera errors instead of silently coercing them
1509            // to "inactive" — a syntax error in the user's `when`
1510            // expression should surface, not hide.
1511            let active = match &h.when {
1512                None => true,
1513                Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
1514            };
1515            let last_run_at = state.hooks.get(&h.name).and_then(|s| s.last_run_at.clone());
1516            Ok(HookRow {
1517                name: h.name.clone(),
1518                phase: match h.phase {
1519                    HookPhase::Pre => "pre",
1520                    HookPhase::Post => "post",
1521                },
1522                when_run: match h.when_run {
1523                    config::WhenRun::Once => "once",
1524                    config::WhenRun::Onchange => "onchange",
1525                    config::WhenRun::Every => "every",
1526                },
1527                last_run_at,
1528                when: h.when.clone(),
1529                active,
1530            })
1531        })
1532        .collect::<Result<Vec<_>>>()?;
1533
1534    print_hooks_table(&rows, icons, color);
1535
1536    let total = rows.len();
1537    let active = rows.iter().filter(|r| r.active).count();
1538    let inactive = total - active;
1539    let ran = rows.iter().filter(|r| r.last_run_at.is_some()).count();
1540    let never = total - ran;
1541    println!();
1542    println!(
1543        "  {total} hooks · {active} active · {inactive} inactive · {ran} ran · {never} never run"
1544    );
1545
1546    Ok(())
1547}
1548
1549#[derive(Debug)]
1550struct HookRow {
1551    name: String,
1552    phase: &'static str,
1553    when_run: &'static str,
1554    last_run_at: Option<String>,
1555    when: Option<String>,
1556    active: bool,
1557}
1558
1559fn print_hooks_table(rows: &[HookRow], icons: Icons, color: bool) {
1560    use owo_colors::OwoColorize as _;
1561    use std::fmt::Write as _;
1562
1563    let name_w = rows
1564        .iter()
1565        .map(|r| r.name.chars().count())
1566        .max()
1567        .unwrap_or(0)
1568        .max("NAME".len());
1569    let phase_w = rows
1570        .iter()
1571        .map(|r| r.phase.len())
1572        .max()
1573        .unwrap_or(0)
1574        .max("PHASE".len());
1575    let when_run_w = rows
1576        .iter()
1577        .map(|r| r.when_run.len())
1578        .max()
1579        .unwrap_or(0)
1580        .max("WHEN_RUN".len());
1581    let last_w = rows
1582        .iter()
1583        .map(|r| {
1584            r.last_run_at
1585                .as_deref()
1586                .map(|s| s.chars().count())
1587                .unwrap_or("(never)".len())
1588        })
1589        .max()
1590        .unwrap_or(0)
1591        .max("LAST_RUN".len());
1592    let status_w = "STATUS".len();
1593
1594    // Header
1595    let mut header = String::new();
1596    let _ = write!(
1597        &mut header,
1598        "  {:<status_w$}  {:<name_w$}  {:<phase_w$}  {:<when_run_w$}  {:<last_w$}  WHEN",
1599        "STATUS", "NAME", "PHASE", "WHEN_RUN", "LAST_RUN"
1600    );
1601    if color {
1602        println!("{}", header.bold());
1603    } else {
1604        println!("{header}");
1605    }
1606
1607    // Separator (re-uses the same sep glyph the list / status table picks).
1608    let bar = |n: usize| icons.sep.to_string().repeat(n);
1609    let sep = format!(
1610        "  {}  {}  {}  {}  {}  {}",
1611        bar(status_w),
1612        bar(name_w),
1613        bar(phase_w),
1614        bar(when_run_w),
1615        bar(last_w),
1616        bar("WHEN".len())
1617    );
1618    if color {
1619        println!("{}", sep.dimmed());
1620    } else {
1621        println!("{sep}");
1622    }
1623
1624    // Rows
1625    for r in rows {
1626        // Status icon picks one of three states. We could expand this
1627        // (✗ failed, ↻ would-rerun-via-onchange-hash) once `hooks list`
1628        // grows enough fields to justify it; today's set is enough to
1629        // make the table scannable.
1630        let (icon, ran) = match (r.active, r.last_run_at.is_some()) {
1631            (false, _) => (icons.inactive, false),
1632            (true, true) => (icons.active, true),
1633            (true, false) => (icons.info, false),
1634        };
1635        let last = r.last_run_at.as_deref().unwrap_or("(never)");
1636        let when_str = r
1637            .when
1638            .as_deref()
1639            .map(strip_braces)
1640            .unwrap_or_else(|| "(always)".to_string());
1641
1642        let cell_status = format!("{icon:<status_w$}");
1643        let cell_name = format!("{:<name_w$}", r.name);
1644        let cell_phase = format!("{:<phase_w$}", r.phase);
1645        let cell_when_run = format!("{:<when_run_w$}", r.when_run);
1646        let cell_last = format!("{last:<last_w$}");
1647
1648        if !color {
1649            println!(
1650                "  {cell_status}  {cell_name}  {cell_phase}  {cell_when_run}  {cell_last}  {when_str}"
1651            );
1652            continue;
1653        }
1654
1655        // Active+ran: green status, bold name. Active-but-never: yellow
1656        // status (the "🆕 new — apply hasn't ticked it" signal). Inactive
1657        // (when-false): dimmed across the row.
1658        if !r.active {
1659            println!(
1660                "  {}  {}  {}  {}  {}  {}",
1661                cell_status.dimmed(),
1662                cell_name.dimmed(),
1663                cell_phase.dimmed(),
1664                cell_when_run.dimmed(),
1665                cell_last.dimmed(),
1666                when_str.dimmed()
1667            );
1668        } else if ran {
1669            println!(
1670                "  {}  {}  {}  {}  {}  {}",
1671                cell_status.green(),
1672                cell_name.cyan().bold(),
1673                cell_phase.dimmed(),
1674                cell_when_run.dimmed(),
1675                cell_last.green(),
1676                when_str.dimmed()
1677            );
1678        } else {
1679            println!(
1680                "  {}  {}  {}  {}  {}  {}",
1681                cell_status.yellow(),
1682                cell_name.cyan().bold(),
1683                cell_phase.dimmed(),
1684                cell_when_run.dimmed(),
1685                cell_last.yellow(),
1686                when_str.dimmed()
1687            );
1688        }
1689    }
1690}
1691
1692/// `yui hooks run [<name>] [--force]` — run a single hook (or every
1693/// hook) on demand. `--force` bypasses the `when_run` state check;
1694/// the `when` filter (`yui.os == 'macos'` etc.) is always honored.
1695pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
1696    let source = resolve_source(source)?;
1697    let yui = YuiVars::detect(&source);
1698    let config = config::load(&source, &yui)?;
1699    let mut engine = template::Engine::new();
1700    let tera_ctx = template::template_context(&yui, &config.vars);
1701
1702    let targets: Vec<&config::HookConfig> = match &name {
1703        Some(want) => {
1704            let m = config
1705                .hook
1706                .iter()
1707                .find(|h| &h.name == want)
1708                .ok_or_else(|| {
1709                    anyhow::anyhow!(
1710                        "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
1711                    )
1712                })?;
1713            vec![m]
1714        }
1715        None => config.hook.iter().collect(),
1716    };
1717
1718    let mut state = hook::State::load(&source)?;
1719    for h in targets {
1720        let outcome = hook::run_hook(
1721            h,
1722            &source,
1723            &yui,
1724            &config.vars,
1725            &mut engine,
1726            &tera_ctx,
1727            &mut state,
1728            /* dry_run */ false,
1729            force,
1730        )?;
1731        let label = match outcome {
1732            HookOutcome::Ran => "ran",
1733            HookOutcome::SkippedOnce => "skipped (once: already ran)",
1734            HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
1735            HookOutcome::SkippedWhenFalse => "skipped (when=false)",
1736            HookOutcome::DryRun => "would run (dry-run)",
1737        };
1738        info!("hook[{}]: {label}", h.name);
1739        if outcome == HookOutcome::Ran {
1740            state.save(&source)?;
1741        }
1742    }
1743    Ok(())
1744}
1745
1746// ---------------------------------------------------------------------------
1747// internals
1748// ---------------------------------------------------------------------------
1749
1750fn process_mount(
1751    source: &Utf8Path,
1752    m: &ResolvedMount,
1753    ctx: &ApplyCtx<'_>,
1754    engine: &mut template::Engine,
1755    tera_ctx: &TeraContext,
1756) -> Result<()> {
1757    let src_root = source.join(&m.src);
1758    if !src_root.is_dir() {
1759        warn!("mount src missing: {src_root}");
1760        return Ok(());
1761    }
1762    walk_and_link(&src_root, &m.dst, ctx, m.strategy, engine, tera_ctx, false)
1763}
1764
1765#[allow(clippy::too_many_arguments)]
1766fn walk_and_link(
1767    src_dir: &Utf8Path,
1768    dst_dir: &Utf8Path,
1769    ctx: &ApplyCtx<'_>,
1770    strategy: MountStrategy,
1771    engine: &mut template::Engine,
1772    tera_ctx: &TeraContext,
1773    parent_covered: bool,
1774) -> Result<()> {
1775    // `.yuiignore` short-circuit — entire subtrees that match are skipped
1776    // without even reading their marker / iterating their children.
1777    if paths::is_ignored(ctx.yuiignore, ctx.source, src_dir, /* is_dir */ true) {
1778        return Ok(());
1779    }
1780
1781    let marker_filename = &ctx.config.mount.marker_filename;
1782    let mut covered = parent_covered;
1783
1784    if strategy == MountStrategy::Marker {
1785        match marker::read_spec(src_dir, marker_filename)? {
1786            None => {} // no marker — fall through to recursive walk
1787            Some(MarkerSpec::PassThrough) => {
1788                // Empty marker = junction this dir at the natural
1789                // mount-derived dst. Subsequent recursion keeps going so
1790                // descendant markers can layer on extra dsts.
1791                link_dir_with_backup(src_dir, dst_dir, ctx)?;
1792                covered = true;
1793            }
1794            Some(MarkerSpec::Explicit { links }) => {
1795                let mut emitted_dir_link = false;
1796                let mut emitted_any = false;
1797                for link in &links {
1798                    // Nested ifs (not let-chains) so the crate's MSRV
1799                    // (rust-version = "1.85") stays buildable.
1800                    if let Some(when) = &link.when {
1801                        if !template::eval_truthy(when, engine, tera_ctx)? {
1802                            continue;
1803                        }
1804                    }
1805                    let dst_str = engine.render(&link.dst, tera_ctx)?;
1806                    let dst = paths::expand_tilde(dst_str.trim());
1807                    if let Some(filename) = &link.src {
1808                        let file_src = src_dir.join(filename);
1809                        if !file_src.is_file() {
1810                            anyhow::bail!(
1811                                "marker at {src_dir}: [[link]] src={filename:?} \
1812                                 not found"
1813                            );
1814                        }
1815                        link_file_with_backup(&file_src, &dst, ctx)?;
1816                    } else {
1817                        link_dir_with_backup(src_dir, &dst, ctx)?;
1818                        emitted_dir_link = true;
1819                    }
1820                    emitted_any = true;
1821                }
1822                if !emitted_any {
1823                    // v0.6+ semantics: with no active links, the walker
1824                    // still descends and per-file defaults still apply.
1825                    // Phrase it so users don't read "skipping" as
1826                    // "subtree blocked" (the v0.5 behaviour).
1827                    info!(
1828                        "marker at {src_dir} had no active links \
1829                         — falling back to defaults"
1830                    );
1831                }
1832                if emitted_dir_link {
1833                    covered = true;
1834                }
1835            }
1836        }
1837    }
1838
1839    for entry in std::fs::read_dir(src_dir)? {
1840        let entry = entry?;
1841        let name_os = entry.file_name();
1842        let Some(name) = name_os.to_str() else {
1843            continue;
1844        };
1845        if name == marker_filename {
1846            continue;
1847        }
1848        if name.ends_with(".tera") {
1849            // Templates are handled by the render flow before linking.
1850            continue;
1851        }
1852        let src_path = src_dir.join(name);
1853        let dst_path = dst_dir.join(name);
1854        let ft = entry.file_type()?;
1855
1856        if paths::is_ignored(ctx.yuiignore, ctx.source, &src_path, ft.is_dir()) {
1857            continue;
1858        }
1859
1860        if ft.is_dir() {
1861            walk_and_link(
1862                &src_path, &dst_path, ctx, strategy, engine, tera_ctx, covered,
1863            )?;
1864        } else if ft.is_file() {
1865            // If an ancestor (or this dir itself) created a dir-level
1866            // junction, the file is already accessible via that junction
1867            // — emitting another per-file link would just duplicate work
1868            // (and on Windows might land at a path that's already
1869            // hard-linked through the parent).
1870            if !covered {
1871                link_file_with_backup(&src_path, &dst_path, ctx)?;
1872            }
1873        }
1874    }
1875    Ok(())
1876}
1877
1878fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1879    use absorb::AbsorbDecision::*;
1880
1881    let decision = absorb::classify(src, dst)?;
1882
1883    if ctx.dry_run {
1884        info!("[dry-run] {decision:?}: {src} → {dst}");
1885        return Ok(());
1886    }
1887
1888    match decision {
1889        InSync => {
1890            // Link is intact (same inode/file-id). Nothing to do.
1891            Ok(())
1892        }
1893        Restore => {
1894            info!("link: {src} → {dst}");
1895            link::link_file(src, dst, ctx.file_mode)?;
1896            Ok(())
1897        }
1898        RelinkOnly => {
1899            // Same content, different inode (e.g. hardlink broken by an
1900            // editor's atomic save). Re-link without touching source.
1901            info!("relink: {src} → {dst}");
1902            link::unlink(dst)?;
1903            link::link_file(src, dst, ctx.file_mode)?;
1904            Ok(())
1905        }
1906        AutoAbsorb => {
1907            // Target newer + content differs: target wins, source updated.
1908            // Honor `[absorb] auto` (kill-switch) and `require_clean_git`.
1909            if !ctx.config.absorb.auto {
1910                return handle_anomaly(
1911                    src,
1912                    dst,
1913                    ctx,
1914                    "absorb.auto = false; treating divergence as anomaly",
1915                );
1916            }
1917            if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
1918                return handle_anomaly(
1919                    src,
1920                    dst,
1921                    ctx,
1922                    "source repo is dirty; deferring auto-absorb",
1923                );
1924            }
1925            absorb_target_into_source(src, dst, ctx)
1926        }
1927        NeedsConfirm => handle_anomaly(
1928            src,
1929            dst,
1930            ctx,
1931            "anomaly: source equals/newer than target but content differs",
1932        ),
1933    }
1934}
1935
1936/// Back up the source-side file, copy the target's content into source,
1937/// then re-link so the freshly-updated source is what target points at.
1938/// "Target wins" — yui's core philosophy.
1939fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1940    info!("absorb: {dst} → {src}");
1941    backup_existing(src, ctx.backup_root, /* is_dir */ false)?;
1942    std::fs::copy(dst, src)?;
1943    link::unlink(dst)?;
1944    link::link_file(src, dst, ctx.file_mode)?;
1945    Ok(())
1946}
1947
1948/// Decide what to do for an anomaly (NeedsConfirm or AutoAbsorb that was
1949/// escalated by `auto = false` / dirty git). Per `[absorb] on_anomaly`:
1950///   - `skip`  → log warning, leave target alone
1951///   - `force` → behave like AutoAbsorb (target wins)
1952///   - `ask`   → on a TTY, show diff + prompt. Off-TTY, downgrade to skip.
1953fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
1954    use crate::config::AnomalyAction::*;
1955    match ctx.config.absorb.on_anomaly {
1956        Skip => {
1957            warn!("anomaly skip: {dst} ({reason})");
1958            Ok(())
1959        }
1960        Force => {
1961            warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
1962            absorb_target_into_source(src, dst, ctx)
1963        }
1964        Ask => {
1965            use std::io::IsTerminal;
1966            if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
1967                if prompt_absorb_with_diff(src, dst, reason)? {
1968                    absorb_target_into_source(src, dst, ctx)
1969                } else {
1970                    warn!("anomaly skipped by user: {dst}");
1971                    Ok(())
1972                }
1973            } else {
1974                warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
1975                Ok(())
1976            }
1977        }
1978    }
1979}
1980
1981fn prompt_absorb_with_diff(src: &Utf8Path, dst: &Utf8Path, reason: &str) -> Result<bool> {
1982    use std::io::Write as _;
1983    let src_content = std::fs::read_to_string(src).unwrap_or_default();
1984    let dst_content = std::fs::read_to_string(dst).unwrap_or_default();
1985    eprintln!();
1986    eprintln!("anomaly: {reason}");
1987    eprintln!("  src: {src}");
1988    eprintln!("  dst: {dst}");
1989    eprintln!();
1990    eprintln!("--- diff (- source, + target) ---");
1991    let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
1992    for change in diff.iter_all_changes() {
1993        let sign = match change.tag() {
1994            similar::ChangeTag::Delete => "-",
1995            similar::ChangeTag::Insert => "+",
1996            similar::ChangeTag::Equal => " ",
1997        };
1998        eprint!("{sign}{change}");
1999    }
2000    eprintln!();
2001    eprint!("absorb target into source? [y/N]: ");
2002    // Flush stderr (where the prompt was written) — flushing stdout was a
2003    // bug; on a buffered stderr (rare but possible) the prompt would be
2004    // hidden until after the user typed something. Caught in PR #15
2005    // review (gemini-code-assist).
2006    std::io::stderr().flush().ok();
2007    let mut input = String::new();
2008    std::io::stdin().read_line(&mut input)?;
2009    let answer = input.trim();
2010    Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
2011}
2012
2013/// Resilient git-clean check: if `git` isn't available or `source` isn't
2014/// a repo, log a warning and proceed as if clean. We don't want a missing
2015/// `git` to block apply — the require_clean_git knob is a *safety net*,
2016/// not a hard prerequisite.
2017fn source_repo_is_clean(source: &Utf8Path) -> bool {
2018    match crate::git::is_clean(source) {
2019        Ok(b) => b,
2020        Err(e) => {
2021            warn!("git clean check failed at {source}: {e} — treating as clean");
2022            true
2023        }
2024    }
2025}
2026
2027fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2028    use absorb::AbsorbDecision::*;
2029    let decision = absorb::classify(src, dst)?;
2030
2031    if ctx.dry_run {
2032        info!("[dry-run] dir {decision:?}: {src} → {dst}");
2033        return Ok(());
2034    }
2035
2036    match decision {
2037        InSync => Ok(()),
2038        Restore => {
2039            info!("link dir: {src} → {dst}");
2040            link::link_dir(src, dst, ctx.dir_mode)?;
2041            Ok(())
2042        }
2043        RelinkOnly => {
2044            // For dirs the classifier doesn't currently produce
2045            // `RelinkOnly` (only InSync / NeedsConfirm), but handle it
2046            // for symmetry with the file path: contents already match,
2047            // so just swap the target for a junction to source.
2048            info!("relink dir: {src} → {dst}");
2049            remove_dir_link_or_real(dst)?;
2050            link::link_dir(src, dst, ctx.dir_mode)?;
2051            Ok(())
2052        }
2053        AutoAbsorb | NeedsConfirm => {
2054            // Reaching `link_dir_with_backup` means we're acting on a
2055            // `.yuilink` marker (or a `[[mount.entry]]` whose `src` is a
2056            // directory) — the user has explicitly opted into
2057            // "this whole subtree is target-as-truth". A dir-level
2058            // NeedsConfirm here is therefore *not* the same kind of
2059            // anomaly that file-level NeedsConfirm represents (a single
2060            // file the user edited and source got newer); it's just
2061            // "source and target dirs are different inodes" — the
2062            // marker already authorised us to merge.
2063            //
2064            // Per-file content conflicts *inside* the merge are still
2065            // a real concern (target has X, source has X with
2066            // different content). Those are surfaced from inside the
2067            // merge itself — see `merge_dir_target_into_source`'s
2068            // file-level dispatch — so the outer-dir decision falls
2069            // straight through to absorb.
2070            //
2071            // The `auto` / `require_clean_git` knobs still gate, so
2072            // turning them off restores the prompt before any
2073            // whole-dir absorb.
2074            if !ctx.config.absorb.auto {
2075                return handle_anomaly_dir(
2076                    src,
2077                    dst,
2078                    ctx,
2079                    "absorb.auto = false; treating divergence as anomaly",
2080                );
2081            }
2082            if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
2083                return handle_anomaly_dir(
2084                    src,
2085                    dst,
2086                    ctx,
2087                    "source repo is dirty; deferring auto-absorb",
2088                );
2089            }
2090            absorb_target_dir_into_source(src, dst, ctx)
2091        }
2092    }
2093}
2094
2095/// `link::unlink` with a documented fallback for the chezmoi-migration
2096/// shape: target is a real (non-link) directory packed with files. The
2097/// caller is responsible for ensuring the target's prior content is
2098/// preserved (in `.yui/backup/...` or because we just merged it into
2099/// source) before reaching here.
2100///
2101/// Anything other than the "non-empty regular dir" case — permission
2102/// denied, target gone, target now a junction or symlink — propagates
2103/// rather than being silently coerced into `remove_dir_all`.
2104fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
2105    if let Err(unlink_err) = link::unlink(dst) {
2106        let meta = std::fs::symlink_metadata(dst)
2107            .with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
2108        let ft = meta.file_type();
2109        if ft.is_dir() && !ft.is_symlink() {
2110            std::fs::remove_dir_all(dst).with_context(|| {
2111                format!(
2112                    "remove_dir_all({dst}) after link::unlink failed: \
2113                     {unlink_err}"
2114                )
2115            })?;
2116        } else {
2117            return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
2118        }
2119    }
2120    Ok(())
2121}
2122
2123/// Recursively merge target's files into source: target wins on file
2124/// conflicts, source-only files are preserved, sub-dirs are created
2125/// in source as needed. Non-regular entries (symlinks / junctions /
2126/// device files) are skipped with a warning — copying their content
2127/// is ill-defined and following them risks looping into target via
2128/// some chain back to source.
2129///
2130/// Mirrors the file-level "AutoAbsorb backs up source, copies target's
2131/// content into source before relinking" semantic for whole dirs.
2132fn merge_dir_target_into_source(
2133    target: &Utf8Path,
2134    source: &Utf8Path,
2135    ctx: &ApplyCtx<'_>,
2136) -> Result<()> {
2137    for entry in std::fs::read_dir(target)? {
2138        let entry = entry?;
2139        let name_os = entry.file_name();
2140        let Some(name) = name_os.to_str() else {
2141            continue;
2142        };
2143        let target_path = target.join(name);
2144        let source_path = source.join(name);
2145        let ft = entry.file_type()?;
2146
2147        if ft.is_dir() && !ft.is_symlink() {
2148            // Target is a real dir. If source has a non-dir entry at
2149            // the same name (regular file, symlink, junction), it
2150            // would block `create_dir_all` and the recursive merge.
2151            // Honor target-wins by clearing the conflicting source
2152            // entry first.
2153            if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
2154                let sft = src_meta.file_type();
2155                if !sft.is_dir() || sft.is_symlink() {
2156                    link::unlink(&source_path).with_context(|| {
2157                        format!("remove conflicting source entry before dir merge: {source_path}")
2158                    })?;
2159                }
2160            }
2161            if !source_path.exists() {
2162                std::fs::create_dir_all(&source_path).with_context(|| {
2163                    format!("create_dir_all({source_path}) during target→source merge")
2164                })?;
2165            }
2166            merge_dir_target_into_source(&target_path, &source_path, ctx)?;
2167        } else if ft.is_file() {
2168            // Target is a regular file. Symmetrical handling: if
2169            // source has a directory or symlink at the same name,
2170            // tear it down first so the file copy can land.
2171            if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
2172                let sft = src_meta.file_type();
2173                if sft.is_dir() && !sft.is_symlink() {
2174                    remove_dir_link_or_real(&source_path).with_context(|| {
2175                        format!("remove conflicting source dir before file merge: {source_path}")
2176                    })?;
2177                } else if sft.is_symlink() {
2178                    link::unlink(&source_path).with_context(|| {
2179                        format!(
2180                            "remove conflicting source symlink before file merge: {source_path}"
2181                        )
2182                    })?;
2183                }
2184            }
2185            if let Some(parent) = source_path.parent() {
2186                if !parent.exists() {
2187                    std::fs::create_dir_all(parent)?;
2188                }
2189            }
2190            // If both sides are now regular files at the same path, run
2191            // the file-level absorb classifier so this single overlap
2192            // is resolved against `[absorb]` policy (auto / skip /
2193            // force / ask) instead of being silently overwritten. The
2194            // dir-level marker provides consent for the *whole-tree*
2195            // merge, but a per-file content collision where the
2196            // source side is *newer* is still a legitimate anomaly
2197            // worth surfacing.
2198            //
2199            // Source-only files were already preserved by virtue of
2200            // the merge not visiting them. Target-only files (where
2201            // `source_path` doesn't exist) skip the classifier and go
2202            // straight to copy below.
2203            if source_path.is_file() {
2204                merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
2205            } else {
2206                std::fs::copy(&target_path, &source_path)
2207                    .with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
2208            }
2209        } else {
2210            warn!(
2211                "merge: skipping non-regular entry {target_path} \
2212                 (symlink / junction / special — content not copied)"
2213            );
2214        }
2215    }
2216    Ok(())
2217}
2218
2219/// Per-file conflict resolution inside the dir merge. Both
2220/// `target_path` and `source_path` exist as regular files — run the
2221/// absorb classifier on the pair and route to the matching policy:
2222///
2223/// - `InSync` / `RelinkOnly` → no-op (contents already match)
2224/// - `AutoAbsorb` (target newer + diff) → copy target → source,
2225///   target-wins per the AutoAbsorb contract.
2226/// - `NeedsConfirm` (source newer + diff, the genuine anomaly) →
2227///   `[absorb] on_anomaly` dispatch:
2228///     - `skip` → leave source alone, target's version is dropped
2229///       (after the outer junction, target ends up with source's content)
2230///     - `force` → copy target → source (target wins anyway)
2231///     - `ask` → TTY prompt with diff; downgrade to skip off-TTY
2232fn merge_resolve_file_conflict(
2233    target_path: &Utf8Path,
2234    source_path: &Utf8Path,
2235    ctx: &ApplyCtx<'_>,
2236) -> Result<()> {
2237    use absorb::AbsorbDecision::*;
2238    let decision = absorb::classify(source_path, target_path)?;
2239    match decision {
2240        InSync | RelinkOnly => Ok(()),
2241        AutoAbsorb => {
2242            std::fs::copy(target_path, source_path).with_context(|| {
2243                format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
2244            })?;
2245            Ok(())
2246        }
2247        Restore => {
2248            // `Restore` is the classifier's "target is missing" arm.
2249            // We only enter this function after the merge loop saw
2250            // `target_path` as a regular file in the read_dir
2251            // iteration, and the caller guards on `source_path.is_file()`
2252            // — both exist by construction, so this branch is
2253            // unreachable.
2254            unreachable!(
2255                "merge_resolve_file_conflict reached with both files present, \
2256                 but classify returned Restore (target {target_path} / source {source_path})"
2257            )
2258        }
2259        NeedsConfirm => {
2260            use crate::config::AnomalyAction::*;
2261            match ctx.config.absorb.on_anomaly {
2262                Skip => {
2263                    warn!(
2264                        "merge anomaly skip: {target_path} (source-newer / content drift) \
2265                         — keeping source version, target version dropped"
2266                    );
2267                    Ok(())
2268                }
2269                Force => {
2270                    warn!(
2271                        "merge anomaly force: {target_path} \
2272                         (source-newer / content drift) — overwriting source"
2273                    );
2274                    std::fs::copy(target_path, source_path)?;
2275                    Ok(())
2276                }
2277                Ask => {
2278                    use std::io::IsTerminal;
2279                    if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
2280                        if prompt_absorb_with_diff(
2281                            source_path,
2282                            target_path,
2283                            "merge: file content differs and source is newer",
2284                        )? {
2285                            std::fs::copy(target_path, source_path)?;
2286                        } else {
2287                            warn!("merge: kept source version by user choice: {source_path}");
2288                        }
2289                        Ok(())
2290                    } else {
2291                        warn!(
2292                            "merge anomaly skip (non-TTY ask mode): {target_path} \
2293                             — keeping source version"
2294                        );
2295                        Ok(())
2296                    }
2297                }
2298            }
2299        }
2300    }
2301}
2302
2303/// Back up source-side, merge target's content into source (target
2304/// wins on conflict), then replace target with a junction to source.
2305/// "Target wins" — yui's core philosophy, generalised from the file
2306/// path to whole directories so a chezmoi-style migrated `~/.config/`
2307/// keeps every file the user actually had instead of stranding most
2308/// of them in `.yui/backup/...`.
2309fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2310    info!("absorb dir: {dst} → {src}");
2311    backup_existing(src, ctx.backup_root, /* is_dir */ true)?;
2312    merge_dir_target_into_source(dst, src, ctx)?;
2313    // Source now carries every regular file from target. Tear down the
2314    // original target dir and re-expose source via a junction.
2315    remove_dir_link_or_real(dst)?;
2316    link::link_dir(src, dst, ctx.dir_mode)?;
2317    Ok(())
2318}
2319
2320/// Dir-level counterpart to `handle_anomaly`. Same `[absorb] on_anomaly`
2321/// dispatch — `skip` warns and walks away, `force` absorbs anyway,
2322/// `ask` prompts on a TTY (downgraded to skip off-TTY).
2323fn handle_anomaly_dir(
2324    src: &Utf8Path,
2325    dst: &Utf8Path,
2326    ctx: &ApplyCtx<'_>,
2327    reason: &str,
2328) -> Result<()> {
2329    use crate::config::AnomalyAction::*;
2330    match ctx.config.absorb.on_anomaly {
2331        Skip => {
2332            warn!("anomaly skip dir: {dst} ({reason})");
2333            Ok(())
2334        }
2335        Force => {
2336            warn!(
2337                "anomaly force dir: {dst} ({reason}) \
2338                 — absorbing target into source"
2339            );
2340            absorb_target_dir_into_source(src, dst, ctx)
2341        }
2342        Ask => {
2343            use std::io::IsTerminal;
2344            if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
2345                eprintln!();
2346                eprintln!("anomaly: {dst}");
2347                eprintln!("  {reason}");
2348                eprintln!("  source: {src}");
2349                eprint!("  absorb target dir into source? (y/N) ");
2350                use std::io::{BufRead as _, Write as _};
2351                std::io::stderr().flush().ok();
2352                let mut buf = String::new();
2353                std::io::stdin().lock().read_line(&mut buf)?;
2354                let answer = buf.trim();
2355                if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
2356                    absorb_target_dir_into_source(src, dst, ctx)
2357                } else {
2358                    warn!("anomaly skipped by user: {dst}");
2359                    Ok(())
2360                }
2361            } else {
2362                warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
2363                Ok(())
2364            }
2365        }
2366    }
2367}
2368
2369fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
2370    let abs_target = absolutize(target)?;
2371    let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
2372    let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
2373    info!("backup → {bp}");
2374    if is_dir {
2375        backup::backup_dir(target, &bp)?;
2376    } else {
2377        backup::backup_file(target, &bp)?;
2378    }
2379    Ok(())
2380}
2381
2382fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
2383    if let Some(s) = source {
2384        return absolutize(&s);
2385    }
2386    if let Ok(s) = std::env::var("YUI_SOURCE") {
2387        return absolutize(Utf8Path::new(&s));
2388    }
2389    let cwd = current_dir_utf8()?;
2390    for ancestor in cwd.ancestors() {
2391        if ancestor.join("config.toml").is_file() {
2392            return Ok(ancestor.to_path_buf());
2393        }
2394    }
2395    if let Some(home) = paths::home_dir() {
2396        for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
2397            let p = home.join(c);
2398            if p.join("config.toml").is_file() {
2399                return Ok(p);
2400            }
2401        }
2402    }
2403    anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
2404}
2405
2406fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
2407    // Expand `~` first so callers can pass `--source ~/dotfiles` directly.
2408    let expanded = paths::expand_tilde(p.as_str());
2409    if expanded.is_absolute() {
2410        return Ok(expanded);
2411    }
2412    let cwd = current_dir_utf8()?;
2413    Ok(cwd.join(expanded))
2414}
2415
2416fn current_dir_utf8() -> Result<Utf8PathBuf> {
2417    let cwd = std::env::current_dir().context("getting cwd")?;
2418    Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
2419}
2420
2421// Note: `home_dir()` lives in `paths.rs` so the tilde-expansion helper and
2422// `resolve_source` share one HOME/USERPROFILE lookup.
2423
2424const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
2425
2426[vars]
2427# user-defined values; templates can reference these as {{ vars.foo }}
2428
2429# [link]
2430# file_mode = "auto"   # auto | symlink | hardlink
2431# dir_mode  = "auto"   # auto | symlink | junction
2432
2433[mount]
2434default_strategy = "marker"
2435
2436[[mount.entry]]
2437src = "home"
2438# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
2439dst = "~"
2440
2441# [[mount.entry]]
2442# src  = "appdata"
2443# dst  = "{{ env(name='APPDATA') }}"
2444# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
2445# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
2446# when = "yui.os == 'windows'"
2447"#;
2448
2449const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
2450# .yui/bin/ is intentionally tracked — it holds your hook scripts.
2451/.yui/state.json
2452/.yui/state.json.tmp
2453/.yui/backup/
2454
2455# >>> yui rendered (auto-managed, do not edit) >>>
2456# <<< yui rendered (auto-managed) <<<
2457
2458# config.local.toml is per-machine; commit a config.local.example.toml instead.
2459config.local.toml
2460"#;
2461
2462#[cfg(test)]
2463mod tests {
2464    use super::*;
2465    use tempfile::TempDir;
2466
2467    fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
2468        Utf8PathBuf::from_path_buf(p).unwrap()
2469    }
2470
2471    /// Convert a path to a TOML-string-safe form (forward slashes).
2472    fn toml_path(p: &Utf8Path) -> String {
2473        p.as_str().replace('\\', "/")
2474    }
2475
2476    #[test]
2477    fn apply_links_a_raw_file() {
2478        let tmp = TempDir::new().unwrap();
2479        let source = utf8(tmp.path().join("dotfiles"));
2480        let target = utf8(tmp.path().join("target"));
2481        std::fs::create_dir_all(source.join("home")).unwrap();
2482        std::fs::create_dir_all(&target).unwrap();
2483        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2484
2485        let cfg = format!(
2486            r#"
2487[[mount.entry]]
2488src = "home"
2489dst = "{}"
2490"#,
2491            toml_path(&target)
2492        );
2493        std::fs::write(source.join("config.toml"), cfg).unwrap();
2494
2495        apply(Some(source), false).unwrap();
2496
2497        let linked = target.join(".bashrc");
2498        assert!(linked.exists(), "expected {linked} to exist");
2499        assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
2500    }
2501
2502    #[test]
2503    fn apply_with_marker_links_whole_directory() {
2504        let tmp = TempDir::new().unwrap();
2505        let source = utf8(tmp.path().join("dotfiles"));
2506        let target = utf8(tmp.path().join("target"));
2507        let nvim_src = source.join("home/nvim");
2508        std::fs::create_dir_all(&nvim_src).unwrap();
2509        std::fs::create_dir_all(&target).unwrap();
2510        std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
2511        std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
2512        std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
2513
2514        let cfg = format!(
2515            r#"
2516[[mount.entry]]
2517src = "home"
2518dst = "{}"
2519"#,
2520            toml_path(&target)
2521        );
2522        std::fs::write(source.join("config.toml"), cfg).unwrap();
2523
2524        apply(Some(source.clone()), false).unwrap();
2525
2526        let nvim_dst = target.join("nvim");
2527        assert!(nvim_dst.exists());
2528        assert_eq!(
2529            std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
2530            "-- hi\n"
2531        );
2532        // Marker file itself shouldn't be visible as a separate link in target;
2533        // however with junction/symlink the whole dir shows up so the marker
2534        // file IS visible inside. That's fine — the marker is informational.
2535    }
2536
2537    #[test]
2538    fn apply_dry_run_does_not_write() {
2539        let tmp = TempDir::new().unwrap();
2540        let source = utf8(tmp.path().join("dotfiles"));
2541        let target = utf8(tmp.path().join("target"));
2542        std::fs::create_dir_all(source.join("home")).unwrap();
2543        std::fs::create_dir_all(&target).unwrap();
2544        std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
2545
2546        let cfg = format!(
2547            r#"
2548[[mount.entry]]
2549src = "home"
2550dst = "{}"
2551"#,
2552            toml_path(&target)
2553        );
2554        std::fs::write(source.join("config.toml"), cfg).unwrap();
2555
2556        apply(Some(source), true).unwrap();
2557
2558        assert!(!target.join(".bashrc").exists());
2559    }
2560
2561    #[test]
2562    fn apply_renders_templates_then_links_rendered_outputs() {
2563        let tmp = TempDir::new().unwrap();
2564        let source = utf8(tmp.path().join("dotfiles"));
2565        let target = utf8(tmp.path().join("target"));
2566        std::fs::create_dir_all(source.join("home")).unwrap();
2567        std::fs::create_dir_all(&target).unwrap();
2568        std::fs::write(
2569            source.join("home/.gitconfig.tera"),
2570            "[user]\n  os = {{ yui.os }}\n",
2571        )
2572        .unwrap();
2573        std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
2574
2575        let cfg = format!(
2576            r#"
2577[[mount.entry]]
2578src = "home"
2579dst = "{}"
2580"#,
2581            toml_path(&target)
2582        );
2583        std::fs::write(source.join("config.toml"), cfg).unwrap();
2584
2585        apply(Some(source.clone()), false).unwrap();
2586
2587        // Raw file: linked.
2588        assert!(target.join(".bashrc").exists());
2589        // Template's rendered output: written to source then linked.
2590        assert!(source.join("home/.gitconfig").exists());
2591        assert!(target.join(".gitconfig").exists());
2592        // The .tera file itself is never linked into target.
2593        assert!(!target.join(".gitconfig.tera").exists());
2594        // Rendered file content carries the yui.os substitution.
2595        let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
2596        assert!(linked.contains("os = "));
2597    }
2598
2599    #[test]
2600    fn apply_marker_override_links_to_custom_dst() {
2601        let tmp = TempDir::new().unwrap();
2602        let source = utf8(tmp.path().join("dotfiles"));
2603        let target_a = utf8(tmp.path().join("target_a"));
2604        let target_b = utf8(tmp.path().join("target_b"));
2605        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2606        std::fs::create_dir_all(&target_a).unwrap();
2607        std::fs::create_dir_all(&target_b).unwrap();
2608        std::fs::write(
2609            source.join("home/.config/nvim/init.lua"),
2610            "-- nvim config\n",
2611        )
2612        .unwrap();
2613
2614        // Marker tells yui to ignore the parent mount's dst for this dir
2615        // and link it to two custom places (the second only if condition matches).
2616        std::fs::write(
2617            source.join("home/.config/nvim/.yuilink"),
2618            format!(
2619                r#"
2620[[link]]
2621dst = "{}/nvim"
2622
2623[[link]]
2624dst = "{}/nvim"
2625when = "{{{{ yui.os == '{}' }}}}"
2626"#,
2627                toml_path(&target_a),
2628                toml_path(&target_b),
2629                std::env::consts::OS
2630            ),
2631        )
2632        .unwrap();
2633
2634        let parent_target = utf8(tmp.path().join("parent_target"));
2635        std::fs::create_dir_all(&parent_target).unwrap();
2636        let cfg = format!(
2637            r#"
2638[[mount.entry]]
2639src = "home"
2640dst = "{}"
2641"#,
2642            toml_path(&parent_target)
2643        );
2644        std::fs::write(source.join("config.toml"), cfg).unwrap();
2645
2646        apply(Some(source.clone()), false).unwrap();
2647
2648        // Both override targets received the link (the second's when matches OS).
2649        assert!(
2650            target_a.join("nvim/init.lua").exists(),
2651            "target_a/nvim/init.lua should be reachable through the link"
2652        );
2653        assert!(
2654            target_b.join("nvim/init.lua").exists(),
2655            "target_b/nvim/init.lua should be reachable through the link"
2656        );
2657        // Parent mount did NOT also link this dir (it would have appeared at
2658        // parent_target/.config/nvim — the marker claims the dir).
2659        assert!(
2660            !parent_target.join(".config/nvim").exists(),
2661            "parent mount should have skipped the marker-claimed sub-dir"
2662        );
2663    }
2664
2665    #[test]
2666    fn apply_marker_inactive_link_falls_through_to_default() {
2667        // v0.6+ semantics: a marker that has only inactive links no
2668        // longer suppresses the parent mount's natural placement. The
2669        // walker keeps descending so per-file defaults still apply.
2670        // (Use `.yuiignore` to actually exclude a subtree.)
2671        let tmp = TempDir::new().unwrap();
2672        let source = utf8(tmp.path().join("dotfiles"));
2673        let target_inactive = utf8(tmp.path().join("inactive"));
2674        let parent_target = utf8(tmp.path().join("parent"));
2675        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2676        std::fs::create_dir_all(&parent_target).unwrap();
2677        std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
2678
2679        // when=false on every link → marker has no active links.
2680        std::fs::write(
2681            source.join("home/.config/nvim/.yuilink"),
2682            format!(
2683                r#"
2684[[link]]
2685dst = "{}/nvim"
2686when = "{{{{ yui.os == 'no-such-os' }}}}"
2687"#,
2688                toml_path(&target_inactive)
2689            ),
2690        )
2691        .unwrap();
2692
2693        let cfg = format!(
2694            r#"
2695[[mount.entry]]
2696src = "home"
2697dst = "{}"
2698"#,
2699            toml_path(&parent_target)
2700        );
2701        std::fs::write(source.join("config.toml"), cfg).unwrap();
2702
2703        apply(Some(source.clone()), false).unwrap();
2704
2705        // Inactive marker target untouched.
2706        assert!(!target_inactive.join("nvim").exists());
2707        // Parent mount's natural placement IS produced — the marker had
2708        // no active dir-level link to claim coverage with.
2709        assert!(parent_target.join(".config/nvim/init.lua").exists());
2710    }
2711
2712    #[test]
2713    fn list_shows_mount_entries_and_marker_overrides() {
2714        let tmp = TempDir::new().unwrap();
2715        let source = utf8(tmp.path().join("dotfiles"));
2716        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2717        std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
2718        std::fs::write(
2719            source.join("home/.config/nvim/.yuilink"),
2720            r#"
2721[[link]]
2722dst = "/custom/nvim"
2723"#,
2724        )
2725        .unwrap();
2726        std::fs::write(
2727            source.join("config.toml"),
2728            r#"
2729[[mount.entry]]
2730src = "home"
2731dst = "/h"
2732"#,
2733        )
2734        .unwrap();
2735
2736        // Just verify it runs without error — output format is covered by
2737        // unit-level helpers below.
2738        list(Some(source), false, None, true).unwrap();
2739    }
2740
2741    #[test]
2742    fn status_reports_in_sync_after_apply() {
2743        let tmp = TempDir::new().unwrap();
2744        let source = utf8(tmp.path().join("dotfiles"));
2745        let target = utf8(tmp.path().join("target"));
2746        std::fs::create_dir_all(source.join("home")).unwrap();
2747        std::fs::create_dir_all(&target).unwrap();
2748        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2749        let cfg = format!(
2750            r#"
2751[[mount.entry]]
2752src = "home"
2753dst = "{}"
2754"#,
2755            toml_path(&target)
2756        );
2757        std::fs::write(source.join("config.toml"), cfg).unwrap();
2758        // First link the target so the link is intact.
2759        apply(Some(source.clone()), false).unwrap();
2760        // status should succeed (everything in-sync).
2761        status(Some(source), None, true).unwrap();
2762    }
2763
2764    #[test]
2765    fn status_reports_template_drift() {
2766        let tmp = TempDir::new().unwrap();
2767        let source = utf8(tmp.path().join("dotfiles"));
2768        let target = utf8(tmp.path().join("target"));
2769        std::fs::create_dir_all(source.join("home")).unwrap();
2770        std::fs::create_dir_all(&target).unwrap();
2771        // Template would render to "fresh" but the rendered file on disk
2772        // says "stale" — simulating a manual edit not reflected back.
2773        std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
2774        std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
2775
2776        let cfg = format!(
2777            r#"
2778[[mount.entry]]
2779src = "home"
2780dst = "{}"
2781"#,
2782            toml_path(&target)
2783        );
2784        std::fs::write(source.join("config.toml"), cfg).unwrap();
2785
2786        let err = status(Some(source), None, true).unwrap_err();
2787        assert!(format!("{err}").contains("diverged"));
2788    }
2789
2790    #[test]
2791    fn status_fails_when_target_missing() {
2792        let tmp = TempDir::new().unwrap();
2793        let source = utf8(tmp.path().join("dotfiles"));
2794        let target = utf8(tmp.path().join("target"));
2795        std::fs::create_dir_all(source.join("home")).unwrap();
2796        std::fs::create_dir_all(&target).unwrap();
2797        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2798        let cfg = format!(
2799            r#"
2800[[mount.entry]]
2801src = "home"
2802dst = "{}"
2803"#,
2804            toml_path(&target)
2805        );
2806        std::fs::write(source.join("config.toml"), cfg).unwrap();
2807        // No apply yet — target/.bashrc doesn't exist.
2808        let err = status(Some(source), None, true).unwrap_err();
2809        assert!(format!("{err}").contains("diverged"));
2810    }
2811
2812    #[test]
2813    fn strip_braces_removes_outer_template_braces() {
2814        assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
2815        assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
2816        assert_eq!(strip_braces("  {{x}}  "), "x");
2817    }
2818
2819    #[test]
2820    fn apply_aborts_on_render_drift() {
2821        let tmp = TempDir::new().unwrap();
2822        let source = utf8(tmp.path().join("dotfiles"));
2823        let target = utf8(tmp.path().join("target"));
2824        std::fs::create_dir_all(source.join("home")).unwrap();
2825        std::fs::create_dir_all(&target).unwrap();
2826        std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
2827        std::fs::write(source.join("home/foo"), "manually edited").unwrap();
2828
2829        let cfg = format!(
2830            r#"
2831[[mount.entry]]
2832src = "home"
2833dst = "{}"
2834"#,
2835            toml_path(&target)
2836        );
2837        std::fs::write(source.join("config.toml"), cfg).unwrap();
2838
2839        let err = apply(Some(source.clone()), false).unwrap_err();
2840        assert!(format!("{err}").contains("drift"));
2841        // Existing rendered file untouched.
2842        assert_eq!(
2843            std::fs::read_to_string(source.join("home/foo")).unwrap(),
2844            "manually edited"
2845        );
2846        // Linking aborted — target empty.
2847        assert!(!target.join("foo").exists());
2848    }
2849
2850    #[test]
2851    fn init_creates_skeleton_when_dir_empty() {
2852        let tmp = TempDir::new().unwrap();
2853        let dir = utf8(tmp.path().join("new_dotfiles"));
2854        init(Some(dir.clone()), false).unwrap();
2855        assert!(dir.join("config.toml").is_file());
2856        assert!(dir.join(".gitignore").is_file());
2857    }
2858
2859    #[test]
2860    fn init_refuses_to_overwrite_existing_config() {
2861        let tmp = TempDir::new().unwrap();
2862        let dir = utf8(tmp.path().join("dotfiles"));
2863        std::fs::create_dir_all(&dir).unwrap();
2864        std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
2865        let err = init(Some(dir), false).unwrap_err();
2866        assert!(format!("{err}").contains("already exists"));
2867    }
2868
2869    /// `init` is now in charge of the `.yui/` state / backup ignore
2870    /// lines, even on a re-run against an existing repo. Pre-fix it
2871    /// silently left a half-populated `.gitignore` alone if the user
2872    /// didn't have the entries in place; now it appends the missing
2873    /// ones idempotently.
2874    #[test]
2875    fn init_appends_missing_gitignore_entries_into_existing_file() {
2876        let tmp = TempDir::new().unwrap();
2877        let dir = utf8(tmp.path().join("dotfiles"));
2878        std::fs::create_dir_all(&dir).unwrap();
2879        // Existing .gitignore that DOESN'T yet have any yui entries.
2880        let user_gitignore = "# user entries\n*.swp\nnode_modules/\n";
2881        std::fs::write(dir.join(".gitignore"), user_gitignore).unwrap();
2882
2883        init(Some(dir.clone()), false).unwrap();
2884
2885        let body = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
2886        // The user's existing lines survive untouched.
2887        assert!(body.contains("*.swp"));
2888        assert!(body.contains("node_modules/"));
2889        // Each yui-required line was appended.
2890        assert!(body.contains("/.yui/state.json"));
2891        assert!(body.contains("/.yui/backup/"));
2892        assert!(body.contains("config.local.toml"));
2893        // Re-running init on the already-fixed-up file is a no-op.
2894        let before_rerun = body.clone();
2895        // `init` would normally bail on an existing config; remove it so
2896        // the second call doesn't trip that guard.
2897        std::fs::remove_file(dir.join("config.toml")).unwrap();
2898        init(Some(dir.clone()), false).unwrap();
2899        let after_rerun = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
2900        assert_eq!(
2901            before_rerun, after_rerun,
2902            "init must be idempotent when the gitignore already has every yui entry"
2903        );
2904    }
2905
2906    /// `init --git-hooks` against an *existing* repo (config.toml
2907    /// already there) skips the scaffold and just installs the hooks.
2908    /// Pre-fix this combo bailed with "config.toml already exists",
2909    /// which forced users with a populated dotfiles repo to delete
2910    /// their config before they could opt into the render-drift hooks.
2911    #[test]
2912    fn init_with_git_hooks_installs_into_existing_repo() {
2913        let tmp = TempDir::new().unwrap();
2914        let dir = utf8(tmp.path().join("dotfiles"));
2915        std::fs::create_dir_all(&dir).unwrap();
2916        let st = std::process::Command::new("git")
2917            .args(["init", "-q"])
2918            .current_dir(dir.as_std_path())
2919            .status()
2920            .expect("git init");
2921        if !st.success() {
2922            return;
2923        }
2924        // Pre-existing user config — init should NOT overwrite it.
2925        let user_config = "# user already wrote this\n";
2926        std::fs::write(dir.join("config.toml"), user_config).unwrap();
2927
2928        // hooks-only invocation: succeeds, leaves config alone.
2929        init(Some(dir.clone()), /* git_hooks */ true).unwrap();
2930
2931        assert_eq!(
2932            std::fs::read_to_string(dir.join("config.toml")).unwrap(),
2933            user_config
2934        );
2935        assert!(dir.join(".git/hooks/pre-commit").is_file());
2936        assert!(dir.join(".git/hooks/pre-push").is_file());
2937    }
2938
2939    /// `init --git-hooks` writes pre-commit / pre-push that run the
2940    /// render-drift check against `.git/hooks/`. We need a real git
2941    /// repo for `git rev-parse --git-path hooks` to point at, so
2942    /// prepare one before calling init.
2943    #[test]
2944    fn init_with_git_hooks_writes_pre_commit_and_pre_push() {
2945        let tmp = TempDir::new().unwrap();
2946        let dir = utf8(tmp.path().join("dotfiles"));
2947        std::fs::create_dir_all(&dir).unwrap();
2948        // Bootstrap a git repo at `dir`.
2949        let st = std::process::Command::new("git")
2950            .args(["init", "-q"])
2951            .current_dir(dir.as_std_path())
2952            .status()
2953            .expect("git init");
2954        if !st.success() {
2955            // Skip if git isn't on PATH on this CI runner.
2956            eprintln!("skipping: git not available");
2957            return;
2958        }
2959        init(Some(dir.clone()), /* git_hooks */ true).unwrap();
2960
2961        let pre_commit = dir.join(".git/hooks/pre-commit");
2962        let pre_push = dir.join(".git/hooks/pre-push");
2963        assert!(pre_commit.is_file(), "pre-commit hook should be written");
2964        assert!(pre_push.is_file(), "pre-push hook should be written");
2965
2966        let body = std::fs::read_to_string(&pre_commit).unwrap();
2967        assert!(
2968            body.contains("yui render --check"),
2969            "pre-commit hook should call `yui render --check`, got: {body}"
2970        );
2971    }
2972
2973    /// `init --git-hooks` against a non-git directory must fail with a
2974    /// clear message instead of silently doing nothing — the user
2975    /// asked for hooks and we couldn't deliver.
2976    #[test]
2977    fn init_with_git_hooks_errors_outside_a_git_repo() {
2978        let tmp = TempDir::new().unwrap();
2979        let dir = utf8(tmp.path().join("not-a-repo"));
2980        std::fs::create_dir_all(&dir).unwrap();
2981        let err = init(Some(dir), /* git_hooks */ true).unwrap_err();
2982        let msg = format!("{err:#}");
2983        assert!(
2984            msg.contains("git repo") || msg.contains("git rev-parse"),
2985            "expected error to mention the git issue, got: {msg}"
2986        );
2987    }
2988
2989    /// Pre-existing hooks are not silently overwritten — yui leaves
2990    /// the user's prior file alone (warns) and writes the missing one.
2991    #[test]
2992    fn init_with_git_hooks_does_not_clobber_existing_hooks() {
2993        let tmp = TempDir::new().unwrap();
2994        let dir = utf8(tmp.path().join("dotfiles"));
2995        std::fs::create_dir_all(&dir).unwrap();
2996        let st = std::process::Command::new("git")
2997            .args(["init", "-q"])
2998            .current_dir(dir.as_std_path())
2999            .status()
3000            .expect("git init");
3001        if !st.success() {
3002            return;
3003        }
3004        let hooks = dir.join(".git/hooks");
3005        std::fs::create_dir_all(&hooks).unwrap();
3006        std::fs::write(hooks.join("pre-commit"), "#! /bin/sh\nexit 0\n").unwrap();
3007
3008        init(Some(dir.clone()), true).unwrap();
3009
3010        // Existing pre-commit untouched, pre-push freshly written.
3011        let pc = std::fs::read_to_string(hooks.join("pre-commit")).unwrap();
3012        assert!(
3013            !pc.contains("yui render --check"),
3014            "existing pre-commit must not be overwritten"
3015        );
3016        let pp = std::fs::read_to_string(hooks.join("pre-push")).unwrap();
3017        assert!(
3018            pp.contains("yui render --check"),
3019            "missing pre-push should be written: {pp}"
3020        );
3021    }
3022
3023    /// Build a minimal `apply`-able dotfiles tree for absorb tests.
3024    /// Returns (source_dir, target_dir).
3025    fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
3026        let source = utf8(tmp.path().join("dotfiles"));
3027        let target = utf8(tmp.path().join("target"));
3028        std::fs::create_dir_all(source.join("home")).unwrap();
3029        std::fs::create_dir_all(&target).unwrap();
3030        let cfg = format!(
3031            r#"
3032[[mount.entry]]
3033src = "home"
3034dst = "{}"
3035"#,
3036            toml_path(&target)
3037        );
3038        std::fs::write(source.join("config.toml"), cfg).unwrap();
3039        (source, target)
3040    }
3041
3042    fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
3043        std::fs::write(path, body).unwrap();
3044        let f = std::fs::OpenOptions::new()
3045            .write(true)
3046            .open(path)
3047            .expect("open writable");
3048        f.set_modified(when).expect("set_modified");
3049    }
3050
3051    #[test]
3052    fn apply_target_newer_absorbs_target_into_source() {
3053        // Target has the user's edit and is mtime-newer than source —
3054        // classifier returns `AutoAbsorb`. yui's "target-as-truth"
3055        // philosophy: target wins, source is updated and backed up.
3056        let tmp = TempDir::new().unwrap();
3057        let (source, target) = setup_minimal_dotfiles(&tmp);
3058
3059        let now = std::time::SystemTime::now();
3060        let past = now - std::time::Duration::from_secs(120);
3061        write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
3062        // Pre-existing target with user's edit, NEWER mtime.
3063        write_with_mtime(&target.join(".bashrc"), "user's edit", now);
3064
3065        apply(Some(source.clone()), false).unwrap();
3066
3067        // Target's content survives — that's the whole point.
3068        assert_eq!(
3069            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3070            "user's edit"
3071        );
3072        // Source has been updated to match target.
3073        assert_eq!(
3074            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3075            "user's edit"
3076        );
3077        // Source's previous content lives under .yui/backup.
3078        let backup_root = source.join(".yui/backup");
3079        let mut found_old = false;
3080        for entry in walkdir(&backup_root) {
3081            if let Ok(s) = std::fs::read_to_string(&entry) {
3082                if s == "default from repo" {
3083                    found_old = true;
3084                    break;
3085                }
3086            }
3087        }
3088        assert!(found_old, "expected backup containing 'default from repo'");
3089    }
3090
3091    #[test]
3092    fn apply_in_sync_target_is_a_no_op() {
3093        // After an initial `apply`, running `apply` again classifies as
3094        // `InSync` and does nothing.
3095        let tmp = TempDir::new().unwrap();
3096        let (source, target) = setup_minimal_dotfiles(&tmp);
3097        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
3098        apply(Some(source.clone()), false).unwrap();
3099        let backup_root = source.join(".yui/backup");
3100        let backup_count_after_first = walkdir(&backup_root).len();
3101
3102        // Second apply — nothing should change.
3103        apply(Some(source.clone()), false).unwrap();
3104        assert_eq!(
3105            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3106            "echo hi\n"
3107        );
3108        let backup_count_after_second = walkdir(&backup_root).len();
3109        assert_eq!(
3110            backup_count_after_first, backup_count_after_second,
3111            "second apply on an in-sync tree should not produce backups"
3112        );
3113    }
3114
3115    #[test]
3116    fn apply_skip_policy_leaves_anomaly_alone() {
3117        // Source newer than target + content differs = NeedsConfirm.
3118        // With on_anomaly = "skip", target stays untouched.
3119        let tmp = TempDir::new().unwrap();
3120        let source = utf8(tmp.path().join("dotfiles"));
3121        let target = utf8(tmp.path().join("target"));
3122        std::fs::create_dir_all(source.join("home")).unwrap();
3123        std::fs::create_dir_all(&target).unwrap();
3124        let cfg = format!(
3125            r#"
3126[absorb]
3127on_anomaly = "skip"
3128
3129[[mount.entry]]
3130src = "home"
3131dst = "{}"
3132"#,
3133            toml_path(&target)
3134        );
3135        std::fs::write(source.join("config.toml"), cfg).unwrap();
3136
3137        let now = std::time::SystemTime::now();
3138        let past = now - std::time::Duration::from_secs(120);
3139        write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
3140        write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
3141
3142        apply(Some(source.clone()), false).unwrap();
3143
3144        // Target untouched (skip policy honored).
3145        assert_eq!(
3146            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3147            "user's edit (older)"
3148        );
3149        // Source untouched too.
3150        assert_eq!(
3151            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3152            "fresh from upstream"
3153        );
3154    }
3155
3156    #[test]
3157    fn apply_force_policy_absorbs_anomaly_anyway() {
3158        // Same anomaly setup, but on_anomaly = "force" → target wins.
3159        let tmp = TempDir::new().unwrap();
3160        let source = utf8(tmp.path().join("dotfiles"));
3161        let target = utf8(tmp.path().join("target"));
3162        std::fs::create_dir_all(source.join("home")).unwrap();
3163        std::fs::create_dir_all(&target).unwrap();
3164        let cfg = format!(
3165            r#"
3166[absorb]
3167on_anomaly = "force"
3168
3169[[mount.entry]]
3170src = "home"
3171dst = "{}"
3172"#,
3173            toml_path(&target)
3174        );
3175        std::fs::write(source.join("config.toml"), cfg).unwrap();
3176
3177        let now = std::time::SystemTime::now();
3178        let past = now - std::time::Duration::from_secs(120);
3179        write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
3180        write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
3181
3182        apply(Some(source.clone()), false).unwrap();
3183
3184        // Target wins despite being mtime-older — force policy.
3185        assert_eq!(
3186            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
3187            "user's edit (older)"
3188        );
3189        assert_eq!(
3190            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3191            "user's edit (older)"
3192        );
3193    }
3194
3195    /// Regression for the Windows-error-145 bug: a `home/.config/.yuilink`
3196    /// (PassThrough) marker pointing at a non-empty regular `~/.config`
3197    /// directory (the typical chezmoi-migrated state, where every file
3198    /// inside is an individual hardlink) used to fail the absorb with
3199    /// `Directory not empty` because `link::unlink` refuses to recurse.
3200    /// After backup we now `remove_dir_all` as a fallback.
3201    ///
3202    /// v0.7+: also exercises the target-wins merge — target's
3203    /// `config.toml` overwrites source's, target's `state.json` lands
3204    /// in source (target was the source of truth), and source-only
3205    /// scaffolding (`.yuilink`) survives the absorb.
3206    #[test]
3207    fn apply_absorbs_non_empty_target_dir_target_wins() {
3208        let tmp = TempDir::new().unwrap();
3209        let source = utf8(tmp.path().join("dotfiles"));
3210        let target = utf8(tmp.path().join("target"));
3211        std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
3212        std::fs::create_dir_all(target.join(".config/app")).unwrap();
3213        // Marker that says "junction this dir at the parent mount's dst"
3214        // — same shape as a typical home/.config/.yuilink.
3215        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3216        std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
3217        // Source-only scaffolding that the absorb must preserve.
3218        std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
3219        // Pre-existing non-empty regular dir at the target — chezmoi /
3220        // any per-file dotfiles flow leaves things in this shape.
3221        std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
3222        std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
3223
3224        let cfg = format!(
3225            r#"
3226[absorb]
3227on_anomaly = "force"
3228
3229[[mount.entry]]
3230src = "home"
3231dst = "{}"
3232"#,
3233            toml_path(&target)
3234        );
3235        std::fs::write(source.join("config.toml"), cfg).unwrap();
3236
3237        // Used to bail with `unlink: ... Directory not empty` here.
3238        apply(Some(source.clone()), false).unwrap();
3239
3240        // Target wins on the conflicting file.
3241        assert_eq!(
3242            std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
3243            "target side"
3244        );
3245        // Target-only file is now reachable via the junction.
3246        assert_eq!(
3247            std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
3248            "{}"
3249        );
3250        // Source's pre-merge state was backed up before being overwritten,
3251        // so the original "src side" / `.yuilink` survive in `.yui/backup/`.
3252        let backup_root = source.join(".yui/backup");
3253        let mut backup_files: Vec<String> = Vec::new();
3254        for entry in walkdir(&backup_root) {
3255            if let Some(n) = entry.file_name() {
3256                backup_files.push(n.to_string());
3257            }
3258        }
3259        assert!(
3260            backup_files.iter().any(|f| f == "config.toml"),
3261            "expected source's config.toml to land in the backup tree, got {backup_files:?}"
3262        );
3263        // Source-only scaffolding survives the merge.
3264        assert!(
3265            source.join("home/.config/app/source-only.toml").exists(),
3266            "source-only file should survive a target-wins merge"
3267        );
3268        // Source picked up target-only state.json via the merge.
3269        assert!(
3270            source.join("home/.config/app/state.json").exists(),
3271            "target-only state.json should be merged into source"
3272        );
3273    }
3274
3275    /// v0.7+: `home/.config/.yuilink` is the user's explicit
3276    /// "this whole subtree is target-as-truth" declaration. A
3277    /// dir-level NeedsConfirm at the marker root is therefore not a
3278    /// real anomaly — the marker is consent. Default `[absorb]` (ask
3279    /// + require_clean_git) should still absorb, no prompt.
3280    #[test]
3281    fn marker_dir_absorbs_with_default_ask_policy() {
3282        let tmp = TempDir::new().unwrap();
3283        let source = utf8(tmp.path().join("dotfiles"));
3284        let target = utf8(tmp.path().join("target"));
3285        std::fs::create_dir_all(source.join("home/.config")).unwrap();
3286        std::fs::create_dir_all(target.join(".config/gh")).unwrap();
3287        // Marker — user opts the whole .config dir into target-as-truth.
3288        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3289        // gh exists only on the target side (no entry in source).
3290        std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\n").unwrap();
3291
3292        // Default [absorb] (no override) — `on_anomaly = "ask"`,
3293        // `auto = true`, `require_clean_git = true`. Pre-v0.7 this
3294        // would have been routed through the ask prompt at dir level.
3295        let cfg = format!(
3296            r#"
3297[[mount.entry]]
3298src = "home"
3299dst = "{}"
3300"#,
3301            toml_path(&target)
3302        );
3303        std::fs::write(source.join("config.toml"), cfg).unwrap();
3304
3305        // Even with default `ask`, the marker-rooted absorb proceeds.
3306        // Test would hang on a stdin prompt if dir-level still treated
3307        // this as an anomaly.
3308        apply(Some(source.clone()), false).unwrap();
3309
3310        // Target-only file is now reachable through the junction and
3311        // recorded in source.
3312        assert!(target.join(".config/gh/hosts.yml").exists());
3313        assert!(source.join("home/.config/gh/hosts.yml").exists());
3314    }
3315
3316    /// File↔dir collisions during merge. Honor target-wins: if source
3317    /// has a regular file at a path where target has a dir, the file
3318    /// gets removed and the dir is created. Symmetrical for the
3319    /// inverse case. Without the conflict-clearing the merge would
3320    /// fail with `not a directory` / `path exists` deep in the recursion.
3321    #[test]
3322    fn merge_handles_file_vs_dir_collisions_target_wins() {
3323        let tmp = TempDir::new().unwrap();
3324        let source = utf8(tmp.path().join("dotfiles"));
3325        let target = utf8(tmp.path().join("target"));
3326        std::fs::create_dir_all(source.join("home/.config/foo")).unwrap();
3327        std::fs::create_dir_all(target.join(".config")).unwrap();
3328        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3329
3330        // Conflict A: source has `foo` as dir, target has `foo` as file.
3331        std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
3332        std::fs::write(target.join(".config/foo"), "target file body").unwrap();
3333        // Conflict B: source has `bar` as file, target has `bar` as dir.
3334        std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
3335        std::fs::create_dir_all(target.join(".config/bar")).unwrap();
3336        std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
3337
3338        let cfg = format!(
3339            r#"
3340[absorb]
3341on_anomaly = "force"
3342
3343[[mount.entry]]
3344src = "home"
3345dst = "{}"
3346"#,
3347            toml_path(&target)
3348        );
3349        std::fs::write(source.join("config.toml"), cfg).unwrap();
3350        apply(Some(source.clone()), false).unwrap();
3351
3352        // After absorb the target's view (which equals source via
3353        // junction) carries target's shapes:
3354        // `foo` is a regular file
3355        let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
3356        assert!(foo_meta.file_type().is_file(), "foo should be a file");
3357        assert_eq!(
3358            std::fs::read_to_string(target.join(".config/foo")).unwrap(),
3359            "target file body"
3360        );
3361        // `bar` is a directory with the nested file
3362        let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
3363        assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
3364        assert_eq!(
3365            std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
3366            "target nested"
3367        );
3368    }
3369
3370    /// Per-file conflict in dir merge — target newer + content
3371    /// differs → AutoAbsorb. Target wins automatically without
3372    /// touching `[absorb] on_anomaly`.
3373    #[test]
3374    fn merge_per_file_target_newer_auto_absorbs() {
3375        let tmp = TempDir::new().unwrap();
3376        let source = utf8(tmp.path().join("dotfiles"));
3377        let target = utf8(tmp.path().join("target"));
3378        std::fs::create_dir_all(source.join("home/.config")).unwrap();
3379        std::fs::create_dir_all(target.join(".config")).unwrap();
3380        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3381
3382        // Source has the older copy, target has the newer edit.
3383        let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3384        write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
3385        std::fs::write(target.join(".config/app.toml"), "user's live edit").unwrap();
3386
3387        // Default `ask` policy — should NOT prompt because the
3388        // classifier returns AutoAbsorb (target newer + diff), which
3389        // bypasses `on_anomaly` entirely.
3390        let cfg = format!(
3391            r#"
3392[[mount.entry]]
3393src = "home"
3394dst = "{}"
3395"#,
3396            toml_path(&target)
3397        );
3398        std::fs::write(source.join("config.toml"), cfg).unwrap();
3399        apply(Some(source.clone()), false).unwrap();
3400
3401        // Target wins.
3402        assert_eq!(
3403            std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3404            "user's live edit"
3405        );
3406    }
3407
3408    /// Per-file conflict — source newer + content differs +
3409    /// `on_anomaly = "skip"` → keep source's version. After the outer
3410    /// junction, target ends up with source's content (so target's
3411    /// file is effectively dropped, matching the file-level `skip`
3412    /// semantic).
3413    #[test]
3414    fn merge_per_file_source_newer_skip_keeps_source() {
3415        let tmp = TempDir::new().unwrap();
3416        let source = utf8(tmp.path().join("dotfiles"));
3417        let target = utf8(tmp.path().join("target"));
3418        std::fs::create_dir_all(source.join("home/.config")).unwrap();
3419        std::fs::create_dir_all(target.join(".config")).unwrap();
3420        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3421
3422        // Target has the older copy, source has the newer edit.
3423        let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3424        write_with_mtime(&target.join(".config/app.toml"), "old target", past);
3425        std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
3426
3427        let cfg = format!(
3428            r#"
3429[absorb]
3430on_anomaly = "skip"
3431
3432[[mount.entry]]
3433src = "home"
3434dst = "{}"
3435"#,
3436            toml_path(&target)
3437        );
3438        std::fs::write(source.join("config.toml"), cfg).unwrap();
3439        apply(Some(source.clone()), false).unwrap();
3440
3441        // Source kept — target now reads source's version through the
3442        // junction (so target's old text is dropped).
3443        assert_eq!(
3444            std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3445            "fresh source"
3446        );
3447    }
3448
3449    /// Per-file conflict — source newer + content differs +
3450    /// `on_anomaly = "force"` → target wins anyway.
3451    #[test]
3452    fn merge_per_file_source_newer_force_overwrites_source() {
3453        let tmp = TempDir::new().unwrap();
3454        let source = utf8(tmp.path().join("dotfiles"));
3455        let target = utf8(tmp.path().join("target"));
3456        std::fs::create_dir_all(source.join("home/.config")).unwrap();
3457        std::fs::create_dir_all(target.join(".config")).unwrap();
3458        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3459
3460        let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3461        write_with_mtime(&target.join(".config/app.toml"), "old target", past);
3462        std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
3463
3464        let cfg = format!(
3465            r#"
3466[absorb]
3467on_anomaly = "force"
3468
3469[[mount.entry]]
3470src = "home"
3471dst = "{}"
3472"#,
3473            toml_path(&target)
3474        );
3475        std::fs::write(source.join("config.toml"), cfg).unwrap();
3476        apply(Some(source.clone()), false).unwrap();
3477
3478        // Target overrides source despite being mtime-older.
3479        assert_eq!(
3480            std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3481            "old target"
3482        );
3483    }
3484
3485    /// Per-file conflict — bytes match → no-op. The merge classifies
3486    /// this as RelinkOnly and skips the copy entirely (saves a lot of
3487    /// I/O when migrating big chezmoi repos where source and target
3488    /// have already shared inodes).
3489    #[test]
3490    fn merge_per_file_identical_content_is_noop() {
3491        let tmp = TempDir::new().unwrap();
3492        let source = utf8(tmp.path().join("dotfiles"));
3493        let target = utf8(tmp.path().join("target"));
3494        std::fs::create_dir_all(source.join("home/.config")).unwrap();
3495        std::fs::create_dir_all(target.join(".config")).unwrap();
3496        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3497        std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
3498        std::fs::write(target.join(".config/app.toml"), "same").unwrap();
3499
3500        // Default policy — bytes match, classifier returns RelinkOnly,
3501        // merge skips the copy. Apply must succeed without prompting.
3502        let cfg = format!(
3503            r#"
3504[[mount.entry]]
3505src = "home"
3506dst = "{}"
3507"#,
3508            toml_path(&target)
3509        );
3510        std::fs::write(source.join("config.toml"), cfg).unwrap();
3511        apply(Some(source.clone()), false).unwrap();
3512
3513        assert_eq!(
3514            std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3515            "same"
3516        );
3517    }
3518
3519    #[test]
3520    fn manual_absorb_command_pulls_target_into_source() {
3521        // Manual `yui absorb <target>` bypasses policy + git checks.
3522        let tmp = TempDir::new().unwrap();
3523        let source = utf8(tmp.path().join("dotfiles"));
3524        let target = utf8(tmp.path().join("target"));
3525        std::fs::create_dir_all(source.join("home")).unwrap();
3526        std::fs::create_dir_all(&target).unwrap();
3527        // on_anomaly = "skip" so passive `apply` would NOT touch this.
3528        let cfg = format!(
3529            r#"
3530[absorb]
3531on_anomaly = "skip"
3532
3533[[mount.entry]]
3534src = "home"
3535dst = "{}"
3536"#,
3537            toml_path(&target)
3538        );
3539        std::fs::write(source.join("config.toml"), cfg).unwrap();
3540        std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
3541        std::fs::write(source.join("home/.bashrc"), "default").unwrap();
3542
3543        // Run absorb directly on the target.
3544        absorb(
3545            Some(source.clone()),
3546            target.join(".bashrc"),
3547            /* dry_run */ false,
3548        )
3549        .unwrap();
3550
3551        // Source picked up target's content (manual absorb is forceful).
3552        assert_eq!(
3553            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3554            "user picked this"
3555        );
3556    }
3557
3558    #[test]
3559    fn manual_absorb_errors_when_target_outside_known_mounts() {
3560        let tmp = TempDir::new().unwrap();
3561        let (source, _target) = setup_minimal_dotfiles(&tmp);
3562        std::fs::write(source.join("home/.bashrc"), "x").unwrap();
3563        let stranger = utf8(tmp.path().join("not-managed/foo"));
3564        std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
3565        std::fs::write(&stranger, "not yui's").unwrap();
3566        let err = absorb(Some(source), stranger, false).unwrap_err();
3567        assert!(format!("{err}").contains("no mount entry"));
3568    }
3569
3570    #[test]
3571    fn yuiignore_excludes_file_from_linking() {
3572        let tmp = TempDir::new().unwrap();
3573        let (source, target) = setup_minimal_dotfiles(&tmp);
3574        std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
3575        std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
3576        // Exclude `lock.json` files anywhere under source.
3577        std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
3578        apply(Some(source.clone()), false).unwrap();
3579        assert!(target.join(".bashrc").exists());
3580        assert!(
3581            !target.join("lock.json").exists(),
3582            "yuiignore should keep lock.json out of target"
3583        );
3584    }
3585
3586    #[test]
3587    fn yuiignore_excludes_directory_subtree() {
3588        let tmp = TempDir::new().unwrap();
3589        let (source, target) = setup_minimal_dotfiles(&tmp);
3590        std::fs::create_dir_all(source.join("home/cache")).unwrap();
3591        std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
3592        std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
3593        std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
3594        // Trailing slash → match dirs only; entire subtree skipped.
3595        std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
3596        apply(Some(source.clone()), false).unwrap();
3597        assert!(target.join(".bashrc").exists());
3598        assert!(
3599            !target.join("cache").exists(),
3600            "yuiignore'd subtree should not appear in target"
3601        );
3602    }
3603
3604    #[test]
3605    fn yuiignore_negation_re_includes_file() {
3606        let tmp = TempDir::new().unwrap();
3607        let (source, target) = setup_minimal_dotfiles(&tmp);
3608        std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
3609        std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
3610        // Ignore all .cache files except keep.cache.
3611        std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
3612        apply(Some(source.clone()), false).unwrap();
3613        assert!(target.join("keep.cache").exists());
3614        assert!(!target.join("drop.cache").exists());
3615    }
3616
3617    #[test]
3618    fn yuiignore_skips_template_in_render() {
3619        let tmp = TempDir::new().unwrap();
3620        let source = utf8(tmp.path().join("dotfiles"));
3621        let target = utf8(tmp.path().join("target"));
3622        std::fs::create_dir_all(source.join("home")).unwrap();
3623        std::fs::create_dir_all(&target).unwrap();
3624        std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
3625        std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
3626        let cfg = format!(
3627            r#"
3628[[mount.entry]]
3629src = "home"
3630dst = "{}"
3631"#,
3632            toml_path(&target)
3633        );
3634        std::fs::write(source.join("config.toml"), cfg).unwrap();
3635        apply(Some(source.clone()), false).unwrap();
3636        // Neither the template nor the rendered output linked.
3637        assert!(!source.join("home/note").exists());
3638        assert!(!target.join("note").exists());
3639        assert!(!target.join("note.tera").exists());
3640    }
3641
3642    /// v0.6+: parent `.yuilink` doesn't stop the walker. A parent
3643    /// marker can junction the whole dir, AND a child marker can layer
3644    /// on extra dsts (e.g. an OS-specific alternate location).
3645    #[test]
3646    fn nested_marker_accumulates_extra_dst() {
3647        let tmp = TempDir::new().unwrap();
3648        let source = utf8(tmp.path().join("dotfiles"));
3649        let parent_target = utf8(tmp.path().join("home"));
3650        let extra_target = utf8(tmp.path().join("extra"));
3651        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3652        std::fs::create_dir_all(&parent_target).unwrap();
3653        std::fs::create_dir_all(&extra_target).unwrap();
3654        std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
3655
3656        // Parent: junction the whole .config dir to <home>/.config.
3657        std::fs::write(
3658            source.join("home/.config/.yuilink"),
3659            format!(
3660                r#"
3661[[link]]
3662dst = "{}/.config"
3663"#,
3664                toml_path(&parent_target)
3665            ),
3666        )
3667        .unwrap();
3668        // Child: ALSO junction nvim/ to an extra path, but only on the
3669        // running OS (so the test exercises an active link).
3670        std::fs::write(
3671            source.join("home/.config/nvim/.yuilink"),
3672            format!(
3673                r#"
3674[[link]]
3675dst = "{}/nvim"
3676when = "{{{{ yui.os == '{}' }}}}"
3677"#,
3678                toml_path(&extra_target),
3679                std::env::consts::OS
3680            ),
3681        )
3682        .unwrap();
3683
3684        let cfg = format!(
3685            r#"
3686[[mount.entry]]
3687src = "home"
3688dst = "{}"
3689"#,
3690            toml_path(&parent_target)
3691        );
3692        std::fs::write(source.join("config.toml"), cfg).unwrap();
3693
3694        apply(Some(source.clone()), false).unwrap();
3695
3696        // Both links are present: parent's whole-.config junction reaches
3697        // init.lua, and the child marker added an additional path.
3698        assert!(parent_target.join(".config/nvim/init.lua").exists());
3699        assert!(extra_target.join("nvim/init.lua").exists());
3700    }
3701
3702    /// v0.6+: `[[link]] src = "<filename>"` links a single sibling file
3703    /// to a custom dst, leaving the rest of the dir to default
3704    /// behaviour. Useful for paths like the PowerShell profile that
3705    /// have to live in a non-`~/.config` location on Windows.
3706    #[test]
3707    fn marker_file_link_targets_specific_file() {
3708        let tmp = TempDir::new().unwrap();
3709        let source = utf8(tmp.path().join("dotfiles"));
3710        let parent_target = utf8(tmp.path().join("home"));
3711        let docs_target = utf8(tmp.path().join("docs"));
3712        std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
3713        std::fs::create_dir_all(&parent_target).unwrap();
3714        std::fs::create_dir_all(&docs_target).unwrap();
3715        std::fs::write(
3716            source.join("home/.config/powershell/profile.ps1"),
3717            "# profile\n",
3718        )
3719        .unwrap();
3720        std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
3721
3722        // File-level entry only — no dir-level [[link]], so the dir
3723        // itself still falls through to the default mount placement.
3724        std::fs::write(
3725            source.join("home/.config/powershell/.yuilink"),
3726            format!(
3727                r#"
3728[[link]]
3729src = "profile.ps1"
3730dst = "{}/Microsoft.PowerShell_profile.ps1"
3731"#,
3732                toml_path(&docs_target)
3733            ),
3734        )
3735        .unwrap();
3736
3737        let cfg = format!(
3738            r#"
3739[[mount.entry]]
3740src = "home"
3741dst = "{}"
3742"#,
3743            toml_path(&parent_target)
3744        );
3745        std::fs::write(source.join("config.toml"), cfg).unwrap();
3746
3747        apply(Some(source.clone()), false).unwrap();
3748
3749        // File-level target gets the link.
3750        assert!(
3751            docs_target
3752                .join("Microsoft.PowerShell_profile.ps1")
3753                .exists()
3754        );
3755        // Default per-file placement still happens for ALL files in the
3756        // dir (the marker had no dir-level [[link]] to claim coverage).
3757        assert!(
3758            parent_target
3759                .join(".config/powershell/profile.ps1")
3760                .exists()
3761        );
3762        assert!(parent_target.join(".config/powershell/extra.txt").exists());
3763    }
3764
3765    /// File-level [[link]] errors clearly when src points at a missing
3766    /// file — config bug, not a silent skip.
3767    #[test]
3768    fn marker_file_link_missing_src_errors() {
3769        let tmp = TempDir::new().unwrap();
3770        let source = utf8(tmp.path().join("dotfiles"));
3771        let parent_target = utf8(tmp.path().join("home"));
3772        let docs_target = utf8(tmp.path().join("docs"));
3773        std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
3774        std::fs::create_dir_all(&parent_target).unwrap();
3775        std::fs::create_dir_all(&docs_target).unwrap();
3776
3777        std::fs::write(
3778            source.join("home/.config/powershell/.yuilink"),
3779            format!(
3780                r#"
3781[[link]]
3782src = "missing.ps1"
3783dst = "{}/profile.ps1"
3784"#,
3785                toml_path(&docs_target)
3786            ),
3787        )
3788        .unwrap();
3789
3790        let cfg = format!(
3791            r#"
3792[[mount.entry]]
3793src = "home"
3794dst = "{}"
3795"#,
3796            toml_path(&parent_target)
3797        );
3798        std::fs::write(source.join("config.toml"), cfg).unwrap();
3799
3800        let err = apply(Some(source.clone()), false).unwrap_err();
3801        assert!(format!("{err:#}").contains("missing.ps1"));
3802    }
3803
3804    fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
3805        let mut out = Vec::new();
3806        let mut stack = vec![root.to_path_buf()];
3807        while let Some(dir) = stack.pop() {
3808            let Ok(entries) = std::fs::read_dir(&dir) else {
3809                continue;
3810            };
3811            for e in entries.flatten() {
3812                let p = utf8(e.path());
3813                if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
3814                    stack.push(p);
3815                } else {
3816                    out.push(p);
3817                }
3818            }
3819        }
3820        out
3821    }
3822}