Skip to main content

yui/
cmd.rs

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