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(
1040    source: Option<Utf8PathBuf>,
1041    icons_override: Option<IconsMode>,
1042    no_color: bool,
1043) -> Result<()> {
1044    use owo_colors::OwoColorize as _;
1045
1046    // Resolve source up-front so probes that depend on it can short-circuit
1047    // gracefully. A missing source is the single most common cause of yui
1048    // misbehaving, so we want to surface it loudly and skip the dependent
1049    // probes rather than blowing up.
1050    let resolved_source = resolve_source(source);
1051
1052    // `YuiVars::detect` reads `yui.source` from the resolved source path
1053    // (so `{{ yui.source }}` renders correctly in config templates); when
1054    // no source is detected we fall back to `.` so identity probes can
1055    // still report os/arch/user/host.
1056    let yui = match &resolved_source {
1057        Ok(s) => YuiVars::detect(s),
1058        Err(_) => YuiVars::detect(Utf8Path::new(".")),
1059    };
1060
1061    // Cache the loaded config — both the icons-override fallback and the
1062    // hooks-section probe need it. `cfg_res` keeps the original error
1063    // around so the `repo / config` probe can render a meaningful
1064    // message instead of just "not loaded".
1065    let cfg_res = match &resolved_source {
1066        Ok(s) => Some(config::load(s, &yui)),
1067        Err(_) => None,
1068    };
1069    let cfg = cfg_res.as_ref().and_then(|r| r.as_ref().ok());
1070    let icons_mode = icons_override
1071        .or_else(|| cfg.map(|c| c.ui.icons))
1072        .unwrap_or_default();
1073    let icons = Icons::for_mode(icons_mode);
1074    let color = !no_color && supports_color_stdout();
1075
1076    let mut probes: Vec<Probe> = Vec::new();
1077
1078    // ── identity ──────────────────────────────────────────────
1079    probes.push(Probe::group("identity"));
1080    probes.push(Probe::ok("os/arch", format!("{} / {}", yui.os, yui.arch)));
1081    probes.push(Probe::ok("user@host", format!("{}@{}", yui.user, yui.host)));
1082
1083    // ── repository ────────────────────────────────────────────
1084    probes.push(Probe::group("repo"));
1085    let mut have_source = false;
1086    match &resolved_source {
1087        Ok(s) => {
1088            have_source = true;
1089            probes.push(Probe::ok("source", s.to_string()));
1090            match cfg_res.as_ref().expect("cfg_res set when source is Ok") {
1091                Ok(c) => {
1092                    probes.push(Probe::ok(
1093                        "config",
1094                        format!(
1095                            "{} mount{} · {} hook{} · {} render rule{}",
1096                            c.mount.entry.len(),
1097                            plural(c.mount.entry.len()),
1098                            c.hook.len(),
1099                            plural(c.hook.len()),
1100                            c.render.rule.len(),
1101                            plural(c.render.rule.len()),
1102                        ),
1103                    ));
1104                }
1105                Err(e) => probes.push(Probe::error("config", format!("{e}"))),
1106            }
1107            // git-clean check is informational here — the actual gate is
1108            // `[absorb] require_clean_git` on apply; warn so the user
1109            // knows auto-absorb will defer if they have uncommitted work.
1110            match crate::git::is_clean(s) {
1111                Ok(true) => probes.push(Probe::ok("git", "clean")),
1112                Ok(false) => probes.push(Probe::warn(
1113                    "git",
1114                    "uncommitted changes — `[absorb] require_clean_git` will defer auto-absorb",
1115                )),
1116                Err(_) => probes.push(Probe::warn(
1117                    "git",
1118                    "no git repo (auto-absorb still works; commit history won't track drift)",
1119                )),
1120            }
1121        }
1122        Err(e) => {
1123            probes.push(Probe::error("source", format!("not found — {e}")));
1124        }
1125    }
1126
1127    // ── link / render mode ────────────────────────────────────
1128    probes.push(Probe::group("links"));
1129    if cfg!(windows) {
1130        probes.push(Probe::ok(
1131            "default mode",
1132            "files=hardlink, dirs=junction (no admin needed)",
1133        ));
1134    } else {
1135        probes.push(Probe::ok("default mode", "files=symlink, dirs=symlink"));
1136    }
1137
1138    // ── hooks ─────────────────────────────────────────────────
1139    if have_source {
1140        if let (Ok(s), Some(c)) = (&resolved_source, cfg) {
1141            probes.push(Probe::group("hooks"));
1142            if c.hook.is_empty() {
1143                probes.push(Probe::ok("hooks", "(none configured)"));
1144            } else {
1145                let mut missing = 0usize;
1146                for h in &c.hook {
1147                    if !s.join(&h.script).is_file() {
1148                        missing += 1;
1149                        probes.push(Probe::error(
1150                            format!("hook[{}]", h.name),
1151                            format!("script not found at {}", h.script),
1152                        ));
1153                    }
1154                }
1155                if missing == 0 {
1156                    probes.push(Probe::ok(
1157                        "scripts",
1158                        format!(
1159                            "{} hook{} configured, all scripts present",
1160                            c.hook.len(),
1161                            plural(c.hook.len())
1162                        ),
1163                    ));
1164                }
1165            }
1166        }
1167    }
1168
1169    // ── chezmoi cleanup hint ─────────────────────────────────
1170    if let Some(home) = paths::home_dir() {
1171        let chezmoi_src = home.join(".local/share/chezmoi");
1172        if chezmoi_src.is_dir() {
1173            probes.push(Probe::group("chezmoi"));
1174            probes.push(Probe::warn(
1175                "legacy source",
1176                format!(
1177                    "{chezmoi_src} still exists — yui doesn't use it, safe to archive once your migration has settled"
1178                ),
1179            ));
1180        }
1181    }
1182
1183    // Render
1184    println!();
1185    if color {
1186        println!("  {}", "yui doctor".bold().underline());
1187    } else {
1188        println!("  yui doctor");
1189    }
1190    println!();
1191    for probe in &probes {
1192        probe.print(&icons, color);
1193    }
1194
1195    let errors = probes.iter().filter(|p| p.is_error()).count();
1196    let warns = probes.iter().filter(|p| p.is_warn()).count();
1197    let oks = probes.iter().filter(|p| p.is_ok()).count();
1198    println!();
1199    let summary = format!("{oks} ok · {warns} warn · {errors} error");
1200    if color {
1201        if errors > 0 {
1202            println!("  {}", summary.red().bold());
1203        } else if warns > 0 {
1204            println!("  {}", summary.yellow());
1205        } else {
1206            println!("  {}", summary.green());
1207        }
1208    } else {
1209        println!("  {summary}");
1210    }
1211
1212    if errors > 0 {
1213        anyhow::bail!("doctor: {errors} probe(s) failed");
1214    }
1215    Ok(())
1216}
1217
1218#[derive(Debug)]
1219enum Probe {
1220    /// Section divider (just a heading, no severity).
1221    Group(&'static str),
1222    Ok {
1223        label: String,
1224        detail: String,
1225    },
1226    Warn {
1227        label: String,
1228        detail: String,
1229    },
1230    Error {
1231        label: String,
1232        detail: String,
1233    },
1234}
1235
1236impl Probe {
1237    fn group(label: &'static str) -> Self {
1238        Self::Group(label)
1239    }
1240    fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
1241        Self::Ok {
1242            label: label.into(),
1243            detail: detail.into(),
1244        }
1245    }
1246    fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
1247        Self::Warn {
1248            label: label.into(),
1249            detail: detail.into(),
1250        }
1251    }
1252    fn error(label: impl Into<String>, detail: impl Into<String>) -> Self {
1253        Self::Error {
1254            label: label.into(),
1255            detail: detail.into(),
1256        }
1257    }
1258    fn is_ok(&self) -> bool {
1259        matches!(self, Self::Ok { .. })
1260    }
1261    fn is_warn(&self) -> bool {
1262        matches!(self, Self::Warn { .. })
1263    }
1264    fn is_error(&self) -> bool {
1265        matches!(self, Self::Error { .. })
1266    }
1267    fn print(&self, icons: &Icons, color: bool) {
1268        use owo_colors::OwoColorize as _;
1269        match self {
1270            Self::Group(name) => {
1271                println!();
1272                if color {
1273                    println!("  {}", name.cyan().bold());
1274                } else {
1275                    println!("  {name}");
1276                }
1277            }
1278            Self::Ok { label, detail } => {
1279                let icon = icons.ok;
1280                // Pad the raw label first; styling adds invisible ANSI
1281                // bytes that `format!("{:<14}")` would count as visible
1282                // width and silently break alignment between rows.
1283                let padded = format!("{label:<14}");
1284                if color {
1285                    println!(
1286                        "    {}  {}  {}",
1287                        icon.green(),
1288                        padded.bold(),
1289                        detail.dimmed()
1290                    );
1291                } else {
1292                    println!("    {icon}  {padded}  {detail}");
1293                }
1294            }
1295            Self::Warn { label, detail } => {
1296                let icon = icons.warn;
1297                let padded = format!("{label:<14}");
1298                if color {
1299                    println!(
1300                        "    {}  {}  {}",
1301                        icon.yellow(),
1302                        padded.bold().yellow(),
1303                        detail
1304                    );
1305                } else {
1306                    println!("    {icon}  {padded}  {detail}");
1307                }
1308            }
1309            Self::Error { label, detail } => {
1310                let icon = icons.error;
1311                let padded = format!("{label:<14}");
1312                if color {
1313                    println!(
1314                        "    {}  {}  {}",
1315                        icon.red().bold(),
1316                        padded.bold().red(),
1317                        detail.red()
1318                    );
1319                } else {
1320                    println!("    {icon}  {padded}  {detail}");
1321                }
1322            }
1323        }
1324    }
1325}
1326
1327fn plural(n: usize) -> &'static str {
1328    if n == 1 { "" } else { "s" }
1329}
1330
1331pub fn gc_backup(_source: Option<Utf8PathBuf>, _older_than: Option<String>) -> Result<()> {
1332    todo!("yui gc-backup — clean up old backups")
1333}
1334
1335/// `yui hooks list` — show every configured hook + its last-run state.
1336pub fn hooks_list(
1337    source: Option<Utf8PathBuf>,
1338    icons_override: Option<IconsMode>,
1339    no_color: bool,
1340) -> Result<()> {
1341    let source = resolve_source(source)?;
1342    let yui = YuiVars::detect(&source);
1343    let config = config::load(&source, &yui)?;
1344    let state = hook::State::load(&source)?;
1345
1346    let icons_mode = icons_override.unwrap_or(config.ui.icons);
1347    let icons = Icons::for_mode(icons_mode);
1348    let color = !no_color && supports_color_stdout();
1349
1350    if config.hook.is_empty() {
1351        println!("(no [[hook]] entries in config)");
1352        return Ok(());
1353    }
1354
1355    // Pre-evaluate the `when` filter for every hook so the status icon
1356    // can distinguish "skipped because the OS gate is false" from
1357    // "active but never run".
1358    let mut engine = template::Engine::new();
1359    let tera_ctx = template::template_context(&yui, &config.vars);
1360    let rows: Vec<HookRow> = config
1361        .hook
1362        .iter()
1363        .map(|h| -> Result<HookRow> {
1364            // Propagate Tera errors instead of silently coercing them
1365            // to "inactive" — a syntax error in the user's `when`
1366            // expression should surface, not hide.
1367            let active = match &h.when {
1368                None => true,
1369                Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
1370            };
1371            let last_run_at = state.hooks.get(&h.name).and_then(|s| s.last_run_at.clone());
1372            Ok(HookRow {
1373                name: h.name.clone(),
1374                phase: match h.phase {
1375                    HookPhase::Pre => "pre",
1376                    HookPhase::Post => "post",
1377                },
1378                when_run: match h.when_run {
1379                    config::WhenRun::Once => "once",
1380                    config::WhenRun::Onchange => "onchange",
1381                    config::WhenRun::Every => "every",
1382                },
1383                last_run_at,
1384                when: h.when.clone(),
1385                active,
1386            })
1387        })
1388        .collect::<Result<Vec<_>>>()?;
1389
1390    print_hooks_table(&rows, icons, color);
1391
1392    let total = rows.len();
1393    let active = rows.iter().filter(|r| r.active).count();
1394    let inactive = total - active;
1395    let ran = rows.iter().filter(|r| r.last_run_at.is_some()).count();
1396    let never = total - ran;
1397    println!();
1398    println!(
1399        "  {total} hooks · {active} active · {inactive} inactive · {ran} ran · {never} never run"
1400    );
1401
1402    Ok(())
1403}
1404
1405#[derive(Debug)]
1406struct HookRow {
1407    name: String,
1408    phase: &'static str,
1409    when_run: &'static str,
1410    last_run_at: Option<String>,
1411    when: Option<String>,
1412    active: bool,
1413}
1414
1415fn print_hooks_table(rows: &[HookRow], icons: Icons, color: bool) {
1416    use owo_colors::OwoColorize as _;
1417    use std::fmt::Write as _;
1418
1419    let name_w = rows
1420        .iter()
1421        .map(|r| r.name.chars().count())
1422        .max()
1423        .unwrap_or(0)
1424        .max("NAME".len());
1425    let phase_w = rows
1426        .iter()
1427        .map(|r| r.phase.len())
1428        .max()
1429        .unwrap_or(0)
1430        .max("PHASE".len());
1431    let when_run_w = rows
1432        .iter()
1433        .map(|r| r.when_run.len())
1434        .max()
1435        .unwrap_or(0)
1436        .max("WHEN_RUN".len());
1437    let last_w = rows
1438        .iter()
1439        .map(|r| {
1440            r.last_run_at
1441                .as_deref()
1442                .map(|s| s.chars().count())
1443                .unwrap_or("(never)".len())
1444        })
1445        .max()
1446        .unwrap_or(0)
1447        .max("LAST_RUN".len());
1448    let status_w = "STATUS".len();
1449
1450    // Header
1451    let mut header = String::new();
1452    let _ = write!(
1453        &mut header,
1454        "  {:<status_w$}  {:<name_w$}  {:<phase_w$}  {:<when_run_w$}  {:<last_w$}  WHEN",
1455        "STATUS", "NAME", "PHASE", "WHEN_RUN", "LAST_RUN"
1456    );
1457    if color {
1458        println!("{}", header.bold());
1459    } else {
1460        println!("{header}");
1461    }
1462
1463    // Separator (re-uses the same sep glyph the list / status table picks).
1464    let bar = |n: usize| icons.sep.to_string().repeat(n);
1465    let sep = format!(
1466        "  {}  {}  {}  {}  {}  {}",
1467        bar(status_w),
1468        bar(name_w),
1469        bar(phase_w),
1470        bar(when_run_w),
1471        bar(last_w),
1472        bar("WHEN".len())
1473    );
1474    if color {
1475        println!("{}", sep.dimmed());
1476    } else {
1477        println!("{sep}");
1478    }
1479
1480    // Rows
1481    for r in rows {
1482        // Status icon picks one of three states. We could expand this
1483        // (✗ failed, ↻ would-rerun-via-onchange-hash) once `hooks list`
1484        // grows enough fields to justify it; today's set is enough to
1485        // make the table scannable.
1486        let (icon, ran) = match (r.active, r.last_run_at.is_some()) {
1487            (false, _) => (icons.inactive, false),
1488            (true, true) => (icons.active, true),
1489            (true, false) => (icons.info, false),
1490        };
1491        let last = r.last_run_at.as_deref().unwrap_or("(never)");
1492        let when_str = r
1493            .when
1494            .as_deref()
1495            .map(strip_braces)
1496            .unwrap_or_else(|| "(always)".to_string());
1497
1498        let cell_status = format!("{icon:<status_w$}");
1499        let cell_name = format!("{:<name_w$}", r.name);
1500        let cell_phase = format!("{:<phase_w$}", r.phase);
1501        let cell_when_run = format!("{:<when_run_w$}", r.when_run);
1502        let cell_last = format!("{last:<last_w$}");
1503
1504        if !color {
1505            println!(
1506                "  {cell_status}  {cell_name}  {cell_phase}  {cell_when_run}  {cell_last}  {when_str}"
1507            );
1508            continue;
1509        }
1510
1511        // Active+ran: green status, bold name. Active-but-never: yellow
1512        // status (the "🆕 new — apply hasn't ticked it" signal). Inactive
1513        // (when-false): dimmed across the row.
1514        if !r.active {
1515            println!(
1516                "  {}  {}  {}  {}  {}  {}",
1517                cell_status.dimmed(),
1518                cell_name.dimmed(),
1519                cell_phase.dimmed(),
1520                cell_when_run.dimmed(),
1521                cell_last.dimmed(),
1522                when_str.dimmed()
1523            );
1524        } else if ran {
1525            println!(
1526                "  {}  {}  {}  {}  {}  {}",
1527                cell_status.green(),
1528                cell_name.cyan().bold(),
1529                cell_phase.dimmed(),
1530                cell_when_run.dimmed(),
1531                cell_last.green(),
1532                when_str.dimmed()
1533            );
1534        } else {
1535            println!(
1536                "  {}  {}  {}  {}  {}  {}",
1537                cell_status.yellow(),
1538                cell_name.cyan().bold(),
1539                cell_phase.dimmed(),
1540                cell_when_run.dimmed(),
1541                cell_last.yellow(),
1542                when_str.dimmed()
1543            );
1544        }
1545    }
1546}
1547
1548/// `yui hooks run [<name>] [--force]` — run a single hook (or every
1549/// hook) on demand. `--force` bypasses the `when_run` state check;
1550/// the `when` filter (`yui.os == 'macos'` etc.) is always honored.
1551pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
1552    let source = resolve_source(source)?;
1553    let yui = YuiVars::detect(&source);
1554    let config = config::load(&source, &yui)?;
1555    let mut engine = template::Engine::new();
1556    let tera_ctx = template::template_context(&yui, &config.vars);
1557
1558    let targets: Vec<&config::HookConfig> = match &name {
1559        Some(want) => {
1560            let m = config
1561                .hook
1562                .iter()
1563                .find(|h| &h.name == want)
1564                .ok_or_else(|| {
1565                    anyhow::anyhow!(
1566                        "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
1567                    )
1568                })?;
1569            vec![m]
1570        }
1571        None => config.hook.iter().collect(),
1572    };
1573
1574    let mut state = hook::State::load(&source)?;
1575    for h in targets {
1576        let outcome = hook::run_hook(
1577            h,
1578            &source,
1579            &yui,
1580            &config.vars,
1581            &mut engine,
1582            &tera_ctx,
1583            &mut state,
1584            /* dry_run */ false,
1585            force,
1586        )?;
1587        let label = match outcome {
1588            HookOutcome::Ran => "ran",
1589            HookOutcome::SkippedOnce => "skipped (once: already ran)",
1590            HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
1591            HookOutcome::SkippedWhenFalse => "skipped (when=false)",
1592            HookOutcome::DryRun => "would run (dry-run)",
1593        };
1594        info!("hook[{}]: {label}", h.name);
1595        if outcome == HookOutcome::Ran {
1596            state.save(&source)?;
1597        }
1598    }
1599    Ok(())
1600}
1601
1602// ---------------------------------------------------------------------------
1603// internals
1604// ---------------------------------------------------------------------------
1605
1606fn process_mount(
1607    source: &Utf8Path,
1608    m: &ResolvedMount,
1609    ctx: &ApplyCtx<'_>,
1610    engine: &mut template::Engine,
1611    tera_ctx: &TeraContext,
1612) -> Result<()> {
1613    let src_root = source.join(&m.src);
1614    if !src_root.is_dir() {
1615        warn!("mount src missing: {src_root}");
1616        return Ok(());
1617    }
1618    walk_and_link(&src_root, &m.dst, ctx, m.strategy, engine, tera_ctx, false)
1619}
1620
1621#[allow(clippy::too_many_arguments)]
1622fn walk_and_link(
1623    src_dir: &Utf8Path,
1624    dst_dir: &Utf8Path,
1625    ctx: &ApplyCtx<'_>,
1626    strategy: MountStrategy,
1627    engine: &mut template::Engine,
1628    tera_ctx: &TeraContext,
1629    parent_covered: bool,
1630) -> Result<()> {
1631    // `.yuiignore` short-circuit — entire subtrees that match are skipped
1632    // without even reading their marker / iterating their children.
1633    if paths::is_ignored(ctx.yuiignore, ctx.source, src_dir, /* is_dir */ true) {
1634        return Ok(());
1635    }
1636
1637    let marker_filename = &ctx.config.mount.marker_filename;
1638    let mut covered = parent_covered;
1639
1640    if strategy == MountStrategy::Marker {
1641        match marker::read_spec(src_dir, marker_filename)? {
1642            None => {} // no marker — fall through to recursive walk
1643            Some(MarkerSpec::PassThrough) => {
1644                // Empty marker = junction this dir at the natural
1645                // mount-derived dst. Subsequent recursion keeps going so
1646                // descendant markers can layer on extra dsts.
1647                link_dir_with_backup(src_dir, dst_dir, ctx)?;
1648                covered = true;
1649            }
1650            Some(MarkerSpec::Explicit { links }) => {
1651                let mut emitted_dir_link = false;
1652                let mut emitted_any = false;
1653                for link in &links {
1654                    // Nested ifs (not let-chains) so the crate's MSRV
1655                    // (rust-version = "1.85") stays buildable.
1656                    if let Some(when) = &link.when {
1657                        if !template::eval_truthy(when, engine, tera_ctx)? {
1658                            continue;
1659                        }
1660                    }
1661                    let dst_str = engine.render(&link.dst, tera_ctx)?;
1662                    let dst = paths::expand_tilde(dst_str.trim());
1663                    if let Some(filename) = &link.src {
1664                        let file_src = src_dir.join(filename);
1665                        if !file_src.is_file() {
1666                            anyhow::bail!(
1667                                "marker at {src_dir}: [[link]] src={filename:?} \
1668                                 not found"
1669                            );
1670                        }
1671                        link_file_with_backup(&file_src, &dst, ctx)?;
1672                    } else {
1673                        link_dir_with_backup(src_dir, &dst, ctx)?;
1674                        emitted_dir_link = true;
1675                    }
1676                    emitted_any = true;
1677                }
1678                if !emitted_any {
1679                    // v0.6+ semantics: with no active links, the walker
1680                    // still descends and per-file defaults still apply.
1681                    // Phrase it so users don't read "skipping" as
1682                    // "subtree blocked" (the v0.5 behaviour).
1683                    info!(
1684                        "marker at {src_dir} had no active links \
1685                         — falling back to defaults"
1686                    );
1687                }
1688                if emitted_dir_link {
1689                    covered = true;
1690                }
1691            }
1692        }
1693    }
1694
1695    for entry in std::fs::read_dir(src_dir)? {
1696        let entry = entry?;
1697        let name_os = entry.file_name();
1698        let Some(name) = name_os.to_str() else {
1699            continue;
1700        };
1701        if name == marker_filename {
1702            continue;
1703        }
1704        if name.ends_with(".tera") {
1705            // Templates are handled by the render flow before linking.
1706            continue;
1707        }
1708        let src_path = src_dir.join(name);
1709        let dst_path = dst_dir.join(name);
1710        let ft = entry.file_type()?;
1711
1712        if paths::is_ignored(ctx.yuiignore, ctx.source, &src_path, ft.is_dir()) {
1713            continue;
1714        }
1715
1716        if ft.is_dir() {
1717            walk_and_link(
1718                &src_path, &dst_path, ctx, strategy, engine, tera_ctx, covered,
1719            )?;
1720        } else if ft.is_file() {
1721            // If an ancestor (or this dir itself) created a dir-level
1722            // junction, the file is already accessible via that junction
1723            // — emitting another per-file link would just duplicate work
1724            // (and on Windows might land at a path that's already
1725            // hard-linked through the parent).
1726            if !covered {
1727                link_file_with_backup(&src_path, &dst_path, ctx)?;
1728            }
1729        }
1730    }
1731    Ok(())
1732}
1733
1734fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1735    use absorb::AbsorbDecision::*;
1736
1737    let decision = absorb::classify(src, dst)?;
1738
1739    if ctx.dry_run {
1740        info!("[dry-run] {decision:?}: {src} → {dst}");
1741        return Ok(());
1742    }
1743
1744    match decision {
1745        InSync => {
1746            // Link is intact (same inode/file-id). Nothing to do.
1747            Ok(())
1748        }
1749        Restore => {
1750            info!("link: {src} → {dst}");
1751            link::link_file(src, dst, ctx.file_mode)?;
1752            Ok(())
1753        }
1754        RelinkOnly => {
1755            // Same content, different inode (e.g. hardlink broken by an
1756            // editor's atomic save). Re-link without touching source.
1757            info!("relink: {src} → {dst}");
1758            link::unlink(dst)?;
1759            link::link_file(src, dst, ctx.file_mode)?;
1760            Ok(())
1761        }
1762        AutoAbsorb => {
1763            // Target newer + content differs: target wins, source updated.
1764            // Honor `[absorb] auto` (kill-switch) and `require_clean_git`.
1765            if !ctx.config.absorb.auto {
1766                return handle_anomaly(
1767                    src,
1768                    dst,
1769                    ctx,
1770                    "absorb.auto = false; treating divergence as anomaly",
1771                );
1772            }
1773            if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
1774                return handle_anomaly(
1775                    src,
1776                    dst,
1777                    ctx,
1778                    "source repo is dirty; deferring auto-absorb",
1779                );
1780            }
1781            absorb_target_into_source(src, dst, ctx)
1782        }
1783        NeedsConfirm => handle_anomaly(
1784            src,
1785            dst,
1786            ctx,
1787            "anomaly: source equals/newer than target but content differs",
1788        ),
1789    }
1790}
1791
1792/// Back up the source-side file, copy the target's content into source,
1793/// then re-link so the freshly-updated source is what target points at.
1794/// "Target wins" — yui's core philosophy.
1795fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1796    info!("absorb: {dst} → {src}");
1797    backup_existing(src, ctx.backup_root, /* is_dir */ false)?;
1798    std::fs::copy(dst, src)?;
1799    link::unlink(dst)?;
1800    link::link_file(src, dst, ctx.file_mode)?;
1801    Ok(())
1802}
1803
1804/// Decide what to do for an anomaly (NeedsConfirm or AutoAbsorb that was
1805/// escalated by `auto = false` / dirty git). Per `[absorb] on_anomaly`:
1806///   - `skip`  → log warning, leave target alone
1807///   - `force` → behave like AutoAbsorb (target wins)
1808///   - `ask`   → on a TTY, show diff + prompt. Off-TTY, downgrade to skip.
1809fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
1810    use crate::config::AnomalyAction::*;
1811    match ctx.config.absorb.on_anomaly {
1812        Skip => {
1813            warn!("anomaly skip: {dst} ({reason})");
1814            Ok(())
1815        }
1816        Force => {
1817            warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
1818            absorb_target_into_source(src, dst, ctx)
1819        }
1820        Ask => {
1821            use std::io::IsTerminal;
1822            if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
1823                if prompt_absorb_with_diff(src, dst, reason)? {
1824                    absorb_target_into_source(src, dst, ctx)
1825                } else {
1826                    warn!("anomaly skipped by user: {dst}");
1827                    Ok(())
1828                }
1829            } else {
1830                warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
1831                Ok(())
1832            }
1833        }
1834    }
1835}
1836
1837fn prompt_absorb_with_diff(src: &Utf8Path, dst: &Utf8Path, reason: &str) -> Result<bool> {
1838    use std::io::Write as _;
1839    let src_content = std::fs::read_to_string(src).unwrap_or_default();
1840    let dst_content = std::fs::read_to_string(dst).unwrap_or_default();
1841    eprintln!();
1842    eprintln!("anomaly: {reason}");
1843    eprintln!("  src: {src}");
1844    eprintln!("  dst: {dst}");
1845    eprintln!();
1846    eprintln!("--- diff (- source, + target) ---");
1847    let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
1848    for change in diff.iter_all_changes() {
1849        let sign = match change.tag() {
1850            similar::ChangeTag::Delete => "-",
1851            similar::ChangeTag::Insert => "+",
1852            similar::ChangeTag::Equal => " ",
1853        };
1854        eprint!("{sign}{change}");
1855    }
1856    eprintln!();
1857    eprint!("absorb target into source? [y/N]: ");
1858    // Flush stderr (where the prompt was written) — flushing stdout was a
1859    // bug; on a buffered stderr (rare but possible) the prompt would be
1860    // hidden until after the user typed something. Caught in PR #15
1861    // review (gemini-code-assist).
1862    std::io::stderr().flush().ok();
1863    let mut input = String::new();
1864    std::io::stdin().read_line(&mut input)?;
1865    let answer = input.trim();
1866    Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
1867}
1868
1869/// Resilient git-clean check: if `git` isn't available or `source` isn't
1870/// a repo, log a warning and proceed as if clean. We don't want a missing
1871/// `git` to block apply — the require_clean_git knob is a *safety net*,
1872/// not a hard prerequisite.
1873fn source_repo_is_clean(source: &Utf8Path) -> bool {
1874    match crate::git::is_clean(source) {
1875        Ok(b) => b,
1876        Err(e) => {
1877            warn!("git clean check failed at {source}: {e} — treating as clean");
1878            true
1879        }
1880    }
1881}
1882
1883fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1884    use absorb::AbsorbDecision::*;
1885    let decision = absorb::classify(src, dst)?;
1886
1887    if ctx.dry_run {
1888        info!("[dry-run] dir {decision:?}: {src} → {dst}");
1889        return Ok(());
1890    }
1891
1892    match decision {
1893        InSync => Ok(()),
1894        Restore => {
1895            info!("link dir: {src} → {dst}");
1896            link::link_dir(src, dst, ctx.dir_mode)?;
1897            Ok(())
1898        }
1899        RelinkOnly => {
1900            // For dirs the classifier doesn't currently produce
1901            // `RelinkOnly` (only InSync / NeedsConfirm), but handle it
1902            // for symmetry with the file path: contents already match,
1903            // so just swap the target for a junction to source.
1904            info!("relink dir: {src} → {dst}");
1905            remove_dir_link_or_real(dst)?;
1906            link::link_dir(src, dst, ctx.dir_mode)?;
1907            Ok(())
1908        }
1909        AutoAbsorb | NeedsConfirm => {
1910            // Reaching `link_dir_with_backup` means we're acting on a
1911            // `.yuilink` marker (or a `[[mount.entry]]` whose `src` is a
1912            // directory) — the user has explicitly opted into
1913            // "this whole subtree is target-as-truth". A dir-level
1914            // NeedsConfirm here is therefore *not* the same kind of
1915            // anomaly that file-level NeedsConfirm represents (a single
1916            // file the user edited and source got newer); it's just
1917            // "source and target dirs are different inodes" — the
1918            // marker already authorised us to merge.
1919            //
1920            // Per-file content conflicts *inside* the merge are still
1921            // a real concern (target has X, source has X with
1922            // different content). Those are surfaced from inside the
1923            // merge itself — see `merge_dir_target_into_source`'s
1924            // file-level dispatch — so the outer-dir decision falls
1925            // straight through to absorb.
1926            //
1927            // The `auto` / `require_clean_git` knobs still gate, so
1928            // turning them off restores the prompt before any
1929            // whole-dir absorb.
1930            if !ctx.config.absorb.auto {
1931                return handle_anomaly_dir(
1932                    src,
1933                    dst,
1934                    ctx,
1935                    "absorb.auto = false; treating divergence as anomaly",
1936                );
1937            }
1938            if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
1939                return handle_anomaly_dir(
1940                    src,
1941                    dst,
1942                    ctx,
1943                    "source repo is dirty; deferring auto-absorb",
1944                );
1945            }
1946            absorb_target_dir_into_source(src, dst, ctx)
1947        }
1948    }
1949}
1950
1951/// `link::unlink` with a documented fallback for the chezmoi-migration
1952/// shape: target is a real (non-link) directory packed with files. The
1953/// caller is responsible for ensuring the target's prior content is
1954/// preserved (in `.yui/backup/...` or because we just merged it into
1955/// source) before reaching here.
1956///
1957/// Anything other than the "non-empty regular dir" case — permission
1958/// denied, target gone, target now a junction or symlink — propagates
1959/// rather than being silently coerced into `remove_dir_all`.
1960fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
1961    if let Err(unlink_err) = link::unlink(dst) {
1962        let meta = std::fs::symlink_metadata(dst)
1963            .with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
1964        let ft = meta.file_type();
1965        if ft.is_dir() && !ft.is_symlink() {
1966            std::fs::remove_dir_all(dst).with_context(|| {
1967                format!(
1968                    "remove_dir_all({dst}) after link::unlink failed: \
1969                     {unlink_err}"
1970                )
1971            })?;
1972        } else {
1973            return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
1974        }
1975    }
1976    Ok(())
1977}
1978
1979/// Recursively merge target's files into source: target wins on file
1980/// conflicts, source-only files are preserved, sub-dirs are created
1981/// in source as needed. Non-regular entries (symlinks / junctions /
1982/// device files) are skipped with a warning — copying their content
1983/// is ill-defined and following them risks looping into target via
1984/// some chain back to source.
1985///
1986/// Mirrors the file-level "AutoAbsorb backs up source, copies target's
1987/// content into source before relinking" semantic for whole dirs.
1988fn merge_dir_target_into_source(
1989    target: &Utf8Path,
1990    source: &Utf8Path,
1991    ctx: &ApplyCtx<'_>,
1992) -> Result<()> {
1993    for entry in std::fs::read_dir(target)? {
1994        let entry = entry?;
1995        let name_os = entry.file_name();
1996        let Some(name) = name_os.to_str() else {
1997            continue;
1998        };
1999        let target_path = target.join(name);
2000        let source_path = source.join(name);
2001        let ft = entry.file_type()?;
2002
2003        if ft.is_dir() && !ft.is_symlink() {
2004            // Target is a real dir. If source has a non-dir entry at
2005            // the same name (regular file, symlink, junction), it
2006            // would block `create_dir_all` and the recursive merge.
2007            // Honor target-wins by clearing the conflicting source
2008            // entry first.
2009            if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
2010                let sft = src_meta.file_type();
2011                if !sft.is_dir() || sft.is_symlink() {
2012                    link::unlink(&source_path).with_context(|| {
2013                        format!("remove conflicting source entry before dir merge: {source_path}")
2014                    })?;
2015                }
2016            }
2017            if !source_path.exists() {
2018                std::fs::create_dir_all(&source_path).with_context(|| {
2019                    format!("create_dir_all({source_path}) during target→source merge")
2020                })?;
2021            }
2022            merge_dir_target_into_source(&target_path, &source_path, ctx)?;
2023        } else if ft.is_file() {
2024            // Target is a regular file. Symmetrical handling: if
2025            // source has a directory or symlink at the same name,
2026            // tear it down first so the file copy can land.
2027            if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
2028                let sft = src_meta.file_type();
2029                if sft.is_dir() && !sft.is_symlink() {
2030                    remove_dir_link_or_real(&source_path).with_context(|| {
2031                        format!("remove conflicting source dir before file merge: {source_path}")
2032                    })?;
2033                } else if sft.is_symlink() {
2034                    link::unlink(&source_path).with_context(|| {
2035                        format!(
2036                            "remove conflicting source symlink before file merge: {source_path}"
2037                        )
2038                    })?;
2039                }
2040            }
2041            if let Some(parent) = source_path.parent() {
2042                if !parent.exists() {
2043                    std::fs::create_dir_all(parent)?;
2044                }
2045            }
2046            // If both sides are now regular files at the same path, run
2047            // the file-level absorb classifier so this single overlap
2048            // is resolved against `[absorb]` policy (auto / skip /
2049            // force / ask) instead of being silently overwritten. The
2050            // dir-level marker provides consent for the *whole-tree*
2051            // merge, but a per-file content collision where the
2052            // source side is *newer* is still a legitimate anomaly
2053            // worth surfacing.
2054            //
2055            // Source-only files were already preserved by virtue of
2056            // the merge not visiting them. Target-only files (where
2057            // `source_path` doesn't exist) skip the classifier and go
2058            // straight to copy below.
2059            if source_path.is_file() {
2060                merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
2061            } else {
2062                std::fs::copy(&target_path, &source_path)
2063                    .with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
2064            }
2065        } else {
2066            warn!(
2067                "merge: skipping non-regular entry {target_path} \
2068                 (symlink / junction / special — content not copied)"
2069            );
2070        }
2071    }
2072    Ok(())
2073}
2074
2075/// Per-file conflict resolution inside the dir merge. Both
2076/// `target_path` and `source_path` exist as regular files — run the
2077/// absorb classifier on the pair and route to the matching policy:
2078///
2079/// - `InSync` / `RelinkOnly` → no-op (contents already match)
2080/// - `AutoAbsorb` (target newer + diff) → copy target → source,
2081///   target-wins per the AutoAbsorb contract.
2082/// - `NeedsConfirm` (source newer + diff, the genuine anomaly) →
2083///   `[absorb] on_anomaly` dispatch:
2084///     - `skip` → leave source alone, target's version is dropped
2085///       (after the outer junction, target ends up with source's content)
2086///     - `force` → copy target → source (target wins anyway)
2087///     - `ask` → TTY prompt with diff; downgrade to skip off-TTY
2088fn merge_resolve_file_conflict(
2089    target_path: &Utf8Path,
2090    source_path: &Utf8Path,
2091    ctx: &ApplyCtx<'_>,
2092) -> Result<()> {
2093    use absorb::AbsorbDecision::*;
2094    let decision = absorb::classify(source_path, target_path)?;
2095    match decision {
2096        InSync | RelinkOnly => Ok(()),
2097        AutoAbsorb => {
2098            std::fs::copy(target_path, source_path).with_context(|| {
2099                format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
2100            })?;
2101            Ok(())
2102        }
2103        Restore => {
2104            // `Restore` is the classifier's "target is missing" arm.
2105            // We only enter this function after the merge loop saw
2106            // `target_path` as a regular file in the read_dir
2107            // iteration, and the caller guards on `source_path.is_file()`
2108            // — both exist by construction, so this branch is
2109            // unreachable.
2110            unreachable!(
2111                "merge_resolve_file_conflict reached with both files present, \
2112                 but classify returned Restore (target {target_path} / source {source_path})"
2113            )
2114        }
2115        NeedsConfirm => {
2116            use crate::config::AnomalyAction::*;
2117            match ctx.config.absorb.on_anomaly {
2118                Skip => {
2119                    warn!(
2120                        "merge anomaly skip: {target_path} (source-newer / content drift) \
2121                         — keeping source version, target version dropped"
2122                    );
2123                    Ok(())
2124                }
2125                Force => {
2126                    warn!(
2127                        "merge anomaly force: {target_path} \
2128                         (source-newer / content drift) — overwriting source"
2129                    );
2130                    std::fs::copy(target_path, source_path)?;
2131                    Ok(())
2132                }
2133                Ask => {
2134                    use std::io::IsTerminal;
2135                    if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
2136                        if prompt_absorb_with_diff(
2137                            source_path,
2138                            target_path,
2139                            "merge: file content differs and source is newer",
2140                        )? {
2141                            std::fs::copy(target_path, source_path)?;
2142                        } else {
2143                            warn!("merge: kept source version by user choice: {source_path}");
2144                        }
2145                        Ok(())
2146                    } else {
2147                        warn!(
2148                            "merge anomaly skip (non-TTY ask mode): {target_path} \
2149                             — keeping source version"
2150                        );
2151                        Ok(())
2152                    }
2153                }
2154            }
2155        }
2156    }
2157}
2158
2159/// Back up source-side, merge target's content into source (target
2160/// wins on conflict), then replace target with a junction to source.
2161/// "Target wins" — yui's core philosophy, generalised from the file
2162/// path to whole directories so a chezmoi-style migrated `~/.config/`
2163/// keeps every file the user actually had instead of stranding most
2164/// of them in `.yui/backup/...`.
2165fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
2166    info!("absorb dir: {dst} → {src}");
2167    backup_existing(src, ctx.backup_root, /* is_dir */ true)?;
2168    merge_dir_target_into_source(dst, src, ctx)?;
2169    // Source now carries every regular file from target. Tear down the
2170    // original target dir and re-expose source via a junction.
2171    remove_dir_link_or_real(dst)?;
2172    link::link_dir(src, dst, ctx.dir_mode)?;
2173    Ok(())
2174}
2175
2176/// Dir-level counterpart to `handle_anomaly`. Same `[absorb] on_anomaly`
2177/// dispatch — `skip` warns and walks away, `force` absorbs anyway,
2178/// `ask` prompts on a TTY (downgraded to skip off-TTY).
2179fn handle_anomaly_dir(
2180    src: &Utf8Path,
2181    dst: &Utf8Path,
2182    ctx: &ApplyCtx<'_>,
2183    reason: &str,
2184) -> Result<()> {
2185    use crate::config::AnomalyAction::*;
2186    match ctx.config.absorb.on_anomaly {
2187        Skip => {
2188            warn!("anomaly skip dir: {dst} ({reason})");
2189            Ok(())
2190        }
2191        Force => {
2192            warn!(
2193                "anomaly force dir: {dst} ({reason}) \
2194                 — absorbing target into source"
2195            );
2196            absorb_target_dir_into_source(src, dst, ctx)
2197        }
2198        Ask => {
2199            use std::io::IsTerminal;
2200            if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
2201                eprintln!();
2202                eprintln!("anomaly: {dst}");
2203                eprintln!("  {reason}");
2204                eprintln!("  source: {src}");
2205                eprint!("  absorb target dir into source? (y/N) ");
2206                use std::io::{BufRead as _, Write as _};
2207                std::io::stderr().flush().ok();
2208                let mut buf = String::new();
2209                std::io::stdin().lock().read_line(&mut buf)?;
2210                let answer = buf.trim();
2211                if answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
2212                    absorb_target_dir_into_source(src, dst, ctx)
2213                } else {
2214                    warn!("anomaly skipped by user: {dst}");
2215                    Ok(())
2216                }
2217            } else {
2218                warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
2219                Ok(())
2220            }
2221        }
2222    }
2223}
2224
2225fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
2226    let abs_target = absolutize(target)?;
2227    let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
2228    let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
2229    info!("backup → {bp}");
2230    if is_dir {
2231        backup::backup_dir(target, &bp)?;
2232    } else {
2233        backup::backup_file(target, &bp)?;
2234    }
2235    Ok(())
2236}
2237
2238fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
2239    if let Some(s) = source {
2240        return absolutize(&s);
2241    }
2242    if let Ok(s) = std::env::var("YUI_SOURCE") {
2243        return absolutize(Utf8Path::new(&s));
2244    }
2245    let cwd = current_dir_utf8()?;
2246    for ancestor in cwd.ancestors() {
2247        if ancestor.join("config.toml").is_file() {
2248            return Ok(ancestor.to_path_buf());
2249        }
2250    }
2251    if let Some(home) = paths::home_dir() {
2252        for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
2253            let p = home.join(c);
2254            if p.join("config.toml").is_file() {
2255                return Ok(p);
2256            }
2257        }
2258    }
2259    anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
2260}
2261
2262fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
2263    // Expand `~` first so callers can pass `--source ~/dotfiles` directly.
2264    let expanded = paths::expand_tilde(p.as_str());
2265    if expanded.is_absolute() {
2266        return Ok(expanded);
2267    }
2268    let cwd = current_dir_utf8()?;
2269    Ok(cwd.join(expanded))
2270}
2271
2272fn current_dir_utf8() -> Result<Utf8PathBuf> {
2273    let cwd = std::env::current_dir().context("getting cwd")?;
2274    Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
2275}
2276
2277// Note: `home_dir()` lives in `paths.rs` so the tilde-expansion helper and
2278// `resolve_source` share one HOME/USERPROFILE lookup.
2279
2280const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
2281
2282[vars]
2283# user-defined values; templates can reference these as {{ vars.foo }}
2284
2285# [link]
2286# file_mode = "auto"   # auto | symlink | hardlink
2287# dir_mode  = "auto"   # auto | symlink | junction
2288
2289[mount]
2290default_strategy = "marker"
2291
2292[[mount.entry]]
2293src = "home"
2294# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
2295dst = "~"
2296
2297# [[mount.entry]]
2298# src  = "appdata"
2299# dst  = "{{ env(name='APPDATA') }}"
2300# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
2301# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
2302# when = "yui.os == 'windows'"
2303"#;
2304
2305const SKELETON_GITIGNORE: &str = r#"# yui per-machine state and backups (regenerable, do not commit).
2306# .yui/bin/ is intentionally tracked — it holds your hook scripts.
2307/.yui/state.json
2308/.yui/state.json.tmp
2309/.yui/backup/
2310
2311# >>> yui rendered (auto-managed, do not edit) >>>
2312# <<< yui rendered (auto-managed) <<<
2313
2314# config.local.toml is per-machine; commit a config.local.example.toml instead.
2315config.local.toml
2316"#;
2317
2318#[cfg(test)]
2319mod tests {
2320    use super::*;
2321    use tempfile::TempDir;
2322
2323    fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
2324        Utf8PathBuf::from_path_buf(p).unwrap()
2325    }
2326
2327    /// Convert a path to a TOML-string-safe form (forward slashes).
2328    fn toml_path(p: &Utf8Path) -> String {
2329        p.as_str().replace('\\', "/")
2330    }
2331
2332    #[test]
2333    fn apply_links_a_raw_file() {
2334        let tmp = TempDir::new().unwrap();
2335        let source = utf8(tmp.path().join("dotfiles"));
2336        let target = utf8(tmp.path().join("target"));
2337        std::fs::create_dir_all(source.join("home")).unwrap();
2338        std::fs::create_dir_all(&target).unwrap();
2339        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2340
2341        let cfg = format!(
2342            r#"
2343[[mount.entry]]
2344src = "home"
2345dst = "{}"
2346"#,
2347            toml_path(&target)
2348        );
2349        std::fs::write(source.join("config.toml"), cfg).unwrap();
2350
2351        apply(Some(source), false).unwrap();
2352
2353        let linked = target.join(".bashrc");
2354        assert!(linked.exists(), "expected {linked} to exist");
2355        assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
2356    }
2357
2358    #[test]
2359    fn apply_with_marker_links_whole_directory() {
2360        let tmp = TempDir::new().unwrap();
2361        let source = utf8(tmp.path().join("dotfiles"));
2362        let target = utf8(tmp.path().join("target"));
2363        let nvim_src = source.join("home/nvim");
2364        std::fs::create_dir_all(&nvim_src).unwrap();
2365        std::fs::create_dir_all(&target).unwrap();
2366        std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
2367        std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
2368        std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
2369
2370        let cfg = format!(
2371            r#"
2372[[mount.entry]]
2373src = "home"
2374dst = "{}"
2375"#,
2376            toml_path(&target)
2377        );
2378        std::fs::write(source.join("config.toml"), cfg).unwrap();
2379
2380        apply(Some(source.clone()), false).unwrap();
2381
2382        let nvim_dst = target.join("nvim");
2383        assert!(nvim_dst.exists());
2384        assert_eq!(
2385            std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
2386            "-- hi\n"
2387        );
2388        // Marker file itself shouldn't be visible as a separate link in target;
2389        // however with junction/symlink the whole dir shows up so the marker
2390        // file IS visible inside. That's fine — the marker is informational.
2391    }
2392
2393    #[test]
2394    fn apply_dry_run_does_not_write() {
2395        let tmp = TempDir::new().unwrap();
2396        let source = utf8(tmp.path().join("dotfiles"));
2397        let target = utf8(tmp.path().join("target"));
2398        std::fs::create_dir_all(source.join("home")).unwrap();
2399        std::fs::create_dir_all(&target).unwrap();
2400        std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
2401
2402        let cfg = format!(
2403            r#"
2404[[mount.entry]]
2405src = "home"
2406dst = "{}"
2407"#,
2408            toml_path(&target)
2409        );
2410        std::fs::write(source.join("config.toml"), cfg).unwrap();
2411
2412        apply(Some(source), true).unwrap();
2413
2414        assert!(!target.join(".bashrc").exists());
2415    }
2416
2417    #[test]
2418    fn apply_renders_templates_then_links_rendered_outputs() {
2419        let tmp = TempDir::new().unwrap();
2420        let source = utf8(tmp.path().join("dotfiles"));
2421        let target = utf8(tmp.path().join("target"));
2422        std::fs::create_dir_all(source.join("home")).unwrap();
2423        std::fs::create_dir_all(&target).unwrap();
2424        std::fs::write(
2425            source.join("home/.gitconfig.tera"),
2426            "[user]\n  os = {{ yui.os }}\n",
2427        )
2428        .unwrap();
2429        std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
2430
2431        let cfg = format!(
2432            r#"
2433[[mount.entry]]
2434src = "home"
2435dst = "{}"
2436"#,
2437            toml_path(&target)
2438        );
2439        std::fs::write(source.join("config.toml"), cfg).unwrap();
2440
2441        apply(Some(source.clone()), false).unwrap();
2442
2443        // Raw file: linked.
2444        assert!(target.join(".bashrc").exists());
2445        // Template's rendered output: written to source then linked.
2446        assert!(source.join("home/.gitconfig").exists());
2447        assert!(target.join(".gitconfig").exists());
2448        // The .tera file itself is never linked into target.
2449        assert!(!target.join(".gitconfig.tera").exists());
2450        // Rendered file content carries the yui.os substitution.
2451        let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
2452        assert!(linked.contains("os = "));
2453    }
2454
2455    #[test]
2456    fn apply_marker_override_links_to_custom_dst() {
2457        let tmp = TempDir::new().unwrap();
2458        let source = utf8(tmp.path().join("dotfiles"));
2459        let target_a = utf8(tmp.path().join("target_a"));
2460        let target_b = utf8(tmp.path().join("target_b"));
2461        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2462        std::fs::create_dir_all(&target_a).unwrap();
2463        std::fs::create_dir_all(&target_b).unwrap();
2464        std::fs::write(
2465            source.join("home/.config/nvim/init.lua"),
2466            "-- nvim config\n",
2467        )
2468        .unwrap();
2469
2470        // Marker tells yui to ignore the parent mount's dst for this dir
2471        // and link it to two custom places (the second only if condition matches).
2472        std::fs::write(
2473            source.join("home/.config/nvim/.yuilink"),
2474            format!(
2475                r#"
2476[[link]]
2477dst = "{}/nvim"
2478
2479[[link]]
2480dst = "{}/nvim"
2481when = "{{{{ yui.os == '{}' }}}}"
2482"#,
2483                toml_path(&target_a),
2484                toml_path(&target_b),
2485                std::env::consts::OS
2486            ),
2487        )
2488        .unwrap();
2489
2490        let parent_target = utf8(tmp.path().join("parent_target"));
2491        std::fs::create_dir_all(&parent_target).unwrap();
2492        let cfg = format!(
2493            r#"
2494[[mount.entry]]
2495src = "home"
2496dst = "{}"
2497"#,
2498            toml_path(&parent_target)
2499        );
2500        std::fs::write(source.join("config.toml"), cfg).unwrap();
2501
2502        apply(Some(source.clone()), false).unwrap();
2503
2504        // Both override targets received the link (the second's when matches OS).
2505        assert!(
2506            target_a.join("nvim/init.lua").exists(),
2507            "target_a/nvim/init.lua should be reachable through the link"
2508        );
2509        assert!(
2510            target_b.join("nvim/init.lua").exists(),
2511            "target_b/nvim/init.lua should be reachable through the link"
2512        );
2513        // Parent mount did NOT also link this dir (it would have appeared at
2514        // parent_target/.config/nvim — the marker claims the dir).
2515        assert!(
2516            !parent_target.join(".config/nvim").exists(),
2517            "parent mount should have skipped the marker-claimed sub-dir"
2518        );
2519    }
2520
2521    #[test]
2522    fn apply_marker_inactive_link_falls_through_to_default() {
2523        // v0.6+ semantics: a marker that has only inactive links no
2524        // longer suppresses the parent mount's natural placement. The
2525        // walker keeps descending so per-file defaults still apply.
2526        // (Use `.yuiignore` to actually exclude a subtree.)
2527        let tmp = TempDir::new().unwrap();
2528        let source = utf8(tmp.path().join("dotfiles"));
2529        let target_inactive = utf8(tmp.path().join("inactive"));
2530        let parent_target = utf8(tmp.path().join("parent"));
2531        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2532        std::fs::create_dir_all(&parent_target).unwrap();
2533        std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
2534
2535        // when=false on every link → marker has no active links.
2536        std::fs::write(
2537            source.join("home/.config/nvim/.yuilink"),
2538            format!(
2539                r#"
2540[[link]]
2541dst = "{}/nvim"
2542when = "{{{{ yui.os == 'no-such-os' }}}}"
2543"#,
2544                toml_path(&target_inactive)
2545            ),
2546        )
2547        .unwrap();
2548
2549        let cfg = format!(
2550            r#"
2551[[mount.entry]]
2552src = "home"
2553dst = "{}"
2554"#,
2555            toml_path(&parent_target)
2556        );
2557        std::fs::write(source.join("config.toml"), cfg).unwrap();
2558
2559        apply(Some(source.clone()), false).unwrap();
2560
2561        // Inactive marker target untouched.
2562        assert!(!target_inactive.join("nvim").exists());
2563        // Parent mount's natural placement IS produced — the marker had
2564        // no active dir-level link to claim coverage with.
2565        assert!(parent_target.join(".config/nvim/init.lua").exists());
2566    }
2567
2568    #[test]
2569    fn list_shows_mount_entries_and_marker_overrides() {
2570        let tmp = TempDir::new().unwrap();
2571        let source = utf8(tmp.path().join("dotfiles"));
2572        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
2573        std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
2574        std::fs::write(
2575            source.join("home/.config/nvim/.yuilink"),
2576            r#"
2577[[link]]
2578dst = "/custom/nvim"
2579"#,
2580        )
2581        .unwrap();
2582        std::fs::write(
2583            source.join("config.toml"),
2584            r#"
2585[[mount.entry]]
2586src = "home"
2587dst = "/h"
2588"#,
2589        )
2590        .unwrap();
2591
2592        // Just verify it runs without error — output format is covered by
2593        // unit-level helpers below.
2594        list(Some(source), false, None, true).unwrap();
2595    }
2596
2597    #[test]
2598    fn status_reports_in_sync_after_apply() {
2599        let tmp = TempDir::new().unwrap();
2600        let source = utf8(tmp.path().join("dotfiles"));
2601        let target = utf8(tmp.path().join("target"));
2602        std::fs::create_dir_all(source.join("home")).unwrap();
2603        std::fs::create_dir_all(&target).unwrap();
2604        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2605        let cfg = format!(
2606            r#"
2607[[mount.entry]]
2608src = "home"
2609dst = "{}"
2610"#,
2611            toml_path(&target)
2612        );
2613        std::fs::write(source.join("config.toml"), cfg).unwrap();
2614        // First link the target so the link is intact.
2615        apply(Some(source.clone()), false).unwrap();
2616        // status should succeed (everything in-sync).
2617        status(Some(source), None, true).unwrap();
2618    }
2619
2620    #[test]
2621    fn status_reports_template_drift() {
2622        let tmp = TempDir::new().unwrap();
2623        let source = utf8(tmp.path().join("dotfiles"));
2624        let target = utf8(tmp.path().join("target"));
2625        std::fs::create_dir_all(source.join("home")).unwrap();
2626        std::fs::create_dir_all(&target).unwrap();
2627        // Template would render to "fresh" but the rendered file on disk
2628        // says "stale" — simulating a manual edit not reflected back.
2629        std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
2630        std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
2631
2632        let cfg = format!(
2633            r#"
2634[[mount.entry]]
2635src = "home"
2636dst = "{}"
2637"#,
2638            toml_path(&target)
2639        );
2640        std::fs::write(source.join("config.toml"), cfg).unwrap();
2641
2642        let err = status(Some(source), None, true).unwrap_err();
2643        assert!(format!("{err}").contains("diverged"));
2644    }
2645
2646    #[test]
2647    fn status_fails_when_target_missing() {
2648        let tmp = TempDir::new().unwrap();
2649        let source = utf8(tmp.path().join("dotfiles"));
2650        let target = utf8(tmp.path().join("target"));
2651        std::fs::create_dir_all(source.join("home")).unwrap();
2652        std::fs::create_dir_all(&target).unwrap();
2653        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2654        let cfg = format!(
2655            r#"
2656[[mount.entry]]
2657src = "home"
2658dst = "{}"
2659"#,
2660            toml_path(&target)
2661        );
2662        std::fs::write(source.join("config.toml"), cfg).unwrap();
2663        // No apply yet — target/.bashrc doesn't exist.
2664        let err = status(Some(source), None, true).unwrap_err();
2665        assert!(format!("{err}").contains("diverged"));
2666    }
2667
2668    #[test]
2669    fn strip_braces_removes_outer_template_braces() {
2670        assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
2671        assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
2672        assert_eq!(strip_braces("  {{x}}  "), "x");
2673    }
2674
2675    #[test]
2676    fn apply_aborts_on_render_drift() {
2677        let tmp = TempDir::new().unwrap();
2678        let source = utf8(tmp.path().join("dotfiles"));
2679        let target = utf8(tmp.path().join("target"));
2680        std::fs::create_dir_all(source.join("home")).unwrap();
2681        std::fs::create_dir_all(&target).unwrap();
2682        std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
2683        std::fs::write(source.join("home/foo"), "manually edited").unwrap();
2684
2685        let cfg = format!(
2686            r#"
2687[[mount.entry]]
2688src = "home"
2689dst = "{}"
2690"#,
2691            toml_path(&target)
2692        );
2693        std::fs::write(source.join("config.toml"), cfg).unwrap();
2694
2695        let err = apply(Some(source.clone()), false).unwrap_err();
2696        assert!(format!("{err}").contains("drift"));
2697        // Existing rendered file untouched.
2698        assert_eq!(
2699            std::fs::read_to_string(source.join("home/foo")).unwrap(),
2700            "manually edited"
2701        );
2702        // Linking aborted — target empty.
2703        assert!(!target.join("foo").exists());
2704    }
2705
2706    #[test]
2707    fn init_creates_skeleton_when_dir_empty() {
2708        let tmp = TempDir::new().unwrap();
2709        let dir = utf8(tmp.path().join("new_dotfiles"));
2710        init(Some(dir.clone()), false).unwrap();
2711        assert!(dir.join("config.toml").is_file());
2712        assert!(dir.join(".gitignore").is_file());
2713    }
2714
2715    #[test]
2716    fn init_refuses_to_overwrite_existing_config() {
2717        let tmp = TempDir::new().unwrap();
2718        let dir = utf8(tmp.path().join("dotfiles"));
2719        std::fs::create_dir_all(&dir).unwrap();
2720        std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
2721        let err = init(Some(dir), false).unwrap_err();
2722        assert!(format!("{err}").contains("already exists"));
2723    }
2724
2725    /// Build a minimal `apply`-able dotfiles tree for absorb tests.
2726    /// Returns (source_dir, target_dir).
2727    fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
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")).unwrap();
2731        std::fs::create_dir_all(&target).unwrap();
2732        let cfg = format!(
2733            r#"
2734[[mount.entry]]
2735src = "home"
2736dst = "{}"
2737"#,
2738            toml_path(&target)
2739        );
2740        std::fs::write(source.join("config.toml"), cfg).unwrap();
2741        (source, target)
2742    }
2743
2744    fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
2745        std::fs::write(path, body).unwrap();
2746        let f = std::fs::OpenOptions::new()
2747            .write(true)
2748            .open(path)
2749            .expect("open writable");
2750        f.set_modified(when).expect("set_modified");
2751    }
2752
2753    #[test]
2754    fn apply_target_newer_absorbs_target_into_source() {
2755        // Target has the user's edit and is mtime-newer than source —
2756        // classifier returns `AutoAbsorb`. yui's "target-as-truth"
2757        // philosophy: target wins, source is updated and backed up.
2758        let tmp = TempDir::new().unwrap();
2759        let (source, target) = setup_minimal_dotfiles(&tmp);
2760
2761        let now = std::time::SystemTime::now();
2762        let past = now - std::time::Duration::from_secs(120);
2763        write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
2764        // Pre-existing target with user's edit, NEWER mtime.
2765        write_with_mtime(&target.join(".bashrc"), "user's edit", now);
2766
2767        apply(Some(source.clone()), false).unwrap();
2768
2769        // Target's content survives — that's the whole point.
2770        assert_eq!(
2771            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2772            "user's edit"
2773        );
2774        // Source has been updated to match target.
2775        assert_eq!(
2776            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2777            "user's edit"
2778        );
2779        // Source's previous content lives under .yui/backup.
2780        let backup_root = source.join(".yui/backup");
2781        let mut found_old = false;
2782        for entry in walkdir(&backup_root) {
2783            if let Ok(s) = std::fs::read_to_string(&entry) {
2784                if s == "default from repo" {
2785                    found_old = true;
2786                    break;
2787                }
2788            }
2789        }
2790        assert!(found_old, "expected backup containing 'default from repo'");
2791    }
2792
2793    #[test]
2794    fn apply_in_sync_target_is_a_no_op() {
2795        // After an initial `apply`, running `apply` again classifies as
2796        // `InSync` and does nothing.
2797        let tmp = TempDir::new().unwrap();
2798        let (source, target) = setup_minimal_dotfiles(&tmp);
2799        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
2800        apply(Some(source.clone()), false).unwrap();
2801        let backup_root = source.join(".yui/backup");
2802        let backup_count_after_first = walkdir(&backup_root).len();
2803
2804        // Second apply — nothing should change.
2805        apply(Some(source.clone()), false).unwrap();
2806        assert_eq!(
2807            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2808            "echo hi\n"
2809        );
2810        let backup_count_after_second = walkdir(&backup_root).len();
2811        assert_eq!(
2812            backup_count_after_first, backup_count_after_second,
2813            "second apply on an in-sync tree should not produce backups"
2814        );
2815    }
2816
2817    #[test]
2818    fn apply_skip_policy_leaves_anomaly_alone() {
2819        // Source newer than target + content differs = NeedsConfirm.
2820        // With on_anomaly = "skip", target stays untouched.
2821        let tmp = TempDir::new().unwrap();
2822        let source = utf8(tmp.path().join("dotfiles"));
2823        let target = utf8(tmp.path().join("target"));
2824        std::fs::create_dir_all(source.join("home")).unwrap();
2825        std::fs::create_dir_all(&target).unwrap();
2826        let cfg = format!(
2827            r#"
2828[absorb]
2829on_anomaly = "skip"
2830
2831[[mount.entry]]
2832src = "home"
2833dst = "{}"
2834"#,
2835            toml_path(&target)
2836        );
2837        std::fs::write(source.join("config.toml"), cfg).unwrap();
2838
2839        let now = std::time::SystemTime::now();
2840        let past = now - std::time::Duration::from_secs(120);
2841        write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
2842        write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
2843
2844        apply(Some(source.clone()), false).unwrap();
2845
2846        // Target untouched (skip policy honored).
2847        assert_eq!(
2848            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2849            "user's edit (older)"
2850        );
2851        // Source untouched too.
2852        assert_eq!(
2853            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2854            "fresh from upstream"
2855        );
2856    }
2857
2858    #[test]
2859    fn apply_force_policy_absorbs_anomaly_anyway() {
2860        // Same anomaly setup, but on_anomaly = "force" → target wins.
2861        let tmp = TempDir::new().unwrap();
2862        let source = utf8(tmp.path().join("dotfiles"));
2863        let target = utf8(tmp.path().join("target"));
2864        std::fs::create_dir_all(source.join("home")).unwrap();
2865        std::fs::create_dir_all(&target).unwrap();
2866        let cfg = format!(
2867            r#"
2868[absorb]
2869on_anomaly = "force"
2870
2871[[mount.entry]]
2872src = "home"
2873dst = "{}"
2874"#,
2875            toml_path(&target)
2876        );
2877        std::fs::write(source.join("config.toml"), cfg).unwrap();
2878
2879        let now = std::time::SystemTime::now();
2880        let past = now - std::time::Duration::from_secs(120);
2881        write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
2882        write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
2883
2884        apply(Some(source.clone()), false).unwrap();
2885
2886        // Target wins despite being mtime-older — force policy.
2887        assert_eq!(
2888            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2889            "user's edit (older)"
2890        );
2891        assert_eq!(
2892            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2893            "user's edit (older)"
2894        );
2895    }
2896
2897    /// Regression for the Windows-error-145 bug: a `home/.config/.yuilink`
2898    /// (PassThrough) marker pointing at a non-empty regular `~/.config`
2899    /// directory (the typical chezmoi-migrated state, where every file
2900    /// inside is an individual hardlink) used to fail the absorb with
2901    /// `Directory not empty` because `link::unlink` refuses to recurse.
2902    /// After backup we now `remove_dir_all` as a fallback.
2903    ///
2904    /// v0.7+: also exercises the target-wins merge — target's
2905    /// `config.toml` overwrites source's, target's `state.json` lands
2906    /// in source (target was the source of truth), and source-only
2907    /// scaffolding (`.yuilink`) survives the absorb.
2908    #[test]
2909    fn apply_absorbs_non_empty_target_dir_target_wins() {
2910        let tmp = TempDir::new().unwrap();
2911        let source = utf8(tmp.path().join("dotfiles"));
2912        let target = utf8(tmp.path().join("target"));
2913        std::fs::create_dir_all(source.join("home/.config/app")).unwrap();
2914        std::fs::create_dir_all(target.join(".config/app")).unwrap();
2915        // Marker that says "junction this dir at the parent mount's dst"
2916        // — same shape as a typical home/.config/.yuilink.
2917        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
2918        std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
2919        // Source-only scaffolding that the absorb must preserve.
2920        std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
2921        // Pre-existing non-empty regular dir at the target — chezmoi /
2922        // any per-file dotfiles flow leaves things in this shape.
2923        std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
2924        std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
2925
2926        let cfg = format!(
2927            r#"
2928[absorb]
2929on_anomaly = "force"
2930
2931[[mount.entry]]
2932src = "home"
2933dst = "{}"
2934"#,
2935            toml_path(&target)
2936        );
2937        std::fs::write(source.join("config.toml"), cfg).unwrap();
2938
2939        // Used to bail with `unlink: ... Directory not empty` here.
2940        apply(Some(source.clone()), false).unwrap();
2941
2942        // Target wins on the conflicting file.
2943        assert_eq!(
2944            std::fs::read_to_string(target.join(".config/app/config.toml")).unwrap(),
2945            "target side"
2946        );
2947        // Target-only file is now reachable via the junction.
2948        assert_eq!(
2949            std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
2950            "{}"
2951        );
2952        // Source's pre-merge state was backed up before being overwritten,
2953        // so the original "src side" / `.yuilink` survive in `.yui/backup/`.
2954        let backup_root = source.join(".yui/backup");
2955        let mut backup_files: Vec<String> = Vec::new();
2956        for entry in walkdir(&backup_root) {
2957            if let Some(n) = entry.file_name() {
2958                backup_files.push(n.to_string());
2959            }
2960        }
2961        assert!(
2962            backup_files.iter().any(|f| f == "config.toml"),
2963            "expected source's config.toml to land in the backup tree, got {backup_files:?}"
2964        );
2965        // Source-only scaffolding survives the merge.
2966        assert!(
2967            source.join("home/.config/app/source-only.toml").exists(),
2968            "source-only file should survive a target-wins merge"
2969        );
2970        // Source picked up target-only state.json via the merge.
2971        assert!(
2972            source.join("home/.config/app/state.json").exists(),
2973            "target-only state.json should be merged into source"
2974        );
2975    }
2976
2977    /// v0.7+: `home/.config/.yuilink` is the user's explicit
2978    /// "this whole subtree is target-as-truth" declaration. A
2979    /// dir-level NeedsConfirm at the marker root is therefore not a
2980    /// real anomaly — the marker is consent. Default `[absorb]` (ask
2981    /// + require_clean_git) should still absorb, no prompt.
2982    #[test]
2983    fn marker_dir_absorbs_with_default_ask_policy() {
2984        let tmp = TempDir::new().unwrap();
2985        let source = utf8(tmp.path().join("dotfiles"));
2986        let target = utf8(tmp.path().join("target"));
2987        std::fs::create_dir_all(source.join("home/.config")).unwrap();
2988        std::fs::create_dir_all(target.join(".config/gh")).unwrap();
2989        // Marker — user opts the whole .config dir into target-as-truth.
2990        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
2991        // gh exists only on the target side (no entry in source).
2992        std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\n").unwrap();
2993
2994        // Default [absorb] (no override) — `on_anomaly = "ask"`,
2995        // `auto = true`, `require_clean_git = true`. Pre-v0.7 this
2996        // would have been routed through the ask prompt at dir level.
2997        let cfg = format!(
2998            r#"
2999[[mount.entry]]
3000src = "home"
3001dst = "{}"
3002"#,
3003            toml_path(&target)
3004        );
3005        std::fs::write(source.join("config.toml"), cfg).unwrap();
3006
3007        // Even with default `ask`, the marker-rooted absorb proceeds.
3008        // Test would hang on a stdin prompt if dir-level still treated
3009        // this as an anomaly.
3010        apply(Some(source.clone()), false).unwrap();
3011
3012        // Target-only file is now reachable through the junction and
3013        // recorded in source.
3014        assert!(target.join(".config/gh/hosts.yml").exists());
3015        assert!(source.join("home/.config/gh/hosts.yml").exists());
3016    }
3017
3018    /// File↔dir collisions during merge. Honor target-wins: if source
3019    /// has a regular file at a path where target has a dir, the file
3020    /// gets removed and the dir is created. Symmetrical for the
3021    /// inverse case. Without the conflict-clearing the merge would
3022    /// fail with `not a directory` / `path exists` deep in the recursion.
3023    #[test]
3024    fn merge_handles_file_vs_dir_collisions_target_wins() {
3025        let tmp = TempDir::new().unwrap();
3026        let source = utf8(tmp.path().join("dotfiles"));
3027        let target = utf8(tmp.path().join("target"));
3028        std::fs::create_dir_all(source.join("home/.config/foo")).unwrap();
3029        std::fs::create_dir_all(target.join(".config")).unwrap();
3030        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3031
3032        // Conflict A: source has `foo` as dir, target has `foo` as file.
3033        std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
3034        std::fs::write(target.join(".config/foo"), "target file body").unwrap();
3035        // Conflict B: source has `bar` as file, target has `bar` as dir.
3036        std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
3037        std::fs::create_dir_all(target.join(".config/bar")).unwrap();
3038        std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
3039
3040        let cfg = format!(
3041            r#"
3042[absorb]
3043on_anomaly = "force"
3044
3045[[mount.entry]]
3046src = "home"
3047dst = "{}"
3048"#,
3049            toml_path(&target)
3050        );
3051        std::fs::write(source.join("config.toml"), cfg).unwrap();
3052        apply(Some(source.clone()), false).unwrap();
3053
3054        // After absorb the target's view (which equals source via
3055        // junction) carries target's shapes:
3056        // `foo` is a regular file
3057        let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
3058        assert!(foo_meta.file_type().is_file(), "foo should be a file");
3059        assert_eq!(
3060            std::fs::read_to_string(target.join(".config/foo")).unwrap(),
3061            "target file body"
3062        );
3063        // `bar` is a directory with the nested file
3064        let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
3065        assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
3066        assert_eq!(
3067            std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
3068            "target nested"
3069        );
3070    }
3071
3072    /// Per-file conflict in dir merge — target newer + content
3073    /// differs → AutoAbsorb. Target wins automatically without
3074    /// touching `[absorb] on_anomaly`.
3075    #[test]
3076    fn merge_per_file_target_newer_auto_absorbs() {
3077        let tmp = TempDir::new().unwrap();
3078        let source = utf8(tmp.path().join("dotfiles"));
3079        let target = utf8(tmp.path().join("target"));
3080        std::fs::create_dir_all(source.join("home/.config")).unwrap();
3081        std::fs::create_dir_all(target.join(".config")).unwrap();
3082        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3083
3084        // Source has the older copy, target has the newer edit.
3085        let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3086        write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
3087        std::fs::write(target.join(".config/app.toml"), "user's live edit").unwrap();
3088
3089        // Default `ask` policy — should NOT prompt because the
3090        // classifier returns AutoAbsorb (target newer + diff), which
3091        // bypasses `on_anomaly` entirely.
3092        let cfg = format!(
3093            r#"
3094[[mount.entry]]
3095src = "home"
3096dst = "{}"
3097"#,
3098            toml_path(&target)
3099        );
3100        std::fs::write(source.join("config.toml"), cfg).unwrap();
3101        apply(Some(source.clone()), false).unwrap();
3102
3103        // Target wins.
3104        assert_eq!(
3105            std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3106            "user's live edit"
3107        );
3108    }
3109
3110    /// Per-file conflict — source newer + content differs +
3111    /// `on_anomaly = "skip"` → keep source's version. After the outer
3112    /// junction, target ends up with source's content (so target's
3113    /// file is effectively dropped, matching the file-level `skip`
3114    /// semantic).
3115    #[test]
3116    fn merge_per_file_source_newer_skip_keeps_source() {
3117        let tmp = TempDir::new().unwrap();
3118        let source = utf8(tmp.path().join("dotfiles"));
3119        let target = utf8(tmp.path().join("target"));
3120        std::fs::create_dir_all(source.join("home/.config")).unwrap();
3121        std::fs::create_dir_all(target.join(".config")).unwrap();
3122        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3123
3124        // Target has the older copy, source has the newer edit.
3125        let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3126        write_with_mtime(&target.join(".config/app.toml"), "old target", past);
3127        std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
3128
3129        let cfg = format!(
3130            r#"
3131[absorb]
3132on_anomaly = "skip"
3133
3134[[mount.entry]]
3135src = "home"
3136dst = "{}"
3137"#,
3138            toml_path(&target)
3139        );
3140        std::fs::write(source.join("config.toml"), cfg).unwrap();
3141        apply(Some(source.clone()), false).unwrap();
3142
3143        // Source kept — target now reads source's version through the
3144        // junction (so target's old text is dropped).
3145        assert_eq!(
3146            std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3147            "fresh source"
3148        );
3149    }
3150
3151    /// Per-file conflict — source newer + content differs +
3152    /// `on_anomaly = "force"` → target wins anyway.
3153    #[test]
3154    fn merge_per_file_source_newer_force_overwrites_source() {
3155        let tmp = TempDir::new().unwrap();
3156        let source = utf8(tmp.path().join("dotfiles"));
3157        let target = utf8(tmp.path().join("target"));
3158        std::fs::create_dir_all(source.join("home/.config")).unwrap();
3159        std::fs::create_dir_all(target.join(".config")).unwrap();
3160        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3161
3162        let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
3163        write_with_mtime(&target.join(".config/app.toml"), "old target", past);
3164        std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
3165
3166        let cfg = format!(
3167            r#"
3168[absorb]
3169on_anomaly = "force"
3170
3171[[mount.entry]]
3172src = "home"
3173dst = "{}"
3174"#,
3175            toml_path(&target)
3176        );
3177        std::fs::write(source.join("config.toml"), cfg).unwrap();
3178        apply(Some(source.clone()), false).unwrap();
3179
3180        // Target overrides source despite being mtime-older.
3181        assert_eq!(
3182            std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3183            "old target"
3184        );
3185    }
3186
3187    /// Per-file conflict — bytes match → no-op. The merge classifies
3188    /// this as RelinkOnly and skips the copy entirely (saves a lot of
3189    /// I/O when migrating big chezmoi repos where source and target
3190    /// have already shared inodes).
3191    #[test]
3192    fn merge_per_file_identical_content_is_noop() {
3193        let tmp = TempDir::new().unwrap();
3194        let source = utf8(tmp.path().join("dotfiles"));
3195        let target = utf8(tmp.path().join("target"));
3196        std::fs::create_dir_all(source.join("home/.config")).unwrap();
3197        std::fs::create_dir_all(target.join(".config")).unwrap();
3198        std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
3199        std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
3200        std::fs::write(target.join(".config/app.toml"), "same").unwrap();
3201
3202        // Default policy — bytes match, classifier returns RelinkOnly,
3203        // merge skips the copy. Apply must succeed without prompting.
3204        let cfg = format!(
3205            r#"
3206[[mount.entry]]
3207src = "home"
3208dst = "{}"
3209"#,
3210            toml_path(&target)
3211        );
3212        std::fs::write(source.join("config.toml"), cfg).unwrap();
3213        apply(Some(source.clone()), false).unwrap();
3214
3215        assert_eq!(
3216            std::fs::read_to_string(target.join(".config/app.toml")).unwrap(),
3217            "same"
3218        );
3219    }
3220
3221    #[test]
3222    fn manual_absorb_command_pulls_target_into_source() {
3223        // Manual `yui absorb <target>` bypasses policy + git checks.
3224        let tmp = TempDir::new().unwrap();
3225        let source = utf8(tmp.path().join("dotfiles"));
3226        let target = utf8(tmp.path().join("target"));
3227        std::fs::create_dir_all(source.join("home")).unwrap();
3228        std::fs::create_dir_all(&target).unwrap();
3229        // on_anomaly = "skip" so passive `apply` would NOT touch this.
3230        let cfg = format!(
3231            r#"
3232[absorb]
3233on_anomaly = "skip"
3234
3235[[mount.entry]]
3236src = "home"
3237dst = "{}"
3238"#,
3239            toml_path(&target)
3240        );
3241        std::fs::write(source.join("config.toml"), cfg).unwrap();
3242        std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
3243        std::fs::write(source.join("home/.bashrc"), "default").unwrap();
3244
3245        // Run absorb directly on the target.
3246        absorb(
3247            Some(source.clone()),
3248            target.join(".bashrc"),
3249            /* dry_run */ false,
3250        )
3251        .unwrap();
3252
3253        // Source picked up target's content (manual absorb is forceful).
3254        assert_eq!(
3255            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
3256            "user picked this"
3257        );
3258    }
3259
3260    #[test]
3261    fn manual_absorb_errors_when_target_outside_known_mounts() {
3262        let tmp = TempDir::new().unwrap();
3263        let (source, _target) = setup_minimal_dotfiles(&tmp);
3264        std::fs::write(source.join("home/.bashrc"), "x").unwrap();
3265        let stranger = utf8(tmp.path().join("not-managed/foo"));
3266        std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
3267        std::fs::write(&stranger, "not yui's").unwrap();
3268        let err = absorb(Some(source), stranger, false).unwrap_err();
3269        assert!(format!("{err}").contains("no mount entry"));
3270    }
3271
3272    #[test]
3273    fn yuiignore_excludes_file_from_linking() {
3274        let tmp = TempDir::new().unwrap();
3275        let (source, target) = setup_minimal_dotfiles(&tmp);
3276        std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
3277        std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
3278        // Exclude `lock.json` files anywhere under source.
3279        std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
3280        apply(Some(source.clone()), false).unwrap();
3281        assert!(target.join(".bashrc").exists());
3282        assert!(
3283            !target.join("lock.json").exists(),
3284            "yuiignore should keep lock.json out of target"
3285        );
3286    }
3287
3288    #[test]
3289    fn yuiignore_excludes_directory_subtree() {
3290        let tmp = TempDir::new().unwrap();
3291        let (source, target) = setup_minimal_dotfiles(&tmp);
3292        std::fs::create_dir_all(source.join("home/cache")).unwrap();
3293        std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
3294        std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
3295        std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
3296        // Trailing slash → match dirs only; entire subtree skipped.
3297        std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
3298        apply(Some(source.clone()), false).unwrap();
3299        assert!(target.join(".bashrc").exists());
3300        assert!(
3301            !target.join("cache").exists(),
3302            "yuiignore'd subtree should not appear in target"
3303        );
3304    }
3305
3306    #[test]
3307    fn yuiignore_negation_re_includes_file() {
3308        let tmp = TempDir::new().unwrap();
3309        let (source, target) = setup_minimal_dotfiles(&tmp);
3310        std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
3311        std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
3312        // Ignore all .cache files except keep.cache.
3313        std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
3314        apply(Some(source.clone()), false).unwrap();
3315        assert!(target.join("keep.cache").exists());
3316        assert!(!target.join("drop.cache").exists());
3317    }
3318
3319    #[test]
3320    fn yuiignore_skips_template_in_render() {
3321        let tmp = TempDir::new().unwrap();
3322        let source = utf8(tmp.path().join("dotfiles"));
3323        let target = utf8(tmp.path().join("target"));
3324        std::fs::create_dir_all(source.join("home")).unwrap();
3325        std::fs::create_dir_all(&target).unwrap();
3326        std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
3327        std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
3328        let cfg = format!(
3329            r#"
3330[[mount.entry]]
3331src = "home"
3332dst = "{}"
3333"#,
3334            toml_path(&target)
3335        );
3336        std::fs::write(source.join("config.toml"), cfg).unwrap();
3337        apply(Some(source.clone()), false).unwrap();
3338        // Neither the template nor the rendered output linked.
3339        assert!(!source.join("home/note").exists());
3340        assert!(!target.join("note").exists());
3341        assert!(!target.join("note.tera").exists());
3342    }
3343
3344    /// v0.6+: parent `.yuilink` doesn't stop the walker. A parent
3345    /// marker can junction the whole dir, AND a child marker can layer
3346    /// on extra dsts (e.g. an OS-specific alternate location).
3347    #[test]
3348    fn nested_marker_accumulates_extra_dst() {
3349        let tmp = TempDir::new().unwrap();
3350        let source = utf8(tmp.path().join("dotfiles"));
3351        let parent_target = utf8(tmp.path().join("home"));
3352        let extra_target = utf8(tmp.path().join("extra"));
3353        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
3354        std::fs::create_dir_all(&parent_target).unwrap();
3355        std::fs::create_dir_all(&extra_target).unwrap();
3356        std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
3357
3358        // Parent: junction the whole .config dir to <home>/.config.
3359        std::fs::write(
3360            source.join("home/.config/.yuilink"),
3361            format!(
3362                r#"
3363[[link]]
3364dst = "{}/.config"
3365"#,
3366                toml_path(&parent_target)
3367            ),
3368        )
3369        .unwrap();
3370        // Child: ALSO junction nvim/ to an extra path, but only on the
3371        // running OS (so the test exercises an active link).
3372        std::fs::write(
3373            source.join("home/.config/nvim/.yuilink"),
3374            format!(
3375                r#"
3376[[link]]
3377dst = "{}/nvim"
3378when = "{{{{ yui.os == '{}' }}}}"
3379"#,
3380                toml_path(&extra_target),
3381                std::env::consts::OS
3382            ),
3383        )
3384        .unwrap();
3385
3386        let cfg = format!(
3387            r#"
3388[[mount.entry]]
3389src = "home"
3390dst = "{}"
3391"#,
3392            toml_path(&parent_target)
3393        );
3394        std::fs::write(source.join("config.toml"), cfg).unwrap();
3395
3396        apply(Some(source.clone()), false).unwrap();
3397
3398        // Both links are present: parent's whole-.config junction reaches
3399        // init.lua, and the child marker added an additional path.
3400        assert!(parent_target.join(".config/nvim/init.lua").exists());
3401        assert!(extra_target.join("nvim/init.lua").exists());
3402    }
3403
3404    /// v0.6+: `[[link]] src = "<filename>"` links a single sibling file
3405    /// to a custom dst, leaving the rest of the dir to default
3406    /// behaviour. Useful for paths like the PowerShell profile that
3407    /// have to live in a non-`~/.config` location on Windows.
3408    #[test]
3409    fn marker_file_link_targets_specific_file() {
3410        let tmp = TempDir::new().unwrap();
3411        let source = utf8(tmp.path().join("dotfiles"));
3412        let parent_target = utf8(tmp.path().join("home"));
3413        let docs_target = utf8(tmp.path().join("docs"));
3414        std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
3415        std::fs::create_dir_all(&parent_target).unwrap();
3416        std::fs::create_dir_all(&docs_target).unwrap();
3417        std::fs::write(
3418            source.join("home/.config/powershell/profile.ps1"),
3419            "# profile\n",
3420        )
3421        .unwrap();
3422        std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
3423
3424        // File-level entry only — no dir-level [[link]], so the dir
3425        // itself still falls through to the default mount placement.
3426        std::fs::write(
3427            source.join("home/.config/powershell/.yuilink"),
3428            format!(
3429                r#"
3430[[link]]
3431src = "profile.ps1"
3432dst = "{}/Microsoft.PowerShell_profile.ps1"
3433"#,
3434                toml_path(&docs_target)
3435            ),
3436        )
3437        .unwrap();
3438
3439        let cfg = format!(
3440            r#"
3441[[mount.entry]]
3442src = "home"
3443dst = "{}"
3444"#,
3445            toml_path(&parent_target)
3446        );
3447        std::fs::write(source.join("config.toml"), cfg).unwrap();
3448
3449        apply(Some(source.clone()), false).unwrap();
3450
3451        // File-level target gets the link.
3452        assert!(
3453            docs_target
3454                .join("Microsoft.PowerShell_profile.ps1")
3455                .exists()
3456        );
3457        // Default per-file placement still happens for ALL files in the
3458        // dir (the marker had no dir-level [[link]] to claim coverage).
3459        assert!(
3460            parent_target
3461                .join(".config/powershell/profile.ps1")
3462                .exists()
3463        );
3464        assert!(parent_target.join(".config/powershell/extra.txt").exists());
3465    }
3466
3467    /// File-level [[link]] errors clearly when src points at a missing
3468    /// file — config bug, not a silent skip.
3469    #[test]
3470    fn marker_file_link_missing_src_errors() {
3471        let tmp = TempDir::new().unwrap();
3472        let source = utf8(tmp.path().join("dotfiles"));
3473        let parent_target = utf8(tmp.path().join("home"));
3474        let docs_target = utf8(tmp.path().join("docs"));
3475        std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
3476        std::fs::create_dir_all(&parent_target).unwrap();
3477        std::fs::create_dir_all(&docs_target).unwrap();
3478
3479        std::fs::write(
3480            source.join("home/.config/powershell/.yuilink"),
3481            format!(
3482                r#"
3483[[link]]
3484src = "missing.ps1"
3485dst = "{}/profile.ps1"
3486"#,
3487                toml_path(&docs_target)
3488            ),
3489        )
3490        .unwrap();
3491
3492        let cfg = format!(
3493            r#"
3494[[mount.entry]]
3495src = "home"
3496dst = "{}"
3497"#,
3498            toml_path(&parent_target)
3499        );
3500        std::fs::write(source.join("config.toml"), cfg).unwrap();
3501
3502        let err = apply(Some(source.clone()), false).unwrap_err();
3503        assert!(format!("{err:#}").contains("missing.ps1"));
3504    }
3505
3506    fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
3507        let mut out = Vec::new();
3508        let mut stack = vec![root.to_path_buf()];
3509        while let Some(dir) = stack.pop() {
3510            let Ok(entries) = std::fs::read_dir(&dir) else {
3511                continue;
3512            };
3513            for e in entries.flatten() {
3514                let p = utf8(e.path());
3515                if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
3516                    stack.push(p);
3517                } else {
3518                    out.push(p);
3519                }
3520            }
3521        }
3522        out
3523    }
3524}