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