use std::cell::Cell;
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, HookPhase, IconsMode, MountStrategy};
use crate::hook::{self, HookOutcome};
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::secret;
use crate::template;
use crate::vars::YuiVars;
use crate::vault;
use crate::{absorb, 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");
let scaffolded = if !config_path.exists() {
std::fs::write(&config_path, SKELETON_CONFIG)?;
info!("initialized yui source repo at {dir}");
info!("created: {config_path}");
true
} else if git_hooks {
info!(
"config.toml already exists at {config_path} \
— skipping scaffold, installing git hooks only"
);
false
} else {
anyhow::bail!("config.toml already exists at {config_path}");
};
ensure_gitignore_yui_entries(&dir)?;
if git_hooks {
install_git_hooks(&dir)?;
}
if scaffolded {
info!("next: edit config.toml, then run `yui apply`");
}
Ok(())
}
const YUI_REQUIRED_GITIGNORE: &[&str] = &[
"/.yui/state.json",
"/.yui/state.json.tmp",
"/.yui/backup/",
"config.local.toml",
];
fn ensure_gitignore_yui_entries(dir: &Utf8Path) -> Result<()> {
let path = dir.join(".gitignore");
if !path.exists() {
std::fs::write(&path, SKELETON_GITIGNORE)?;
info!("created: {path}");
return Ok(());
}
let existing = std::fs::read_to_string(&path)?;
let missing: Vec<&str> = YUI_REQUIRED_GITIGNORE
.iter()
.copied()
.filter(|entry| !existing.lines().any(|line| line.trim() == *entry))
.collect();
if missing.is_empty() {
return Ok(());
}
let mut next = existing;
if !next.is_empty() && !next.ends_with('\n') {
next.push('\n');
}
if !next.is_empty() {
next.push('\n');
}
next.push_str("# yui per-machine state and backups (added by `yui init`).\n");
for entry in &missing {
next.push_str(entry);
next.push('\n');
}
std::fs::write(&path, next)?;
info!(
"updated .gitignore: appended {} yui entr{} ({})",
missing.len(),
if missing.len() == 1 { "y" } else { "ies" },
missing.join(", ")
);
Ok(())
}
fn install_git_hooks(source: &Utf8Path) -> Result<()> {
let out = std::process::Command::new("git")
.args(["rev-parse", "--git-path", "hooks"])
.current_dir(source.as_std_path())
.output()
.with_context(|| format!("git rev-parse --git-path hooks in {source}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
anyhow::bail!(
"--git-hooks: {source} doesn't look like a git repo \
(run `git init` first). git: {}",
stderr.trim()
);
}
let raw = String::from_utf8(out.stdout)?;
let hooks_dir = {
let p = Utf8PathBuf::from(raw.trim());
if p.is_absolute() { p } else { source.join(p) }
};
std::fs::create_dir_all(&hooks_dir).with_context(|| format!("mkdir -p {hooks_dir}"))?;
for (name, body) in [("pre-commit", PRE_COMMIT_HOOK), ("pre-push", PRE_PUSH_HOOK)] {
let path = hooks_dir.join(name);
if path.exists() {
warn!("--git-hooks: {path} already exists — leaving it alone");
continue;
}
std::fs::write(&path, body).with_context(|| format!("write hook {path}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms)?;
}
info!("installed: {path}");
}
Ok(())
}
const PRE_COMMIT_HOOK: &str = r#"#!/bin/sh
# Installed by `yui init --git-hooks`.
# Reject the commit if any `*.tera` template would render to something
# that diverges from the rendered output staged alongside it. Run
# `yui apply` (or `yui render`) to refresh and re-commit.
exec yui render --check
"#;
const PRE_PUSH_HOOK: &str = r#"#!/bin/sh
# Installed by `yui init --git-hooks`.
# Same render-drift check as pre-commit, mirrored on push so a
# `--no-verify` commit doesn't sneak diverged state to the remote.
exec yui render --check
"#;
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 mut engine = template::Engine::new();
let tera_ctx = template::template_context(&yui, &config.vars);
hook::run_phase(
&config,
&source,
&yui,
&mut engine,
&tera_ctx,
HookPhase::Pre,
dry_run,
)?;
let secret_report = secret::decrypt_all(&source, &config, dry_run)?;
log_secret_report(&secret_report);
if secret_report.has_drift() {
anyhow::bail!(
"secret drift detected ({} file(s)); the plaintext sibling diverged \
from the canonical .age — run `yui secret encrypt <path>` to roll \
the edit back into ciphertext before re-running apply",
secret_report.diverged.len()
);
}
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()
);
}
if !dry_run && config.render.manage_gitignore {
let mut managed: Vec<Utf8PathBuf> = render::report_managed_paths(&render_report)
.into_iter()
.chain(secret_report.managed_paths().cloned())
.collect();
managed.sort();
managed.dedup();
render::write_managed_section(&source, &managed)?;
}
let mounts = mount::resolve(
&source,
&config.mount.entry,
config.mount.default_strategy,
&mut engine,
&tera_ctx,
)?;
let backup_root = source.join(&config.backup.dir);
let ctx = ApplyCtx {
config: &config,
source: &source,
file_mode: resolve_file_mode(config.link.file_mode),
dir_mode: resolve_dir_mode(config.link.dir_mode),
backup_root: &backup_root,
dry_run,
sticky_anomaly: Cell::new(None),
quit_requested: Cell::new(false),
};
info!("source: {source}");
info!("modes: file={:?} dir={:?}", ctx.file_mode, ctx.dir_mode);
if dry_run {
info!("dry-run: nothing will be written");
}
let mut yuiignore = paths::YuiIgnoreStack::new();
yuiignore.push_dir(&source)?;
let walk_result = (|| -> Result<()> {
for m in &mounts {
info!("mount: {} → {}", m.src, m.dst);
process_mount(m, &ctx, &mut engine, &tera_ctx, &mut yuiignore)?;
}
Ok(())
})();
yuiignore.pop_dir(&source);
walk_result?;
hook::run_phase(
&config,
&source,
&yui,
&mut engine,
&tera_ctx,
HookPhase::Post,
dry_run,
)?;
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}");
}
}
fn log_secret_report(r: &secret::SecretReport) {
if !r.written.is_empty() {
info!("decrypted {} secret file(s)", r.written.len());
}
if !r.unchanged.is_empty() {
info!("decrypted {} secret(s) unchanged", r.unchanged.len());
}
for d in &r.diverged {
warn!("plaintext sibling diverged from .age: {d}");
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AnomalyChoice {
Absorb,
Overwrite,
Skip,
Quit,
}
struct ApplyCtx<'a> {
config: &'a Config,
source: &'a Utf8Path,
file_mode: EffectiveFileMode,
dir_mode: EffectiveDirMode,
backup_root: &'a Utf8Path,
dry_run: bool,
sticky_anomaly: Cell<Option<AnomalyChoice>>,
quit_requested: Cell<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| paths::expand_tilde(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 = paths::source_walker(source).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::Explicit { 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| paths::expand_tilde(s.trim()).to_string())
.unwrap_or_else(|_| link.dst.clone());
let src_display = match &link.src {
Some(filename) => rel.join(filename),
None => rel.clone(),
};
items.push(ListItem {
src: src_display,
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 effective_dry_run = dry_run || check;
let report = render::render_all(&source, &config, &yui, effective_dry_run)?;
log_render_report(&report);
if !effective_dry_run && config.render.manage_gitignore {
let managed = render::report_managed_paths(&report);
render::write_managed_section(&source, &managed)?;
}
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 secret_init(source: Option<Utf8PathBuf>, comment: Option<String>) -> Result<()> {
let source = resolve_source(source)?;
let yui = YuiVars::detect(&source);
let config = config::load(&source, &yui)?;
let identity_path = paths::expand_tilde(&config.secrets.identity);
if identity_path.exists() {
anyhow::bail!(
"identity file already exists at {identity_path}; \
refusing to overwrite. Delete it first if you really \
mean to start fresh (you'll lose access to existing \
.age files encrypted to its public key)."
);
}
let (secret, public) = secret::generate_x25519_keypair();
let now = jiff::Zoned::now().to_string();
let body = format!(
"# created: {now}\n\
# public key: {public}\n\
{secret}\n"
);
secret::write_private_file(&identity_path, body.as_bytes())?;
info!("wrote identity file: {identity_path}");
let config_path = source.join("config.toml");
let comment = comment.unwrap_or_else(|| format!("{} {}", yui.host, yui.user));
let entry_comment = format!("{comment} — added by `yui secret init` on {now}");
let config_existing = match std::fs::read_to_string(&config_path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => anyhow::bail!("read {config_path}: {e}"),
};
let updated_config = append_recipient_to_config(&config_existing, &entry_comment, &public)?;
std::fs::write(&config_path, updated_config)?;
info!("appended public key to {config_path}");
println!();
println!(" age identity: {identity_path}");
println!(" public key: {public}");
println!();
println!(
" Next: encrypt a file with `yui secret encrypt <path>`. \
The plaintext sibling will be auto-decrypted on every `yui apply`."
);
Ok(())
}
fn append_recipient_to_config(existing: &str, comment: &str, public: &str) -> Result<String> {
use toml_edit::{Array, DocumentMut, Item, Table, Value};
let mut doc: DocumentMut = if existing.trim().is_empty() {
DocumentMut::new()
} else {
existing
.parse()
.map_err(|e| anyhow::anyhow!("config.toml is not valid TOML: {e}"))?
};
if !doc.contains_key("secrets") {
let mut t = Table::new();
t.set_implicit(false);
doc.insert("secrets", Item::Table(t));
}
let secrets = doc["secrets"].as_table_mut().ok_or_else(|| {
anyhow::anyhow!("[secrets] in config.toml is not a table — refusing to clobber")
})?;
if !secrets.contains_key("recipients") {
secrets.insert("recipients", Item::Value(Value::Array(Array::new())));
}
let recipients = secrets["recipients"]
.as_array_mut()
.ok_or_else(|| anyhow::anyhow!("[secrets].recipients is not an array"))?;
let already_present = recipients.iter().any(|v| v.as_str() == Some(public));
if already_present {
return Ok(doc.to_string());
}
let mut value = Value::from(public);
let prefix = format!("\n # {comment}\n ");
*value.decor_mut() = toml_edit::Decor::new(prefix, "");
recipients.push_formatted(value);
recipients.set_trailing("\n");
recipients.set_trailing_comma(true);
Ok(doc.to_string())
}
pub fn secret_encrypt(
source: Option<Utf8PathBuf>,
path: Utf8PathBuf,
force: bool,
rm_plaintext: bool,
) -> Result<()> {
let source = resolve_source(source)?;
let yui = YuiVars::detect(&source);
let config = config::load(&source, &yui)?;
if !config.secrets.enabled() {
anyhow::bail!(
"no recipients configured — run `yui secret init` to generate \
a keypair, or add at least one entry to `[secrets] recipients`."
);
}
let plaintext_path = if path.is_absolute() {
path.clone()
} else {
absolutize(&path)?
};
if !plaintext_path.is_file() {
anyhow::bail!("plaintext file not found: {plaintext_path}");
}
let cipher_path = Utf8PathBuf::from(format!("{plaintext_path}.age"));
if cipher_path.exists() && !force {
anyhow::bail!("{cipher_path} already exists; pass --force to overwrite");
}
let plaintext = std::fs::read(&plaintext_path)?;
let recipients = secret::parse_passkey_recipients(&config.secrets.recipients)?;
let cipher = secret::encrypt_to_passkeys(&plaintext, &recipients)?;
std::fs::write(&cipher_path, &cipher)?;
info!("encrypted {plaintext_path} → {cipher_path}");
if config.render.manage_gitignore && plaintext_path.starts_with(&source) {
render::add_to_managed_section(&source, &plaintext_path)?;
}
info!("run `yui apply` to refresh links and the rest of the managed section");
if rm_plaintext {
if plaintext_path.starts_with(&source) {
std::fs::remove_file(&plaintext_path)?;
info!("removed plaintext: {plaintext_path}");
} else {
warn!(
"plaintext lives outside source ({plaintext_path}); \
skipping --rm-plaintext as a safety check"
);
}
}
Ok(())
}
pub fn secret_store(source: Option<Utf8PathBuf>, force: bool) -> Result<()> {
let source = resolve_source(source)?;
let yui = YuiVars::detect(&source);
let config = config::load(&source, &yui)?;
let vault_cfg = config.secrets.vault.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"[secrets.vault] is not configured — set provider \
(\"bitwarden\" or \"1password\") and item before \
calling store"
)
})?;
let identity_path = paths::expand_tilde(&config.secrets.identity);
if !identity_path.is_file() {
anyhow::bail!(
"no X25519 identity at {identity_path}; run `yui secret init` first \
(store needs that file's content to push to the vault)"
);
}
let plaintext = std::fs::read(&identity_path)?;
secret::validate_x25519_identity_bytes(&plaintext)?;
let vault = vault::driver(vault_cfg);
vault.precheck()?;
info!(
"pushing X25519 identity to {} item {:?}",
vault.provider_name(),
config::VAULT_ITEM_NAME
);
vault.store(config::VAULT_ITEM_NAME, &plaintext, force)?;
println!();
println!(
" X25519 identity pushed to {} item {:?}",
vault.provider_name(),
config::VAULT_ITEM_NAME
);
println!(" On a new machine, run `yui secret unlock`.");
Ok(())
}
pub fn secret_unlock(source: Option<Utf8PathBuf>) -> Result<()> {
let source = resolve_source(source)?;
let yui = YuiVars::detect(&source);
let config = config::load(&source, &yui)?;
let vault_cfg = config.secrets.vault.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"[secrets.vault] is not configured — nothing to unlock. \
Run `yui secret init` + `yui secret store` on an existing \
machine first, then commit + push the config."
)
})?;
let identity_path = paths::expand_tilde(&config.secrets.identity);
if identity_path.exists() {
anyhow::bail!(
"{identity_path} already exists — refusing to clobber a live \
X25519 identity. Delete it first if you really mean to \
re-unlock from scratch."
);
}
let vault = vault::driver(vault_cfg);
vault.precheck()?;
info!(
"fetching X25519 identity from {} item {:?}",
vault.provider_name(),
config::VAULT_ITEM_NAME
);
let plaintext = vault.fetch(config::VAULT_ITEM_NAME)?;
secret::validate_x25519_identity_bytes(&plaintext)?;
secret::write_private_file(&identity_path, &plaintext)?;
info!("wrote X25519 identity: {identity_path}");
println!();
println!(" X25519 identity restored at {identity_path}");
println!(" Run `yui apply` next.");
Ok(())
}
pub fn update(source: Option<Utf8PathBuf>, dry_run: bool) -> Result<()> {
let source = resolve_source(source)?;
if !crate::git::is_clean(&source)? {
anyhow::bail!(
"source repo {source} has uncommitted changes — \
commit or stash before `yui update` (or run \
`git pull` + `yui apply` manually if you know what \
you're doing)"
);
}
info!("git pull --ff-only at {source}");
let status = std::process::Command::new("git")
.arg("-C")
.arg(source.as_str())
.arg("pull")
.arg("--ff-only")
.status()
.map_err(|e| anyhow::anyhow!("invoking git: {e}"))?;
if !status.success() {
anyhow::bail!("git pull --ff-only failed at {source}");
}
apply(Some(source), dry_run)
}
pub fn unmanaged(
source: Option<Utf8PathBuf>,
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 = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
let color = !no_color && supports_color_stdout();
let mut engine = template::Engine::new();
let tera_ctx = template::template_context(&yui, &config.vars);
let mount_srcs: Vec<Utf8PathBuf> = config
.mount
.entry
.iter()
.map(|e| -> Result<Utf8PathBuf> {
let rendered = engine.render(e.src.as_str(), &tera_ctx)?;
Ok(paths::resolve_mount_src(&source, rendered.trim()))
})
.collect::<Result<_>>()?;
let mut items: Vec<Utf8PathBuf> = Vec::new();
let walker = paths::source_walker(&source).build();
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;
}
let std_path = entry.path();
let path = match Utf8PathBuf::from_path_buf(std_path.to_path_buf()) {
Ok(p) => p,
Err(_) => continue,
};
if is_repo_meta(&path, &source, &config.mount.marker_filename) {
continue;
}
if mount_srcs.iter().any(|m| path.starts_with(m)) {
continue;
}
items.push(path);
}
items.sort();
if items.is_empty() {
println!(" no unmanaged files under {source}");
return Ok(());
}
print_unmanaged_table(&items, &source, color);
println!();
println!(" {} unmanaged file(s)", items.len());
Ok(())
}
fn is_repo_meta(path: &Utf8Path, source: &Utf8Path, marker_filename: &str) -> bool {
let Some(name) = path.file_name() else {
return false;
};
if name.ends_with(".tera") {
return true;
}
if name == marker_filename || name == ".yuiignore" {
return true;
}
let parent = path.parent().unwrap_or(Utf8Path::new(""));
let at_root = parent == source;
if at_root && name == ".gitignore" {
return true;
}
if at_root && (name == "config.toml" || name == "config.local.toml") {
return true;
}
if at_root
&& name.starts_with("config.")
&& (name.ends_with(".toml") || name.ends_with(".example.toml"))
{
return true;
}
false
}
fn print_unmanaged_table(items: &[Utf8PathBuf], source: &Utf8Path, color: bool) {
use owo_colors::OwoColorize as _;
if color {
println!(" {}", "PATH (relative to source)".dimmed());
} else {
println!(" PATH (relative to source)");
}
for p in items {
let rel = p
.strip_prefix(source)
.map(Utf8PathBuf::from)
.unwrap_or_else(|_| p.clone());
if color {
println!(" {}", rel.cyan());
} else {
println!(" {rel}");
}
}
}
pub fn diff(
source: Option<Utf8PathBuf>,
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 mut engine = template::Engine::new();
let tera_ctx = template::template_context(&yui, &config.vars);
let mounts = mount::resolve(
&source,
&config.mount.entry,
config.mount.default_strategy,
&mut engine,
&tera_ctx,
)?;
let _icons = Icons::for_mode(icons_override.unwrap_or(config.ui.icons));
let color = !no_color && supports_color_stdout();
let mut report: Vec<StatusItem> = Vec::new();
let mut yuiignore = paths::YuiIgnoreStack::new();
yuiignore.push_dir(&source)?;
let walk_result = (|| -> Result<()> {
for m in &mounts {
let src_root = m.src.clone();
if !src_root.is_dir() {
continue;
}
classify_walk(
&src_root,
&m.dst,
&config,
m.strategy,
&mut engine,
&tera_ctx,
&source,
&mut yuiignore,
&mut report,
)?;
}
Ok(())
})();
yuiignore.pop_dir(&source);
walk_result?;
let render_report = render::render_all(&source, &config, &yui, true)?;
for rendered in &render_report.diverged {
let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
report.push(StatusItem {
src: tera_path,
dst: rendered.clone(),
state: StatusState::RenderDrift,
});
}
let mut printed = 0usize;
for item in &report {
if !diff_worth_printing(&item.state) {
continue;
}
let src_abs = resolve_diff_src(item, &source);
print_unified_diff(
&src_abs,
&item.dst,
&item.state,
&source,
&config,
&yui,
color,
);
printed += 1;
}
if printed == 0 {
println!(" no diff — every entry is in sync (or only needs a relink)");
} else {
println!();
println!(
" {printed} entr{} with content drift",
if printed == 1 { "y" } else { "ies" }
);
}
Ok(())
}
fn resolve_diff_src(item: &StatusItem, source: &Utf8Path) -> Utf8PathBuf {
match item.state {
StatusState::RenderDrift => item.src.clone(),
StatusState::Link(_) => source.join(&item.src),
}
}
fn diff_worth_printing(state: &StatusState) -> bool {
use absorb::AbsorbDecision::*;
match state {
StatusState::Link(InSync) => false,
StatusState::Link(Restore) => false, StatusState::Link(RelinkOnly) => false, StatusState::Link(_) => true,
StatusState::RenderDrift => true,
}
}
fn print_unified_diff(
src: &Utf8Path,
dst: &Utf8Path,
state: &StatusState,
source_root: &Utf8Path,
config: &Config,
yui: &YuiVars,
color: bool,
) {
use owo_colors::OwoColorize as _;
let header = match state {
StatusState::RenderDrift => format!("--- render drift: {src} (template) vs {dst}"),
_ => format!("--- {src} → {dst}"),
};
if color {
println!("{}", header.bold());
} else {
println!("{header}");
}
if src.is_dir() || dst.is_dir() {
println!("(directory entry — content listing skipped)");
println!();
return;
}
let src_content = match state {
StatusState::RenderDrift => match render::render_to_string(src, source_root, config, yui) {
Ok(Some(s)) => s,
Ok(None) => {
println!(
"(template would be skipped on this host — drift will resolve on next render)"
);
println!();
return;
}
Err(e) => {
println!("(error rendering template: {e})");
println!();
return;
}
},
_ => match read_text_for_diff(src) {
DiffSide::Text(s) => s,
DiffSide::Binary => {
println!("(binary file or non-UTF-8 content — diff skipped)");
println!();
return;
}
},
};
let dst_content = match read_text_for_diff(dst) {
DiffSide::Text(s) => s,
DiffSide::Binary => {
println!("(binary file or non-UTF-8 content — diff skipped)");
println!();
return;
}
};
print_unified_text_diff(
&src_content,
&dst_content,
src.as_str(),
dst.as_str(),
color,
);
println!();
}
fn print_unified_text_diff(src: &str, dst: &str, src_label: &str, dst_label: &str, color: bool) {
use owo_colors::OwoColorize as _;
let diff = similar::TextDiff::from_lines(src, dst);
let formatted = diff.unified_diff().header(src_label, dst_label).to_string();
for line in formatted.lines() {
if !color {
println!("{line}");
} else if line.starts_with("+++") || line.starts_with("---") {
println!("{}", line.dimmed());
} else if line.starts_with("@@") {
println!("{}", line.cyan());
} else if line.starts_with('+') {
println!("{}", line.green());
} else if line.starts_with('-') {
println!("{}", line.red());
} else {
println!("{line}");
}
}
}
enum DiffSide {
Text(String),
Binary,
}
fn read_text_for_diff(p: &Utf8Path) -> DiffSide {
match std::fs::read_to_string(p) {
Ok(s) => DiffSide::Text(s),
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => DiffSide::Binary,
Err(_) => DiffSide::Text(String::new()),
}
}
pub fn status(
source: Option<Utf8PathBuf>,
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 mut engine = template::Engine::new();
let tera_ctx = template::template_context(&yui, &config.vars);
let mounts = mount::resolve(
&source,
&config.mount.entry,
config.mount.default_strategy,
&mut engine,
&tera_ctx,
)?;
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 mut report: Vec<StatusItem> = Vec::new();
let render_report = render::render_all(&source, &config, &yui, true)?;
for rendered in &render_report.diverged {
let tera_path = Utf8PathBuf::from(format!("{rendered}.tera"));
report.push(StatusItem {
src: relative_for_display(&source, &tera_path),
dst: rendered.clone(),
state: StatusState::RenderDrift,
});
}
let mut yuiignore = paths::YuiIgnoreStack::new();
yuiignore.push_dir(&source)?;
let walk_result = (|| -> Result<()> {
for m in &mounts {
let src_root = m.src.clone();
if !src_root.is_dir() {
warn!("mount src missing: {src_root}");
continue;
}
classify_walk(
&src_root,
&m.dst,
&config,
m.strategy,
&mut engine,
&tera_ctx,
&source,
&mut yuiignore,
&mut report,
)?;
}
Ok(())
})();
yuiignore.pop_dir(&source);
walk_result?;
report.sort_by(|a, b| a.src.cmp(&b.src).then_with(|| a.dst.cmp(&b.dst)));
print_status_table(&report, icons, color);
let drift = report.iter().filter(|r| !r.state.is_in_sync()).count();
println!();
let total = report.len();
let in_sync = total - drift;
if drift == 0 {
println!(" {total} entries · all in sync");
Ok(())
} else {
println!(" {total} entries · {in_sync} in sync · {drift} diverged");
anyhow::bail!("status: {drift} entries diverged from source")
}
}
#[derive(Debug)]
struct StatusItem {
src: Utf8PathBuf,
dst: Utf8PathBuf,
state: StatusState,
}
#[derive(Debug, Clone, Copy)]
enum StatusState {
Link(absorb::AbsorbDecision),
RenderDrift,
}
impl StatusState {
fn is_in_sync(self) -> bool {
matches!(self, Self::Link(absorb::AbsorbDecision::InSync))
}
}
#[allow(clippy::too_many_arguments)]
fn classify_walk(
src_dir: &Utf8Path,
dst_dir: &Utf8Path,
config: &Config,
strategy: MountStrategy,
engine: &mut template::Engine,
tera_ctx: &TeraContext,
source_root: &Utf8Path,
yuiignore: &mut paths::YuiIgnoreStack,
report: &mut Vec<StatusItem>,
) -> Result<()> {
classify_walk_inner(
src_dir,
dst_dir,
config,
strategy,
engine,
tera_ctx,
source_root,
yuiignore,
report,
false,
)
}
#[allow(clippy::too_many_arguments)]
fn classify_walk_inner(
src_dir: &Utf8Path,
dst_dir: &Utf8Path,
config: &Config,
strategy: MountStrategy,
engine: &mut template::Engine,
tera_ctx: &TeraContext,
source_root: &Utf8Path,
yuiignore: &mut paths::YuiIgnoreStack,
report: &mut Vec<StatusItem>,
parent_covered: bool,
) -> Result<()> {
if yuiignore.is_ignored(src_dir, true) {
return Ok(());
}
yuiignore.push_dir(src_dir)?;
let result = classify_walk_inner_body(
src_dir,
dst_dir,
config,
strategy,
engine,
tera_ctx,
source_root,
yuiignore,
report,
parent_covered,
);
yuiignore.pop_dir(src_dir);
result
}
#[allow(clippy::too_many_arguments)]
fn classify_walk_inner_body(
src_dir: &Utf8Path,
dst_dir: &Utf8Path,
config: &Config,
strategy: MountStrategy,
engine: &mut template::Engine,
tera_ctx: &TeraContext,
source_root: &Utf8Path,
yuiignore: &mut paths::YuiIgnoreStack,
report: &mut Vec<StatusItem>,
parent_covered: bool,
) -> Result<()> {
let marker_filename = &config.mount.marker_filename;
let mut covered = parent_covered;
if strategy == MountStrategy::Marker {
match marker::read_spec(src_dir, marker_filename)? {
None => {}
Some(MarkerSpec::PassThrough) => {
let decision = absorb::classify(src_dir, dst_dir)?;
report.push(StatusItem {
src: relative_for_display(source_root, src_dir),
dst: dst_dir.to_path_buf(),
state: StatusState::Link(decision),
});
covered = true;
}
Some(MarkerSpec::Explicit { links }) => {
let mut emitted_dir_link = 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 = paths::expand_tilde(dst_str.trim());
if let Some(filename) = &link.src {
let file_src = src_dir.join(filename);
if !file_src.is_file() {
anyhow::bail!(
"marker at {src_dir}: [[link]] src={filename:?} \
not found"
);
}
let decision = absorb::classify(&file_src, &dst)?;
report.push(StatusItem {
src: relative_for_display(source_root, &file_src),
dst,
state: StatusState::Link(decision),
});
} else {
let decision = absorb::classify(src_dir, &dst)?;
report.push(StatusItem {
src: relative_for_display(source_root, src_dir),
dst,
state: StatusState::Link(decision),
});
emitted_dir_link = true;
}
}
if emitted_dir_link {
covered = true;
}
}
}
}
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 || 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 yuiignore.is_ignored(&src_path, ft.is_dir()) {
continue;
}
if ft.is_dir() {
classify_walk_inner(
&src_path,
&dst_path,
config,
strategy,
engine,
tera_ctx,
source_root,
yuiignore,
report,
covered,
)?;
} else if ft.is_file() && !covered {
let decision = absorb::classify(&src_path, &dst_path)?;
report.push(StatusItem {
src: relative_for_display(source_root, &src_path),
dst: dst_path,
state: StatusState::Link(decision),
});
}
}
Ok(())
}
fn relative_for_display(source_root: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
p.strip_prefix(source_root)
.map(Utf8PathBuf::from)
.unwrap_or_else(|_| p.to_path_buf())
}
fn print_status_table(items: &[StatusItem], 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.as_str().chars().count())
.max()
.unwrap_or(0)
.max("DST".len());
let state_label_w = items
.iter()
.map(|i| state_label(i.state).len())
.max()
.unwrap_or(0)
.max("STATE".len() - 2); let state_w = state_label_w + 2;
print_status_header(state_w, src_w, dst_w, color);
let sep = render_status_separator(icons.sep, state_w, src_w, dst_w, icons.arrow);
if color {
use owo_colors::OwoColorize as _;
println!("{}", sep.dimmed());
} else {
println!("{sep}");
}
for item in items {
print_status_row(item, icons, state_w, src_w, dst_w, color);
}
}
fn state_label(s: StatusState) -> &'static str {
use absorb::AbsorbDecision::*;
match s {
StatusState::Link(InSync) => "in-sync",
StatusState::Link(RelinkOnly) => "relink",
StatusState::Link(AutoAbsorb) => "drift (auto)",
StatusState::Link(NeedsConfirm) => "drift (anomaly)",
StatusState::Link(Restore) => "missing",
StatusState::RenderDrift => "render drift",
}
}
fn state_icon(s: StatusState, icons: Icons) -> &'static str {
use absorb::AbsorbDecision::*;
match s {
StatusState::Link(InSync) => icons.ok,
StatusState::Link(RelinkOnly) => icons.warn,
StatusState::Link(AutoAbsorb) => icons.warn,
StatusState::Link(NeedsConfirm) => icons.error,
StatusState::Link(Restore) => icons.info,
StatusState::RenderDrift => icons.error,
}
}
fn print_status_header(state_w: usize, src_w: usize, dst_w: usize, color: bool) {
use owo_colors::OwoColorize as _;
let line = format!(
" {:<state_w$} {:<src_w$} {:<dst_w$}",
"STATE", "SRC", "DST"
);
if color {
println!("{}", line.bold());
} else {
println!("{line}");
}
}
fn render_status_separator(
sep_ch: char,
state_w: usize,
src_w: usize,
dst_w: usize,
arrow: &str,
) -> String {
let bar = |n: usize| sep_ch.to_string().repeat(n);
format!(
" {} {} {} {}",
bar(state_w),
bar(src_w),
bar(arrow.chars().count()),
bar(dst_w)
)
}
fn print_status_row(
item: &StatusItem,
icons: Icons,
state_w: usize,
src_w: usize,
dst_w: usize,
color: bool,
) {
use owo_colors::OwoColorize as _;
let icon = state_icon(item.state, icons);
let label = state_label(item.state);
let state_text = format!("{icon} {label}");
let src_display = item.src.as_str().replace('\\', "/");
let dst_display = item.dst.as_str().replace('\\', "/");
let arrow = icons.arrow;
let cell_state = format!("{:<state_w$}", state_text);
let cell_src = format!("{:<src_w$}", src_display);
let cell_dst = format!("{:<dst_w$}", dst_display);
if !color {
println!(" {cell_state} {cell_src} {arrow} {cell_dst}");
return;
}
use absorb::AbsorbDecision::*;
let state_colored = match item.state {
StatusState::Link(InSync) => cell_state.green().to_string(),
StatusState::Link(RelinkOnly) | StatusState::Link(AutoAbsorb) => {
cell_state.yellow().to_string()
}
StatusState::Link(NeedsConfirm) => cell_state.red().to_string(),
StatusState::Link(Restore) => cell_state.cyan().to_string(),
StatusState::RenderDrift => cell_state.red().to_string(),
};
let src_colored = cell_src.cyan().to_string();
let arrow_colored = arrow.dimmed().to_string();
let dst_colored = cell_dst.dimmed().to_string();
println!(" {state_colored} {src_colored} {arrow_colored} {dst_colored}");
}
pub fn absorb(
source: Option<Utf8PathBuf>,
target: Utf8PathBuf,
dry_run: bool,
yes: bool,
) -> Result<()> {
let source = resolve_source(source)?;
let target = absolutize(&target)?;
let yui = YuiVars::detect(&source);
let config = config::load(&source, &yui)?;
let mut engine = template::Engine::new();
let tera_ctx = template::template_context(&yui, &config.vars);
let src_path = match find_source_for_target(&source, &config, &target, &mut engine, &tera_ctx)?
{
Some(s) => s,
None => anyhow::bail!(
"no mount entry / .yuilink override claims target {target}; \
pass a path inside a known dst"
),
};
info!("source for {target}: {src_path}");
print_absorb_diff(&src_path, &target);
if dry_run {
info!("[dry-run] would absorb {target} → {src_path}");
return Ok(());
}
if !yes {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
anyhow::bail!(
"manual absorb refuses to run off-TTY without --yes \
(would silently overwrite {src_path})"
);
}
if !prompt_yes_no("absorb target into source?")? {
warn!("manual absorb cancelled by user: {target}");
return Ok(());
}
}
let backup_root = source.join(&config.backup.dir);
let ctx = ApplyCtx {
config: &config,
source: &source,
file_mode: resolve_file_mode(config.link.file_mode),
dir_mode: resolve_dir_mode(config.link.dir_mode),
backup_root: &backup_root,
dry_run: false,
sticky_anomaly: Cell::new(None),
quit_requested: Cell::new(false),
};
absorb_target_into_source(&src_path, &target, &ctx)
}
fn print_absorb_diff(src: &Utf8Path, dst: &Utf8Path) {
use owo_colors::OwoColorize as _;
use std::io::IsTerminal;
let color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none();
eprintln!();
if color {
eprintln!(
"{} {} {}",
"── unified diff ──".bold(),
"[-] src".red().bold(),
"[+] dst".green().bold()
);
eprintln!(" {} {}", "[-] src:".red(), src);
eprintln!(" {} {}", "[+] dst:".green(), dst);
} else {
eprintln!("── unified diff ── [-] src [+] dst");
eprintln!(" [-] src: {src}");
eprintln!(" [+] dst: {dst}");
}
eprintln!();
if src.is_dir() || dst.is_dir() {
eprintln!("(directory absorb — content listing skipped)");
eprintln!();
return;
}
let src_content = match read_text_for_diff(src) {
DiffSide::Text(s) => s,
DiffSide::Binary => {
eprintln!("(binary file or non-UTF-8 content — diff skipped)");
eprintln!();
return;
}
};
let dst_content = match read_text_for_diff(dst) {
DiffSide::Text(s) => s,
DiffSide::Binary => {
eprintln!("(binary file or non-UTF-8 content — diff skipped)");
eprintln!();
return;
}
};
let diff = similar::TextDiff::from_lines(&src_content, &dst_content);
for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
let header = hunk.header().to_string();
if color {
eprintln!("{}", header.cyan());
} else {
eprintln!("{header}");
}
for change in hunk.iter_changes() {
let line = change.value();
let line = line.strip_suffix('\n').unwrap_or(line);
match change.tag() {
similar::ChangeTag::Delete => {
if color {
eprintln!("{} {}", "-".red().bold(), line.red());
} else {
eprintln!("- {line}");
}
}
similar::ChangeTag::Insert => {
if color {
eprintln!("{} {}", "+".green().bold(), line.green());
} else {
eprintln!("+ {line}");
}
}
similar::ChangeTag::Equal => {
if color {
eprintln!(" {}", line.dimmed());
} else {
eprintln!(" {line}");
}
}
}
}
}
eprintln!();
}
fn prompt_yes_no(question: &str) -> Result<bool> {
use std::io::Write as _;
eprint!("{question} [y/N]: ");
std::io::stderr().flush().ok();
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let answer = input.trim();
Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
}
fn find_source_for_target(
source: &Utf8Path,
config: &Config,
target: &Utf8Path,
engine: &mut template::Engine,
tera_ctx: &TeraContext,
) -> Result<Option<Utf8PathBuf>> {
for entry in &config.mount.entry {
if let Some(when) = &entry.when {
if !template::eval_truthy(when, engine, tera_ctx)? {
continue;
}
}
let dst_str = engine.render(&entry.dst, tera_ctx)?;
let dst_root = paths::expand_tilde(dst_str.trim());
if let Ok(rel) = target.strip_prefix(&dst_root) {
let src_str = engine.render(entry.src.as_str(), tera_ctx)?;
let candidate = paths::resolve_mount_src(source, src_str.trim()).join(rel);
if paths::is_ignored_at(source, &candidate, candidate.is_dir())? {
continue;
}
return Ok(Some(candidate));
}
}
let walker = paths::source_walker(source).build();
let marker_filename = &config.mount.marker_filename;
for ent in walker {
let ent = match ent {
Ok(e) => e,
Err(_) => continue,
};
if !ent.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
if ent.path().file_name().and_then(|n| n.to_str()) != Some(marker_filename.as_str()) {
continue;
}
let dir = match ent.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::Explicit { links } = spec else {
continue;
};
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 = paths::expand_tilde(dst_str.trim());
if let Some(filename) = &link.src {
let file_src = dir_utf8.join(filename);
if !file_src.is_file() {
anyhow::bail!(
"marker at {dir_utf8}: [[link]] src={filename:?} \
not found"
);
}
if target == dst {
return Ok(Some(file_src));
}
continue;
}
if target == dst {
return Ok(Some(dir_utf8));
}
if let Ok(rel) = target.strip_prefix(&dst) {
return Ok(Some(dir_utf8.join(rel)));
}
}
}
Ok(None)
}
pub fn doctor(
source: Option<Utf8PathBuf>,
icons_override: Option<IconsMode>,
no_color: bool,
) -> Result<()> {
use owo_colors::OwoColorize as _;
let resolved_source = resolve_source(source);
let yui = match &resolved_source {
Ok(s) => YuiVars::detect(s),
Err(_) => YuiVars::detect(Utf8Path::new(".")),
};
let cfg_res = match &resolved_source {
Ok(s) => Some(config::load(s, &yui)),
Err(_) => None,
};
let cfg = cfg_res.as_ref().and_then(|r| r.as_ref().ok());
let icons_mode = icons_override
.or_else(|| cfg.map(|c| c.ui.icons))
.unwrap_or_default();
let icons = Icons::for_mode(icons_mode);
let color = !no_color && supports_color_stdout();
let mut probes: Vec<Probe> = Vec::new();
probes.push(Probe::group("identity"));
probes.push(Probe::ok("os/arch", format!("{} / {}", yui.os, yui.arch)));
probes.push(Probe::ok("user@host", format!("{}@{}", yui.user, yui.host)));
probes.push(Probe::group("repo"));
let mut have_source = false;
match &resolved_source {
Ok(s) => {
have_source = true;
probes.push(Probe::ok("source", s.to_string()));
match cfg_res.as_ref().expect("cfg_res set when source is Ok") {
Ok(c) => {
probes.push(Probe::ok(
"config",
format!(
"{} mount{} · {} hook{} · {} render rule{}",
c.mount.entry.len(),
plural(c.mount.entry.len()),
c.hook.len(),
plural(c.hook.len()),
c.render.rule.len(),
plural(c.render.rule.len()),
),
));
}
Err(e) => probes.push(Probe::error("config", format!("{e}"))),
}
match crate::git::is_clean(s) {
Ok(true) => probes.push(Probe::ok("git", "clean")),
Ok(false) => probes.push(Probe::warn(
"git",
"uncommitted changes — `[absorb] require_clean_git` will defer auto-absorb",
)),
Err(_) => probes.push(Probe::warn(
"git",
"no git repo (auto-absorb still works; commit history won't track drift)",
)),
}
}
Err(e) => {
probes.push(Probe::error("source", format!("not found — {e}")));
}
}
probes.push(Probe::group("links"));
if cfg!(windows) {
probes.push(Probe::ok(
"default mode",
"files=hardlink, dirs=junction (no admin needed)",
));
} else {
probes.push(Probe::ok("default mode", "files=symlink, dirs=symlink"));
}
if have_source {
if let (Ok(s), Some(c)) = (&resolved_source, cfg) {
probes.push(Probe::group("hooks"));
if c.hook.is_empty() {
probes.push(Probe::ok("hooks", "(none configured)"));
} else {
let mut missing = 0usize;
for h in &c.hook {
if !s.join(&h.script).is_file() {
missing += 1;
probes.push(Probe::error(
format!("hook[{}]", h.name),
format!("script not found at {}", h.script),
));
}
}
if missing == 0 {
probes.push(Probe::ok(
"scripts",
format!(
"{} hook{} configured, all scripts present",
c.hook.len(),
plural(c.hook.len())
),
));
}
}
}
}
if let Some(home) = paths::home_dir() {
let chezmoi_src = home.join(".local/share/chezmoi");
if chezmoi_src.is_dir() {
probes.push(Probe::group("chezmoi"));
probes.push(Probe::warn(
"legacy source",
format!(
"{chezmoi_src} still exists — yui doesn't use it, safe to archive once your migration has settled"
),
));
}
}
println!();
if color {
println!(" {}", "yui doctor".bold().underline());
} else {
println!(" yui doctor");
}
println!();
for probe in &probes {
probe.print(&icons, color);
}
let errors = probes.iter().filter(|p| p.is_error()).count();
let warns = probes.iter().filter(|p| p.is_warn()).count();
let oks = probes.iter().filter(|p| p.is_ok()).count();
println!();
let summary = format!("{oks} ok · {warns} warn · {errors} error");
if color {
if errors > 0 {
println!(" {}", summary.red().bold());
} else if warns > 0 {
println!(" {}", summary.yellow());
} else {
println!(" {}", summary.green());
}
} else {
println!(" {summary}");
}
if errors > 0 {
anyhow::bail!("doctor: {errors} probe(s) failed");
}
Ok(())
}
#[derive(Debug)]
enum Probe {
Group(&'static str),
Ok {
label: String,
detail: String,
},
Warn {
label: String,
detail: String,
},
Error {
label: String,
detail: String,
},
}
impl Probe {
fn group(label: &'static str) -> Self {
Self::Group(label)
}
fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self::Ok {
label: label.into(),
detail: detail.into(),
}
}
fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self::Warn {
label: label.into(),
detail: detail.into(),
}
}
fn error(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self::Error {
label: label.into(),
detail: detail.into(),
}
}
fn is_ok(&self) -> bool {
matches!(self, Self::Ok { .. })
}
fn is_warn(&self) -> bool {
matches!(self, Self::Warn { .. })
}
fn is_error(&self) -> bool {
matches!(self, Self::Error { .. })
}
fn print(&self, icons: &Icons, color: bool) {
use owo_colors::OwoColorize as _;
match self {
Self::Group(name) => {
println!();
if color {
println!(" {}", name.cyan().bold());
} else {
println!(" {name}");
}
}
Self::Ok { label, detail } => {
let icon = icons.ok;
let padded = format!("{label:<14}");
if color {
println!(
" {} {} {}",
icon.green(),
padded.bold(),
detail.dimmed()
);
} else {
println!(" {icon} {padded} {detail}");
}
}
Self::Warn { label, detail } => {
let icon = icons.warn;
let padded = format!("{label:<14}");
if color {
println!(
" {} {} {}",
icon.yellow(),
padded.bold().yellow(),
detail
);
} else {
println!(" {icon} {padded} {detail}");
}
}
Self::Error { label, detail } => {
let icon = icons.error;
let padded = format!("{label:<14}");
if color {
println!(
" {} {} {}",
icon.red().bold(),
padded.bold().red(),
detail.red()
);
} else {
println!(" {icon} {padded} {detail}");
}
}
}
}
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
pub fn gc_backup(
source: Option<Utf8PathBuf>,
older_than: Option<String>,
dry_run: 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 backup_root = source.join(&config.backup.dir);
let icons_mode = icons_override.unwrap_or(config.ui.icons);
let icons = Icons::for_mode(icons_mode);
let color = !no_color && supports_color_stdout();
if !backup_root.is_dir() {
println!(" no backup tree at {backup_root}");
return Ok(());
}
let mut entries = walk_gc_backups(&backup_root)?;
if entries.is_empty() {
println!(" no yui-stamped backups under {backup_root}");
return Ok(());
}
entries.sort_by_key(|e| e.ts);
let now = jiff::Zoned::now();
match older_than {
None => {
let refs: Vec<&BackupEntry> = entries.iter().collect();
print_gc_table(&refs, &backup_root, &now, icons, color);
println!();
println!(
" {} entries · {} total — pass --older-than DUR (e.g. 30d) to delete",
entries.len(),
format_bytes(entries.iter().map(|e| e.size_bytes).sum())
);
Ok(())
}
Some(dur_str) => {
let span = parse_human_duration(&dur_str)?;
let cutoff = now
.checked_sub(span)
.map_err(|e| anyhow::anyhow!("invalid duration {dur_str:?}: {e}"))?;
let cutoff_dt = cutoff.datetime();
let total_before: u64 = entries.iter().map(|e| e.size_bytes).sum();
let to_delete: Vec<&BackupEntry> =
entries.iter().filter(|e| e.ts < cutoff_dt).collect();
if to_delete.is_empty() {
println!(
" no backups older than {dur_str} (oldest: {})",
format_age(entries[0].ts, &now)
);
return Ok(());
}
print_gc_table(&to_delete, &backup_root, &now, icons, color);
println!();
let total_freed: u64 = to_delete.iter().map(|e| e.size_bytes).sum();
if dry_run {
println!(
" [dry-run] would remove {} of {} entries · would free {} of {}",
to_delete.len(),
entries.len(),
format_bytes(total_freed),
format_bytes(total_before),
);
return Ok(());
}
for entry in &to_delete {
match entry.kind {
BackupKind::File => std::fs::remove_file(&entry.path)?,
BackupKind::Dir => std::fs::remove_dir_all(&entry.path)?,
}
if let Some(parent) = entry.path.parent() {
cleanup_empty_parents(parent, &backup_root);
}
}
println!(
" removed {} of {} entries · freed {} (was {}, now {})",
to_delete.len(),
entries.len(),
format_bytes(total_freed),
format_bytes(total_before),
format_bytes(total_before - total_freed),
);
Ok(())
}
}
}
#[derive(Debug)]
struct BackupEntry {
path: Utf8PathBuf,
ts: jiff::civil::DateTime,
kind: BackupKind,
size_bytes: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BackupKind {
File,
Dir,
}
fn walk_gc_backups(root: &Utf8Path) -> Result<Vec<BackupEntry>> {
let mut out = Vec::new();
walk_gc_backups_rec(root, &mut out)?;
Ok(out)
}
fn walk_gc_backups_rec(dir: &Utf8Path, out: &mut Vec<BackupEntry>) -> Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let name_os = entry.file_name();
let Some(name) = name_os.to_str() else {
continue;
};
let path = dir.join(name);
let ft = entry.file_type()?;
if ft.is_dir() {
if let Some(ts) = parse_backup_suffix(name) {
let size = dir_size(&path)?;
out.push(BackupEntry {
path,
ts,
kind: BackupKind::Dir,
size_bytes: size,
});
} else {
walk_gc_backups_rec(&path, out)?;
}
} else if ft.is_file() {
if let Some(ts) = parse_backup_suffix(name) {
let size = entry.metadata()?.len();
out.push(BackupEntry {
path,
ts,
kind: BackupKind::File,
size_bytes: size,
});
}
}
}
Ok(())
}
fn dir_size(dir: &Utf8Path) -> Result<u64> {
let mut total: u64 = 0;
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let ft = entry.file_type()?;
if ft.is_dir() {
let p = match Utf8PathBuf::from_path_buf(entry.path()) {
Ok(p) => p,
Err(_) => continue,
};
total = total.saturating_add(dir_size(&p)?);
} else if ft.is_file() {
total = total.saturating_add(entry.metadata()?.len());
}
}
Ok(total)
}
fn cleanup_empty_parents(start: &Utf8Path, root: &Utf8Path) {
let mut cur = start.to_path_buf();
loop {
if cur == *root {
return;
}
if std::fs::remove_dir(&cur).is_err() {
return;
}
match cur.parent() {
Some(p) => cur = p.to_path_buf(),
None => return,
}
}
}
fn parse_backup_suffix(name: &str) -> Option<jiff::civil::DateTime> {
if let Some(ts) = parse_ts_at_end(name) {
return Some(ts);
}
if let Some((before, _ext)) = name.rsplit_once('.') {
if let Some(ts) = parse_ts_at_end(before) {
return Some(ts);
}
}
None
}
fn parse_ts_at_end(s: &str) -> Option<jiff::civil::DateTime> {
if s.len() < 20 {
return None;
}
let split_at = s.len() - 19;
if s.as_bytes()[split_at] != b'_' {
return None;
}
parse_ts(&s[split_at + 1..])
}
fn parse_ts(s: &str) -> Option<jiff::civil::DateTime> {
if s.len() != 18 || s.as_bytes()[8] != b'_' {
return None;
}
for (i, &b) in s.as_bytes().iter().enumerate() {
if i == 8 {
continue;
}
if !b.is_ascii_digit() {
return None;
}
}
let year: i16 = s[0..4].parse().ok()?;
let month: i8 = s[4..6].parse().ok()?;
let day: i8 = s[6..8].parse().ok()?;
let hour: i8 = s[9..11].parse().ok()?;
let minute: i8 = s[11..13].parse().ok()?;
let second: i8 = s[13..15].parse().ok()?;
let ms: i32 = s[15..18].parse().ok()?;
jiff::civil::DateTime::new(year, month, day, hour, minute, second, ms * 1_000_000).ok()
}
fn parse_human_duration(s: &str) -> Result<jiff::Span> {
let s = s.trim();
let split = s
.bytes()
.position(|b| b.is_ascii_alphabetic())
.ok_or_else(|| anyhow::anyhow!("invalid duration {s:?}: missing unit (e.g. 30d, 2w)"))?;
let n: i64 = s[..split]
.trim()
.parse()
.map_err(|_| anyhow::anyhow!("invalid duration {s:?}: bad leading number"))?;
if n < 0 {
anyhow::bail!("invalid duration {s:?}: negative durations don't make sense");
}
let unit = s[split..].to_ascii_lowercase();
let span = match unit.as_str() {
"y" | "yr" | "year" | "years" => jiff::Span::new().years(n),
"mo" | "month" | "months" => jiff::Span::new().months(n),
"w" | "wk" | "week" | "weeks" => jiff::Span::new().weeks(n),
"d" | "day" | "days" => jiff::Span::new().days(n),
"h" | "hr" | "hour" | "hours" => jiff::Span::new().hours(n),
"m" | "min" | "minute" | "minutes" => jiff::Span::new().minutes(n),
other => {
anyhow::bail!(
"invalid duration {s:?}: unknown unit {other:?} \
(use y / mo / w / d / h / m)"
)
}
};
Ok(span)
}
fn format_bytes(n: u64) -> String {
const KIB: u64 = 1024;
const MIB: u64 = KIB * 1024;
const GIB: u64 = MIB * 1024;
if n >= GIB {
format!("{:.1} GiB", n as f64 / GIB as f64)
} else if n >= MIB {
format!("{:.1} MiB", n as f64 / MIB as f64)
} else if n >= KIB {
format!("{:.1} KiB", n as f64 / KIB as f64)
} else {
format!("{n} B")
}
}
fn format_age(ts: jiff::civil::DateTime, now: &jiff::Zoned) -> String {
let Ok(ts_zoned) = ts.to_zoned(now.time_zone().clone()) else {
return "?".into();
};
let secs = match (now - &ts_zoned).total(jiff::Unit::Second) {
Ok(s) => s as i64,
Err(_) => return "?".into(),
};
if secs < 0 {
return "future".into();
}
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86_400 {
format!("{}h", secs / 3600)
} else if secs < 86_400 * 30 {
format!("{}d", secs / 86_400)
} else if secs < 86_400 * 365 {
format!("{}mo", secs / (86_400 * 30))
} else {
format!("{}y", secs / (86_400 * 365))
}
}
fn print_gc_table(
entries: &[&BackupEntry],
backup_root: &Utf8Path,
now: &jiff::Zoned,
_icons: Icons,
color: bool,
) {
use owo_colors::OwoColorize as _;
let rows: Vec<(String, String, String)> = entries
.iter()
.map(|e| {
let rel = e
.path
.strip_prefix(backup_root)
.map(Utf8PathBuf::from)
.unwrap_or_else(|_| e.path.clone());
let path_disp = match e.kind {
BackupKind::Dir => format!("{rel}/"),
BackupKind::File => rel.to_string(),
};
(format_age(e.ts, now), format_bytes(e.size_bytes), path_disp)
})
.collect();
let age_w = rows.iter().map(|r| r.0.len()).max().unwrap_or(3);
let size_w = rows.iter().map(|r| r.1.len()).max().unwrap_or(4);
if color {
println!(
" {:<age_w$} {:>size_w$} {}",
"AGE".dimmed(),
"SIZE".dimmed(),
"PATH".dimmed(),
);
} else {
println!(" {:<age_w$} {:>size_w$} PATH", "AGE", "SIZE");
}
for (age, size, path) in &rows {
if color {
println!(
" {:<age_w$} {:>size_w$} {}",
age.yellow(),
size,
path.cyan(),
);
} else {
println!(" {:<age_w$} {:>size_w$} {}", age, size, path);
}
}
}
pub fn hooks_list(
source: Option<Utf8PathBuf>,
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 state = hook::State::load(&source)?;
let icons_mode = icons_override.unwrap_or(config.ui.icons);
let icons = Icons::for_mode(icons_mode);
let color = !no_color && supports_color_stdout();
if config.hook.is_empty() {
println!("(no [[hook]] entries in config)");
return Ok(());
}
let mut engine = template::Engine::new();
let tera_ctx = template::template_context(&yui, &config.vars);
let rows: Vec<HookRow> = config
.hook
.iter()
.map(|h| -> Result<HookRow> {
let active = match &h.when {
None => true,
Some(w) => template::eval_truthy(w, &mut engine, &tera_ctx)?,
};
let last_run_at = state.hooks.get(&h.name).and_then(|s| s.last_run_at.clone());
Ok(HookRow {
name: h.name.clone(),
phase: match h.phase {
HookPhase::Pre => "pre",
HookPhase::Post => "post",
},
when_run: match h.when_run {
config::WhenRun::Once => "once",
config::WhenRun::Onchange => "onchange",
config::WhenRun::Every => "every",
},
last_run_at,
when: h.when.clone(),
active,
})
})
.collect::<Result<Vec<_>>>()?;
print_hooks_table(&rows, icons, color);
let total = rows.len();
let active = rows.iter().filter(|r| r.active).count();
let inactive = total - active;
let ran = rows.iter().filter(|r| r.last_run_at.is_some()).count();
let never = total - ran;
println!();
println!(
" {total} hooks · {active} active · {inactive} inactive · {ran} ran · {never} never run"
);
Ok(())
}
#[derive(Debug)]
struct HookRow {
name: String,
phase: &'static str,
when_run: &'static str,
last_run_at: Option<String>,
when: Option<String>,
active: bool,
}
fn print_hooks_table(rows: &[HookRow], icons: Icons, color: bool) {
use owo_colors::OwoColorize as _;
use std::fmt::Write as _;
let name_w = rows
.iter()
.map(|r| r.name.chars().count())
.max()
.unwrap_or(0)
.max("NAME".len());
let phase_w = rows
.iter()
.map(|r| r.phase.len())
.max()
.unwrap_or(0)
.max("PHASE".len());
let when_run_w = rows
.iter()
.map(|r| r.when_run.len())
.max()
.unwrap_or(0)
.max("WHEN_RUN".len());
let last_w = rows
.iter()
.map(|r| {
r.last_run_at
.as_deref()
.map(|s| s.chars().count())
.unwrap_or("(never)".len())
})
.max()
.unwrap_or(0)
.max("LAST_RUN".len());
let status_w = "STATUS".len();
let mut header = String::new();
let _ = write!(
&mut header,
" {:<status_w$} {:<name_w$} {:<phase_w$} {:<when_run_w$} {:<last_w$} WHEN",
"STATUS", "NAME", "PHASE", "WHEN_RUN", "LAST_RUN"
);
if color {
println!("{}", header.bold());
} else {
println!("{header}");
}
let bar = |n: usize| icons.sep.to_string().repeat(n);
let sep = format!(
" {} {} {} {} {} {}",
bar(status_w),
bar(name_w),
bar(phase_w),
bar(when_run_w),
bar(last_w),
bar("WHEN".len())
);
if color {
println!("{}", sep.dimmed());
} else {
println!("{sep}");
}
for r in rows {
let (icon, ran) = match (r.active, r.last_run_at.is_some()) {
(false, _) => (icons.inactive, false),
(true, true) => (icons.active, true),
(true, false) => (icons.info, false),
};
let last = r.last_run_at.as_deref().unwrap_or("(never)");
let when_str = r
.when
.as_deref()
.map(strip_braces)
.unwrap_or_else(|| "(always)".to_string());
let cell_status = format!("{icon:<status_w$}");
let cell_name = format!("{:<name_w$}", r.name);
let cell_phase = format!("{:<phase_w$}", r.phase);
let cell_when_run = format!("{:<when_run_w$}", r.when_run);
let cell_last = format!("{last:<last_w$}");
if !color {
println!(
" {cell_status} {cell_name} {cell_phase} {cell_when_run} {cell_last} {when_str}"
);
continue;
}
if !r.active {
println!(
" {} {} {} {} {} {}",
cell_status.dimmed(),
cell_name.dimmed(),
cell_phase.dimmed(),
cell_when_run.dimmed(),
cell_last.dimmed(),
when_str.dimmed()
);
} else if ran {
println!(
" {} {} {} {} {} {}",
cell_status.green(),
cell_name.cyan().bold(),
cell_phase.dimmed(),
cell_when_run.dimmed(),
cell_last.green(),
when_str.dimmed()
);
} else {
println!(
" {} {} {} {} {} {}",
cell_status.yellow(),
cell_name.cyan().bold(),
cell_phase.dimmed(),
cell_when_run.dimmed(),
cell_last.yellow(),
when_str.dimmed()
);
}
}
}
pub fn hooks_run(source: Option<Utf8PathBuf>, name: Option<String>, force: bool) -> Result<()> {
let source = resolve_source(source)?;
let yui = YuiVars::detect(&source);
let config = config::load(&source, &yui)?;
let mut engine = template::Engine::new();
let tera_ctx = template::template_context(&yui, &config.vars);
let targets: Vec<&config::HookConfig> = match &name {
Some(want) => {
let m = config
.hook
.iter()
.find(|h| &h.name == want)
.ok_or_else(|| {
anyhow::anyhow!(
"no [[hook]] named {want:?}; run `yui hooks list` to see available names"
)
})?;
vec![m]
}
None => config.hook.iter().collect(),
};
let mut state = hook::State::load(&source)?;
for h in targets {
let outcome = hook::run_hook(
h,
&source,
&yui,
&config.vars,
&mut engine,
&tera_ctx,
&mut state,
false,
force,
)?;
let label = match outcome {
HookOutcome::Ran => "ran",
HookOutcome::SkippedOnce => "skipped (once: already ran)",
HookOutcome::SkippedUnchanged => "skipped (onchange: hash matches)",
HookOutcome::SkippedWhenFalse => "skipped (when=false)",
HookOutcome::DryRun => "would run (dry-run)",
};
info!("hook[{}]: {label}", h.name);
if outcome == HookOutcome::Ran {
state.save(&source)?;
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn process_mount(
m: &ResolvedMount,
ctx: &ApplyCtx<'_>,
engine: &mut template::Engine,
tera_ctx: &TeraContext,
yuiignore: &mut paths::YuiIgnoreStack,
) -> Result<()> {
let src_root = m.src.clone();
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, yuiignore, false,
)
}
#[allow(clippy::too_many_arguments)]
fn walk_and_link(
src_dir: &Utf8Path,
dst_dir: &Utf8Path,
ctx: &ApplyCtx<'_>,
strategy: MountStrategy,
engine: &mut template::Engine,
tera_ctx: &TeraContext,
yuiignore: &mut paths::YuiIgnoreStack,
parent_covered: bool,
) -> Result<()> {
if yuiignore.is_ignored(src_dir, true) {
return Ok(());
}
yuiignore.push_dir(src_dir)?;
let result = walk_and_link_body(
src_dir,
dst_dir,
ctx,
strategy,
engine,
tera_ctx,
yuiignore,
parent_covered,
);
yuiignore.pop_dir(src_dir);
result
}
#[allow(clippy::too_many_arguments)]
fn walk_and_link_body(
src_dir: &Utf8Path,
dst_dir: &Utf8Path,
ctx: &ApplyCtx<'_>,
strategy: MountStrategy,
engine: &mut template::Engine,
tera_ctx: &TeraContext,
yuiignore: &mut paths::YuiIgnoreStack,
parent_covered: bool,
) -> Result<()> {
let marker_filename = &ctx.config.mount.marker_filename;
let mut covered = parent_covered;
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)?;
covered = true;
}
Some(MarkerSpec::Explicit { links }) => {
let mut emitted_dir_link = false;
let mut emitted_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 = paths::expand_tilde(dst_str.trim());
if let Some(filename) = &link.src {
let file_src = src_dir.join(filename);
if !file_src.is_file() {
anyhow::bail!(
"marker at {src_dir}: [[link]] src={filename:?} \
not found"
);
}
link_file_with_backup(&file_src, &dst, ctx)?;
} else {
link_dir_with_backup(src_dir, &dst, ctx)?;
emitted_dir_link = true;
}
emitted_any = true;
}
if !emitted_any {
info!(
"marker at {src_dir} had no active links \
— falling back to defaults"
);
}
if emitted_dir_link {
covered = true;
}
}
}
}
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 yuiignore.is_ignored(&src_path, ft.is_dir()) {
continue;
}
if ft.is_dir() {
walk_and_link(
&src_path, &dst_path, ctx, strategy, engine, tera_ctx, yuiignore, covered,
)?;
} else if ft.is_file() {
if !covered {
link_file_with_backup(&src_path, &dst_path, ctx)?;
}
}
}
Ok(())
}
fn link_file_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
use absorb::AbsorbDecision::*;
if ctx.quit_requested.get() {
return Ok(());
}
let decision = absorb::classify(src, dst)?;
if ctx.dry_run {
info!("[dry-run] {decision:?}: {src} → {dst}");
return Ok(());
}
match decision {
InSync => {
Ok(())
}
Restore => {
info!("link: {src} → {dst}");
link::link_file(src, dst, ctx.file_mode)?;
Ok(())
}
RelinkOnly => {
info!("relink: {src} → {dst}");
link::unlink(dst)?;
link::link_file(src, dst, ctx.file_mode)?;
Ok(())
}
AutoAbsorb => {
if !ctx.config.absorb.auto {
return handle_anomaly(
src,
dst,
ctx,
"absorb.auto = false; treating divergence as anomaly",
);
}
if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
return handle_anomaly(
src,
dst,
ctx,
"source repo is dirty; deferring auto-absorb",
);
}
absorb_target_into_source(src, dst, ctx)
}
NeedsConfirm => handle_anomaly(
src,
dst,
ctx,
"anomaly: source equals/newer than target but content differs",
),
}
}
fn absorb_target_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
info!("absorb: {dst} → {src}");
backup_existing(src, ctx.backup_root, false)?;
std::fs::copy(dst, src)?;
link::unlink(dst)?;
link::link_file(src, dst, ctx.file_mode)?;
Ok(())
}
fn overwrite_source_into_target(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
info!("overwrite: {src} → {dst}");
backup_existing(dst, ctx.backup_root, false)?;
link::unlink(dst)?;
link::link_file(src, dst, ctx.file_mode)?;
Ok(())
}
fn handle_anomaly(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>, reason: &str) -> Result<()> {
use crate::config::AnomalyAction::*;
match ctx.config.absorb.on_anomaly {
Skip => {
warn!("anomaly skip: {dst} ({reason})");
Ok(())
}
Force => {
warn!("anomaly force: {dst} ({reason}) — absorbing target into source");
absorb_target_into_source(src, dst, ctx)
}
Ask => match prompt_anomaly(ctx, src, dst, reason)? {
AnomalyChoice::Absorb => absorb_target_into_source(src, dst, ctx),
AnomalyChoice::Overwrite => overwrite_source_into_target(src, dst, ctx),
AnomalyChoice::Skip => {
warn!("anomaly skipped by user: {dst}");
Ok(())
}
AnomalyChoice::Quit => {
warn!("anomaly: user requested quit; stopping apply at {dst}");
ctx.quit_requested.set(true);
Ok(())
}
},
}
}
fn prompt_anomaly(
ctx: &ApplyCtx<'_>,
src: &Utf8Path,
dst: &Utf8Path,
reason: &str,
) -> Result<AnomalyChoice> {
if ctx.quit_requested.get() {
return Ok(AnomalyChoice::Quit);
}
if let Some(c) = ctx.sticky_anomaly.get() {
return Ok(c);
}
use std::io::IsTerminal;
use std::io::Write as _;
if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
return Ok(AnomalyChoice::Skip);
}
eprintln!();
eprintln!("anomaly: {reason}");
eprintln!(" src: {src}");
eprintln!(" dst: {dst}");
print_absorb_diff(src, dst);
loop {
eprintln!(" [a/A] absorb target → source (this / all remaining)");
eprintln!(" [o/O] overwrite source → target (this / all remaining)");
eprintln!(" [s/S] skip leave as-is (this / all remaining)");
eprintln!(" [d] diff re-show the diff");
eprintln!(" [q] quit skip this and stop apply");
eprint!("choice [s]: ");
std::io::stderr().flush().ok();
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let trimmed = input.trim();
let choice = match trimmed {
"" | "s" | "n" => AnomalyChoice::Skip,
"a" | "y" => AnomalyChoice::Absorb,
"o" => AnomalyChoice::Overwrite,
"q" => AnomalyChoice::Quit,
"A" => {
ctx.sticky_anomaly.set(Some(AnomalyChoice::Absorb));
AnomalyChoice::Absorb
}
"O" => {
ctx.sticky_anomaly.set(Some(AnomalyChoice::Overwrite));
AnomalyChoice::Overwrite
}
"S" => {
ctx.sticky_anomaly.set(Some(AnomalyChoice::Skip));
AnomalyChoice::Skip
}
"d" => {
print_absorb_diff(src, dst);
continue;
}
other => {
eprintln!("unknown choice: {other:?}");
continue;
}
};
return Ok(choice);
}
}
fn source_repo_is_clean(source: &Utf8Path) -> bool {
match crate::git::is_clean(source) {
Ok(b) => b,
Err(e) => {
warn!("git clean check failed at {source}: {e} — treating as clean");
true
}
}
}
fn link_dir_with_backup(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
use absorb::AbsorbDecision::*;
if ctx.quit_requested.get() {
return Ok(());
}
let decision = absorb::classify(src, dst)?;
if ctx.dry_run {
info!("[dry-run] dir {decision:?}: {src} → {dst}");
return Ok(());
}
match decision {
InSync => Ok(()),
Restore => {
info!("link dir: {src} → {dst}");
link::link_dir(src, dst, ctx.dir_mode)?;
Ok(())
}
RelinkOnly => {
info!("relink dir: {src} → {dst}");
remove_dir_link_or_real(dst)?;
link::link_dir(src, dst, ctx.dir_mode)?;
Ok(())
}
AutoAbsorb | NeedsConfirm => {
if !ctx.config.absorb.auto {
return handle_anomaly_dir(
src,
dst,
ctx,
"absorb.auto = false; treating divergence as anomaly",
);
}
if ctx.config.absorb.require_clean_git && !source_repo_is_clean(ctx.source) {
return handle_anomaly_dir(
src,
dst,
ctx,
"source repo is dirty; deferring auto-absorb",
);
}
absorb_target_dir_into_source(src, dst, ctx)
}
}
}
fn remove_dir_link_or_real(dst: &Utf8Path) -> Result<()> {
if let Err(unlink_err) = link::unlink(dst) {
let meta = std::fs::symlink_metadata(dst)
.with_context(|| format!("stat {dst} after link::unlink failed: {unlink_err}"))?;
let ft = meta.file_type();
if ft.is_dir() && !ft.is_symlink() {
std::fs::remove_dir_all(dst).with_context(|| {
format!(
"remove_dir_all({dst}) after link::unlink failed: \
{unlink_err}"
)
})?;
} else {
return Err(unlink_err).with_context(|| format!("unlink({dst}) before relink"));
}
}
Ok(())
}
fn merge_dir_target_into_source(
target: &Utf8Path,
source: &Utf8Path,
ctx: &ApplyCtx<'_>,
) -> Result<()> {
for entry in std::fs::read_dir(target)? {
if ctx.quit_requested.get() {
return Ok(());
}
let entry = entry?;
let name_os = entry.file_name();
let Some(name) = name_os.to_str() else {
continue;
};
let target_path = target.join(name);
let source_path = source.join(name);
let ft = entry.file_type()?;
if ft.is_dir() && !ft.is_symlink() {
if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
let sft = src_meta.file_type();
if !sft.is_dir() || sft.is_symlink() {
link::unlink(&source_path).with_context(|| {
format!("remove conflicting source entry before dir merge: {source_path}")
})?;
}
}
if !source_path.exists() {
std::fs::create_dir_all(&source_path).with_context(|| {
format!("create_dir_all({source_path}) during target→source merge")
})?;
}
merge_dir_target_into_source(&target_path, &source_path, ctx)?;
} else if ft.is_file() {
if let Ok(src_meta) = std::fs::symlink_metadata(&source_path) {
let sft = src_meta.file_type();
if sft.is_dir() && !sft.is_symlink() {
remove_dir_link_or_real(&source_path).with_context(|| {
format!("remove conflicting source dir before file merge: {source_path}")
})?;
} else if sft.is_symlink() {
link::unlink(&source_path).with_context(|| {
format!(
"remove conflicting source symlink before file merge: {source_path}"
)
})?;
}
}
if let Some(parent) = source_path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent)?;
}
}
if source_path.is_file() {
merge_resolve_file_conflict(&target_path, &source_path, ctx)?;
} else {
std::fs::copy(&target_path, &source_path)
.with_context(|| format!("copy({target_path} → {source_path}) during merge"))?;
}
} else {
warn!(
"merge: skipping non-regular entry {target_path} \
(symlink / junction / special — content not copied)"
);
}
}
Ok(())
}
fn merge_resolve_file_conflict(
target_path: &Utf8Path,
source_path: &Utf8Path,
ctx: &ApplyCtx<'_>,
) -> Result<()> {
use absorb::AbsorbDecision::*;
let decision = absorb::classify(source_path, target_path)?;
match decision {
InSync | RelinkOnly => Ok(()),
AutoAbsorb => {
std::fs::copy(target_path, source_path).with_context(|| {
format!("copy({target_path} → {source_path}) during merge AutoAbsorb")
})?;
Ok(())
}
Restore => {
unreachable!(
"merge_resolve_file_conflict reached with both files present, \
but classify returned Restore (target {target_path} / source {source_path})"
)
}
NeedsConfirm => {
use crate::config::AnomalyAction::*;
match ctx.config.absorb.on_anomaly {
Skip => {
warn!(
"merge anomaly skip: {target_path} (source-newer / content drift) \
— keeping source version, target version dropped"
);
Ok(())
}
Force => {
warn!(
"merge anomaly force: {target_path} \
(source-newer / content drift) — overwriting source"
);
std::fs::copy(target_path, source_path)?;
Ok(())
}
Ask => {
let choice = prompt_anomaly(
ctx,
source_path,
target_path,
"merge: file content differs and source is newer",
)?;
match choice {
AnomalyChoice::Absorb => {
std::fs::copy(target_path, source_path)?;
Ok(())
}
AnomalyChoice::Overwrite => {
backup_existing(target_path, ctx.backup_root, false)?;
std::fs::copy(source_path, target_path)?;
Ok(())
}
AnomalyChoice::Skip => {
warn!("merge: kept source version by user choice: {source_path}");
Ok(())
}
AnomalyChoice::Quit => {
warn!("merge: user requested quit; stopping at {target_path}");
ctx.quit_requested.set(true);
Ok(())
}
}
}
}
}
}
}
fn absorb_target_dir_into_source(src: &Utf8Path, dst: &Utf8Path, ctx: &ApplyCtx<'_>) -> Result<()> {
info!("absorb dir: {dst} → {src}");
backup_existing(src, ctx.backup_root, true)?;
merge_dir_target_into_source(dst, src, ctx)?;
if ctx.quit_requested.get() {
warn!(
"absorb dir interrupted by user quit: {dst} \
— leaving target tree intact; source backup at {}",
ctx.backup_root
);
return Ok(());
}
remove_dir_link_or_real(dst)?;
link::link_dir(src, dst, ctx.dir_mode)?;
Ok(())
}
fn overwrite_source_dir_into_target(
src: &Utf8Path,
dst: &Utf8Path,
ctx: &ApplyCtx<'_>,
) -> Result<()> {
info!("overwrite dir: {src} → {dst}");
backup_existing(dst, ctx.backup_root, true)?;
remove_dir_link_or_real(dst)?;
link::link_dir(src, dst, ctx.dir_mode)?;
Ok(())
}
fn handle_anomaly_dir(
src: &Utf8Path,
dst: &Utf8Path,
ctx: &ApplyCtx<'_>,
reason: &str,
) -> Result<()> {
use crate::config::AnomalyAction::*;
match ctx.config.absorb.on_anomaly {
Skip => {
warn!("anomaly skip dir: {dst} ({reason})");
Ok(())
}
Force => {
warn!(
"anomaly force dir: {dst} ({reason}) \
— absorbing target into source"
);
absorb_target_dir_into_source(src, dst, ctx)
}
Ask => match prompt_anomaly(ctx, src, dst, reason)? {
AnomalyChoice::Absorb => absorb_target_dir_into_source(src, dst, ctx),
AnomalyChoice::Overwrite => overwrite_source_dir_into_target(src, dst, ctx),
AnomalyChoice::Skip => {
warn!("anomaly skipped by user: {dst}");
Ok(())
}
AnomalyChoice::Quit => {
warn!("anomaly dir: user requested quit; stopping apply at {dst}");
ctx.quit_requested.set(true);
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) = paths::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> {
let expanded = paths::expand_tilde(p.as_str());
if expanded.is_absolute() {
return Ok(expanded);
}
let cwd = current_dir_utf8()?;
Ok(cwd.join(expanded))
}
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()))
}
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"
# `~` expands to $HOME / $USERPROFILE per OS at apply time, no Tera needed.
dst = "~"
# [[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 per-machine state and backups (regenerable, do not commit).
# .yui/bin/ is intentionally tracked — it holds your hook scripts.
/.yui/state.json
/.yui/state.json.tmp
/.yui/backup/
# >>> 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_inactive_link_falls_through_to_default() {
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/init.lua").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 status_reports_in_sync_after_apply() {
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.clone()), false).unwrap();
status(Some(source), None, true).unwrap();
}
#[test]
fn status_reports_template_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/.gitconfig.tera"), "fresh").unwrap();
std::fs::write(source.join("home/.gitconfig"), "stale").unwrap();
let cfg = format!(
r#"
[[mount.entry]]
src = "home"
dst = "{}"
"#,
toml_path(&target)
);
std::fs::write(source.join("config.toml"), cfg).unwrap();
let err = status(Some(source), None, true).unwrap_err();
assert!(format!("{err}").contains("diverged"));
}
#[test]
fn status_fails_when_target_missing() {
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();
let err = status(Some(source), None, true).unwrap_err();
assert!(format!("{err}").contains("diverged"));
}
#[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 init_appends_missing_gitignore_entries_into_existing_file() {
let tmp = TempDir::new().unwrap();
let dir = utf8(tmp.path().join("dotfiles"));
std::fs::create_dir_all(&dir).unwrap();
let user_gitignore = "# user entries\n*.swp\nnode_modules/\n";
std::fs::write(dir.join(".gitignore"), user_gitignore).unwrap();
init(Some(dir.clone()), false).unwrap();
let body = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
assert!(body.contains("*.swp"));
assert!(body.contains("node_modules/"));
assert!(body.contains("/.yui/state.json"));
assert!(body.contains("/.yui/backup/"));
assert!(body.contains("config.local.toml"));
let before_rerun = body.clone();
std::fs::remove_file(dir.join("config.toml")).unwrap();
init(Some(dir.clone()), false).unwrap();
let after_rerun = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
assert_eq!(
before_rerun, after_rerun,
"init must be idempotent when the gitignore already has every yui entry"
);
}
#[test]
fn init_with_git_hooks_installs_into_existing_repo() {
let tmp = TempDir::new().unwrap();
let dir = utf8(tmp.path().join("dotfiles"));
std::fs::create_dir_all(&dir).unwrap();
let st = std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(dir.as_std_path())
.status()
.expect("git init");
if !st.success() {
return;
}
let user_config = "# user already wrote this\n";
std::fs::write(dir.join("config.toml"), user_config).unwrap();
init(Some(dir.clone()), true).unwrap();
assert_eq!(
std::fs::read_to_string(dir.join("config.toml")).unwrap(),
user_config
);
assert!(dir.join(".git/hooks/pre-commit").is_file());
assert!(dir.join(".git/hooks/pre-push").is_file());
}
#[test]
fn init_with_git_hooks_writes_pre_commit_and_pre_push() {
let tmp = TempDir::new().unwrap();
let dir = utf8(tmp.path().join("dotfiles"));
std::fs::create_dir_all(&dir).unwrap();
let st = std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(dir.as_std_path())
.status()
.expect("git init");
if !st.success() {
eprintln!("skipping: git not available");
return;
}
init(Some(dir.clone()), true).unwrap();
let pre_commit = dir.join(".git/hooks/pre-commit");
let pre_push = dir.join(".git/hooks/pre-push");
assert!(pre_commit.is_file(), "pre-commit hook should be written");
assert!(pre_push.is_file(), "pre-push hook should be written");
let body = std::fs::read_to_string(&pre_commit).unwrap();
assert!(
body.contains("yui render --check"),
"pre-commit hook should call `yui render --check`, got: {body}"
);
}
#[test]
fn init_with_git_hooks_errors_outside_a_git_repo() {
let tmp = TempDir::new().unwrap();
let dir = utf8(tmp.path().join("not-a-repo"));
std::fs::create_dir_all(&dir).unwrap();
let err = init(Some(dir), true).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("git repo") || msg.contains("git rev-parse"),
"expected error to mention the git issue, got: {msg}"
);
}
#[test]
fn init_with_git_hooks_does_not_clobber_existing_hooks() {
let tmp = TempDir::new().unwrap();
let dir = utf8(tmp.path().join("dotfiles"));
std::fs::create_dir_all(&dir).unwrap();
let st = std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(dir.as_std_path())
.status()
.expect("git init");
if !st.success() {
return;
}
let hooks = dir.join(".git/hooks");
std::fs::create_dir_all(&hooks).unwrap();
std::fs::write(hooks.join("pre-commit"), "#! /bin/sh\nexit 0\n").unwrap();
init(Some(dir.clone()), true).unwrap();
let pc = std::fs::read_to_string(hooks.join("pre-commit")).unwrap();
assert!(
!pc.contains("yui render --check"),
"existing pre-commit must not be overwritten"
);
let pp = std::fs::read_to_string(hooks.join("pre-push")).unwrap();
assert!(
pp.contains("yui render --check"),
"missing pre-push should be written: {pp}"
);
}
fn setup_minimal_dotfiles(tmp: &TempDir) -> (Utf8PathBuf, Utf8PathBuf) {
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();
let cfg = format!(
r#"
[[mount.entry]]
src = "home"
dst = "{}"
"#,
toml_path(&target)
);
std::fs::write(source.join("config.toml"), cfg).unwrap();
(source, target)
}
fn write_with_mtime(path: &Utf8Path, body: &str, when: std::time::SystemTime) {
std::fs::write(path, body).unwrap();
let f = std::fs::OpenOptions::new()
.write(true)
.open(path)
.expect("open writable");
f.set_modified(when).expect("set_modified");
}
#[test]
fn apply_target_newer_absorbs_target_into_source() {
let tmp = TempDir::new().unwrap();
let (source, target) = setup_minimal_dotfiles(&tmp);
let now = std::time::SystemTime::now();
let past = now - std::time::Duration::from_secs(120);
write_with_mtime(&source.join("home/.bashrc"), "default from repo", past);
write_with_mtime(&target.join(".bashrc"), "user's edit", now);
apply(Some(source.clone()), false).unwrap();
assert_eq!(
std::fs::read_to_string(target.join(".bashrc")).unwrap(),
"user's edit"
);
assert_eq!(
std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
"user's edit"
);
let backup_root = source.join(".yui/backup");
let mut found_old = false;
for entry in walkdir(&backup_root) {
if let Ok(s) = std::fs::read_to_string(&entry) {
if s == "default from repo" {
found_old = true;
break;
}
}
}
assert!(found_old, "expected backup containing 'default from repo'");
}
#[test]
fn apply_in_sync_target_is_a_no_op() {
let tmp = TempDir::new().unwrap();
let (source, target) = setup_minimal_dotfiles(&tmp);
std::fs::write(source.join("home/.bashrc"), "echo hi\n").unwrap();
apply(Some(source.clone()), false).unwrap();
let backup_root = source.join(".yui/backup");
let backup_count_after_first = walkdir(&backup_root).len();
apply(Some(source.clone()), false).unwrap();
assert_eq!(
std::fs::read_to_string(target.join(".bashrc")).unwrap(),
"echo hi\n"
);
let backup_count_after_second = walkdir(&backup_root).len();
assert_eq!(
backup_count_after_first, backup_count_after_second,
"second apply on an in-sync tree should not produce backups"
);
}
#[test]
fn apply_skip_policy_leaves_anomaly_alone() {
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();
let cfg = format!(
r#"
[absorb]
on_anomaly = "skip"
[[mount.entry]]
src = "home"
dst = "{}"
"#,
toml_path(&target)
);
std::fs::write(source.join("config.toml"), cfg).unwrap();
let now = std::time::SystemTime::now();
let past = now - std::time::Duration::from_secs(120);
write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
apply(Some(source.clone()), false).unwrap();
assert_eq!(
std::fs::read_to_string(target.join(".bashrc")).unwrap(),
"user's edit (older)"
);
assert_eq!(
std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
"fresh from upstream"
);
}
#[test]
fn apply_force_policy_absorbs_anomaly_anyway() {
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();
let cfg = format!(
r#"
[absorb]
on_anomaly = "force"
[[mount.entry]]
src = "home"
dst = "{}"
"#,
toml_path(&target)
);
std::fs::write(source.join("config.toml"), cfg).unwrap();
let now = std::time::SystemTime::now();
let past = now - std::time::Duration::from_secs(120);
write_with_mtime(&target.join(".bashrc"), "user's edit (older)", past);
write_with_mtime(&source.join("home/.bashrc"), "fresh from upstream", now);
apply(Some(source.clone()), false).unwrap();
assert_eq!(
std::fs::read_to_string(target.join(".bashrc")).unwrap(),
"user's edit (older)"
);
assert_eq!(
std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
"user's edit (older)"
);
}
#[test]
fn apply_absorbs_non_empty_target_dir_target_wins() {
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/.config/app")).unwrap();
std::fs::create_dir_all(target.join(".config/app")).unwrap();
std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
std::fs::write(source.join("home/.config/app/config.toml"), "src side").unwrap();
std::fs::write(source.join("home/.config/app/source-only.toml"), "src").unwrap();
std::fs::write(target.join(".config/app/config.toml"), "target side").unwrap();
std::fs::write(target.join(".config/app/state.json"), "{}").unwrap();
let cfg = format!(
r#"
[absorb]
on_anomaly = "force"
[[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(".config/app/config.toml")).unwrap(),
"target side"
);
assert_eq!(
std::fs::read_to_string(target.join(".config/app/state.json")).unwrap(),
"{}"
);
let backup_root = source.join(".yui/backup");
let mut backup_files: Vec<String> = Vec::new();
for entry in walkdir(&backup_root) {
if let Some(n) = entry.file_name() {
backup_files.push(n.to_string());
}
}
assert!(
backup_files.iter().any(|f| f == "config.toml"),
"expected source's config.toml to land in the backup tree, got {backup_files:?}"
);
assert!(
source.join("home/.config/app/source-only.toml").exists(),
"source-only file should survive a target-wins merge"
);
assert!(
source.join("home/.config/app/state.json").exists(),
"target-only state.json should be merged into source"
);
}
#[test]
fn marker_dir_absorbs_with_default_ask_policy() {
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/.config")).unwrap();
std::fs::create_dir_all(target.join(".config/gh")).unwrap();
std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
std::fs::write(target.join(".config/gh/hosts.yml"), "oauth_token: x\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();
assert!(target.join(".config/gh/hosts.yml").exists());
assert!(source.join("home/.config/gh/hosts.yml").exists());
}
#[test]
fn merge_handles_file_vs_dir_collisions_target_wins() {
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/.config/foo")).unwrap();
std::fs::create_dir_all(target.join(".config")).unwrap();
std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
std::fs::write(source.join("home/.config/foo/leaf.txt"), "src").unwrap();
std::fs::write(target.join(".config/foo"), "target file body").unwrap();
std::fs::write(source.join("home/.config/bar"), "src file body").unwrap();
std::fs::create_dir_all(target.join(".config/bar")).unwrap();
std::fs::write(target.join(".config/bar/inside.txt"), "target nested").unwrap();
let cfg = format!(
r#"
[absorb]
on_anomaly = "force"
[[mount.entry]]
src = "home"
dst = "{}"
"#,
toml_path(&target)
);
std::fs::write(source.join("config.toml"), cfg).unwrap();
apply(Some(source.clone()), false).unwrap();
let foo_meta = std::fs::symlink_metadata(target.join(".config/foo")).unwrap();
assert!(foo_meta.file_type().is_file(), "foo should be a file");
assert_eq!(
std::fs::read_to_string(target.join(".config/foo")).unwrap(),
"target file body"
);
let bar_meta = std::fs::symlink_metadata(target.join(".config/bar")).unwrap();
assert!(bar_meta.file_type().is_dir(), "bar should be a dir");
assert_eq!(
std::fs::read_to_string(target.join(".config/bar/inside.txt")).unwrap(),
"target nested"
);
}
#[test]
fn merge_per_file_target_newer_auto_absorbs() {
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/.config")).unwrap();
std::fs::create_dir_all(target.join(".config")).unwrap();
std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
write_with_mtime(&source.join("home/.config/app.toml"), "old src", past);
std::fs::write(target.join(".config/app.toml"), "user's live edit").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(".config/app.toml")).unwrap(),
"user's live edit"
);
}
#[test]
fn merge_per_file_source_newer_skip_keeps_source() {
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/.config")).unwrap();
std::fs::create_dir_all(target.join(".config")).unwrap();
std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
write_with_mtime(&target.join(".config/app.toml"), "old target", past);
std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
let cfg = format!(
r#"
[absorb]
on_anomaly = "skip"
[[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(".config/app.toml")).unwrap(),
"fresh source"
);
}
#[test]
fn merge_per_file_source_newer_force_overwrites_source() {
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/.config")).unwrap();
std::fs::create_dir_all(target.join(".config")).unwrap();
std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
let past = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
write_with_mtime(&target.join(".config/app.toml"), "old target", past);
std::fs::write(source.join("home/.config/app.toml"), "fresh source").unwrap();
let cfg = format!(
r#"
[absorb]
on_anomaly = "force"
[[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(".config/app.toml")).unwrap(),
"old target"
);
}
#[test]
fn merge_per_file_identical_content_is_noop() {
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/.config")).unwrap();
std::fs::create_dir_all(target.join(".config")).unwrap();
std::fs::write(source.join("home/.config/.yuilink"), "").unwrap();
std::fs::write(source.join("home/.config/app.toml"), "same").unwrap();
std::fs::write(target.join(".config/app.toml"), "same").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(".config/app.toml")).unwrap(),
"same"
);
}
#[test]
fn manual_absorb_command_pulls_target_into_source() {
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();
let cfg = format!(
r#"
[absorb]
on_anomaly = "skip"
[[mount.entry]]
src = "home"
dst = "{}"
"#,
toml_path(&target)
);
std::fs::write(source.join("config.toml"), cfg).unwrap();
std::fs::write(target.join(".bashrc"), "user picked this").unwrap();
std::fs::write(source.join("home/.bashrc"), "default").unwrap();
absorb(
Some(source.clone()),
target.join(".bashrc"),
false,
true,
)
.unwrap();
assert_eq!(
std::fs::read_to_string(source.join("home/.bashrc")).unwrap(),
"user picked this"
);
}
#[test]
fn manual_absorb_errors_when_target_outside_known_mounts() {
let tmp = TempDir::new().unwrap();
let (source, _target) = setup_minimal_dotfiles(&tmp);
std::fs::write(source.join("home/.bashrc"), "x").unwrap();
let stranger = utf8(tmp.path().join("not-managed/foo"));
std::fs::create_dir_all(stranger.parent().unwrap()).unwrap();
std::fs::write(&stranger, "not yui's").unwrap();
let err = absorb(Some(source), stranger, false, true).unwrap_err();
assert!(format!("{err}").contains("no mount entry"));
}
#[test]
fn yuiignore_excludes_file_from_linking() {
let tmp = TempDir::new().unwrap();
let (source, target) = setup_minimal_dotfiles(&tmp);
std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
std::fs::write(source.join("home/lock.json"), "ignored").unwrap();
std::fs::write(source.join(".yuiignore"), "**/lock.json\n").unwrap();
apply(Some(source.clone()), false).unwrap();
assert!(target.join(".bashrc").exists());
assert!(
!target.join("lock.json").exists(),
"yuiignore should keep lock.json out of target"
);
}
#[test]
fn yuiignore_excludes_directory_subtree() {
let tmp = TempDir::new().unwrap();
let (source, target) = setup_minimal_dotfiles(&tmp);
std::fs::create_dir_all(source.join("home/cache")).unwrap();
std::fs::write(source.join("home/.bashrc"), "kept").unwrap();
std::fs::write(source.join("home/cache/a"), "ignored").unwrap();
std::fs::write(source.join("home/cache/b"), "also ignored").unwrap();
std::fs::write(source.join(".yuiignore"), "home/cache/\n").unwrap();
apply(Some(source.clone()), false).unwrap();
assert!(target.join(".bashrc").exists());
assert!(
!target.join("cache").exists(),
"yuiignore'd subtree should not appear in target"
);
}
#[test]
fn yuiignore_negation_re_includes_file() {
let tmp = TempDir::new().unwrap();
let (source, target) = setup_minimal_dotfiles(&tmp);
std::fs::write(source.join("home/keep.cache"), "kept by negation").unwrap();
std::fs::write(source.join("home/drop.cache"), "ignored").unwrap();
std::fs::write(source.join(".yuiignore"), "*.cache\n!keep.cache\n").unwrap();
apply(Some(source.clone()), false).unwrap();
assert!(target.join("keep.cache").exists());
assert!(!target.join("drop.cache").exists());
}
#[test]
fn nested_yuiignore_only_affects_its_subtree() {
let tmp = TempDir::new().unwrap();
let (source, target) = setup_minimal_dotfiles(&tmp);
std::fs::create_dir_all(source.join("home/inner")).unwrap();
std::fs::write(source.join("home/secret.txt"), "outer keep").unwrap();
std::fs::write(source.join("home/inner/secret.txt"), "inner drop").unwrap();
std::fs::write(source.join("home/inner/keep.txt"), "inner keep").unwrap();
std::fs::write(source.join("home/inner/.yuiignore"), "secret*\n").unwrap();
apply(Some(source.clone()), false).unwrap();
assert!(
target.join("secret.txt").exists(),
"outer secret.txt is outside the nested .yuiignore scope"
);
assert!(target.join("inner/keep.txt").exists());
assert!(
!target.join("inner/secret.txt").exists(),
"inner secret.txt should be excluded by the nested .yuiignore"
);
}
#[test]
fn nested_yuiignore_negation_overrides_root_rule() {
let tmp = TempDir::new().unwrap();
let (source, target) = setup_minimal_dotfiles(&tmp);
std::fs::create_dir_all(source.join("home/keepers")).unwrap();
std::fs::write(source.join("home/drop.lock"), "outer drop").unwrap();
std::fs::write(source.join("home/keepers/wanted.lock"), "inner keep").unwrap();
std::fs::write(source.join(".yuiignore"), "*.lock\n").unwrap();
std::fs::write(source.join("home/keepers/.yuiignore"), "!*.lock\n").unwrap();
apply(Some(source.clone()), false).unwrap();
assert!(
!target.join("drop.lock").exists(),
"root rule still drops outer .lock file"
);
assert!(
target.join("keepers/wanted.lock").exists(),
"nested negation re-includes .lock under keepers/"
);
}
#[test]
fn nested_yuiignore_status_walk_scoped() {
let tmp = TempDir::new().unwrap();
let (source, _target) = setup_minimal_dotfiles(&tmp);
std::fs::create_dir_all(source.join("home/a")).unwrap();
std::fs::create_dir_all(source.join("home/b")).unwrap();
std::fs::write(source.join("home/a/foo.txt"), "a-foo").unwrap();
std::fs::write(source.join("home/b/foo.txt"), "b-foo").unwrap();
std::fs::write(source.join("home/a/.yuiignore"), "foo.txt\n").unwrap();
apply(Some(source.clone()), false).unwrap();
let res = status(Some(source), None, true);
assert!(res.is_ok() || matches!(&res, Err(e) if format!("{e}").contains("diverged")));
}
#[test]
fn yuiignore_skips_template_in_render() {
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/note.tera"), "{{ yui.os }}").unwrap();
std::fs::write(source.join(".yuiignore"), "home/note*\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();
assert!(!source.join("home/note").exists());
assert!(!target.join("note").exists());
assert!(!target.join("note.tera").exists());
}
#[test]
fn apply_decrypts_age_files_to_sibling_and_links() {
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/.ssh")).unwrap();
std::fs::create_dir_all(&target).unwrap();
let identity_path = utf8(tmp.path().join("age.txt"));
let (secret, public) = secret::generate_x25519_keypair();
std::fs::write(&identity_path, format!("{secret}\n")).unwrap();
let recipient = secret::parse_x25519_recipient(&public).unwrap();
let cipher = secret::encrypt_x25519(b"-- super secret key --\n", &[recipient]).unwrap();
std::fs::write(source.join("home/.ssh/id_ed25519.age"), &cipher).unwrap();
let cfg = format!(
r#"
[[mount.entry]]
src = "home"
dst = "{}"
[secrets]
identity = "{}"
recipients = ["{}"]
"#,
toml_path(&target),
toml_path(&identity_path),
public
);
std::fs::write(source.join("config.toml"), cfg).unwrap();
apply(Some(source.clone()), false).unwrap();
assert!(source.join("home/.ssh/id_ed25519").exists());
let target_bytes = std::fs::read(target.join(".ssh/id_ed25519")).unwrap();
assert_eq!(target_bytes, b"-- super secret key --\n");
let gi = std::fs::read_to_string(source.join(".gitignore")).unwrap();
assert!(
gi.contains("home/.ssh/id_ed25519"),
".gitignore should list the decrypted plaintext sibling: {gi}"
);
}
#[test]
fn apply_bails_on_secret_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();
let identity_path = utf8(tmp.path().join("age.txt"));
let (secret_key, public) = secret::generate_x25519_keypair();
std::fs::write(&identity_path, format!("{secret_key}\n")).unwrap();
let recipient = secret::parse_x25519_recipient(&public).unwrap();
let cipher = secret::encrypt_x25519(b"v1 content\n", &[recipient]).unwrap();
std::fs::write(source.join("home/secret.age"), &cipher).unwrap();
std::fs::write(source.join("home/secret"), "edited locally\n").unwrap();
let cfg = format!(
r#"
[[mount.entry]]
src = "home"
dst = "{}"
[secrets]
identity = "{}"
recipients = ["{}"]
"#,
toml_path(&target),
toml_path(&identity_path),
public
);
std::fs::write(source.join("config.toml"), cfg).unwrap();
let err = apply(Some(source.clone()), false).unwrap_err();
assert!(
format!("{err:#}").contains("secret drift"),
"expected secret drift error, got: {err:#}"
);
}
#[test]
fn append_recipient_creates_secrets_table_when_missing() {
let result =
append_recipient_to_config("", "host alice", "age1abcrecipientpublickey").unwrap();
let parsed: toml::Table = toml::from_str(&result).unwrap();
let secrets = parsed.get("secrets").and_then(|v| v.as_table()).unwrap();
let recipients = secrets
.get("recipients")
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(recipients.len(), 1);
assert_eq!(recipients[0].as_str(), Some("age1abcrecipientpublickey"));
}
#[test]
fn append_recipient_preserves_existing_other_tables() {
let existing = r#"
[vars]
greet = "hi"
[secrets]
recipients = ["age1machine_a"]
[ui]
icons = "ascii"
"#;
let result = append_recipient_to_config(existing, "host b", "age1machine_b").unwrap();
let parsed: toml::Table = toml::from_str(&result).unwrap();
assert!(parsed.get("vars").is_some());
assert!(parsed.get("secrets").is_some());
assert!(parsed.get("ui").is_some());
let recipients = parsed["secrets"]["recipients"].as_array().unwrap();
assert_eq!(recipients.len(), 2);
let pubs: Vec<&str> = recipients.iter().filter_map(|v| v.as_str()).collect();
assert!(pubs.contains(&"age1machine_a"));
assert!(pubs.contains(&"age1machine_b"));
}
#[test]
fn append_recipient_is_idempotent_on_duplicate() {
let existing = r#"[secrets]
recipients = ["age1same"]
"#;
let result = append_recipient_to_config(existing, "anyone", "age1same").unwrap();
let parsed: toml::Table = toml::from_str(&result).unwrap();
let recipients = parsed["secrets"]["recipients"].as_array().unwrap();
assert_eq!(recipients.len(), 1, "duplicate must not be appended twice");
}
#[test]
fn append_recipient_creates_recipients_array_when_secrets_table_empty() {
let existing = r#"[secrets]
identity = "~/.config/yui/age.txt"
"#;
let result = append_recipient_to_config(existing, "h", "age1new").unwrap();
let parsed: toml::Table = toml::from_str(&result).unwrap();
let secrets = parsed["secrets"].as_table().unwrap();
assert_eq!(
secrets["identity"].as_str(),
Some("~/.config/yui/age.txt"),
"existing identity field must survive"
);
let recipients = secrets["recipients"].as_array().unwrap();
assert_eq!(recipients.len(), 1);
assert_eq!(recipients[0].as_str(), Some("age1new"));
}
#[test]
fn apply_without_recipients_skips_secret_walker() {
let tmp = TempDir::new().unwrap();
let (source, _target) = setup_minimal_dotfiles(&tmp);
std::fs::write(source.join("home/.bashrc"), "x").unwrap();
std::fs::write(source.join("home/some.junk.age"), b"not actually a cipher").unwrap();
apply(Some(source.clone()), false).unwrap();
}
#[test]
fn nested_marker_accumulates_extra_dst() {
let tmp = TempDir::new().unwrap();
let source = utf8(tmp.path().join("dotfiles"));
let parent_target = utf8(tmp.path().join("home"));
let extra_target = utf8(tmp.path().join("extra"));
std::fs::create_dir_all(source.join("home/.config/nvim")).unwrap();
std::fs::create_dir_all(&parent_target).unwrap();
std::fs::create_dir_all(&extra_target).unwrap();
std::fs::write(source.join("home/.config/nvim/init.lua"), "-- nvim\n").unwrap();
std::fs::write(
source.join("home/.config/.yuilink"),
format!(
r#"
[[link]]
dst = "{}/.config"
"#,
toml_path(&parent_target)
),
)
.unwrap();
std::fs::write(
source.join("home/.config/nvim/.yuilink"),
format!(
r#"
[[link]]
dst = "{}/nvim"
when = "{{{{ yui.os == '{}' }}}}"
"#,
toml_path(&extra_target),
std::env::consts::OS
),
)
.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!(parent_target.join(".config/nvim/init.lua").exists());
assert!(extra_target.join("nvim/init.lua").exists());
}
#[test]
fn marker_file_link_targets_specific_file() {
let tmp = TempDir::new().unwrap();
let source = utf8(tmp.path().join("dotfiles"));
let parent_target = utf8(tmp.path().join("home"));
let docs_target = utf8(tmp.path().join("docs"));
std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
std::fs::create_dir_all(&parent_target).unwrap();
std::fs::create_dir_all(&docs_target).unwrap();
std::fs::write(
source.join("home/.config/powershell/profile.ps1"),
"# profile\n",
)
.unwrap();
std::fs::write(source.join("home/.config/powershell/extra.txt"), "extra\n").unwrap();
std::fs::write(
source.join("home/.config/powershell/.yuilink"),
format!(
r#"
[[link]]
src = "profile.ps1"
dst = "{}/Microsoft.PowerShell_profile.ps1"
"#,
toml_path(&docs_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!(
docs_target
.join("Microsoft.PowerShell_profile.ps1")
.exists()
);
assert!(
parent_target
.join(".config/powershell/profile.ps1")
.exists()
);
assert!(parent_target.join(".config/powershell/extra.txt").exists());
}
#[test]
fn marker_file_link_missing_src_errors() {
let tmp = TempDir::new().unwrap();
let source = utf8(tmp.path().join("dotfiles"));
let parent_target = utf8(tmp.path().join("home"));
let docs_target = utf8(tmp.path().join("docs"));
std::fs::create_dir_all(source.join("home/.config/powershell")).unwrap();
std::fs::create_dir_all(&parent_target).unwrap();
std::fs::create_dir_all(&docs_target).unwrap();
std::fs::write(
source.join("home/.config/powershell/.yuilink"),
format!(
r#"
[[link]]
src = "missing.ps1"
dst = "{}/profile.ps1"
"#,
toml_path(&docs_target)
),
)
.unwrap();
let cfg = format!(
r#"
[[mount.entry]]
src = "home"
dst = "{}"
"#,
toml_path(&parent_target)
);
std::fs::write(source.join("config.toml"), cfg).unwrap();
let err = apply(Some(source.clone()), false).unwrap_err();
assert!(format!("{err:#}").contains("missing.ps1"));
}
#[test]
fn unmanaged_finds_files_outside_any_mount() {
let tmp = TempDir::new().unwrap();
let (source, _target) = setup_minimal_dotfiles(&tmp);
std::fs::write(source.join("home/.bashrc"), "x").unwrap();
std::fs::write(source.join("orphan.txt"), "y").unwrap();
std::fs::create_dir_all(source.join("notes")).unwrap();
std::fs::write(source.join("notes/scratch.md"), "z").unwrap();
unmanaged(Some(source.clone()), None, true).unwrap();
let yui = YuiVars::detect(&source);
let cfg = config::load(&source, &yui).unwrap();
let mount_srcs: Vec<Utf8PathBuf> = cfg
.mount
.entry
.iter()
.map(|m| source.join(&m.src))
.collect();
let walker = paths::source_walker(&source).build();
let mut unmanaged_paths = Vec::new();
for entry in walker.flatten() {
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let p = match Utf8PathBuf::from_path_buf(entry.path().to_path_buf()) {
Ok(p) => p,
Err(_) => continue,
};
if is_repo_meta(&p, &source, &cfg.mount.marker_filename) {
continue;
}
if mount_srcs.iter().any(|m| p.starts_with(m)) {
continue;
}
unmanaged_paths.push(p);
}
let names: Vec<String> = unmanaged_paths
.iter()
.filter_map(|p| p.file_name().map(String::from))
.collect();
assert!(names.contains(&"orphan.txt".into()));
assert!(names.contains(&"scratch.md".into()));
assert!(!names.contains(&".bashrc".into()), "mount-claimed file");
assert!(!names.contains(&"config.toml".into()), "repo meta");
}
#[test]
fn is_repo_meta_recognises_yui_scaffold() {
let source = Utf8Path::new("/dot");
assert!(is_repo_meta(
Utf8Path::new("/dot/config.toml"),
source,
".yuilink",
));
assert!(is_repo_meta(
Utf8Path::new("/dot/config.local.toml"),
source,
".yuilink",
));
assert!(is_repo_meta(
Utf8Path::new("/dot/config.linux.toml"),
source,
".yuilink",
));
assert!(is_repo_meta(
Utf8Path::new("/dot/config.local.example.toml"),
source,
".yuilink",
));
assert!(is_repo_meta(
Utf8Path::new("/dot/.gitignore"),
source,
".yuilink",
));
assert!(is_repo_meta(
Utf8Path::new("/dot/home/.config/foo/.yuilink"),
source,
".yuilink",
));
assert!(is_repo_meta(
Utf8Path::new("/dot/home/.gitconfig.tera"),
source,
".yuilink",
));
assert!(!is_repo_meta(
Utf8Path::new("/dot/home/.config/myapp/config.toml"),
source,
".yuilink",
));
assert!(!is_repo_meta(
Utf8Path::new("/dot/home/.config/git/.gitignore"),
source,
".yuilink",
));
}
#[test]
fn unmanaged_respects_inactive_mount_entries() {
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_active")).unwrap();
std::fs::create_dir_all(source.join("home_other_os")).unwrap();
std::fs::create_dir_all(&target).unwrap();
std::fs::write(source.join("home_active/.bashrc"), "active").unwrap();
std::fs::write(source.join("home_other_os/.bashrc"), "inactive").unwrap();
let cfg = format!(
r#"
[[mount.entry]]
src = "home_active"
dst = "{target}"
[[mount.entry]]
src = "home_other_os"
dst = "{target}"
when = "yui.os == 'definitely_not_a_real_os'"
"#,
target = toml_path(&target)
);
std::fs::write(source.join("config.toml"), cfg).unwrap();
let yui = YuiVars::detect(&source);
let cfg = config::load(&source, &yui).unwrap();
let mount_srcs: Vec<Utf8PathBuf> = cfg
.mount
.entry
.iter()
.map(|m| source.join(&m.src))
.collect();
let inactive_file = source.join("home_other_os/.bashrc");
let claimed = mount_srcs.iter().any(|m| inactive_file.starts_with(m));
assert!(
claimed,
"raw config.mount.entry should claim files even under inactive mounts"
);
}
#[test]
fn diff_shows_drift_skips_in_sync() {
let tmp = TempDir::new().unwrap();
let (source, target) = setup_minimal_dotfiles(&tmp);
std::fs::write(source.join("home/.bashrc"), "first\nsecond\n").unwrap();
apply(Some(source.clone()), false).unwrap();
std::fs::remove_file(target.join(".bashrc")).unwrap();
std::fs::write(target.join(".bashrc"), "first\nEDITED\n").unwrap();
diff(Some(source.clone()), None, true).unwrap();
}
#[test]
fn read_text_for_diff_classifies_correctly() {
let tmp = TempDir::new().unwrap();
let root = utf8(tmp.path().to_path_buf());
let txt = root.join("a.txt");
std::fs::write(&txt, "hello\n").unwrap();
match read_text_for_diff(&txt) {
DiffSide::Text(s) => assert_eq!(s, "hello\n"),
DiffSide::Binary => panic!("text file misclassified as binary"),
}
let bin = root.join("b.bin");
std::fs::write(&bin, [0xff, 0xfe, 0x00, 0xff]).unwrap();
assert!(matches!(read_text_for_diff(&bin), DiffSide::Binary));
let missing = root.join("missing.txt");
match read_text_for_diff(&missing) {
DiffSide::Text(s) => assert!(s.is_empty()),
DiffSide::Binary => panic!("missing file misclassified as binary"),
}
}
#[test]
fn diff_render_drift_uses_rendered_output_not_raw_template() {
let tmp = TempDir::new().unwrap();
let (source, _target) = setup_minimal_dotfiles(&tmp);
std::fs::write(source.join("home/note.tera"), "os = {{ yui.os }}\n").unwrap();
std::fs::write(source.join("home/note"), "os = ancient\n").unwrap();
let yui = YuiVars::detect(&source);
let cfg = config::load(&source, &yui).unwrap();
let rendered =
render::render_to_string(&source.join("home/note.tera"), &source, &cfg, &yui)
.unwrap()
.expect("template should render on this host");
assert!(rendered.starts_with("os = "));
assert!(
!rendered.contains("{{"),
"rendered output must not contain raw Tera tags"
);
}
#[test]
fn resolve_diff_src_absolutizes_link_rows() {
let source = Utf8Path::new("/dot");
let link_item = StatusItem {
src: Utf8PathBuf::from("home/.bashrc"),
dst: Utf8PathBuf::from("/h/u/.bashrc"),
state: StatusState::Link(absorb::AbsorbDecision::AutoAbsorb),
};
assert_eq!(
resolve_diff_src(&link_item, source),
Utf8PathBuf::from("/dot/home/.bashrc"),
);
let render_item = StatusItem {
src: Utf8PathBuf::from("/dot/home/foo.tera"),
dst: Utf8PathBuf::from("/dot/home/foo"),
state: StatusState::RenderDrift,
};
assert_eq!(
resolve_diff_src(&render_item, source),
Utf8PathBuf::from("/dot/home/foo.tera"),
);
}
#[test]
fn diff_classifier_skips_uninteresting_states() {
use absorb::AbsorbDecision::*;
assert!(!diff_worth_printing(&StatusState::Link(InSync)));
assert!(!diff_worth_printing(&StatusState::Link(Restore)));
assert!(!diff_worth_printing(&StatusState::Link(RelinkOnly)));
assert!(diff_worth_printing(&StatusState::Link(AutoAbsorb)));
assert!(diff_worth_printing(&StatusState::Link(NeedsConfirm)));
assert!(diff_worth_printing(&StatusState::RenderDrift));
}
#[test]
fn update_errors_when_source_is_not_a_git_repo() {
let tmp = TempDir::new().unwrap();
let source = utf8(tmp.path().join("dotfiles"));
std::fs::create_dir_all(&source).unwrap();
std::fs::write(source.join("config.toml"), "").unwrap();
let err = update(Some(source), false).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("not a git repository")
|| msg.contains("uncommitted")
|| msg.contains("git"),
"unexpected error: {msg}",
);
}
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
}
#[test]
fn parse_backup_suffix_recognises_file_with_extension() {
let dt = parse_backup_suffix("foo_20260429_143022123.yml").unwrap();
assert_eq!(dt.year(), 2026);
assert_eq!(dt.month(), 4);
assert_eq!(dt.day(), 29);
assert_eq!(dt.hour(), 14);
assert_eq!(dt.minute(), 30);
assert_eq!(dt.second(), 22);
}
#[test]
fn parse_backup_suffix_recognises_dotfile_no_extension() {
let dt = parse_backup_suffix(".gitconfig_20260429_143022123").unwrap();
assert_eq!(dt.year(), 2026);
}
#[test]
fn parse_backup_suffix_recognises_directory_form() {
let dt = parse_backup_suffix("nvim_20260429_143022123").unwrap();
assert_eq!(dt.day(), 29);
}
#[test]
fn parse_backup_suffix_recognises_multi_dot_filename() {
let dt = parse_backup_suffix("archive.tar.gz_20260429_143022123.gz").unwrap();
assert_eq!(dt.month(), 4);
}
#[test]
fn parse_backup_suffix_rejects_non_yui_names() {
assert!(parse_backup_suffix("README.md").is_none());
assert!(parse_backup_suffix("notes_2026.txt").is_none());
assert!(parse_backup_suffix("almost_20260429_14302212").is_none()); assert!(parse_backup_suffix("almost_20260429-143022123").is_none()); assert!(parse_backup_suffix("_20260429_143022123").is_none());
}
#[test]
fn parse_human_duration_basic_units() {
let s = parse_human_duration("30d").unwrap();
assert_eq!(s.get_days(), 30);
let s = parse_human_duration("2w").unwrap();
assert_eq!(s.get_weeks(), 2);
let s = parse_human_duration("12h").unwrap();
assert_eq!(s.get_hours(), 12);
let s = parse_human_duration("5m").unwrap();
assert_eq!(s.get_minutes(), 5);
let s = parse_human_duration("6mo").unwrap();
assert_eq!(s.get_months(), 6);
let s = parse_human_duration("1y").unwrap();
assert_eq!(s.get_years(), 1);
}
#[test]
fn parse_human_duration_case_insensitive_and_whitespace() {
let s = parse_human_duration(" 90D ").unwrap();
assert_eq!(s.get_days(), 90);
let s = parse_human_duration("3WEEKS").unwrap();
assert_eq!(s.get_weeks(), 3);
}
#[test]
fn parse_human_duration_rejects_garbage() {
assert!(parse_human_duration("").is_err());
assert!(parse_human_duration("d30").is_err());
assert!(parse_human_duration("30").is_err()); assert!(parse_human_duration("30x").is_err()); assert!(parse_human_duration("-1d").is_err()); }
#[test]
fn walk_gc_backups_collects_files_and_dir_snapshots() {
let tmp = TempDir::new().unwrap();
let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
std::fs::write(
root.join("C/Users/u/.config/foo_20260429_143022123.yml"),
"old yml",
)
.unwrap();
std::fs::create_dir_all(root.join("C/Users/u/nvim_20260101_000000000/lua")).unwrap();
std::fs::write(
root.join("C/Users/u/nvim_20260101_000000000/init.lua"),
"ok",
)
.unwrap();
std::fs::write(
root.join("C/Users/u/nvim_20260101_000000000/lua/x.lua"),
"kk",
)
.unwrap();
std::fs::write(root.join("C/Users/u/.config/README.md"), "user note").unwrap();
let entries = walk_gc_backups(&root).unwrap();
assert_eq!(entries.len(), 2, "two backup roots, not three");
let kinds: Vec<_> = entries.iter().map(|e| e.kind).collect();
assert!(kinds.contains(&BackupKind::File));
assert!(kinds.contains(&BackupKind::Dir));
let dir_entry = entries.iter().find(|e| e.kind == BackupKind::Dir).unwrap();
assert!(dir_entry.size_bytes >= 4); }
#[test]
fn cleanup_empty_parents_stops_at_root_and_at_non_empty() {
let tmp = TempDir::new().unwrap();
let root = utf8(tmp.path().to_path_buf()).join(".yui/backup");
std::fs::create_dir_all(root.join("C/Users/u/.config")).unwrap();
std::fs::write(root.join("C/Users/u/sibling_keep"), "x").unwrap();
cleanup_empty_parents(&root.join("C/Users/u/.config"), &root);
assert!(!root.join("C/Users/u/.config").exists(), "empty leaf gone");
assert!(root.join("C/Users/u").exists(), "stops at non-empty parent");
assert!(root.exists(), "backup root preserved");
}
#[test]
fn gc_backup_survey_keeps_all_entries() {
let tmp = TempDir::new().unwrap();
let source = utf8(tmp.path().join("dotfiles"));
std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
std::fs::write(source.join("config.toml"), "").unwrap();
let backup = source.join(".yui/backup");
std::fs::write(backup.join("a_20260101_000000000.txt"), "old").unwrap();
std::fs::write(backup.join("b_20260415_120000000.txt"), "fresh").unwrap();
gc_backup(Some(source.clone()), None, false, None, true).unwrap();
assert!(backup.join("a_20260101_000000000.txt").exists());
assert!(backup.join("b_20260415_120000000.txt").exists());
}
#[test]
fn gc_backup_prune_removes_old_files_only() {
let tmp = TempDir::new().unwrap();
let source = utf8(tmp.path().join("dotfiles"));
std::fs::create_dir_all(source.join(".yui/backup/sub")).unwrap();
std::fs::write(source.join("config.toml"), "").unwrap();
let backup = source.join(".yui/backup");
std::fs::write(backup.join("sub/old_20200101_000000000.txt"), "old").unwrap();
let tomorrow = jiff::Zoned::now()
.checked_add(jiff::Span::new().days(1))
.unwrap();
let bdt = jiff::fmt::strtime::BrokenDownTime::from(&tomorrow);
let future_ts = bdt.to_string("%Y%m%d_%H%M%S%3f").unwrap();
std::fs::write(backup.join(format!("fresh_{future_ts}.txt")), "fresh").unwrap();
std::fs::write(backup.join("notes.md"), "mine").unwrap();
gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
assert!(!backup.join("sub/old_20200101_000000000.txt").exists());
assert!(!backup.join("sub").exists(), "empty parent removed");
assert!(backup.exists());
assert!(backup.join(format!("fresh_{future_ts}.txt")).exists());
assert!(backup.join("notes.md").exists(), "user file untouched");
}
#[test]
fn gc_backup_dry_run_does_not_delete() {
let tmp = TempDir::new().unwrap();
let source = utf8(tmp.path().join("dotfiles"));
std::fs::create_dir_all(source.join(".yui/backup")).unwrap();
std::fs::write(source.join("config.toml"), "").unwrap();
let backup = source.join(".yui/backup");
std::fs::write(backup.join("old_20200101_000000000.txt"), "old").unwrap();
gc_backup(Some(source.clone()), Some("30d".into()), true, None, true).unwrap();
assert!(
backup.join("old_20200101_000000000.txt").exists(),
"dry-run keeps everything in place"
);
}
#[test]
fn gc_backup_prune_handles_directory_snapshot() {
let tmp = TempDir::new().unwrap();
let source = utf8(tmp.path().join("dotfiles"));
std::fs::create_dir_all(source.join(".yui/backup/mirror/u")).unwrap();
std::fs::write(source.join("config.toml"), "").unwrap();
let backup = source.join(".yui/backup");
let snap = backup.join("mirror/u/nvim_20200101_000000000");
std::fs::create_dir_all(snap.join("lua")).unwrap();
std::fs::write(snap.join("init.lua"), "x").unwrap();
std::fs::write(snap.join("lua/y.lua"), "y").unwrap();
gc_backup(Some(source.clone()), Some("30d".into()), false, None, true).unwrap();
assert!(!snap.exists(), "dir snapshot removed wholesale");
assert!(!backup.join("mirror").exists(), "empty mirror chain pruned");
assert!(backup.exists(), "backup root preserved");
}
fn ctx_for_test(tmp: &TempDir) -> (Config, Utf8PathBuf, Utf8PathBuf) {
let source = utf8(tmp.path().join("src"));
let backup_root = source.join(".yui/backup");
std::fs::create_dir_all(&source).unwrap();
let cfg = Config::default();
(cfg, source, backup_root)
}
#[test]
fn prompt_anomaly_short_circuits_on_quit_requested() {
let tmp = TempDir::new().unwrap();
let (cfg, source, backup_root) = ctx_for_test(&tmp);
let src_file = source.join("a");
let dst_file = utf8(tmp.path().join("dst"));
std::fs::write(&src_file, "X").unwrap();
std::fs::write(&dst_file, "Y").unwrap();
let ctx = ApplyCtx {
config: &cfg,
source: &source,
file_mode: resolve_file_mode(cfg.link.file_mode),
dir_mode: resolve_dir_mode(cfg.link.dir_mode),
backup_root: &backup_root,
dry_run: false,
sticky_anomaly: Cell::new(None),
quit_requested: Cell::new(true),
};
let got = prompt_anomaly(&ctx, &src_file, &dst_file, "test").unwrap();
assert_eq!(got, AnomalyChoice::Quit);
}
#[test]
fn prompt_anomaly_short_circuits_on_sticky_choice() {
let tmp = TempDir::new().unwrap();
let (cfg, source, backup_root) = ctx_for_test(&tmp);
let src_file = source.join("a");
let dst_file = utf8(tmp.path().join("dst"));
std::fs::write(&src_file, "X").unwrap();
std::fs::write(&dst_file, "Y").unwrap();
let ctx = ApplyCtx {
config: &cfg,
source: &source,
file_mode: resolve_file_mode(cfg.link.file_mode),
dir_mode: resolve_dir_mode(cfg.link.dir_mode),
backup_root: &backup_root,
dry_run: false,
sticky_anomaly: Cell::new(Some(AnomalyChoice::Overwrite)),
quit_requested: Cell::new(false),
};
let got = prompt_anomaly(&ctx, &src_file, &dst_file, "test").unwrap();
assert_eq!(got, AnomalyChoice::Overwrite);
}
#[test]
fn overwrite_source_into_target_replaces_target_and_backs_up() {
let tmp = TempDir::new().unwrap();
let (cfg, source, backup_root) = ctx_for_test(&tmp);
let src_file = source.join("a");
let dst_file = utf8(tmp.path().join("dst"));
std::fs::write(&src_file, "from source").unwrap();
std::fs::write(&dst_file, "diverged target content").unwrap();
let ctx = ApplyCtx {
config: &cfg,
source: &source,
file_mode: resolve_file_mode(cfg.link.file_mode),
dir_mode: resolve_dir_mode(cfg.link.dir_mode),
backup_root: &backup_root,
dry_run: false,
sticky_anomaly: Cell::new(None),
quit_requested: Cell::new(false),
};
overwrite_source_into_target(&src_file, &dst_file, &ctx).unwrap();
assert_eq!(std::fs::read_to_string(&dst_file).unwrap(), "from source");
assert_eq!(std::fs::read_to_string(&src_file).unwrap(), "from source");
let mut found_old = false;
for entry in walkdir(&backup_root) {
if let Ok(s) = std::fs::read_to_string(&entry) {
if s == "diverged target content" {
found_old = true;
break;
}
}
}
assert!(
found_old,
"expected backup containing target's diverged content"
);
}
#[test]
fn link_file_with_backup_short_circuits_when_quit_requested() {
let tmp = TempDir::new().unwrap();
let (mut cfg, source, backup_root) = ctx_for_test(&tmp);
cfg.absorb.on_anomaly = crate::config::AnomalyAction::Force;
let src_file = source.join("a");
let dst_file = utf8(tmp.path().join("dst"));
let now = std::time::SystemTime::now();
let past = now - std::time::Duration::from_secs(120);
write_with_mtime(&dst_file, "target old", past);
write_with_mtime(&src_file, "source new", now);
let dst_before = std::fs::read_to_string(&dst_file).unwrap();
let src_before = std::fs::read_to_string(&src_file).unwrap();
let ctx = ApplyCtx {
config: &cfg,
source: &source,
file_mode: resolve_file_mode(cfg.link.file_mode),
dir_mode: resolve_dir_mode(cfg.link.dir_mode),
backup_root: &backup_root,
dry_run: false,
sticky_anomaly: Cell::new(None),
quit_requested: Cell::new(true),
};
link_file_with_backup(&src_file, &dst_file, &ctx).unwrap();
assert_eq!(std::fs::read_to_string(&dst_file).unwrap(), dst_before);
assert_eq!(std::fs::read_to_string(&src_file).unwrap(), src_before);
assert!(
!backup_root.exists() || walkdir(&backup_root).is_empty(),
"no backup should be produced when quit is requested"
);
}
}