Skip to main content

yui/
cmd.rs

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