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