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::{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        file_mode: resolve_file_mode(config.link.file_mode),
79        dir_mode: resolve_dir_mode(config.link.dir_mode),
80        backup_root: &backup_root,
81        dry_run,
82    };
83
84    info!("source: {source}");
85    info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
86    if dry_run {
87        info!("dry-run: nothing will be written");
88    }
89
90    for m in &mounts {
91        info!("mount: {} → {}", m.src, m.dst);
92        process_mount(&source, m, &ctx, &mut engine, &tera_ctx)?;
93    }
94    Ok(())
95}
96
97fn log_render_report(r: &RenderReport) {
98    if !r.written.is_empty() {
99        info!("rendered {} new file(s)", r.written.len());
100    }
101    if !r.unchanged.is_empty() {
102        info!("rendered {} file(s) unchanged", r.unchanged.len());
103    }
104    if !r.skipped_when_false.is_empty() {
105        info!(
106            "skipped {} template(s) (when=false)",
107            r.skipped_when_false.len()
108        );
109    }
110    for d in &r.diverged {
111        warn!("rendered file diverged from template: {d}");
112    }
113}
114
115/// Bundle of immutable settings threaded through the apply walk.
116struct ApplyCtx<'a> {
117    config: &'a Config,
118    file_mode: EffectiveFileMode,
119    dir_mode: EffectiveDirMode,
120    backup_root: &'a Utf8Path,
121    dry_run: bool,
122}
123
124/// Show the resolved src→dst mappings for the current source repo.
125///
126/// By default only entries whose `when` matches the current host are shown
127/// (`active`). With `--all`, inactive entries are included with a dim row
128/// and the `when` condition that excluded them.
129pub fn list(
130    source: Option<Utf8PathBuf>,
131    all: bool,
132    icons_override: Option<IconsMode>,
133    no_color: bool,
134) -> Result<()> {
135    let source = resolve_source(source)?;
136    let yui = YuiVars::detect(&source);
137    let config = config::load(&source, &yui)?;
138
139    let icons_mode = icons_override.unwrap_or(config.ui.icons);
140    let icons = Icons::for_mode(icons_mode);
141    let color = !no_color && supports_color_stdout();
142
143    let items = collect_list_items(&source, &config, &yui)?;
144    let displayed: Vec<&ListItem> = if all {
145        items.iter().collect()
146    } else {
147        items.iter().filter(|i| i.active).collect()
148    };
149
150    print_list_table(&displayed, icons, color);
151
152    let total = items.len();
153    let active = items.iter().filter(|i| i.active).count();
154    let inactive = total - active;
155    println!();
156    if all {
157        println!("  {total} entries · {active} active · {inactive} inactive");
158    } else {
159        println!(
160            "  {} of {} entries shown ({} inactive hidden — use --all)",
161            active, total, inactive
162        );
163    }
164    Ok(())
165}
166
167#[derive(Debug)]
168struct ListItem {
169    src: Utf8PathBuf,
170    dst: String,
171    when: Option<String>,
172    active: bool,
173}
174
175fn collect_list_items(source: &Utf8Path, config: &Config, yui: &YuiVars) -> Result<Vec<ListItem>> {
176    let mut engine = template::Engine::new();
177    let tera_ctx = template::template_context(yui, &config.vars);
178    let mut items = Vec::new();
179
180    // 1. config.toml [[mount.entry]] entries
181    for entry in &config.mount.entry {
182        let active = match &entry.when {
183            None => true,
184            Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
185        };
186        let dst = engine
187            .render(&entry.dst, &tera_ctx)
188            .map(|s| s.trim().to_string())
189            .unwrap_or_else(|_| entry.dst.clone());
190        items.push(ListItem {
191            src: entry.src.clone(),
192            dst,
193            when: entry.when.clone(),
194            active,
195        });
196    }
197
198    // 2. .yuilink overrides under source
199    let walker = ignore::WalkBuilder::new(source)
200        .hidden(false)
201        .git_ignore(false)
202        .ignore(false)
203        .build();
204    let marker_filename = &config.mount.marker_filename;
205    for entry in walker {
206        let entry = match entry {
207            Ok(e) => e,
208            Err(_) => continue,
209        };
210        if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
211            continue;
212        }
213        if entry.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
214            continue;
215        }
216        let dir = match entry.path().parent() {
217            Some(d) => d,
218            None => continue,
219        };
220        let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
221            Ok(p) => p,
222            Err(_) => continue,
223        };
224        let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
225            Some(s) => s,
226            None => continue,
227        };
228        let MarkerSpec::Override { links } = spec else {
229            continue; // PassThrough markers are already implied by mount entry
230        };
231        let rel = dir_utf8
232            .strip_prefix(source)
233            .map(Utf8PathBuf::from)
234            .unwrap_or(dir_utf8);
235        for link in &links {
236            let active = match &link.when {
237                None => true,
238                Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
239            };
240            let dst = engine
241                .render(&link.dst, &tera_ctx)
242                .map(|s| s.trim().to_string())
243                .unwrap_or_else(|_| link.dst.clone());
244            items.push(ListItem {
245                src: rel.clone(),
246                dst,
247                when: link.when.clone(),
248                active,
249            });
250        }
251    }
252
253    items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
254    Ok(items)
255}
256
257fn supports_color_stdout() -> bool {
258    use std::io::IsTerminal;
259    std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
260}
261
262fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
263    let src_w = items
264        .iter()
265        .map(|i| i.src.as_str().chars().count())
266        .max()
267        .unwrap_or(0)
268        .max("SRC".len());
269    let dst_w = items
270        .iter()
271        .map(|i| i.dst.chars().count())
272        .max()
273        .unwrap_or(0)
274        .max("DST".len());
275
276    let status_w = "STATUS".len();
277    let arrow_w = icons.arrow.chars().count();
278
279    // Header
280    print_header(status_w, src_w, arrow_w, dst_w, color);
281
282    // Separator
283    let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
284    if color {
285        use owo_colors::OwoColorize as _;
286        println!("{}", sep.dimmed());
287    } else {
288        println!("{sep}");
289    }
290
291    // Rows
292    for item in items {
293        print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
294    }
295}
296
297fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
298    use owo_colors::OwoColorize as _;
299    let mut line = String::new();
300    let _ = write!(
301        &mut line,
302        "  {:<status_w$}  {:<src_w$}  {:<arrow_w$}  {:<dst_w$}  WHEN",
303        "STATUS", "SRC", "", "DST"
304    );
305    if color {
306        println!("{}", line.bold());
307    } else {
308        println!("{line}");
309    }
310}
311
312fn render_separator(
313    sep_ch: char,
314    status_w: usize,
315    src_w: usize,
316    arrow_w: usize,
317    dst_w: usize,
318) -> String {
319    let bar = |n: usize| sep_ch.to_string().repeat(n);
320    format!(
321        "  {}  {}  {}  {}  {}",
322        bar(status_w),
323        bar(src_w),
324        bar(arrow_w),
325        bar(dst_w),
326        bar("WHEN".len())
327    )
328}
329
330fn print_row(
331    item: &ListItem,
332    icons: Icons,
333    status_w: usize,
334    src_w: usize,
335    arrow_w: usize,
336    dst_w: usize,
337    color: bool,
338) {
339    use owo_colors::OwoColorize as _;
340    let status = if item.active {
341        icons.active
342    } else {
343        icons.inactive
344    };
345    let when_str = item
346        .when
347        .as_deref()
348        .map(strip_braces)
349        .unwrap_or_else(|| "(always)".to_string());
350
351    // Normalize backslashes to forward slashes for cross-platform display.
352    let src_display = item.src.as_str().replace('\\', "/");
353    let src = src_display.as_str();
354    let dst = &item.dst;
355    let arrow = icons.arrow;
356
357    // Pad each cell to its column width FIRST, then apply color. Doing it
358    // the other way round lets ANSI escape codes count as printable chars
359    // in `format!("{:<w$}")`, which silently breaks alignment when colors
360    // are enabled (caught in PR #11 review).
361    let cell_status = format!("{:<status_w$}", status);
362    let cell_src = format!("{:<src_w$}", src);
363    let cell_arrow = format!("{:<arrow_w$}", arrow);
364    let cell_dst = format!("{:<dst_w$}", dst);
365
366    if !color {
367        println!("  {cell_status}  {cell_src}  {cell_arrow}  {cell_dst}  {when_str}");
368        return;
369    }
370
371    if item.active {
372        println!(
373            "  {}  {}  {}  {}  {}",
374            cell_status.green(),
375            cell_src.cyan(),
376            cell_arrow.dimmed(),
377            cell_dst.green(),
378            when_str.dimmed()
379        );
380    } else {
381        println!(
382            "  {}  {}  {}  {}  {}",
383            cell_status.red().dimmed(),
384            cell_src.dimmed(),
385            cell_arrow.dimmed(),
386            cell_dst.dimmed(),
387            when_str.dimmed()
388        );
389    }
390}
391
392/// Strip the outer `{{ ... }}` Tera braces from a `when` expression for
393/// display purposes (shorter line, easier to read at a glance).
394fn strip_braces(expr: &str) -> String {
395    let trimmed = expr.trim();
396    if let Some(inner) = trimmed
397        .strip_prefix("{{")
398        .and_then(|s| s.strip_suffix("}}"))
399    {
400        inner.trim().to_string()
401    } else {
402        trimmed.to_string()
403    }
404}
405
406pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
407    let source = resolve_source(source)?;
408    let yui = YuiVars::detect(&source);
409    let config = config::load(&source, &yui)?;
410    // --check is a stricter dry-run: never writes, exits non-zero on drift.
411    let report = render::render_all(&source, &config, &yui, dry_run || check)?;
412    log_render_report(&report);
413    if check && report.has_drift() {
414        anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
415    }
416    Ok(())
417}
418
419pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
420    // For now `link` and `apply` do the same thing (no render/absorb yet).
421    apply(source, dry_run)
422}
423
424pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
425    let _source = resolve_source(source)?;
426    if paths_arg.is_empty() {
427        anyhow::bail!("yui unlink: provide at least one target path");
428    }
429    for p in paths_arg {
430        let abs = absolutize(&p)?;
431        info!("unlink: {abs}");
432        link::unlink(&abs)?;
433    }
434    Ok(())
435}
436
437pub fn status(_source: Option<Utf8PathBuf>) -> Result<()> {
438    todo!("yui status — drift detection (needs absorb classifier)")
439}
440
441pub fn absorb(_source: Option<Utf8PathBuf>, _target: Utf8PathBuf, _dry_run: bool) -> Result<()> {
442    todo!("yui absorb — manual absorb (needs absorb classifier)")
443}
444
445pub fn doctor(source: Option<Utf8PathBuf>) -> Result<()> {
446    let yui = YuiVars::detect(Utf8Path::new("."));
447    println!("yui doctor");
448    println!("==========");
449    println!("os:    {}", yui.os);
450    println!("arch:  {}", yui.arch);
451    println!("user:  {}", yui.user);
452    println!("host:  {}", yui.host);
453    match resolve_source(source) {
454        Ok(s) => {
455            println!("source: {s}");
456            // Probe: try loading config
457            match config::load(&s, &yui) {
458                Ok(cfg) => println!(
459                    "config: ok ({} mount entries, {} render rules)",
460                    cfg.mount.entry.len(),
461                    cfg.render.rule.len()
462                ),
463                Err(e) => println!("config: ERROR — {e}"),
464            }
465        }
466        Err(e) => println!("source: NOT FOUND — {e}"),
467    }
468    println!();
469    println!("link mode (auto resolves to):");
470    if cfg!(windows) {
471        println!("  files: hardlink");
472        println!("  dirs:  junction");
473    } else {
474        println!("  files: symlink");
475        println!("  dirs:  symlink");
476    }
477    Ok(())
478}
479
480pub fn gc_backup(_source: Option<Utf8PathBuf>, _older_than: Option<String>) -> Result<()> {
481    todo!("yui gc-backup — clean up old backups")
482}
483
484// ---------------------------------------------------------------------------
485// internals
486// ---------------------------------------------------------------------------
487
488fn process_mount(
489    source: &Utf8Path,
490    m: &ResolvedMount,
491    ctx: &ApplyCtx<'_>,
492    engine: &mut template::Engine,
493    tera_ctx: &TeraContext,
494) -> Result<()> {
495    let src_root = source.join(&m.src);
496    if !src_root.is_dir() {
497        warn!("mount src missing: {src_root}");
498        return Ok(());
499    }
500    walk_and_link(&src_root, &m.dst, ctx, m.strategy, engine, tera_ctx)
501}
502
503fn walk_and_link(
504    src_dir: &Utf8Path,
505    dst_dir: &Utf8Path,
506    ctx: &ApplyCtx<'_>,
507    strategy: MountStrategy,
508    engine: &mut template::Engine,
509    tera_ctx: &TeraContext,
510) -> Result<()> {
511    let marker_filename = &ctx.config.mount.marker_filename;
512
513    if strategy == MountStrategy::Marker {
514        match marker::read_spec(src_dir, marker_filename)? {
515            None => {} // no marker — fall through to recursive walk
516            Some(MarkerSpec::PassThrough) => {
517                link_dir_with_backup(src_dir, dst_dir, ctx)?;
518                return Ok(());
519            }
520            Some(MarkerSpec::Override { links }) => {
521                let mut linked_any = false;
522                for link in &links {
523                    // Nested ifs (not let-chains) so the crate's MSRV
524                    // (rust-version = "1.85") stays buildable; let-chains
525                    // were stabilized in 1.88.
526                    if let Some(when) = &link.when {
527                        if !template::eval_truthy(when, engine, tera_ctx)? {
528                            continue;
529                        }
530                    }
531                    let dst_str = engine.render(&link.dst, tera_ctx)?;
532                    let dst = Utf8PathBuf::from(dst_str.trim());
533                    link_dir_with_backup(src_dir, &dst, ctx)?;
534                    linked_any = true;
535                }
536                if !linked_any {
537                    info!("marker override at {src_dir} had no active links — skipping");
538                }
539                return Ok(());
540            }
541        }
542    }
543
544    for entry in std::fs::read_dir(src_dir)? {
545        let entry = entry?;
546        let name_os = entry.file_name();
547        let Some(name) = name_os.to_str() else {
548            continue;
549        };
550        if name == marker_filename {
551            continue;
552        }
553        if name.ends_with(".tera") {
554            // Templates are handled by the render flow before linking.
555            continue;
556        }
557
558        let src_path = src_dir.join(name);
559        let dst_path = dst_dir.join(name);
560        let ft = entry.file_type()?;
561
562        if ft.is_dir() {
563            walk_and_link(&src_path, &dst_path, ctx, strategy, engine, tera_ctx)?;
564        } else if ft.is_file() {
565            link_file_with_backup(&src_path, &dst_path, ctx)?;
566        }
567    }
568    Ok(())
569}
570
571fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
572    if ctx.dry_run {
573        info!("[dry-run] link file: {src} → {dst}");
574        return Ok(());
575    }
576    if std::fs::symlink_metadata(dst).is_ok() {
577        backup_existing(dst, ctx.backup_root, /*is_dir=*/ false)?;
578        link::unlink(dst)?;
579    }
580    info!("link file: {src} → {dst}");
581    link::link_file(src, dst, ctx.file_mode)?;
582    Ok(())
583}
584
585fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
586    if ctx.dry_run {
587        info!("[dry-run] link dir: {src} → {dst}");
588        return Ok(());
589    }
590    if std::fs::symlink_metadata(dst).is_ok() {
591        backup_existing(dst, ctx.backup_root, /*is_dir=*/ true)?;
592        link::unlink(dst)?;
593    }
594    info!("link dir: {src} → {dst}");
595    link::link_dir(src, dst, ctx.dir_mode)?;
596    Ok(())
597}
598
599fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
600    let abs_target = absolutize(target)?;
601    let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
602    let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
603    info!("backup → {bp}");
604    if is_dir {
605        backup::backup_dir(target, &bp)?;
606    } else {
607        backup::backup_file(target, &bp)?;
608    }
609    Ok(())
610}
611
612fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
613    if let Some(s) = source {
614        return absolutize(&s);
615    }
616    if let Ok(s) = std::env::var("YUI_SOURCE") {
617        return absolutize(Utf8Path::new(&s));
618    }
619    let cwd = current_dir_utf8()?;
620    for ancestor in cwd.ancestors() {
621        if ancestor.join("config.toml").is_file() {
622            return Ok(ancestor.to_path_buf());
623        }
624    }
625    if let Some(home) = home_dir() {
626        for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
627            let p = home.join(c);
628            if p.join("config.toml").is_file() {
629                return Ok(p);
630            }
631        }
632    }
633    anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
634}
635
636fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
637    if p.is_absolute() {
638        return Ok(p.to_path_buf());
639    }
640    let cwd = current_dir_utf8()?;
641    Ok(cwd.join(p))
642}
643
644fn current_dir_utf8() -> Result<Utf8PathBuf> {
645    let cwd = std::env::current_dir().context("getting cwd")?;
646    Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
647}
648
649fn home_dir() -> Option<Utf8PathBuf> {
650    std::env::var("HOME")
651        .ok()
652        .or_else(|| std::env::var("USERPROFILE").ok())
653        .map(Utf8PathBuf::from)
654}
655
656const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
657
658[vars]
659# user-defined values; templates can reference these as {{ vars.foo }}
660
661# [link]
662# file_mode = "auto"   # auto | symlink | hardlink
663# dir_mode  = "auto"   # auto | symlink | junction
664
665[mount]
666default_strategy = "marker"
667
668[[mount.entry]]
669src = "home"
670dst = "{{ env(name='HOME') | default(value=env(name='USERPROFILE')) }}"
671
672# [[mount.entry]]
673# src  = "appdata"
674# dst  = "{{ env(name='APPDATA') }}"
675# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
676# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
677# when = "yui.os == 'windows'"
678"#;
679
680const SKELETON_GITIGNORE: &str = r#"# yui internals (regenerable, do not commit)
681/.yui/
682
683# >>> yui rendered (auto-managed, do not edit) >>>
684# <<< yui rendered (auto-managed) <<<
685
686# config.local.toml is per-machine; commit a config.local.example.toml instead.
687config.local.toml
688"#;
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693    use tempfile::TempDir;
694
695    fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
696        Utf8PathBuf::from_path_buf(p).unwrap()
697    }
698
699    /// Convert a path to a TOML-string-safe form (forward slashes).
700    fn toml_path(p: &Utf8Path) -> String {
701        p.as_str().replace('\\', "/")
702    }
703
704    #[test]
705    fn apply_links_a_raw_file() {
706        let tmp = TempDir::new().unwrap();
707        let source = utf8(tmp.path().join("dotfiles"));
708        let target = utf8(tmp.path().join("target"));
709        std::fs::create_dir_all(source.join("home")).unwrap();
710        std::fs::create_dir_all(&target).unwrap();
711        std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
712
713        let cfg = format!(
714            r#"
715[[mount.entry]]
716src = "home"
717dst = "{}"
718"#,
719            toml_path(&target)
720        );
721        std::fs::write(source.join("config.toml"), cfg).unwrap();
722
723        apply(Some(source), false).unwrap();
724
725        let linked = target.join(".bashrc");
726        assert!(linked.exists(), "expected {linked} to exist");
727        assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
728    }
729
730    #[test]
731    fn apply_with_marker_links_whole_directory() {
732        let tmp = TempDir::new().unwrap();
733        let source = utf8(tmp.path().join("dotfiles"));
734        let target = utf8(tmp.path().join("target"));
735        let nvim_src = source.join("home/nvim");
736        std::fs::create_dir_all(&nvim_src).unwrap();
737        std::fs::create_dir_all(&target).unwrap();
738        std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
739        std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
740        std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
741
742        let cfg = format!(
743            r#"
744[[mount.entry]]
745src = "home"
746dst = "{}"
747"#,
748            toml_path(&target)
749        );
750        std::fs::write(source.join("config.toml"), cfg).unwrap();
751
752        apply(Some(source.clone()), false).unwrap();
753
754        let nvim_dst = target.join("nvim");
755        assert!(nvim_dst.exists());
756        assert_eq!(
757            std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
758            "-- hi\n"
759        );
760        // Marker file itself shouldn't be visible as a separate link in target;
761        // however with junction/symlink the whole dir shows up so the marker
762        // file IS visible inside. That's fine — the marker is informational.
763    }
764
765    #[test]
766    fn apply_dry_run_does_not_write() {
767        let tmp = TempDir::new().unwrap();
768        let source = utf8(tmp.path().join("dotfiles"));
769        let target = utf8(tmp.path().join("target"));
770        std::fs::create_dir_all(source.join("home")).unwrap();
771        std::fs::create_dir_all(&target).unwrap();
772        std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
773
774        let cfg = format!(
775            r#"
776[[mount.entry]]
777src = "home"
778dst = "{}"
779"#,
780            toml_path(&target)
781        );
782        std::fs::write(source.join("config.toml"), cfg).unwrap();
783
784        apply(Some(source), true).unwrap();
785
786        assert!(!target.join(".bashrc").exists());
787    }
788
789    #[test]
790    fn apply_renders_templates_then_links_rendered_outputs() {
791        let tmp = TempDir::new().unwrap();
792        let source = utf8(tmp.path().join("dotfiles"));
793        let target = utf8(tmp.path().join("target"));
794        std::fs::create_dir_all(source.join("home")).unwrap();
795        std::fs::create_dir_all(&target).unwrap();
796        std::fs::write(
797            source.join("home/.gitconfig.tera"),
798            "[user]\n  os = {{ yui.os }}\n",
799        )
800        .unwrap();
801        std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
802
803        let cfg = format!(
804            r#"
805[[mount.entry]]
806src = "home"
807dst = "{}"
808"#,
809            toml_path(&target)
810        );
811        std::fs::write(source.join("config.toml"), cfg).unwrap();
812
813        apply(Some(source.clone()), false).unwrap();
814
815        // Raw file: linked.
816        assert!(target.join(".bashrc").exists());
817        // Template's rendered output: written to source then linked.
818        assert!(source.join("home/.gitconfig").exists());
819        assert!(target.join(".gitconfig").exists());
820        // The .tera file itself is never linked into target.
821        assert!(!target.join(".gitconfig.tera").exists());
822        // Rendered file content carries the yui.os substitution.
823        let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
824        assert!(linked.contains("os = "));
825    }
826
827    #[test]
828    fn apply_marker_override_links_to_custom_dst() {
829        let tmp = TempDir::new().unwrap();
830        let source = utf8(tmp.path().join("dotfiles"));
831        let target_a = utf8(tmp.path().join("target_a"));
832        let target_b = utf8(tmp.path().join("target_b"));
833        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
834        std::fs::create_dir_all(&target_a).unwrap();
835        std::fs::create_dir_all(&target_b).unwrap();
836        std::fs::write(
837            source.join("home/.config/nvim/init.lua"),
838            "-- nvim config\n",
839        )
840        .unwrap();
841
842        // Marker tells yui to ignore the parent mount's dst for this dir
843        // and link it to two custom places (the second only if condition matches).
844        std::fs::write(
845            source.join("home/.config/nvim/.yuilink"),
846            format!(
847                r#"
848[[link]]
849dst = "{}/nvim"
850
851[[link]]
852dst = "{}/nvim"
853when = "{{{{ yui.os == '{}' }}}}"
854"#,
855                toml_path(&target_a),
856                toml_path(&target_b),
857                std::env::consts::OS
858            ),
859        )
860        .unwrap();
861
862        let parent_target = utf8(tmp.path().join("parent_target"));
863        std::fs::create_dir_all(&parent_target).unwrap();
864        let cfg = format!(
865            r#"
866[[mount.entry]]
867src = "home"
868dst = "{}"
869"#,
870            toml_path(&parent_target)
871        );
872        std::fs::write(source.join("config.toml"), cfg).unwrap();
873
874        apply(Some(source.clone()), false).unwrap();
875
876        // Both override targets received the link (the second's when matches OS).
877        assert!(
878            target_a.join("nvim/init.lua").exists(),
879            "target_a/nvim/init.lua should be reachable through the link"
880        );
881        assert!(
882            target_b.join("nvim/init.lua").exists(),
883            "target_b/nvim/init.lua should be reachable through the link"
884        );
885        // Parent mount did NOT also link this dir (it would have appeared at
886        // parent_target/.config/nvim — the marker claims the dir).
887        assert!(
888            !parent_target.join(".config/nvim").exists(),
889            "parent mount should have skipped the marker-claimed sub-dir"
890        );
891    }
892
893    #[test]
894    fn apply_marker_override_skips_inactive_link() {
895        let tmp = TempDir::new().unwrap();
896        let source = utf8(tmp.path().join("dotfiles"));
897        let target_inactive = utf8(tmp.path().join("inactive"));
898        let parent_target = utf8(tmp.path().join("parent"));
899        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
900        std::fs::create_dir_all(&parent_target).unwrap();
901        std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
902
903        // when=false on every link → marker has no active links.
904        std::fs::write(
905            source.join("home/.config/nvim/.yuilink"),
906            format!(
907                r#"
908[[link]]
909dst = "{}/nvim"
910when = "{{{{ yui.os == 'no-such-os' }}}}"
911"#,
912                toml_path(&target_inactive)
913            ),
914        )
915        .unwrap();
916
917        let cfg = format!(
918            r#"
919[[mount.entry]]
920src = "home"
921dst = "{}"
922"#,
923            toml_path(&parent_target)
924        );
925        std::fs::write(source.join("config.toml"), cfg).unwrap();
926
927        apply(Some(source.clone()), false).unwrap();
928
929        // Inactive target untouched.
930        assert!(!target_inactive.join("nvim").exists());
931        // Parent mount also skipped the dir (marker claims it even when
932        // all links are inactive — the user's intent was per-dir override).
933        assert!(!parent_target.join(".config/nvim").exists());
934    }
935
936    #[test]
937    fn list_shows_mount_entries_and_marker_overrides() {
938        let tmp = TempDir::new().unwrap();
939        let source = utf8(tmp.path().join("dotfiles"));
940        std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
941        std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
942        std::fs::write(
943            source.join("home/.config/nvim/.yuilink"),
944            r#"
945[[link]]
946dst = "/custom/nvim"
947"#,
948        )
949        .unwrap();
950        std::fs::write(
951            source.join("config.toml"),
952            r#"
953[[mount.entry]]
954src = "home"
955dst = "/h"
956"#,
957        )
958        .unwrap();
959
960        // Just verify it runs without error — output format is covered by
961        // unit-level helpers below.
962        list(Some(source), false, None, true).unwrap();
963    }
964
965    #[test]
966    fn strip_braces_removes_outer_template_braces() {
967        assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
968        assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
969        assert_eq!(strip_braces("  {{x}}  "), "x");
970    }
971
972    #[test]
973    fn apply_aborts_on_render_drift() {
974        let tmp = TempDir::new().unwrap();
975        let source = utf8(tmp.path().join("dotfiles"));
976        let target = utf8(tmp.path().join("target"));
977        std::fs::create_dir_all(source.join("home")).unwrap();
978        std::fs::create_dir_all(&target).unwrap();
979        std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
980        std::fs::write(source.join("home/foo"), "manually edited").unwrap();
981
982        let cfg = format!(
983            r#"
984[[mount.entry]]
985src = "home"
986dst = "{}"
987"#,
988            toml_path(&target)
989        );
990        std::fs::write(source.join("config.toml"), cfg).unwrap();
991
992        let err = apply(Some(source.clone()), false).unwrap_err();
993        assert!(format!("{err}").contains("drift"));
994        // Existing rendered file untouched.
995        assert_eq!(
996            std::fs::read_to_string(source.join("home/foo")).unwrap(),
997            "manually edited"
998        );
999        // Linking aborted — target empty.
1000        assert!(!target.join("foo").exists());
1001    }
1002
1003    #[test]
1004    fn init_creates_skeleton_when_dir_empty() {
1005        let tmp = TempDir::new().unwrap();
1006        let dir = utf8(tmp.path().join("new_dotfiles"));
1007        init(Some(dir.clone()), false).unwrap();
1008        assert!(dir.join("config.toml").is_file());
1009        assert!(dir.join(".gitignore").is_file());
1010    }
1011
1012    #[test]
1013    fn init_refuses_to_overwrite_existing_config() {
1014        let tmp = TempDir::new().unwrap();
1015        let dir = utf8(tmp.path().join("dotfiles"));
1016        std::fs::create_dir_all(&dir).unwrap();
1017        std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
1018        let err = init(Some(dir), false).unwrap_err();
1019        assert!(format!("{err}").contains("already exists"));
1020    }
1021
1022    #[test]
1023    fn apply_with_existing_target_backs_up() {
1024        let tmp = TempDir::new().unwrap();
1025        let source = utf8(tmp.path().join("dotfiles"));
1026        let target = utf8(tmp.path().join("target"));
1027        std::fs::create_dir_all(source.join("home")).unwrap();
1028        std::fs::create_dir_all(&target).unwrap();
1029        std::fs::write(source.join("home/.bashrc"), "new content").unwrap();
1030        // Pre-existing target file with different content.
1031        std::fs::write(target.join(".bashrc"), "old content").unwrap();
1032
1033        let cfg = format!(
1034            r#"
1035[[mount.entry]]
1036src = "home"
1037dst = "{}"
1038"#,
1039            toml_path(&target)
1040        );
1041        std::fs::write(source.join("config.toml"), cfg).unwrap();
1042
1043        apply(Some(source.clone()), false).unwrap();
1044
1045        // Target now has new content (linked from source).
1046        assert_eq!(
1047            std::fs::read_to_string(target.join(".bashrc")).unwrap(),
1048            "new content"
1049        );
1050
1051        // A backup of the old content should exist somewhere under .yui/backup.
1052        let backup_root = source.join(".yui/backup");
1053        assert!(backup_root.exists(), "backup root should exist");
1054        let mut found_old = false;
1055        for entry in walkdir(&backup_root) {
1056            if let Ok(s) = std::fs::read_to_string(&entry) {
1057                if s == "old content" {
1058                    found_old = true;
1059                    break;
1060                }
1061            }
1062        }
1063        assert!(found_old, "expected backup containing 'old content'");
1064    }
1065
1066    fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
1067        let mut out = Vec::new();
1068        let mut stack = vec![root.to_path_buf()];
1069        while let Some(dir) = stack.pop() {
1070            let Ok(entries) = std::fs::read_dir(&dir) else {
1071                continue;
1072            };
1073            for e in entries.flatten() {
1074                let p = utf8(e.path());
1075                if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
1076                    stack.push(p);
1077                } else {
1078                    out.push(p);
1079                }
1080            }
1081        }
1082        out
1083    }
1084}