use std::fmt::Write as _;
use anyhow::{Context as _, Result};
use camino::{Utf8Path, Utf8PathBuf};
use tera::Context as TeraContext;
use tracing::{info, warn};
use crate::config::{self, Config, IconsMode, MountStrategy};
use crate::icons::Icons;
use crate::link::{self, EffectiveDirMode, EffectiveFileMode, resolve_dir_mode, resolve_file_mode};
use crate::marker::{self, MarkerSpec};
use crate::mount::{self, ResolvedMount};
use crate::render::{self, RenderReport};
use crate::template;
use crate::vars::YuiVars;
use crate::{backup, paths};
pub fn init(source: Option<Utf8PathBuf>, _git_hooks: bool) -> Result<()> {
let dir = match source {
Some(s) => absolutize(&s)?,
None => current_dir_utf8()?,
};
std::fs::create_dir_all(&dir)?;
let config_path = dir.join("config.toml");
if config_path.exists() {
anyhow::bail!("config.toml already exists at {config_path}");
}
std::fs::write(&config_path, SKELETON_CONFIG)?;
let gitignore_path = dir.join(".gitignore");
if !gitignore_path.exists() {
std::fs::write(&gitignore_path, SKELETON_GITIGNORE)?;
}
info!("initialized yui source repo at {dir}");
info!("created: {config_path}");
info!("next: edit config.toml, then run `yui apply`");
Ok(())
}
pub fn apply(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
let source = resolve_source(source)?;
let yui = YuiVars::detect(&source);
let config = config::load(&source, &yui)?;
let render_report = render::render_all(&source, &config, &yui, dry_run)?;
log_render_report(&render_report);
if render_report.has_drift() {
anyhow::bail!(
"render drift detected ({} file(s)); reflect target edits back into the .tera before re-running apply",
render_report.diverged.len()
);
}
let mut engine = template::Engine::new();
let tera_ctx = template::template_context(&yui, &config.vars);
let mounts = mount::resolve(
&config.mount.entry,
config.mount.default_strategy,
&mut engine,
&tera_ctx,
)?;
let backup_root = source.join(&config.backup.dir);
let ctx = ApplyCtx {
config: &config,
file_mode: resolve_file_mode(config.link.file_mode),
dir_mode: resolve_dir_mode(config.link.dir_mode),
backup_root: &backup_root,
dry_run,
};
info!("source: {source}");
info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
if dry_run {
info!("dry-run: nothing will be written");
}
for m in &mounts {
info!("mount: {} → {}", m.src, m.dst);
process_mount(&source, m, &ctx, &mut engine, &tera_ctx)?;
}
Ok(())
}
fn log_render_report(r: &RenderReport) {
if !r.written.is_empty() {
info!("rendered {} new file(s)", r.written.len());
}
if !r.unchanged.is_empty() {
info!("rendered {} file(s) unchanged", r.unchanged.len());
}
if !r.skipped_when_false.is_empty() {
info!(
"skipped {} template(s) (when=false)",
r.skipped_when_false.len()
);
}
for d in &r.diverged {
warn!("rendered file diverged from template: {d}");
}
}
struct ApplyCtx<'a> {
config: &'a Config,
file_mode: EffectiveFileMode,
dir_mode: EffectiveDirMode,
backup_root: &'a Utf8Path,
dry_run: bool,
}
pub fn list(
source: Option<Utf8PathBuf>,
all: bool,
icons_override: Option<IconsMode>,
no_color: bool,
) -> Result<()> {
let source = resolve_source(source)?;
let yui = YuiVars::detect(&source);
let config = config::load(&source, &yui)?;
let icons_mode = icons_override.unwrap_or(config.ui.icons);
let icons = Icons::for_mode(icons_mode);
let color = !no_color && supports_color_stdout();
let items = collect_list_items(&source, &config, &yui)?;
let displayed: Vec<&ListItem> = if all {
items.iter().collect()
} else {
items.iter().filter(|i| i.active).collect()
};
print_list_table(&displayed, icons, color);
let total = items.len();
let active = items.iter().filter(|i| i.active).count();
let inactive = total - active;
println!();
if all {
println!(" {total} entries · {active} active · {inactive} inactive");
} else {
println!(
" {} of {} entries shown ({} inactive hidden — use --all)",
active, total, inactive
);
}
Ok(())
}
#[derive(Debug)]
struct ListItem {
src: Utf8PathBuf,
dst: String,
when: Option<String>,
active: bool,
}
fn collect_list_items(source: &Utf8Path, config: &Config, yui: &YuiVars) -> Result<Vec<ListItem>> {
let mut engine = template::Engine::new();
let tera_ctx = template::template_context(yui, &config.vars);
let mut items = Vec::new();
for entry in &config.mount.entry {
let active = match &entry.when {
None => true,
Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
};
let dst = engine
.render(&entry.dst, &tera_ctx)
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| entry.dst.clone());
items.push(ListItem {
src: entry.src.clone(),
dst,
when: entry.when.clone(),
active,
});
}
let walker = ignore::WalkBuilder::new(source)
.hidden(false)
.git_ignore(false)
.ignore(false)
.build();
let marker_filename = &config.mount.marker_filename;
for entry in walker {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
if entry.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
continue;
}
let dir = match entry.path().parent() {
Some(d) => d,
None => continue,
};
let dir_utf8 = match Utf8PathBuf::from_path_buf(dir.to_path_buf()) {
Ok(p) => p,
Err(_) => continue,
};
let spec = match marker::read_spec(&dir_utf8, marker_filename)? {
Some(s) => s,
None => continue,
};
let MarkerSpec::Override { links } = spec else {
continue; };
let rel = dir_utf8
.strip_prefix(source)
.map(Utf8PathBuf::from)
.unwrap_or(dir_utf8);
for link in &links {
let active = match &link.when {
None => true,
Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
};
let dst = engine
.render(&link.dst, &tera_ctx)
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| link.dst.clone());
items.push(ListItem {
src: rel.clone(),
dst,
when: link.when.clone(),
active,
});
}
}
items.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
Ok(items)
}
fn supports_color_stdout() -> bool {
use std::io::IsTerminal;
std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
}
fn print_list_table(items: &[&ListItem], icons: Icons, color: bool) {
let src_w = items
.iter()
.map(|i| i.src.as_str().chars().count())
.max()
.unwrap_or(0)
.max("SRC".len());
let dst_w = items
.iter()
.map(|i| i.dst.chars().count())
.max()
.unwrap_or(0)
.max("DST".len());
let status_w = "STATUS".len();
let arrow_w = icons.arrow.chars().count();
print_header(status_w, src_w, arrow_w, dst_w, color);
let sep = render_separator(icons.sep, status_w, src_w, arrow_w, dst_w);
if color {
use owo_colors::OwoColorize as _;
println!("{}", sep.dimmed());
} else {
println!("{sep}");
}
for item in items {
print_row(item, icons, status_w, src_w, arrow_w, dst_w, color);
}
}
fn print_header(status_w: usize, src_w: usize, arrow_w: usize, dst_w: usize, color: bool) {
use owo_colors::OwoColorize as _;
let mut line = String::new();
let _ = write!(
&mut line,
" {:<status_w$} {:<src_w$} {:<arrow_w$} {:<dst_w$} WHEN",
"STATUS", "SRC", "", "DST"
);
if color {
println!("{}", line.bold());
} else {
println!("{line}");
}
}
fn render_separator(
sep_ch: char,
status_w: usize,
src_w: usize,
arrow_w: usize,
dst_w: usize,
) -> String {
let bar = |n: usize| sep_ch.to_string().repeat(n);
format!(
" {} {} {} {} {}",
bar(status_w),
bar(src_w),
bar(arrow_w),
bar(dst_w),
bar("WHEN".len())
)
}
fn print_row(
item: &ListItem,
icons: Icons,
status_w: usize,
src_w: usize,
arrow_w: usize,
dst_w: usize,
color: bool,
) {
use owo_colors::OwoColorize as _;
let status = if item.active {
icons.active
} else {
icons.inactive
};
let when_str = item
.when
.as_deref()
.map(strip_braces)
.unwrap_or_else(|| "(always)".to_string());
let src_display = item.src.as_str().replace('\\', "/");
let src = src_display.as_str();
let dst = &item.dst;
let arrow = icons.arrow;
let cell_status = format!("{:<status_w$}", status);
let cell_src = format!("{:<src_w$}", src);
let cell_arrow = format!("{:<arrow_w$}", arrow);
let cell_dst = format!("{:<dst_w$}", dst);
if !color {
println!(" {cell_status} {cell_src} {cell_arrow} {cell_dst} {when_str}");
return;
}
if item.active {
println!(
" {} {} {} {} {}",
cell_status.green(),
cell_src.cyan(),
cell_arrow.dimmed(),
cell_dst.green(),
when_str.dimmed()
);
} else {
println!(
" {} {} {} {} {}",
cell_status.red().dimmed(),
cell_src.dimmed(),
cell_arrow.dimmed(),
cell_dst.dimmed(),
when_str.dimmed()
);
}
}
fn strip_braces(expr: &str) -> String {
let trimmed = expr.trim();
if let Some(inner) = trimmed
.strip_prefix("{{")
.and_then(|s| s.strip_suffix("}}"))
{
inner.trim().to_string()
} else {
trimmed.to_string()
}
}
pub fn render(source: Option<Utf8PathBuf>, check: bool, dry_run: bool) -> Result<()> {
let source = resolve_source(source)?;
let yui = YuiVars::detect(&source);
let config = config::load(&source, &yui)?;
let report = render::render_all(&source, &config, &yui, dry_run || check)?;
log_render_report(&report);
if check && report.has_drift() {
anyhow::bail!("render drift detected ({} file(s))", report.diverged.len());
}
Ok(())
}
pub fn link(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
apply(source, dry_run)
}
pub fn unlink(source: Option<Utf8PathBuf>, paths_arg: Vec<Utf8PathBuf>) -> Result<()> {
let _source = resolve_source(source)?;
if paths_arg.is_empty() {
anyhow::bail!("yui unlink: provide at least one target path");
}
for p in paths_arg {
let abs = absolutize(&p)?;
info!("unlink: {abs}");
link::unlink(&abs)?;
}
Ok(())
}
pub fn status(_source: Option<Utf8PathBuf>) -> Result<()> {
todo!("yui status — drift detection (needs absorb classifier)")
}
pub fn absorb(_source: Option<Utf8PathBuf>, _target: Utf8PathBuf, _dry_run: bool) -> Result<()> {
todo!("yui absorb — manual absorb (needs absorb classifier)")
}
pub fn doctor(source: Option<Utf8PathBuf>) -> Result<()> {
let yui = YuiVars::detect(Utf8Path::new("."));
println!("yui doctor");
println!("==========");
println!("os: {}", yui.os);
println!("arch: {}", yui.arch);
println!("user: {}", yui.user);
println!("host: {}", yui.host);
match resolve_source(source) {
Ok(s) => {
println!("source: {s}");
match config::load(&s, &yui) {
Ok(cfg) => println!(
"config: ok ({} mount entries, {} render rules)",
cfg.mount.entry.len(),
cfg.render.rule.len()
),
Err(e) => println!("config: ERROR — {e}"),
}
}
Err(e) => println!("source: NOT FOUND — {e}"),
}
println!();
println!("link mode (auto resolves to):");
if cfg!(windows) {
println!(" files: hardlink");
println!(" dirs: junction");
} else {
println!(" files: symlink");
println!(" dirs: symlink");
}
Ok(())
}
pub fn gc_backup(_source: Option<Utf8PathBuf>, _older_than: Option<String>) -> Result<()> {
todo!("yui gc-backup — clean up old backups")
}
fn process_mount(
source: &Utf8Path,
m: &ResolvedMount,
ctx: &ApplyCtx<'_>,
engine: &mut template::Engine,
tera_ctx: &TeraContext,
) -> Result<()> {
let src_root = source.join(&m.src);
if !src_root.is_dir() {
warn!("mount src missing: {src_root}");
return Ok(());
}
walk_and_link(&src_root, &m.dst, ctx, m.strategy, engine, tera_ctx)
}
fn walk_and_link(
src_dir: &Utf8Path,
dst_dir: &Utf8Path,
ctx: &ApplyCtx<'_>,
strategy: MountStrategy,
engine: &mut template::Engine,
tera_ctx: &TeraContext,
) -> Result<()> {
let marker_filename = &ctx.config.mount.marker_filename;
if strategy == MountStrategy::Marker {
match marker::read_spec(src_dir, marker_filename)? {
None => {} Some(MarkerSpec::PassThrough) => {
link_dir_with_backup(src_dir, dst_dir, ctx)?;
return Ok(());
}
Some(MarkerSpec::Override { links }) => {
let mut linked_any = false;
for link in &links {
if let Some(when) = &link.when {
if !template::eval_truthy(when, engine, tera_ctx)? {
continue;
}
}
let dst_str = engine.render(&link.dst, tera_ctx)?;
let dst = Utf8PathBuf::from(dst_str.trim());
link_dir_with_backup(src_dir, &dst, ctx)?;
linked_any = true;
}
if !linked_any {
info!("marker override at {src_dir} had no active links — skipping");
}
return Ok(());
}
}
}
for entry in std::fs::read_dir(src_dir)? {
let entry = entry?;
let name_os = entry.file_name();
let Some(name) = name_os.to_str() else {
continue;
};
if name == marker_filename {
continue;
}
if name.ends_with(".tera") {
continue;
}
let src_path = src_dir.join(name);
let dst_path = dst_dir.join(name);
let ft = entry.file_type()?;
if ft.is_dir() {
walk_and_link(&src_path, &dst_path, ctx, strategy, engine, tera_ctx)?;
} else if ft.is_file() {
link_file_with_backup(&src_path, &dst_path, ctx)?;
}
}
Ok(())
}
fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
if ctx.dry_run {
info!("[dry-run] link file: {src} → {dst}");
return Ok(());
}
if std::fs::symlink_metadata(dst).is_ok() {
backup_existing(dst, ctx.backup_root, false)?;
link::unlink(dst)?;
}
info!("link file: {src} → {dst}");
link::link_file(src, dst, ctx.file_mode)?;
Ok(())
}
fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
if ctx.dry_run {
info!("[dry-run] link dir: {src} → {dst}");
return Ok(());
}
if std::fs::symlink_metadata(dst).is_ok() {
backup_existing(dst, ctx.backup_root, true)?;
link::unlink(dst)?;
}
info!("link dir: {src} → {dst}");
link::link_dir(src, dst, ctx.dir_mode)?;
Ok(())
}
fn backup_existing(target: &Utf8Path, backup_root: &Utf8Path, is_dir: bool) -> Result<()> {
let abs_target = absolutize(target)?;
let ts = backup::current_timestamp("%Y%m%d_%H%M%S%3f")?;
let bp = paths::append_timestamp(&paths::mirror_into_backup(backup_root, &abs_target), &ts);
info!("backup → {bp}");
if is_dir {
backup::backup_dir(target, &bp)?;
} else {
backup::backup_file(target, &bp)?;
}
Ok(())
}
fn resolve_source(source: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
if let Some(s) = source {
return absolutize(&s);
}
if let Ok(s) = std::env::var("YUI_SOURCE") {
return absolutize(Utf8Path::new(&s));
}
let cwd = current_dir_utf8()?;
for ancestor in cwd.ancestors() {
if ancestor.join("config.toml").is_file() {
return Ok(ancestor.to_path_buf());
}
}
if let Some(home) = home_dir() {
for c in ["dotfiles", ".dotfiles", "src/dotfiles"] {
let p = home.join(c);
if p.join("config.toml").is_file() {
return Ok(p);
}
}
}
anyhow::bail!("source repo not found (set --source / $YUI_SOURCE)")
}
fn absolutize(p: &Utf8Path) -> Result<Utf8PathBuf> {
if p.is_absolute() {
return Ok(p.to_path_buf());
}
let cwd = current_dir_utf8()?;
Ok(cwd.join(p))
}
fn current_dir_utf8() -> Result<Utf8PathBuf> {
let cwd = std::env::current_dir().context("getting cwd")?;
Utf8PathBuf::from_path_buf(cwd).map_err(|p| anyhow::anyhow!("non-UTF8 cwd: {}", p.display()))
}
fn home_dir() -> Option<Utf8PathBuf> {
std::env::var("HOME")
.ok()
.or_else(|| std::env::var("USERPROFILE").ok())
.map(Utf8PathBuf::from)
}
const SKELETON_CONFIG: &str = r#"# yui config — see https://github.com/yukimemi/yui
[vars]
# user-defined values; templates can reference these as {{ vars.foo }}
# [link]
# file_mode = "auto" # auto | symlink | hardlink
# dir_mode = "auto" # auto | symlink | junction
[mount]
default_strategy = "marker"
[[mount.entry]]
src = "home"
dst = "{{ env(name='HOME') | default(value=env(name='USERPROFILE')) }}"
# [[mount.entry]]
# src = "appdata"
# dst = "{{ env(name='APPDATA') }}"
# # NOTE: write `when` as a *bare* expression (no `{{ … }}`) so it survives
# # config.toml's whole-file Tera render and shows up cleanly in `yui list`.
# when = "yui.os == 'windows'"
"#;
const SKELETON_GITIGNORE: &str = r#"# yui internals (regenerable, do not commit)
/.yui/
# >>> yui rendered (auto-managed, do not edit) >>>
# <<< yui rendered (auto-managed) <<<
# config.local.toml is per-machine; commit a config.local.example.toml instead.
config.local.toml
"#;
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
Utf8PathBuf::from_path_buf(p).unwrap()
}
fn toml_path(p: &Utf8Path) -> String {
p.as_str().replace('\\', "/")
}
#[test]
fn apply_links_a_raw_file() {
let tmp = TempDir::new().unwrap();
let source = utf8(tmp.path().join("dotfiles"));
let target = utf8(tmp.path().join("target"));
std::fs::create_dir_all(source.join("home")).unwrap();
std::fs::create_dir_all(&target).unwrap();
std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
let cfg = format!(
r#"
[[mount.entry]]
src = "home"
dst = "{}"
"#,
toml_path(&target)
);
std::fs::write(source.join("config.toml"), cfg).unwrap();
apply(Some(source), false).unwrap();
let linked = target.join(".bashrc");
assert!(linked.exists(), "expected {linked} to exist");
assert_eq!(std::fs::read_to_string(&linked).unwrap(), "echo hi\n");
}
#[test]
fn apply_with_marker_links_whole_directory() {
let tmp = TempDir::new().unwrap();
let source = utf8(tmp.path().join("dotfiles"));
let target = utf8(tmp.path().join("target"));
let nvim_src = source.join("home/nvim");
std::fs::create_dir_all(&nvim_src).unwrap();
std::fs::create_dir_all(&target).unwrap();
std::fs::write(nvim_src.join(".yuilink"), "").unwrap();
std::fs::write(nvim_src.join("init.lua"), "-- hi\n").unwrap();
std::fs::write(nvim_src.join("plugins.lua"), "-- plugins\n").unwrap();
let cfg = format!(
r#"
[[mount.entry]]
src = "home"
dst = "{}"
"#,
toml_path(&target)
);
std::fs::write(source.join("config.toml"), cfg).unwrap();
apply(Some(source.clone()), false).unwrap();
let nvim_dst = target.join("nvim");
assert!(nvim_dst.exists());
assert_eq!(
std::fs::read_to_string(nvim_dst.join("init.lua")).unwrap(),
"-- hi\n"
);
}
#[test]
fn apply_dry_run_does_not_write() {
let tmp = TempDir::new().unwrap();
let source = utf8(tmp.path().join("dotfiles"));
let target = utf8(tmp.path().join("target"));
std::fs::create_dir_all(source.join("home")).unwrap();
std::fs::create_dir_all(&target).unwrap();
std::fs::write(source.join("home/.bashrc"), "echo hi").unwrap();
let cfg = format!(
r#"
[[mount.entry]]
src = "home"
dst = "{}"
"#,
toml_path(&target)
);
std::fs::write(source.join("config.toml"), cfg).unwrap();
apply(Some(source), true).unwrap();
assert!(!target.join(".bashrc").exists());
}
#[test]
fn apply_renders_templates_then_links_rendered_outputs() {
let tmp = TempDir::new().unwrap();
let source = utf8(tmp.path().join("dotfiles"));
let target = utf8(tmp.path().join("target"));
std::fs::create_dir_all(source.join("home")).unwrap();
std::fs::create_dir_all(&target).unwrap();
std::fs::write(
source.join("home/.gitconfig.tera"),
"[user]\n os = {{ yui.os }}\n",
)
.unwrap();
std::fs::write(source.join("home/.bashrc"), "raw").unwrap();
let cfg = format!(
r#"
[[mount.entry]]
src = "home"
dst = "{}"
"#,
toml_path(&target)
);
std::fs::write(source.join("config.toml"), cfg).unwrap();
apply(Some(source.clone()), false).unwrap();
assert!(target.join(".bashrc").exists());
assert!(source.join("home/.gitconfig").exists());
assert!(target.join(".gitconfig").exists());
assert!(!target.join(".gitconfig.tera").exists());
let linked = std::fs::read_to_string(target.join(".gitconfig")).unwrap();
assert!(linked.contains("os = "));
}
#[test]
fn apply_marker_override_links_to_custom_dst() {
let tmp = TempDir::new().unwrap();
let source = utf8(tmp.path().join("dotfiles"));
let target_a = utf8(tmp.path().join("target_a"));
let target_b = utf8(tmp.path().join("target_b"));
std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
std::fs::create_dir_all(&target_a).unwrap();
std::fs::create_dir_all(&target_b).unwrap();
std::fs::write(
source.join("home/.config/nvim/init.lua"),
"-- nvim config\n",
)
.unwrap();
std::fs::write(
source.join("home/.config/nvim/.yuilink"),
format!(
r#"
[[link]]
dst = "{}/nvim"
[[link]]
dst = "{}/nvim"
when = "{{{{ yui.os == '{}' }}}}"
"#,
toml_path(&target_a),
toml_path(&target_b),
std::env::consts::OS
),
)
.unwrap();
let parent_target = utf8(tmp.path().join("parent_target"));
std::fs::create_dir_all(&parent_target).unwrap();
let cfg = format!(
r#"
[[mount.entry]]
src = "home"
dst = "{}"
"#,
toml_path(&parent_target)
);
std::fs::write(source.join("config.toml"), cfg).unwrap();
apply(Some(source.clone()), false).unwrap();
assert!(
target_a.join("nvim/init.lua").exists(),
"target_a/nvim/init.lua should be reachable through the link"
);
assert!(
target_b.join("nvim/init.lua").exists(),
"target_b/nvim/init.lua should be reachable through the link"
);
assert!(
!parent_target.join(".config/nvim").exists(),
"parent mount should have skipped the marker-claimed sub-dir"
);
}
#[test]
fn apply_marker_override_skips_inactive_link() {
let tmp = TempDir::new().unwrap();
let source = utf8(tmp.path().join("dotfiles"));
let target_inactive = utf8(tmp.path().join("inactive"));
let parent_target = utf8(tmp.path().join("parent"));
std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
std::fs::create_dir_all(&parent_target).unwrap();
std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
std::fs::write(
source.join("home/.config/nvim/.yuilink"),
format!(
r#"
[[link]]
dst = "{}/nvim"
when = "{{{{ yui.os == 'no-such-os' }}}}"
"#,
toml_path(&target_inactive)
),
)
.unwrap();
let cfg = format!(
r#"
[[mount.entry]]
src = "home"
dst = "{}"
"#,
toml_path(&parent_target)
);
std::fs::write(source.join("config.toml"), cfg).unwrap();
apply(Some(source.clone()), false).unwrap();
assert!(!target_inactive.join("nvim").exists());
assert!(!parent_target.join(".config/nvim").exists());
}
#[test]
fn list_shows_mount_entries_and_marker_overrides() {
let tmp = TempDir::new().unwrap();
let source = utf8(tmp.path().join("dotfiles"));
std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
std::fs::write(source.join("home/.config/nvim/init.lua"), "x").unwrap();
std::fs::write(
source.join("home/.config/nvim/.yuilink"),
r#"
[[link]]
dst = "/custom/nvim"
"#,
)
.unwrap();
std::fs::write(
source.join("config.toml"),
r#"
[[mount.entry]]
src = "home"
dst = "/h"
"#,
)
.unwrap();
list(Some(source), false, None, true).unwrap();
}
#[test]
fn strip_braces_removes_outer_template_braces() {
assert_eq!(strip_braces("{{ yui.os == 'linux' }}"), "yui.os == 'linux'");
assert_eq!(strip_braces("yui.os == 'linux'"), "yui.os == 'linux'");
assert_eq!(strip_braces(" {{x}} "), "x");
}
#[test]
fn apply_aborts_on_render_drift() {
let tmp = TempDir::new().unwrap();
let source = utf8(tmp.path().join("dotfiles"));
let target = utf8(tmp.path().join("target"));
std::fs::create_dir_all(source.join("home")).unwrap();
std::fs::create_dir_all(&target).unwrap();
std::fs::write(source.join("home/foo.tera"), "fresh body").unwrap();
std::fs::write(source.join("home/foo"), "manually edited").unwrap();
let cfg = format!(
r#"
[[mount.entry]]
src = "home"
dst = "{}"
"#,
toml_path(&target)
);
std::fs::write(source.join("config.toml"), cfg).unwrap();
let err = apply(Some(source.clone()), false).unwrap_err();
assert!(format!("{err}").contains("drift"));
assert_eq!(
std::fs::read_to_string(source.join("home/foo")).unwrap(),
"manually edited"
);
assert!(!target.join("foo").exists());
}
#[test]
fn init_creates_skeleton_when_dir_empty() {
let tmp = TempDir::new().unwrap();
let dir = utf8(tmp.path().join("new_dotfiles"));
init(Some(dir.clone()), false).unwrap();
assert!(dir.join("config.toml").is_file());
assert!(dir.join(".gitignore").is_file());
}
#[test]
fn init_refuses_to_overwrite_existing_config() {
let tmp = TempDir::new().unwrap();
let dir = utf8(tmp.path().join("dotfiles"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("config.toml"), "preexisting").unwrap();
let err = init(Some(dir), false).unwrap_err();
assert!(format!("{err}").contains("already exists"));
}
#[test]
fn apply_with_existing_target_backs_up() {
let tmp = TempDir::new().unwrap();
let source = utf8(tmp.path().join("dotfiles"));
let target = utf8(tmp.path().join("target"));
std::fs::create_dir_all(source.join("home")).unwrap();
std::fs::create_dir_all(&target).unwrap();
std::fs::write(source.join("home/.bashrc"), "new content").unwrap();
std::fs::write(target.join(".bashrc"), "old content").unwrap();
let cfg = format!(
r#"
[[mount.entry]]
src = "home"
dst = "{}"
"#,
toml_path(&target)
);
std::fs::write(source.join("config.toml"), cfg).unwrap();
apply(Some(source.clone()), false).unwrap();
assert_eq!(
std::fs::read_to_string(target.join(".bashrc")).unwrap(),
"new content"
);
let backup_root = source.join(".yui/backup");
assert!(backup_root.exists(), "backup root should exist");
let mut found_old = false;
for entry in walkdir(&backup_root) {
if let Ok(s) = std::fs::read_to_string(&entry) {
if s == "old content" {
found_old = true;
break;
}
}
}
assert!(found_old, "expected backup containing 'old content'");
}
fn walkdir(root: &Utf8Path) -> Vec<Utf8PathBuf> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for e in entries.flatten() {
let p = utf8(e.path());
if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
stack.push(p);
} else {
out.push(p);
}
}
}
out
}
}