Skip to main content

yui/
cmd.rs

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