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::Override { 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            items.push(ListItem {
282                src: rel.clone(),
283                dst,
284                when: link.when.clone(),
285                active,
286            });
287        }
288    }
289
290    items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
291    Ok(items)
292}
293
294fn supports_color_stdout() -> bool {
295    use std::io::IsTerminal;
296    std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
297}
298
299fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
300    let src_w = items
301        .iter()
302        .map(|i| i.src.as_str().chars().count())
303        .max()
304        .unwrap_or(0)
305        .max("SRC".len());
306    let dst_w = items
307        .iter()
308        .map(|i| i.dst.chars().count())
309        .max()
310        .unwrap_or(0)
311        .max("DST".len());
312
313    let status_w = "STATUS".len();
314    let arrow_w = icons.arrow.chars().count();
315
316    // Header
317    print_header(status_w, src_w, arrow_w, dst_w, color);
318
319    // Separator
320    let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
321    if color {
322        use owo_colors::OwoColorize as _;
323        println!("{}", sep.dimmed());
324    } else {
325        println!("{sep}");
326    }
327
328    // Rows
329    for item in items {
330        print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
331    }
332}
333
334fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
335    use owo_colors::OwoColorize as _;
336    let mut line = String::new();
337    let _ = write!(
338        &mut line,
339        "  {:<status_w$}  {:<src_w$}  {:<arrow_w$}  {:<dst_w$}  WHEN",
340        "STATUS", "SRC", "", "DST"
341    );
342    if color {
343        println!("{}", line.bold());
344    } else {
345        println!("{line}");
346    }
347}
348
349fn render_separator(
350    sep_ch: char,
351    status_w: usize,
352    src_w: usize,
353    arrow_w: usize,
354    dst_w: usize,
355) -> String {
356    let bar = |n: usize| sep_ch.to_string().repeat(n);
357    format!(
358        "  {}  {}  {}  {}  {}",
359        bar(status_w),
360        bar(src_w),
361        bar(arrow_w),
362        bar(dst_w),
363        bar("WHEN".len())
364    )
365}
366
367fn print_row(
368    item: &ListItem,
369    icons: Icons,
370    status_w: usize,
371    src_w: usize,
372    arrow_w: usize,
373    dst_w: usize,
374    color: bool,
375) {
376    use owo_colors::OwoColorize as _;
377    let status = if item.active {
378        icons.active
379    } else {
380        icons.inactive
381    };
382    let when_str = item
383        .when
384        .as_deref()
385        .map(strip_braces)
386        .unwrap_or_else(|| "(always)".to_string());
387
388    // Normalize backslashes to forward slashes for cross-platform display.
389    let src_display = item.src.as_str().replace('\\', "/");
390    let src = src_display.as_str();
391    let dst = &item.dst;
392    let arrow = icons.arrow;
393
394    // Pad each cell to its column width FIRST, then apply color. Doing it
395    // the other way round lets ANSI escape codes count as printable chars
396    // in `format!("{:<w$}")`, which silently breaks alignment when colors
397    // are enabled (caught in PR #11 review).
398    let cell_status = format!("{:<status_w$}", status);
399    let cell_src = format!("{:<src_w$}", src);
400    let cell_arrow = format!("{:<arrow_w$}", arrow);
401    let cell_dst = format!("{:<dst_w$}", dst);
402
403    if !color {
404        println!("  {cell_status}  {cell_src}  {cell_arrow}  {cell_dst}  {when_str}");
405        return;
406    }
407
408    if item.active {
409        println!(
410            "  {}  {}  {}  {}  {}",
411            cell_status.green(),
412            cell_src.cyan(),
413            cell_arrow.dimmed(),
414            cell_dst.green(),
415            when_str.dimmed()
416        );
417    } else {
418        println!(
419            "  {}  {}  {}  {}  {}",
420            cell_status.red().dimmed(),
421            cell_src.dimmed(),
422            cell_arrow.dimmed(),
423            cell_dst.dimmed(),
424            when_str.dimmed()
425        );
426    }
427}
428
429/// Strip the outer `{{ ... }}` Tera braces from a `when` expression for
430/// display purposes (shorter line, easier to read at a glance).
431fn strip_braces(expr: &str) -> String {
432    let trimmed = expr.trim();
433    if let Some(inner) = trimmed
434        .strip_prefix("{{")
435        .and_then(|s| s.strip_suffix("}}"))
436    {
437        inner.trim().to_string()
438    } else {
439        trimmed.to_string()
440    }
441}
442
443pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
444    let source = resolve_source(source)?;
445    let yui = YuiVars::detect(&source);
446    let config = config::load(&source, &yui)?;
447    let yuiignore = paths::load_yuiignore(&source)?;
448    // --check is a stricter dry-run: never writes, exits non-zero on drift.
449    let report = render::render_all(&source, &config, &yui, &yuiignore, dry_run || check)?;
450    log_render_report(&report);
451    if check && report.has_drift() {
452        anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
453    }
454    Ok(())
455}
456
457pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
458    // For now `link` and `apply` do the same thing (no render/absorb yet).
459    apply(source, dry_run)
460}
461
462pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
463    let _source = resolve_source(source)?;
464    if paths_arg.is_empty() {
465        anyhow::bail!("yui unlink: provide at least one target path");
466    }
467    for p in paths_arg {
468        let abs = absolutize(&p)?;
469        info!("unlink: {abs}");
470        link::unlink(&abs)?;
471    }
472    Ok(())
473}
474
475/// Show every src→dst pair's drift state against the current host.
476///
477/// Walks each `[[mount.entry]]`'s source tree, honoring `.yuilink`
478/// markers (PassThrough = single dir-level link, Override = one or more
479/// custom dsts), classifies each pair via [`crate::absorb::classify`],
480/// and additionally surfaces any **render drift** — rendered files
481/// whose content has diverged from what the matching `.tera` template
482/// would produce now (i.e. the user edited the rendered file in place
483/// without reflecting the change back into the template).
484///
485/// Exits non-zero (via `anyhow::bail!`) when anything diverges, so
486/// `yui status && …` can gate workflows on a clean tree.
487pub fn status(
488    source: Option<Utf8PathBuf>,
489    icons_override: Option<IconsMode>,
490    no_color: bool,
491) -> Result<()> {
492    let source = resolve_source(source)?;
493    let yui = YuiVars::detect(&source);
494    let config = config::load(&source, &yui)?;
495
496    let mut engine = template::Engine::new();
497    let tera_ctx = template::template_context(&yui, &config.vars);
498    let mounts = mount::resolve(
499        &config.mount.entry,
500        config.mount.default_strategy,
501        &mut engine,
502        &tera_ctx,
503    )?;
504
505    let icons_mode = icons_override.unwrap_or(config.ui.icons);
506    let icons = Icons::for_mode(icons_mode);
507    let color = !no_color && supports_color_stdout();
508
509    let mut report: Vec<StatusItem> = Vec::new();
510    // Load `.yuiignore` once and reuse for both render-drift detection
511    // and the link-drift walk below.
512    let yuiignore = paths::load_yuiignore(&source)?;
513
514    // 1. Template drift — render in dry-run mode and surface anything
515    //    whose rendered counterpart on disk no longer matches.
516    let render_report =
517        render::render_all(&source, &config, &yui, &yuiignore, /* dry_run */ true)?;
518    for rendered in &render_report.diverged {
519        // `diverged` holds the rendered path; the template lives at
520        // `<rendered>.tera`. Show the .tera as src so it's clear which
521        // file the user needs to update.
522        let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
523        report.push(StatusItem {
524            src: relative_for_display(&source, &tera_path),
525            dst: rendered.clone(),
526            state: StatusState::RenderDrift,
527        });
528    }
529
530    // 2. Link drift — classify each src→dst pair under every mount.
531    for m in &mounts {
532        let src_root = source.join(&m.src);
533        if !src_root.is_dir() {
534            warn!("mount src missing: {src_root}");
535            continue;
536        }
537        classify_walk(
538            &src_root,
539            &m.dst,
540            &config,
541            m.strategy,
542            &mut engine,
543            &tera_ctx,
544            &source,
545            &yuiignore,
546            &mut report,
547        )?;
548    }
549
550    report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
551
552    print_status_table(&report, icons, color);
553
554    let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
555
556    println!();
557    let total = report.len();
558    let in_sync = total - drift;
559    if drift == 0 {
560        println!("  {total} entries · all in sync");
561        Ok(())
562    } else {
563        println!("  {total} entries · {in_sync} in sync · {drift} diverged");
564        anyhow::bail!("status: {drift} entries diverged from source")
565    }
566}
567
568#[derive(Debug)]
569struct StatusItem {
570    /// Path under the source tree (display only).
571    src: Utf8PathBuf,
572    /// Resolved target path (or rendered output path for `RenderDrift`).
573    dst: Utf8PathBuf,
574    state: StatusState,
575}
576
577#[derive(Debug, Clone, Copy)]
578enum StatusState {
579    Link(absorb::AbsorbDecision),
580    /// Rendered output diverges from current `.tera` template — user
581    /// edited the rendered file directly without updating the template.
582    RenderDrift,
583}
584
585impl StatusState {
586    fn is_in_sync(self) -> bool {
587        matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
588    }
589}
590
591#[allow(clippy::too_many_arguments)]
592fn classify_walk(
593    src_dir: &Utf8Path,
594    dst_dir: &Utf8Path,
595    config: &Config,
596    strategy: MountStrategy,
597    engine: &mut template::Engine,
598    tera_ctx: &TeraContext,
599    source_root: &Utf8Path,
600    yuiignore: &ignore::gitignore::Gitignore,
601    report: &mut Vec<StatusItem>,
602) -> Result<()> {
603    if paths::is_ignored(yuiignore, source_root, src_dir, /* is_dir */ true) {
604        return Ok(());
605    }
606
607    let marker_filename = &config.mount.marker_filename;
608
609    if strategy == MountStrategy::Marker {
610        match marker::read_spec(src_dir, marker_filename)? {
611            None => {} // no marker — fall through to recursive walk
612            Some(MarkerSpec::PassThrough) => {
613                let decision = absorb::classify(src_dir, dst_dir)?;
614                report.push(StatusItem {
615                    src: relative_for_display(source_root, src_dir),
616                    dst: dst_dir.to_path_buf(),
617                    state: StatusState::Link(decision),
618                });
619                return Ok(());
620            }
621            Some(MarkerSpec::Override { links }) => {
622                for link in &links {
623                    if let Some(when) = &link.when {
624                        if !template::eval_truthy(when, engine, tera_ctx)? {
625                            continue;
626                        }
627                    }
628                    let dst_str = engine.render(&link.dst, tera_ctx)?;
629                    let dst = paths::expand_tilde(dst_str.trim());
630                    let decision = absorb::classify(src_dir, &dst)?;
631                    report.push(StatusItem {
632                        src: relative_for_display(source_root, src_dir),
633                        dst,
634                        state: StatusState::Link(decision),
635                    });
636                }
637                return Ok(());
638            }
639        }
640    }
641
642    for entry in std::fs::read_dir(src_dir)? {
643        let entry = entry?;
644        let name_os = entry.file_name();
645        let Some(name) = name_os.to_str() else {
646            continue;
647        };
648        if name == marker_filename || name.ends_with(".tera") {
649            continue;
650        }
651        let src_path = src_dir.join(name);
652        let dst_path = dst_dir.join(name);
653        let ft = entry.file_type()?;
654        if paths::is_ignored(yuiignore, source_root, &src_path, ft.is_dir()) {
655            continue;
656        }
657        if ft.is_dir() {
658            classify_walk(
659                &src_path,
660                &dst_path,
661                config,
662                strategy,
663                engine,
664                tera_ctx,
665                source_root,
666                yuiignore,
667                report,
668            )?;
669        } else if ft.is_file() {
670            let decision = absorb::classify(&src_path, &dst_path)?;
671            report.push(StatusItem {
672                src: relative_for_display(source_root, &src_path),
673                dst: dst_path,
674                state: StatusState::Link(decision),
675            });
676        }
677    }
678    Ok(())
679}
680
681fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
682    p.strip_prefix(source_root)
683        .map(Utf8PathBuf::from)
684        .unwrap_or_else(|_| p.to_path_buf())
685}
686
687fn print_status_table(items: &[StatusItem], icons: Icons, color: bool) {
688    let src_w = items
689        .iter()
690        .map(|i| i.src.as_str().chars().count())
691        .max()
692        .unwrap_or(0)
693        .max("SRC".len());
694    let dst_w = items
695        .iter()
696        .map(|i| i.dst.as_str().chars().count())
697        .max()
698        .unwrap_or(0)
699        .max("DST".len());
700    // STATE column = icon (1ch) + space + longest label
701    let state_label_w = items
702        .iter()
703        .map(|i| state_label(i.state).len())
704        .max()
705        .unwrap_or(0)
706        .max("STATE".len() - 2); // "STATE" header takes 5 chars; the icon prefix accounts for 2
707    let state_w = state_label_w + 2; // " " + label
708
709    print_status_header(state_w, src_w, dst_w, color);
710    let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
711    if color {
712        use owo_colors::OwoColorize as _;
713        println!("{}", sep.dimmed());
714    } else {
715        println!("{sep}");
716    }
717    for item in items {
718        print_status_row(item, icons, state_w, src_w, dst_w, color);
719    }
720}
721
722fn state_label(s: StatusState) -> &'static str {
723    use absorb::AbsorbDecision::*;
724    match s {
725        StatusState::Link(InSync) => "in-sync",
726        StatusState::Link(RelinkOnly) => "relink",
727        StatusState::Link(AutoAbsorb) => "drift (auto)",
728        StatusState::Link(NeedsConfirm) => "drift (anomaly)",
729        StatusState::Link(Restore) => "missing",
730        StatusState::RenderDrift => "render drift",
731    }
732}
733
734fn state_icon(s: StatusState, icons: Icons) -> &'static str {
735    use absorb::AbsorbDecision::*;
736    match s {
737        StatusState::Link(InSync) => icons.ok,
738        StatusState::Link(RelinkOnly) => icons.warn,
739        StatusState::Link(AutoAbsorb) => icons.warn,
740        StatusState::Link(NeedsConfirm) => icons.error,
741        StatusState::Link(Restore) => icons.info,
742        StatusState::RenderDrift => icons.error,
743    }
744}
745
746fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
747    use owo_colors::OwoColorize as _;
748    // STATE is the only column with data above; "WHEN" intentionally omitted
749    // since status only shows mounts that are already active on this host.
750    let line = format!(
751        "  {:<state_w$}  {:<src_w$}     {:<dst_w$}",
752        "STATE", "SRC", "DST"
753    );
754    if color {
755        println!("{}", line.bold());
756    } else {
757        println!("{line}");
758    }
759}
760
761fn render_status_separator(
762    sep_ch: char,
763    state_w: usize,
764    src_w: usize,
765    dst_w: usize,
766    arrow: &str,
767) -> String {
768    let bar = |n: usize| sep_ch.to_string().repeat(n);
769    format!(
770        "  {}  {}  {}  {}",
771        bar(state_w),
772        bar(src_w),
773        bar(arrow.chars().count()),
774        bar(dst_w)
775    )
776}
777
778fn print_status_row(
779    item: &StatusItem,
780    icons: Icons,
781    state_w: usize,
782    src_w: usize,
783    dst_w: usize,
784    color: bool,
785) {
786    use owo_colors::OwoColorize as _;
787    let icon = state_icon(item.state, icons);
788    let label = state_label(item.state);
789    let state_text = format!("{icon} {label}");
790    let src_display = item.src.as_str().replace('\\', "/");
791    let dst_display = item.dst.as_str().replace('\\', "/");
792    let arrow = icons.arrow;
793
794    let cell_state = format!("{:<state_w$}", state_text);
795    let cell_src = format!("{:<src_w$}", src_display);
796    let cell_dst = format!("{:<dst_w$}", dst_display);
797
798    if !color {
799        println!("  {cell_state}  {cell_src}  {arrow}  {cell_dst}");
800        return;
801    }
802
803    use absorb::AbsorbDecision::*;
804    let state_colored = match item.state {
805        StatusState::Link(InSync) => cell_state.green().to_string(),
806        StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
807            cell_state.yellow().to_string()
808        }
809        StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
810        StatusState::Link(Restore) => cell_state.cyan().to_string(),
811        StatusState::RenderDrift => cell_state.red().to_string(),
812    };
813    let src_colored = cell_src.cyan().to_string();
814    let arrow_colored = arrow.dimmed().to_string();
815    let dst_colored = cell_dst.dimmed().to_string();
816    println!("  {state_colored}  {src_colored}  {arrow_colored}  {dst_colored}");
817}
818
819/// Manually absorb a single target file back into source.
820///
821/// Used when `apply` has skipped an anomaly (`[absorb] on_anomaly = "skip"`
822/// or non-TTY ask) but the user has decided that target is right. Bypasses
823/// policy + git-clean checks: this is an explicit user request.
824///
825/// Walks `[[mount.entry]]` and `.yuilink` overrides to find which source
826/// path "owns" the given target. Errors loudly if no mount claims it.
827pub fn absorb(source: Option<Utf8PathBuf>, target: Utf8PathBuf, dry_run: bool) -> Result<()> {
828    let source = resolve_source(source)?;
829    let target = absolutize(&target)?;
830    let yui = YuiVars::detect(&source);
831    let config = config::load(&source, &yui)?;
832
833    let mut engine = template::Engine::new();
834    let tera_ctx = template::template_context(&yui, &config.vars);
835    // Single load — the matcher is shared with both find_source_for_target
836    // and the eventual ApplyCtx below.
837    let yuiignore = paths::load_yuiignore(&source)?;
838
839    let src_path = match find_source_for_target(
840        &source,
841        &config,
842        &target,
843        &mut engine,
844        &tera_ctx,
845        &yuiignore,
846    )? {
847        Some(s) => s,
848        None => anyhow::bail!(
849            "no mount entry / .yuilink override claims target {target}; \
850                 pass a path inside a known dst"
851        ),
852    };
853
854    info!("source for {target}: {src_path}");
855
856    if dry_run {
857        info!("[dry-run] would absorb {target} → {src_path}");
858        return Ok(());
859    }
860
861    let backup_root = source.join(&config.backup.dir);
862    let ctx = ApplyCtx {
863        config: &config,
864        source: &source,
865        yuiignore: &yuiignore,
866        file_mode: resolve_file_mode(config.link.file_mode),
867        dir_mode: resolve_dir_mode(config.link.dir_mode),
868        backup_root: &backup_root,
869        dry_run: false,
870    };
871
872    // Manual absorb is an explicit user request — bypass `auto`,
873    // `require_clean_git`, and `on_anomaly` policy entirely.
874    absorb_target_into_source(&src_path, &target, &ctx)
875}
876
877/// Walk mount entries + `.yuilink` Override markers to find the source
878/// file/dir that the given target maps back to. Returns `None` when no
879/// mount or marker claims the path.
880fn find_source_for_target(
881    source: &Utf8Path,
882    config: &Config,
883    target: &Utf8Path,
884    engine: &mut template::Engine,
885    tera_ctx: &TeraContext,
886    yuiignore: &ignore::gitignore::Gitignore,
887) -> Result<Option<Utf8PathBuf>> {
888    // 1. Mount entries — render dst, see if target is inside it.
889    for entry in &config.mount.entry {
890        if let Some(when) = &entry.when {
891            if !template::eval_truthy(when, engine, tera_ctx)? {
892                continue;
893            }
894        }
895        let dst_str = engine.render(&entry.dst, tera_ctx)?;
896        let dst_root = paths::expand_tilde(dst_str.trim());
897        if let Ok(rel) = target.strip_prefix(&dst_root) {
898            let candidate = source.join(&entry.src).join(rel);
899            // Honor `.yuiignore` even on manual absorb — if you've
900            // ignored a path, you've explicitly opted out of yui's
901            // managing it.
902            if paths::is_ignored(yuiignore, source, &candidate, candidate.is_dir()) {
903                continue;
904            }
905            return Ok(Some(candidate));
906        }
907    }
908
909    // 2. `.yuilink` Override markers — walk source, parse, render each
910    //    `[[link]] dst`, see if target is the rendered dst (or nested
911    //    inside a junction'd dir). Skips `.yui/` (backup mirrors etc.).
912    let walker = paths::source_walker(source).build();
913    let marker_filename = &config.mount.marker_filename;
914    for ent in walker {
915        let ent = match ent {
916            Ok(e) => e,
917            Err(_) => continue,
918        };
919        if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
920            continue;
921        }
922        if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
923            continue;
924        }
925        let dir = match ent.path().parent() {
926            Some(d) => d,
927            None => continue,
928        };
929        let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
930            Ok(p) => p,
931            Err(_) => continue,
932        };
933        if paths::is_ignored(yuiignore, source, &dir_utf8, true) {
934            continue;
935        }
936        let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
937            Some(s) => s,
938            None => continue,
939        };
940        let MarkerSpec::Override { links } = spec else {
941            continue;
942        };
943        for link in &links {
944            if let Some(when) = &link.when {
945                if !template::eval_truthy(when, engine, tera_ctx)? {
946                    continue;
947                }
948            }
949            let dst_str = engine.render(&link.dst, tera_ctx)?;
950            let dst = paths::expand_tilde(dst_str.trim());
951            if target == dst {
952                return Ok(Some(dir_utf8));
953            }
954            if let Ok(rel) = target.strip_prefix(&dst) {
955                return Ok(Some(dir_utf8.join(rel)));
956            }
957        }
958    }
959
960    Ok(None)
961}
962
963pub fn doctor(source: Option<Utf8PathBuf>) -> Result<()> {
964    let yui = YuiVars::detect(Utf8Path::new("."));
965    println!("yui doctor");
966    println!("==========");
967    println!("os:    {}", yui.os);
968    println!("arch:  {}", yui.arch);
969    println!("user:  {}", yui.user);
970    println!("host:  {}", yui.host);
971    match resolve_source(source) {
972        Ok(s) => {
973            println!("source: {s}");
974            // Probe: try loading config
975            match config::load(&s, &yui) {
976                Ok(cfg) => println!(
977                    "config: ok ({} mount entries, {} render rules)",
978                    cfg.mount.entry.len(),
979                    cfg.render.rule.len()
980                ),
981                Err(e) => println!("config: ERROR — {e}"),
982            }
983        }
984        Err(e) => println!("source: NOT FOUND — {e}"),
985    }
986    println!();
987    println!("link mode (auto resolves to):");
988    if cfg!(windows) {
989        println!("  files: hardlink");
990        println!("  dirs:  junction");
991    } else {
992        println!("  files: symlink");
993        println!("  dirs:  symlink");
994    }
995    Ok(())
996}
997
998pub fn gc_backup(_source: Option<Utf8PathBuf>, _older_than: Option<String>) -> Result<()> {
999    todo!("yui gc-backup — clean up old backups")
1000}
1001
1002/// `yui hooks list` — show every configured hook + its last-run state.
1003pub fn hooks_list(source: Option<Utf8PathBuf>) -> Result<()> {
1004    let source = resolve_source(source)?;
1005    let yui = YuiVars::detect(&source);
1006    let config = config::load(&source, &yui)?;
1007    let state = hook::State::load(&source)?;
1008
1009    if config.hook.is_empty() {
1010        println!("(no [[hook]] entries in config)");
1011        return Ok(());
1012    }
1013
1014    for h in &config.hook {
1015        let phase = match h.phase {
1016            HookPhase::Pre => "pre",
1017            HookPhase::Post => "post",
1018        };
1019        let when_run = match h.when_run {
1020            config::WhenRun::Once => "once",
1021            config::WhenRun::Onchange => "onchange",
1022            config::WhenRun::Every => "every",
1023        };
1024        let last = state
1025            .hooks
1026            .get(&h.name)
1027            .and_then(|s| s.last_run_at.as_deref())
1028            .unwrap_or("(never)");
1029        println!(
1030            "{name:<20}  phase={phase:<4}  when_run={when_run:<8}  last_run_at={last}",
1031            name = h.name,
1032        );
1033        if let Some(when) = &h.when {
1034            println!("                       when = {when}");
1035        }
1036        println!("                       script = {}", h.script);
1037        println!(
1038            "                       command = {} {}",
1039            h.command,
1040            h.args.join(" ")
1041        );
1042    }
1043    Ok(())
1044}
1045
1046/// `yui hooks run [<name>] [--force]` — run a single hook (or every
1047/// hook) on demand. `--force` bypasses the `when_run` state check;
1048/// the `when` filter (`yui.os == 'macos'` etc.) is always honored.
1049pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
1050    let source = resolve_source(source)?;
1051    let yui = YuiVars::detect(&source);
1052    let config = config::load(&source, &yui)?;
1053    let mut engine = template::Engine::new();
1054    let tera_ctx = template::template_context(&yui, &config.vars);
1055
1056    let targets: Vec<&config::HookConfig> = match &name {
1057        Some(want) => {
1058            let m = config
1059                .hook
1060                .iter()
1061                .find(|h| &h.name == want)
1062                .ok_or_else(|| {
1063                    anyhow::anyhow!(
1064                        "no [[hook]] named {want:?}; run `yui hooks list` to see available names"
1065                    )
1066                })?;
1067            vec![m]
1068        }
1069        None => config.hook.iter().collect(),
1070    };
1071
1072    let mut state = hook::State::load(&source)?;
1073    for h in targets {
1074        let outcome = hook::run_hook(
1075            h,
1076            &source,
1077            &yui,
1078            &config.vars,
1079            &mut engine,
1080            &tera_ctx,
1081            &mut state,
1082            /* dry_run */ false,
1083            force,
1084        )?;
1085        let label = match outcome {
1086            HookOutcome::Ran => "ran",
1087            HookOutcome::SkippedOnce => "skipped (once: already ran)",
1088            HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
1089            HookOutcome::SkippedWhenFalse => "skipped (when=false)",
1090            HookOutcome::DryRun => "would run (dry-run)",
1091        };
1092        info!("hook[{}]: {label}", h.name);
1093        if outcome == HookOutcome::Ran {
1094            state.save(&source)?;
1095        }
1096    }
1097    Ok(())
1098}
1099
1100// ---------------------------------------------------------------------------
1101// internals
1102// ---------------------------------------------------------------------------
1103
1104fn process_mount(
1105    source: &Utf8Path,
1106    m: &ResolvedMount,
1107    ctx: &ApplyCtx<'_>,
1108    engine: &mut template::Engine,
1109    tera_ctx: &TeraContext,
1110) -> Result<()> {
1111    let src_root = source.join(&m.src);
1112    if !src_root.is_dir() {
1113        warn!("mount src missing: {src_root}");
1114        return Ok(());
1115    }
1116    walk_and_link(&src_root, &m.dst, ctx, m.strategy, engine, tera_ctx)
1117}
1118
1119fn walk_and_link(
1120    src_dir: &Utf8Path,
1121    dst_dir: &Utf8Path,
1122    ctx: &ApplyCtx<'_>,
1123    strategy: MountStrategy,
1124    engine: &mut template::Engine,
1125    tera_ctx: &TeraContext,
1126) -> Result<()> {
1127    // `.yuiignore` short-circuit — entire subtrees that match are skipped
1128    // without even reading their marker / iterating their children.
1129    if paths::is_ignored(ctx.yuiignore, ctx.source, src_dir, /* is_dir */ true) {
1130        return Ok(());
1131    }
1132
1133    let marker_filename = &ctx.config.mount.marker_filename;
1134
1135    if strategy == MountStrategy::Marker {
1136        match marker::read_spec(src_dir, marker_filename)? {
1137            None => {} // no marker — fall through to recursive walk
1138            Some(MarkerSpec::PassThrough) => {
1139                link_dir_with_backup(src_dir, dst_dir, ctx)?;
1140                return Ok(());
1141            }
1142            Some(MarkerSpec::Override { links }) => {
1143                let mut linked_any = false;
1144                for link in &links {
1145                    // Nested ifs (not let-chains) so the crate's MSRV
1146                    // (rust-version = "1.85") stays buildable; let-chains
1147                    // were stabilized in 1.88.
1148                    if let Some(when) = &link.when {
1149                        if !template::eval_truthy(when, engine, tera_ctx)? {
1150                            continue;
1151                        }
1152                    }
1153                    let dst_str = engine.render(&link.dst, tera_ctx)?;
1154                    let dst = paths::expand_tilde(dst_str.trim());
1155                    link_dir_with_backup(src_dir, &dst, ctx)?;
1156                    linked_any = true;
1157                }
1158                if !linked_any {
1159                    info!("marker override at {src_dir} had no active links — skipping");
1160                }
1161                return Ok(());
1162            }
1163        }
1164    }
1165
1166    for entry in std::fs::read_dir(src_dir)? {
1167        let entry = entry?;
1168        let name_os = entry.file_name();
1169        let Some(name) = name_os.to_str() else {
1170            continue;
1171        };
1172        if name == marker_filename {
1173            continue;
1174        }
1175        if name.ends_with(".tera") {
1176            // Templates are handled by the render flow before linking.
1177            continue;
1178        }
1179        let src_path = src_dir.join(name);
1180        let dst_path = dst_dir.join(name);
1181        let ft = entry.file_type()?;
1182
1183        if paths::is_ignored(ctx.yuiignore, ctx.source, &src_path, ft.is_dir()) {
1184            continue;
1185        }
1186
1187        if ft.is_dir() {
1188            walk_and_link(&src_path, &dst_path, ctx, strategy, engine, tera_ctx)?;
1189        } else if ft.is_file() {
1190            link_file_with_backup(&src_path, &dst_path, ctx)?;
1191        }
1192    }
1193    Ok(())
1194}
1195
1196fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1197    use absorb::AbsorbDecision::*;
1198
1199    let decision = absorb::classify(src, dst)?;
1200
1201    if ctx.dry_run {
1202        info!("[dry-run] {decision:?}: {src} → {dst}");
1203        return Ok(());
1204    }
1205
1206    match decision {
1207        InSync => {
1208            // Link is intact (same inode/file-id). Nothing to do.
1209            Ok(())
1210        }
1211        Restore => {
1212            info!("link: {src} → {dst}");
1213            link::link_file(src, dst, ctx.file_mode)?;
1214            Ok(())
1215        }
1216        RelinkOnly => {
1217            // Same content, different inode (e.g. hardlink broken by an
1218            // editor's atomic save). Re-link without touching source.
1219            info!("relink: {src} → {dst}");
1220            link::unlink(dst)?;
1221            link::link_file(src, dst, ctx.file_mode)?;
1222            Ok(())
1223        }
1224        AutoAbsorb => {
1225            // Target newer + content differs: target wins, source updated.
1226            // Honor `[absorb] auto` (kill-switch) and `require_clean_git`.
1227            if !ctx.config.absorb.auto {
1228                return handle_anomaly(
1229                    src,
1230                    dst,
1231                    ctx,
1232                    "absorb.auto = false; treating divergence as anomaly",
1233                );
1234            }
1235            if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
1236                return handle_anomaly(
1237                    src,
1238                    dst,
1239                    ctx,
1240                    "source repo is dirty; deferring auto-absorb",
1241                );
1242            }
1243            absorb_target_into_source(src, dst, ctx)
1244        }
1245        NeedsConfirm => handle_anomaly(
1246            src,
1247            dst,
1248            ctx,
1249            "anomaly: source equals/newer than target but content differs",
1250        ),
1251    }
1252}
1253
1254/// Back up the source-side file, copy the target's content into source,
1255/// then re-link so the freshly-updated source is what target points at.
1256/// "Target wins" — yui's core philosophy.
1257fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1258    info!("absorb: {dst} → {src}");
1259    backup_existing(src, ctx.backup_root, /* is_dir */ false)?;
1260    std::fs::copy(dst, src)?;
1261    link::unlink(dst)?;
1262    link::link_file(src, dst, ctx.file_mode)?;
1263    Ok(())
1264}
1265
1266/// Decide what to do for an anomaly (NeedsConfirm or AutoAbsorb that was
1267/// escalated by `auto = false` / dirty git). Per `[absorb] on_anomaly`:
1268///   - `skip`  → log warning, leave target alone
1269///   - `force` → behave like AutoAbsorb (target wins)
1270///   - `ask`   → on a TTY, show diff + prompt. Off-TTY, downgrade to skip.
1271fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
1272    use crate::config::AnomalyAction::*;
1273    match ctx.config.absorb.on_anomaly {
1274        Skip => {
1275            warn!("anomaly skip: {dst} ({reason})");
1276            Ok(())
1277        }
1278        Force => {
1279            warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
1280            absorb_target_into_source(src, dst, ctx)
1281        }
1282        Ask => {
1283            use std::io::IsTerminal;
1284            if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
1285                if prompt_absorb_with_diff(src, dst, reason)? {
1286                    absorb_target_into_source(src, dst, ctx)
1287                } else {
1288                    warn!("anomaly skipped by user: {dst}");
1289                    Ok(())
1290                }
1291            } else {
1292                warn!("anomaly skip (non-TTY ask mode): {dst} ({reason})");
1293                Ok(())
1294            }
1295        }
1296    }
1297}
1298
1299fn prompt_absorb_with_diff(src: &Utf8Path, dst: &Utf8Path, reason: &str) -> Result<bool> {
1300    use std::io::Write as _;
1301    let src_content = std::fs::read_to_string(src).unwrap_or_default();
1302    let dst_content = std::fs::read_to_string(dst).unwrap_or_default();
1303    eprintln!();
1304    eprintln!("anomaly: {reason}");
1305    eprintln!("  src: {src}");
1306    eprintln!("  dst: {dst}");
1307    eprintln!();
1308    eprintln!("--- diff (- source, + target) ---");
1309    let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
1310    for change in diff.iter_all_changes() {
1311        let sign = match change.tag() {
1312            similar::ChangeTag::Delete => "-",
1313            similar::ChangeTag::Insert => "+",
1314            similar::ChangeTag::Equal => " ",
1315        };
1316        eprint!("{sign}{change}");
1317    }
1318    eprintln!();
1319    eprint!("absorb target into source? [y/N]: ");
1320    // Flush stderr (where the prompt was written) — flushing stdout was a
1321    // bug; on a buffered stderr (rare but possible) the prompt would be
1322    // hidden until after the user typed something. Caught in PR #15
1323    // review (gemini-code-assist).
1324    std::io::stderr().flush().ok();
1325    let mut input = String::new();
1326    std::io::stdin().read_line(&mut input)?;
1327    let answer = input.trim();
1328    Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
1329}
1330
1331/// Resilient git-clean check: if `git` isn't available or `source` isn't
1332/// a repo, log a warning and proceed as if clean. We don't want a missing
1333/// `git` to block apply — the require_clean_git knob is a *safety net*,
1334/// not a hard prerequisite.
1335fn source_repo_is_clean(source: &Utf8Path) -> bool {
1336    match crate::git::is_clean(source) {
1337        Ok(b) => b,
1338        Err(e) => {
1339            warn!("git clean check failed at {source}: {e} — treating as clean");
1340            true
1341        }
1342    }
1343}
1344
1345fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
1346    use absorb::AbsorbDecision::*;
1347    let decision = absorb::classify(src, dst)?;
1348
1349    if ctx.dry_run {
1350        info!("[dry-run] dir {decision:?}: {src} → {dst}");
1351        return Ok(());
1352    }
1353
1354    match decision {
1355        InSync => Ok(()),
1356        Restore => {
1357            info!("link dir: {src} → {dst}");
1358            link::link_dir(src, dst, ctx.dir_mode)?;
1359            Ok(())
1360        }
1361        _ => {
1362            // Directory drift: we don't deep-merge the contents (apps can
1363            // legitimately add files inside a junction'd dir, and yui has
1364            // no way to know which side is authoritative). Fall back to
1365            // backup + replace, the same behaviour as before the
1366            // absorb classifier landed.
1367            backup_existing(dst, ctx.backup_root, /* is_dir */ true)?;
1368            link::unlink(dst)?;
1369            info!("relink dir: {src} → {dst}");
1370            link::link_dir(src, dst, ctx.dir_mode)?;
1371            Ok(())
1372        }
1373    }
1374}
1375
1376fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
1377    let abs_target = absolutize(target)?;
1378    let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
1379    let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
1380    info!("backup → {bp}");
1381    if is_dir {
1382        backup::backup_dir(target, &bp)?;
1383    } else {
1384        backup::backup_file(target, &bp)?;
1385    }
1386    Ok(())
1387}
1388
1389fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
1390    if let Some(s) = source {
1391        return absolutize(&s);
1392    }
1393    if let Ok(s) = std::env::var("YUI_SOURCE") {
1394        return absolutize(Utf8Path::new(&s));
1395    }
1396    let cwd = current_dir_utf8()?;
1397    for ancestor in cwd.ancestors() {
1398        if ancestor.join("config.toml").is_file() {
1399            return Ok(ancestor.to_path_buf());
1400        }
1401    }
1402    if let Some(home) = paths::home_dir() {
1403        for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
1404            let p = home.join(c);
1405            if p.join("config.toml").is_file() {
1406                return Ok(p);
1407            }
1408        }
1409    }
1410    anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
1411}
1412
1413fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
1414    // Expand `~` first so callers can pass `--source ~/dotfiles` directly.
1415    let expanded = paths::expand_tilde(p.as_str());
1416    if expanded.is_absolute() {
1417        return Ok(expanded);
1418    }
1419    let cwd = current_dir_utf8()?;
1420    Ok(cwd.join(expanded))
1421}
1422
1423fn current_dir_utf8() -> Result<Utf8PathBuf> {
1424    let cwd = std::env::current_dir().context("getting cwd")?;
1425    Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
1426}
1427
1428// Note: `home_dir()` lives in `paths.rs` so the tilde-expansion helper and
1429// `resolve_source` share one HOME/USERPROFILE lookup.
1430
1431const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
1432
1433[vars]
1434# user-defined values; templates can reference these as {{ vars.foo }}
1435
1436# [link]
1437# file_mode = "auto"   # auto | symlink | hardlink
1438# dir_mode  = "auto"   # auto | symlink | junction
1439
1440[mount]
1441default_strategy = "marker"
1442
1443[[mount.entry]]
1444src = "home"
1445# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
1446dst = "~"
1447
1448# [[mount.entry]]
1449# src  = "appdata"
1450# dst  = "{{ env(name='APPDATA') }}"
1451# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
1452# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
1453# when = "yui.os == 'windows'"
1454"#;
1455
1456const SKELETON_GITIGNORE: &str = r#"# yui internals (regenerable, do not commit)
1457/.yui/
1458
1459# >>> yui rendered (auto-managed, do not edit) >>>
1460# <<< yui rendered (auto-managed) <<<
1461
1462# config.local.toml is per-machine; commit a config.local.example.toml instead.
1463config.local.toml
1464"#;
1465
1466#[cfg(test)]
1467mod tests {
1468    use super::*;
1469    use tempfile::TempDir;
1470
1471    fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
1472        Utf8PathBuf::from_path_buf(p).unwrap()
1473    }
1474
1475    /// Convert a path to a TOML-string-safe form (forward slashes).
1476    fn toml_path(p: &Utf8Path) -> String {
1477        p.as_str().replace('\\', "/")
1478    }
1479
1480    #[test]
1481    fn apply_links_a_raw_file() {
1482        let tmp = TempDir::new().unwrap();
1483        let source = utf8(tmp.path().join("dotfiles"));
1484        let target = utf8(tmp.path().join("target"));
1485        std::fs::create_dir_all(source.join("home")).unwrap();
1486        std::fs::create_dir_all(&target).unwrap();
1487        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
1488
1489        let cfg = format!(
1490            r#"
1491[[mount.entry]]
1492src = "home"
1493dst = "{}"
1494"#,
1495            toml_path(&target)
1496        );
1497        std::fs::write(source.join("config.toml"), cfg).unwrap();
1498
1499        apply(Some(source), false).unwrap();
1500
1501        let linked = target.join(".bashrc");
1502        assert!(linked.exists(), "expected {linked} to exist");
1503        assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
1504    }
1505
1506    #[test]
1507    fn apply_with_marker_links_whole_directory() {
1508        let tmp = TempDir::new().unwrap();
1509        let source = utf8(tmp.path().join("dotfiles"));
1510        let target = utf8(tmp.path().join("target"));
1511        let nvim_src = source.join("home/nvim");
1512        std::fs::create_dir_all(&nvim_src).unwrap();
1513        std::fs::create_dir_all(&target).unwrap();
1514        std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
1515        std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
1516        std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
1517
1518        let cfg = format!(
1519            r#"
1520[[mount.entry]]
1521src = "home"
1522dst = "{}"
1523"#,
1524            toml_path(&target)
1525        );
1526        std::fs::write(source.join("config.toml"), cfg).unwrap();
1527
1528        apply(Some(source.clone()), false).unwrap();
1529
1530        let nvim_dst = target.join("nvim");
1531        assert!(nvim_dst.exists());
1532        assert_eq!(
1533            std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
1534            "-- hi\n"
1535        );
1536        // Marker file itself shouldn't be visible as a separate link in target;
1537        // however with junction/symlink the whole dir shows up so the marker
1538        // file IS visible inside. That's fine — the marker is informational.
1539    }
1540
1541    #[test]
1542    fn apply_dry_run_does_not_write() {
1543        let tmp = TempDir::new().unwrap();
1544        let source = utf8(tmp.path().join("dotfiles"));
1545        let target = utf8(tmp.path().join("target"));
1546        std::fs::create_dir_all(source.join("home")).unwrap();
1547        std::fs::create_dir_all(&target).unwrap();
1548        std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
1549
1550        let cfg = format!(
1551            r#"
1552[[mount.entry]]
1553src = "home"
1554dst = "{}"
1555"#,
1556            toml_path(&target)
1557        );
1558        std::fs::write(source.join("config.toml"), cfg).unwrap();
1559
1560        apply(Some(source), true).unwrap();
1561
1562        assert!(!target.join(".bashrc").exists());
1563    }
1564
1565    #[test]
1566    fn apply_renders_templates_then_links_rendered_outputs() {
1567        let tmp = TempDir::new().unwrap();
1568        let source = utf8(tmp.path().join("dotfiles"));
1569        let target = utf8(tmp.path().join("target"));
1570        std::fs::create_dir_all(source.join("home")).unwrap();
1571        std::fs::create_dir_all(&target).unwrap();
1572        std::fs::write(
1573            source.join("home/.gitconfig.tera"),
1574            "[user]\n  os = {{ yui.os }}\n",
1575        )
1576        .unwrap();
1577        std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
1578
1579        let cfg = format!(
1580            r#"
1581[[mount.entry]]
1582src = "home"
1583dst = "{}"
1584"#,
1585            toml_path(&target)
1586        );
1587        std::fs::write(source.join("config.toml"), cfg).unwrap();
1588
1589        apply(Some(source.clone()), false).unwrap();
1590
1591        // Raw file: linked.
1592        assert!(target.join(".bashrc").exists());
1593        // Template's rendered output: written to source then linked.
1594        assert!(source.join("home/.gitconfig").exists());
1595        assert!(target.join(".gitconfig").exists());
1596        // The .tera file itself is never linked into target.
1597        assert!(!target.join(".gitconfig.tera").exists());
1598        // Rendered file content carries the yui.os substitution.
1599        let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
1600        assert!(linked.contains("os = "));
1601    }
1602
1603    #[test]
1604    fn apply_marker_override_links_to_custom_dst() {
1605        let tmp = TempDir::new().unwrap();
1606        let source = utf8(tmp.path().join("dotfiles"));
1607        let target_a = utf8(tmp.path().join("target_a"));
1608        let target_b = utf8(tmp.path().join("target_b"));
1609        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
1610        std::fs::create_dir_all(&target_a).unwrap();
1611        std::fs::create_dir_all(&target_b).unwrap();
1612        std::fs::write(
1613            source.join("home/.config/nvim/init.lua"),
1614            "-- nvim config\n",
1615        )
1616        .unwrap();
1617
1618        // Marker tells yui to ignore the parent mount's dst for this dir
1619        // and link it to two custom places (the second only if condition matches).
1620        std::fs::write(
1621            source.join("home/.config/nvim/.yuilink"),
1622            format!(
1623                r#"
1624[[link]]
1625dst = "{}/nvim"
1626
1627[[link]]
1628dst = "{}/nvim"
1629when = "{{{{ yui.os == '{}' }}}}"
1630"#,
1631                toml_path(&target_a),
1632                toml_path(&target_b),
1633                std::env::consts::OS
1634            ),
1635        )
1636        .unwrap();
1637
1638        let parent_target = utf8(tmp.path().join("parent_target"));
1639        std::fs::create_dir_all(&parent_target).unwrap();
1640        let cfg = format!(
1641            r#"
1642[[mount.entry]]
1643src = "home"
1644dst = "{}"
1645"#,
1646            toml_path(&parent_target)
1647        );
1648        std::fs::write(source.join("config.toml"), cfg).unwrap();
1649
1650        apply(Some(source.clone()), false).unwrap();
1651
1652        // Both override targets received the link (the second's when matches OS).
1653        assert!(
1654            target_a.join("nvim/init.lua").exists(),
1655            "target_a/nvim/init.lua should be reachable through the link"
1656        );
1657        assert!(
1658            target_b.join("nvim/init.lua").exists(),
1659            "target_b/nvim/init.lua should be reachable through the link"
1660        );
1661        // Parent mount did NOT also link this dir (it would have appeared at
1662        // parent_target/.config/nvim — the marker claims the dir).
1663        assert!(
1664            !parent_target.join(".config/nvim").exists(),
1665            "parent mount should have skipped the marker-claimed sub-dir"
1666        );
1667    }
1668
1669    #[test]
1670    fn apply_marker_override_skips_inactive_link() {
1671        let tmp = TempDir::new().unwrap();
1672        let source = utf8(tmp.path().join("dotfiles"));
1673        let target_inactive = utf8(tmp.path().join("inactive"));
1674        let parent_target = utf8(tmp.path().join("parent"));
1675        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
1676        std::fs::create_dir_all(&parent_target).unwrap();
1677        std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
1678
1679        // when=false on every link → marker has no active links.
1680        std::fs::write(
1681            source.join("home/.config/nvim/.yuilink"),
1682            format!(
1683                r#"
1684[[link]]
1685dst = "{}/nvim"
1686when = "{{{{ yui.os == 'no-such-os' }}}}"
1687"#,
1688                toml_path(&target_inactive)
1689            ),
1690        )
1691        .unwrap();
1692
1693        let cfg = format!(
1694            r#"
1695[[mount.entry]]
1696src = "home"
1697dst = "{}"
1698"#,
1699            toml_path(&parent_target)
1700        );
1701        std::fs::write(source.join("config.toml"), cfg).unwrap();
1702
1703        apply(Some(source.clone()), false).unwrap();
1704
1705        // Inactive target untouched.
1706        assert!(!target_inactive.join("nvim").exists());
1707        // Parent mount also skipped the dir (marker claims it even when
1708        // all links are inactive — the user's intent was per-dir override).
1709        assert!(!parent_target.join(".config/nvim").exists());
1710    }
1711
1712    #[test]
1713    fn list_shows_mount_entries_and_marker_overrides() {
1714        let tmp = TempDir::new().unwrap();
1715        let source = utf8(tmp.path().join("dotfiles"));
1716        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
1717        std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
1718        std::fs::write(
1719            source.join("home/.config/nvim/.yuilink"),
1720            r#"
1721[[link]]
1722dst = "/custom/nvim"
1723"#,
1724        )
1725        .unwrap();
1726        std::fs::write(
1727            source.join("config.toml"),
1728            r#"
1729[[mount.entry]]
1730src = "home"
1731dst = "/h"
1732"#,
1733        )
1734        .unwrap();
1735
1736        // Just verify it runs without error — output format is covered by
1737        // unit-level helpers below.
1738        list(Some(source), false, None, true).unwrap();
1739    }
1740
1741    #[test]
1742    fn status_reports_in_sync_after_apply() {
1743        let tmp = TempDir::new().unwrap();
1744        let source = utf8(tmp.path().join("dotfiles"));
1745        let target = utf8(tmp.path().join("target"));
1746        std::fs::create_dir_all(source.join("home")).unwrap();
1747        std::fs::create_dir_all(&target).unwrap();
1748        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
1749        let cfg = format!(
1750            r#"
1751[[mount.entry]]
1752src = "home"
1753dst = "{}"
1754"#,
1755            toml_path(&target)
1756        );
1757        std::fs::write(source.join("config.toml"), cfg).unwrap();
1758        // First link the target so the link is intact.
1759        apply(Some(source.clone()), false).unwrap();
1760        // status should succeed (everything in-sync).
1761        status(Some(source), None, true).unwrap();
1762    }
1763
1764    #[test]
1765    fn status_reports_template_drift() {
1766        let tmp = TempDir::new().unwrap();
1767        let source = utf8(tmp.path().join("dotfiles"));
1768        let target = utf8(tmp.path().join("target"));
1769        std::fs::create_dir_all(source.join("home")).unwrap();
1770        std::fs::create_dir_all(&target).unwrap();
1771        // Template would render to "fresh" but the rendered file on disk
1772        // says "stale" — simulating a manual edit not reflected back.
1773        std::fs::write(source.join("home/.gitconfig.tera"), "fresh").unwrap();
1774        std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
1775
1776        let cfg = format!(
1777            r#"
1778[[mount.entry]]
1779src = "home"
1780dst = "{}"
1781"#,
1782            toml_path(&target)
1783        );
1784        std::fs::write(source.join("config.toml"), cfg).unwrap();
1785
1786        let err = status(Some(source), None, true).unwrap_err();
1787        assert!(format!("{err}").contains("diverged"));
1788    }
1789
1790    #[test]
1791    fn status_fails_when_target_missing() {
1792        let tmp = TempDir::new().unwrap();
1793        let source = utf8(tmp.path().join("dotfiles"));
1794        let target = utf8(tmp.path().join("target"));
1795        std::fs::create_dir_all(source.join("home")).unwrap();
1796        std::fs::create_dir_all(&target).unwrap();
1797        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
1798        let cfg = format!(
1799            r#"
1800[[mount.entry]]
1801src = "home"
1802dst = "{}"
1803"#,
1804            toml_path(&target)
1805        );
1806        std::fs::write(source.join("config.toml"), cfg).unwrap();
1807        // No apply yet — target/.bashrc doesn't exist.
1808        let err = status(Some(source), None, true).unwrap_err();
1809        assert!(format!("{err}").contains("diverged"));
1810    }
1811
1812    #[test]
1813    fn strip_braces_removes_outer_template_braces() {
1814        assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
1815        assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
1816        assert_eq!(strip_braces("  {{x}}  "), "x");
1817    }
1818
1819    #[test]
1820    fn apply_aborts_on_render_drift() {
1821        let tmp = TempDir::new().unwrap();
1822        let source = utf8(tmp.path().join("dotfiles"));
1823        let target = utf8(tmp.path().join("target"));
1824        std::fs::create_dir_all(source.join("home")).unwrap();
1825        std::fs::create_dir_all(&target).unwrap();
1826        std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
1827        std::fs::write(source.join("home/foo"), "manually edited").unwrap();
1828
1829        let cfg = format!(
1830            r#"
1831[[mount.entry]]
1832src = "home"
1833dst = "{}"
1834"#,
1835            toml_path(&target)
1836        );
1837        std::fs::write(source.join("config.toml"), cfg).unwrap();
1838
1839        let err = apply(Some(source.clone()), false).unwrap_err();
1840        assert!(format!("{err}").contains("drift"));
1841        // Existing rendered file untouched.
1842        assert_eq!(
1843            std::fs::read_to_string(source.join("home/foo")).unwrap(),
1844            "manually edited"
1845        );
1846        // Linking aborted — target empty.
1847        assert!(!target.join("foo").exists());
1848    }
1849
1850    #[test]
1851    fn init_creates_skeleton_when_dir_empty() {
1852        let tmp = TempDir::new().unwrap();
1853        let dir = utf8(tmp.path().join("new_dotfiles"));
1854        init(Some(dir.clone()), false).unwrap();
1855        assert!(dir.join("config.toml").is_file());
1856        assert!(dir.join(".gitignore").is_file());
1857    }
1858
1859    #[test]
1860    fn init_refuses_to_overwrite_existing_config() {
1861        let tmp = TempDir::new().unwrap();
1862        let dir = utf8(tmp.path().join("dotfiles"));
1863        std::fs::create_dir_all(&dir).unwrap();
1864        std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
1865        let err = init(Some(dir), false).unwrap_err();
1866        assert!(format!("{err}").contains("already exists"));
1867    }
1868
1869    /// Build a minimal `apply`-able dotfiles tree for absorb tests.
1870    /// Returns (source_dir, target_dir).
1871    fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
1872        let source = utf8(tmp.path().join("dotfiles"));
1873        let target = utf8(tmp.path().join("target"));
1874        std::fs::create_dir_all(source.join("home")).unwrap();
1875        std::fs::create_dir_all(&target).unwrap();
1876        let cfg = format!(
1877            r#"
1878[[mount.entry]]
1879src = "home"
1880dst = "{}"
1881"#,
1882            toml_path(&target)
1883        );
1884        std::fs::write(source.join("config.toml"), cfg).unwrap();
1885        (source, target)
1886    }
1887
1888    fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
1889        std::fs::write(path, body).unwrap();
1890        let f = std::fs::OpenOptions::new()
1891            .write(true)
1892            .open(path)
1893            .expect("open writable");
1894        f.set_modified(when).expect("set_modified");
1895    }
1896
1897    #[test]
1898    fn apply_target_newer_absorbs_target_into_source() {
1899        // Target has the user's edit and is mtime-newer than source —
1900        // classifier returns `AutoAbsorb`. yui's "target-as-truth"
1901        // philosophy: target wins, source is updated and backed up.
1902        let tmp = TempDir::new().unwrap();
1903        let (source, target) = setup_minimal_dotfiles(&tmp);
1904
1905        let now = std::time::SystemTime::now();
1906        let past = now - std::time::Duration::from_secs(120);
1907        write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
1908        // Pre-existing target with user's edit, NEWER mtime.
1909        write_with_mtime(&target.join(".bashrc"), "user's edit", now);
1910
1911        apply(Some(source.clone()), false).unwrap();
1912
1913        // Target's content survives — that's the whole point.
1914        assert_eq!(
1915            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
1916            "user's edit"
1917        );
1918        // Source has been updated to match target.
1919        assert_eq!(
1920            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
1921            "user's edit"
1922        );
1923        // Source's previous content lives under .yui/backup.
1924        let backup_root = source.join(".yui/backup");
1925        let mut found_old = false;
1926        for entry in walkdir(&backup_root) {
1927            if let Ok(s) = std::fs::read_to_string(&entry) {
1928                if s == "default from repo" {
1929                    found_old = true;
1930                    break;
1931                }
1932            }
1933        }
1934        assert!(found_old, "expected backup containing 'default from repo'");
1935    }
1936
1937    #[test]
1938    fn apply_in_sync_target_is_a_no_op() {
1939        // After an initial `apply`, running `apply` again classifies as
1940        // `InSync` and does nothing.
1941        let tmp = TempDir::new().unwrap();
1942        let (source, target) = setup_minimal_dotfiles(&tmp);
1943        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
1944        apply(Some(source.clone()), false).unwrap();
1945        let backup_root = source.join(".yui/backup");
1946        let backup_count_after_first = walkdir(&backup_root).len();
1947
1948        // Second apply — nothing should change.
1949        apply(Some(source.clone()), false).unwrap();
1950        assert_eq!(
1951            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
1952            "echo hi\n"
1953        );
1954        let backup_count_after_second = walkdir(&backup_root).len();
1955        assert_eq!(
1956            backup_count_after_first, backup_count_after_second,
1957            "second apply on an in-sync tree should not produce backups"
1958        );
1959    }
1960
1961    #[test]
1962    fn apply_skip_policy_leaves_anomaly_alone() {
1963        // Source newer than target + content differs = NeedsConfirm.
1964        // With on_anomaly = "skip", target stays untouched.
1965        let tmp = TempDir::new().unwrap();
1966        let source = utf8(tmp.path().join("dotfiles"));
1967        let target = utf8(tmp.path().join("target"));
1968        std::fs::create_dir_all(source.join("home")).unwrap();
1969        std::fs::create_dir_all(&target).unwrap();
1970        let cfg = format!(
1971            r#"
1972[absorb]
1973on_anomaly = "skip"
1974
1975[[mount.entry]]
1976src = "home"
1977dst = "{}"
1978"#,
1979            toml_path(&target)
1980        );
1981        std::fs::write(source.join("config.toml"), cfg).unwrap();
1982
1983        let now = std::time::SystemTime::now();
1984        let past = now - std::time::Duration::from_secs(120);
1985        write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
1986        write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
1987
1988        apply(Some(source.clone()), false).unwrap();
1989
1990        // Target untouched (skip policy honored).
1991        assert_eq!(
1992            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
1993            "user's edit (older)"
1994        );
1995        // Source untouched too.
1996        assert_eq!(
1997            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
1998            "fresh from upstream"
1999        );
2000    }
2001
2002    #[test]
2003    fn apply_force_policy_absorbs_anomaly_anyway() {
2004        // Same anomaly setup, but on_anomaly = "force" → target wins.
2005        let tmp = TempDir::new().unwrap();
2006        let source = utf8(tmp.path().join("dotfiles"));
2007        let target = utf8(tmp.path().join("target"));
2008        std::fs::create_dir_all(source.join("home")).unwrap();
2009        std::fs::create_dir_all(&target).unwrap();
2010        let cfg = format!(
2011            r#"
2012[absorb]
2013on_anomaly = "force"
2014
2015[[mount.entry]]
2016src = "home"
2017dst = "{}"
2018"#,
2019            toml_path(&target)
2020        );
2021        std::fs::write(source.join("config.toml"), cfg).unwrap();
2022
2023        let now = std::time::SystemTime::now();
2024        let past = now - std::time::Duration::from_secs(120);
2025        write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
2026        write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
2027
2028        apply(Some(source.clone()), false).unwrap();
2029
2030        // Target wins despite being mtime-older — force policy.
2031        assert_eq!(
2032            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
2033            "user's edit (older)"
2034        );
2035        assert_eq!(
2036            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2037            "user's edit (older)"
2038        );
2039    }
2040
2041    #[test]
2042    fn manual_absorb_command_pulls_target_into_source() {
2043        // Manual `yui absorb <target>` bypasses policy + git checks.
2044        let tmp = TempDir::new().unwrap();
2045        let source = utf8(tmp.path().join("dotfiles"));
2046        let target = utf8(tmp.path().join("target"));
2047        std::fs::create_dir_all(source.join("home")).unwrap();
2048        std::fs::create_dir_all(&target).unwrap();
2049        // on_anomaly = "skip" so passive `apply` would NOT touch this.
2050        let cfg = format!(
2051            r#"
2052[absorb]
2053on_anomaly = "skip"
2054
2055[[mount.entry]]
2056src = "home"
2057dst = "{}"
2058"#,
2059            toml_path(&target)
2060        );
2061        std::fs::write(source.join("config.toml"), cfg).unwrap();
2062        std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
2063        std::fs::write(source.join("home/.bashrc"), "default").unwrap();
2064
2065        // Run absorb directly on the target.
2066        absorb(
2067            Some(source.clone()),
2068            target.join(".bashrc"),
2069            /* dry_run */ false,
2070        )
2071        .unwrap();
2072
2073        // Source picked up target's content (manual absorb is forceful).
2074        assert_eq!(
2075            std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
2076            "user picked this"
2077        );
2078    }
2079
2080    #[test]
2081    fn manual_absorb_errors_when_target_outside_known_mounts() {
2082        let tmp = TempDir::new().unwrap();
2083        let (source, _target) = setup_minimal_dotfiles(&tmp);
2084        std::fs::write(source.join("home/.bashrc"), "x").unwrap();
2085        let stranger = utf8(tmp.path().join("not-managed/foo"));
2086        std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
2087        std::fs::write(&stranger, "not yui's").unwrap();
2088        let err = absorb(Some(source), stranger, false).unwrap_err();
2089        assert!(format!("{err}").contains("no mount entry"));
2090    }
2091
2092    #[test]
2093    fn yuiignore_excludes_file_from_linking() {
2094        let tmp = TempDir::new().unwrap();
2095        let (source, target) = setup_minimal_dotfiles(&tmp);
2096        std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
2097        std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
2098        // Exclude `lock.json` files anywhere under source.
2099        std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
2100        apply(Some(source.clone()), false).unwrap();
2101        assert!(target.join(".bashrc").exists());
2102        assert!(
2103            !target.join("lock.json").exists(),
2104            "yuiignore should keep lock.json out of target"
2105        );
2106    }
2107
2108    #[test]
2109    fn yuiignore_excludes_directory_subtree() {
2110        let tmp = TempDir::new().unwrap();
2111        let (source, target) = setup_minimal_dotfiles(&tmp);
2112        std::fs::create_dir_all(source.join("home/cache")).unwrap();
2113        std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
2114        std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
2115        std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
2116        // Trailing slash → match dirs only; entire subtree skipped.
2117        std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
2118        apply(Some(source.clone()), false).unwrap();
2119        assert!(target.join(".bashrc").exists());
2120        assert!(
2121            !target.join("cache").exists(),
2122            "yuiignore'd subtree should not appear in target"
2123        );
2124    }
2125
2126    #[test]
2127    fn yuiignore_negation_re_includes_file() {
2128        let tmp = TempDir::new().unwrap();
2129        let (source, target) = setup_minimal_dotfiles(&tmp);
2130        std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
2131        std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
2132        // Ignore all .cache files except keep.cache.
2133        std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
2134        apply(Some(source.clone()), false).unwrap();
2135        assert!(target.join("keep.cache").exists());
2136        assert!(!target.join("drop.cache").exists());
2137    }
2138
2139    #[test]
2140    fn yuiignore_skips_template_in_render() {
2141        let tmp = TempDir::new().unwrap();
2142        let source = utf8(tmp.path().join("dotfiles"));
2143        let target = utf8(tmp.path().join("target"));
2144        std::fs::create_dir_all(source.join("home")).unwrap();
2145        std::fs::create_dir_all(&target).unwrap();
2146        std::fs::write(source.join("home/note.tera"), "{{ yui.os }}").unwrap();
2147        std::fs::write(source.join(".yuiignore"), "home/note*\n").unwrap();
2148        let cfg = format!(
2149            r#"
2150[[mount.entry]]
2151src = "home"
2152dst = "{}"
2153"#,
2154            toml_path(&target)
2155        );
2156        std::fs::write(source.join("config.toml"), cfg).unwrap();
2157        apply(Some(source.clone()), false).unwrap();
2158        // Neither the template nor the rendered output linked.
2159        assert!(!source.join("home/note").exists());
2160        assert!(!target.join("note").exists());
2161        assert!(!target.join("note.tera").exists());
2162    }
2163
2164    fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
2165        let mut out = Vec::new();
2166        let mut stack = vec![root.to_path_buf()];
2167        while let Some(dir) = stack.pop() {
2168            let Ok(entries) = std::fs::read_dir(&dir) else {
2169                continue;
2170            };
2171            for e in entries.flatten() {
2172                let p = utf8(e.path());
2173                if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
2174                    stack.push(p);
2175                } else {
2176                    out.push(p);
2177                }
2178            }
2179        }
2180        out
2181    }
2182}