use std::path::{Path, PathBuf};
use eyre::{Result, bail};
use indexmap::IndexMap;
use itertools::Itertools;
use regex::Regex;
use serde::Deserialize;
use crate::config::{Config, ConfigMap, Settings};
use crate::dirs;
use crate::file;
use crate::path::PathExt;
use crate::ui::prompt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileMode {
Symlink,
SymlinkEach,
Copy,
Template,
}
impl FileMode {
pub fn parse(s: &str) -> Option<Self> {
match s {
"symlink" => Some(Self::Symlink),
"symlink-each" => Some(Self::SymlinkEach),
"copy" => Some(Self::Copy),
"template" => Some(Self::Template),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Self::Symlink => "symlink",
Self::SymlinkEach => "symlink-each",
Self::Copy => "copy",
Self::Template => "template",
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum FileTomlEntry {
Source(String),
Table {
#[serde(default)]
source: Option<String>,
#[serde(default)]
mode: Option<String>,
#[serde(default)]
exclude: Option<Vec<String>>,
},
}
#[derive(Debug, Clone)]
pub struct FileRequest {
pub target_raw: String,
pub target: PathBuf,
pub source: PathBuf,
pub mode: FileMode,
pub exclude: Vec<glob::Pattern>,
pub base: PathBuf,
}
#[derive(Debug, PartialEq, Eq)]
pub enum FileState {
Applied,
Missing,
Differs(String),
SourceMissing,
}
pub fn files_from_config(config: &Config) -> Vec<FileRequest> {
files_from_config_files(&config.config_files)
}
pub fn files_from_config_files(config_files: &ConfigMap) -> Vec<FileRequest> {
let mut merged: IndexMap<PathBuf, FileRequest> = IndexMap::new();
for (path, cf) in config_files.iter().rev() {
let base = path.parent().unwrap_or(Path::new(".")).to_path_buf();
let Some(dotfiles) = cf.dotfiles_config() else {
continue;
};
for (target_raw, value) in dotfiles.0 {
let Some(entry) = file_entry_from_toml(&target_raw, value) else {
continue;
};
merge_file_entry(target_raw, entry, &base, &mut merged);
}
}
merged.into_values().collect()
}
fn file_entry_from_toml(target_raw: &str, value: toml::Value) -> Option<FileTomlEntry> {
match &value {
toml::Value::String(_) => {}
toml::Value::Table(table)
if table.is_empty()
|| table.contains_key("mode")
|| table.contains_key("exclude")
|| (table.contains_key("source")
&& !table.contains_key("block")
&& !table.contains_key("line")
&& !table.contains_key("template")
&& !table.contains_key("comment")) => {}
toml::Value::Table(_) => return None,
_ => {
warn!("[dotfiles].\"{target_raw}\": expected string or table entry, ignoring entry");
return None;
}
}
match value.try_into() {
Ok(entry) => Some(entry),
Err(err) => {
warn!("[dotfiles].\"{target_raw}\": invalid file entry: {err}");
None
}
}
}
fn merge_file_entry(
target_raw: String,
entry: FileTomlEntry,
base: &Path,
merged: &mut IndexMap<PathBuf, FileRequest>,
) {
let (source, mode, exclude) = match entry {
FileTomlEntry::Source(source) => (Some(source), None, None),
FileTomlEntry::Table {
source,
mode,
exclude,
} => (source, mode, exclude),
};
let exclude = exclude
.unwrap_or_default()
.into_iter()
.filter_map(|pattern| match glob::Pattern::new(&pattern) {
Ok(pattern) => Some(pattern),
Err(err) => {
warn!("[dotfiles].\"{target_raw}\": invalid exclude pattern '{pattern}': {err}");
None
}
})
.collect::<Vec<_>>();
let mode = match mode.as_deref() {
None => default_mode(),
Some(m) => match FileMode::parse(m) {
Some(m) => m,
None => {
warn!("[dotfiles].\"{target_raw}\": unknown mode '{m}', ignoring entry");
return;
}
},
};
let target = file::replace_path(&target_raw);
if target.is_relative() {
warn!(
"[dotfiles].\"{target_raw}\": target must be absolute or start with ~/, ignoring entry"
);
return;
}
let source = match source {
Some(source) => {
let source = file::replace_path(&source);
if source.is_relative() {
base.join(source)
} else {
source
}
}
None => match implied_source(&target) {
Ok(source) => source,
Err(err) => {
warn!("[dotfiles].\"{target_raw}\": {err}, ignoring entry");
return;
}
},
};
for req in expand_request(
target_raw,
target,
source,
mode,
exclude,
base.to_path_buf(),
) {
merged.insert(req.target.clone(), req);
}
}
pub fn default_mode() -> FileMode {
let settings = Settings::get();
let mode = settings.dotfiles.default_mode.as_str();
match FileMode::parse(mode) {
Some(mode) => mode,
None => {
warn!("dotfiles.default_mode: unknown mode '{mode}', using symlink");
FileMode::Symlink
}
}
}
pub fn dotfiles_root() -> PathBuf {
file::replace_path(&Settings::get().dotfiles.root)
}
pub fn implied_source(target: &Path) -> Result<PathBuf> {
let home: &Path = &dirs::HOME;
let rel = target.strip_prefix(home).map_err(|_| {
eyre::eyre!(
"source is required for targets outside $HOME: {}",
target.display_user()
)
})?;
if rel.as_os_str().is_empty() {
bail!("source is required for the home directory itself");
}
Ok(dotfiles_root().join(rel))
}
pub fn source_is_implied(req: &FileRequest) -> bool {
match implied_source(&req.target) {
Ok(source) => source == req.source,
Err(_) => false,
}
}
pub fn resolve_target_arg(target: &str) -> PathBuf {
file::replace_path(target)
}
pub fn matches_target(req_target: &Path, req_raw: &str, filters: &[String]) -> bool {
filters.is_empty()
|| filters.iter().any(|filter| {
filter == req_raw || {
let resolved = resolve_target_arg(filter);
resolved == req_target
}
})
}
pub fn copy_path(source: &Path, target: &Path) -> Result<()> {
if let Some(parent) = target.parent() {
file::create_dir_all(parent)?;
}
if source.is_dir() {
if target.exists() && !target.is_dir() {
remove_existing(target)?;
}
file::create_dir_all(target)?;
file::copy_dir_all(source, target)?;
} else {
if target.is_symlink() {
file::remove_file(target)?;
}
file::copy(source, target)?;
}
Ok(())
}
fn expand_request(
target_raw: String,
target: PathBuf,
source: PathBuf,
mode: FileMode,
exclude: Vec<glob::Pattern>,
base: PathBuf,
) -> Vec<FileRequest> {
if !is_glob_pattern(&source) {
return vec![FileRequest {
target_raw,
target,
source,
mode,
exclude,
base,
}];
}
let source_pattern = source.to_string_lossy().to_string();
let matches = match glob::glob(&source_pattern) {
Ok(paths) => paths
.filter_map(|path| match path {
Ok(path) => Some(path),
Err(err) => {
warn!(
"[dotfiles].\"{target_raw}\": error reading source pattern {source_pattern}: {err}"
);
None
}
})
.sorted()
.collect_vec(),
Err(err) => {
warn!("[dotfiles].\"{target_raw}\": invalid source pattern: {err}");
return vec![];
}
};
if matches.is_empty() {
warn!("[dotfiles].\"{target_raw}\": source pattern matched no files, ignoring entry");
return vec![];
}
let target_pattern = target.to_string_lossy().to_string();
if !is_glob_pattern(&target) {
if matches.len() > 1 {
warn!(
"[dotfiles].\"{target_raw}\": source pattern matched multiple paths but target has no wildcard, ignoring entry"
);
return vec![];
}
return vec![FileRequest {
target_raw,
target,
source: matches[0].clone(),
mode,
exclude,
base,
}];
}
matches
.into_iter()
.filter_map(|matched_source| {
let captures = match wildcard_captures(&source_pattern, &matched_source) {
Ok(captures) => captures,
Err(err) => {
warn!("[dotfiles].\"{target_raw}\": {err}");
return None;
}
};
let Some(target_path) = expand_target_pattern(&target_pattern, &captures) else {
warn!(
"[dotfiles].\"{target_raw}\": target wildcard count does not match source pattern, ignoring {}",
matched_source.display_user()
);
return None;
};
Some(FileRequest {
target_raw: target_path.display_user().to_string(),
target: target_path,
source: matched_source,
mode,
exclude: exclude.clone(),
base: base.clone(),
})
})
.collect()
}
fn is_glob_pattern(path: &Path) -> bool {
path.to_string_lossy()
.chars()
.any(|c| matches!(c, '*' | '?' | '['))
}
fn wildcard_captures(pattern: &str, path: &Path) -> Result<Vec<String>> {
let path = normalize_path_separators(&path.to_string_lossy());
let re = wildcard_regex(pattern)?;
let Some(captures) = re.captures(&path) else {
bail!("source pattern did not match {path}");
};
Ok((1..captures.len())
.map(|i| {
captures
.get(i)
.map(|m| m.as_str().to_string())
.unwrap_or_default()
})
.collect())
}
fn wildcard_regex(pattern: &str) -> Result<Regex> {
let mut re = String::from("^");
let pattern = normalize_path_separators(pattern);
let mut chars = pattern.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'*' if chars.peek() == Some(&'*') => {
chars.next();
if chars.peek() == Some(&'/') {
chars.next();
re.push_str("(?:(.*)/)?");
} else {
re.push_str("(.*)");
}
}
'*' => re.push_str("([^/]*)"),
'?' => re.push_str("([^/])"),
'[' => {
let Some(class) = read_glob_class(&mut chars) else {
re.push_str("\\[");
continue;
};
re.push('(');
re.push_str(&class);
re.push(')');
}
_ => re.push_str(®ex::escape(&ch.to_string())),
}
}
re.push('$');
Ok(Regex::new(&re)?)
}
fn expand_target_pattern(pattern: &str, captures: &[String]) -> Option<PathBuf> {
let mut out = String::new();
let pattern = normalize_path_separators(pattern);
let mut chars = pattern.chars().peekable();
let mut captures = captures.iter();
while let Some(ch) = chars.next() {
match ch {
'*' if chars.peek() == Some(&'*') => {
chars.next();
let capture = normalize_path_separators(captures.next()?);
if chars.peek() == Some(&'/') {
chars.next();
if !capture.is_empty() {
out.push_str(&capture);
out.push('/');
}
} else {
out.push_str(&capture);
}
}
'*' => out.push_str(&normalize_path_separators(captures.next()?)),
'?' => out.push_str(&normalize_path_separators(captures.next()?)),
'[' => {
read_glob_class(&mut chars)?;
out.push_str(&normalize_path_separators(captures.next()?));
}
_ => out.push(ch),
}
}
if captures.next().is_some() {
return None;
}
Some(PathBuf::from(native_path_separators(&out)))
}
fn normalize_path_separators(path: &str) -> String {
path.replace('\\', "/")
}
fn native_path_separators(path: &str) -> String {
if std::path::MAIN_SEPARATOR == '/' {
path.to_string()
} else {
path.replace('/', std::path::MAIN_SEPARATOR_STR)
}
}
fn read_glob_class<I>(chars: &mut std::iter::Peekable<I>) -> Option<String>
where
I: Iterator<Item = char>,
{
let mut class = String::from("[");
if chars.peek() == Some(&'!') {
chars.next();
class.push('^');
}
for ch in chars.by_ref() {
class.push(ch);
if ch == ']' {
return Some(class);
}
}
None
}
pub fn check(config: &Config, req: &FileRequest) -> Result<FileState> {
if !req.source.exists() {
return Ok(FileState::SourceMissing);
}
let rendered = match req.mode {
FileMode::Template => Some(render_template(config, req)?),
_ => None,
};
check_rendered(req, rendered.as_deref())
}
fn check_rendered(req: &FileRequest, rendered: Option<&str>) -> Result<FileState> {
match req.mode {
FileMode::Symlink => check_symlink(&req.source, &req.target),
FileMode::SymlinkEach => check_symlink_each(req),
FileMode::Copy if req.source.is_dir() => check_copy_dir(req),
FileMode::Copy => check_copy(&req.source, &req.target),
FileMode::Template => {
let state = check_content(
&req.target,
rendered.expect("rendered template content").as_bytes(),
)?;
#[cfg(unix)]
if state == FileState::Applied {
use std::os::unix::fs::PermissionsExt;
let mode_of =
|p: &Path| -> Result<u32> { Ok(p.metadata()?.permissions().mode() & 0o7777) };
if mode_of(&req.source)? != mode_of(&req.target)? {
return Ok(FileState::Differs("permissions differ".into()));
}
}
Ok(state)
}
}
}
fn check_symlink(source: &Path, target: &Path) -> Result<FileState> {
if cfg!(windows) && source.is_file() {
return check_copy(source, target);
}
if target.is_symlink() {
let dest = std::fs::read_link(target)?;
if dest == *source || points_at_same_file(target, source) {
Ok(FileState::Applied)
} else {
Ok(FileState::Differs(format!(
"symlink points to {}",
dest.display_user()
)))
}
} else if target.exists() {
Ok(FileState::Differs("exists but is not a symlink".into()))
} else {
Ok(FileState::Missing)
}
}
fn points_at_same_file(target: &Path, source: &Path) -> bool {
match (target.canonicalize(), source.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
fn check_symlink_each(req: &FileRequest) -> Result<FileState> {
if !req.source.is_dir() {
bail!(
"mode symlink-each requires the source to be a directory: {}",
req.source.display_user()
);
}
let stale = stale_links(req)?;
let stale_reason = || format!("{} stale link(s)", stale.len());
let files = walk_source_files(req)?;
if files.is_empty() {
return if req.target.is_dir() {
if stale.is_empty() {
Ok(FileState::Applied)
} else {
Ok(FileState::Differs(stale_reason()))
}
} else if req.target.exists() || req.target.is_symlink() {
Ok(FileState::Differs("exists but is not a directory".into()))
} else {
Ok(FileState::Missing)
};
}
let mut applied = 0;
let mut missing = 0;
let mut differs: Option<String> = None;
for (source, target) in files {
match check_symlink(&source, &target)? {
FileState::Applied => applied += 1,
FileState::Missing => missing += 1,
FileState::Differs(reason) => {
differs.get_or_insert(format!("{}: {reason}", target.display_user()));
}
FileState::SourceMissing => unreachable!("walked from source"),
}
}
if let Some(reason) = differs {
Ok(FileState::Differs(reason))
} else if missing > 0 && applied > 0 {
Ok(FileState::Differs(format!(
"{applied} file(s) linked, {missing} missing"
)))
} else if missing > 0 {
Ok(FileState::Missing)
} else if !stale.is_empty() {
Ok(FileState::Differs(stale_reason()))
} else {
Ok(FileState::Applied)
}
}
fn check_copy(source: &Path, target: &Path) -> Result<FileState> {
check_content(target, &file::read(source)?)
}
fn check_copy_dir(req: &FileRequest) -> Result<FileState> {
if !req.target.exists() {
return Ok(FileState::Missing);
}
if !req.target.is_dir() {
return Ok(FileState::Differs("exists but is not a directory".into()));
}
for (source, target) in walk_source_files(req)? {
match check_content(&target, &file::read(&source)?)? {
FileState::Applied => {}
_ => {
let rel = target.strip_prefix(&req.target).unwrap_or(&target);
return Ok(FileState::Differs(format!("{} differs", rel.display())));
}
}
}
Ok(FileState::Applied)
}
fn check_content(target: &Path, expected: &[u8]) -> Result<FileState> {
if target.is_symlink() {
return Ok(FileState::Differs("exists but is a symlink".into()));
}
if !target.exists() {
return Ok(FileState::Missing);
}
if target.is_dir() {
return Ok(FileState::Differs("exists but is a directory".into()));
}
if file::read(target)? == expected {
Ok(FileState::Applied)
} else {
Ok(FileState::Differs("content differs".into()))
}
}
pub fn render_template(config: &Config, req: &FileRequest) -> Result<String> {
let raw = file::read_to_string(&req.source)?;
let mut tera = crate::tera::get_tera(Some(&req.base));
let rendered = crate::tera::render_str(&mut tera, &raw, &config.tera_ctx).map_err(|err| {
eyre::eyre!(
"[dotfiles].\"{}\": failed to render template {}: {err}",
req.target_raw,
req.source.display_user()
)
})?;
Ok(rendered)
}
fn needed_dirs(req: &FileRequest) -> Result<Vec<PathBuf>> {
let mut out = indexmap::IndexSet::new();
out.insert(req.target.clone());
for (_, target) in walk_source_files(req)? {
let mut dir = target.parent();
while let Some(d) = dir {
if d == req.target {
break;
}
out.insert(d.to_path_buf());
dir = d.parent();
}
}
Ok(out.into_iter().collect())
}
fn stale_links(req: &FileRequest) -> Result<Vec<PathBuf>> {
if cfg!(windows) || !req.target.is_dir() || req.target.is_symlink() {
return Ok(vec![]);
}
let mut out = vec![];
for entry in walkdir::WalkDir::new(&req.target).sort_by_file_name() {
let entry = match entry {
Ok(entry) => entry,
Err(err) => {
debug!("files: walking {}: {err}", req.target.display_user());
continue;
}
};
if !entry.file_type().is_symlink() {
continue;
}
let dest = match std::fs::read_link(entry.path()) {
Ok(dest) => dest,
Err(err) => {
debug!("files: reading {}: {err}", entry.path().display_user());
continue;
}
};
let Ok(rel) = entry.path().strip_prefix(&req.target) else {
continue;
};
let expected = req.source.join(rel);
if dest == expected && (!expected.exists() || is_excluded(rel, &req.exclude)) {
out.push(entry.path().to_path_buf());
}
}
Ok(out)
}
fn is_excluded(rel: &Path, patterns: &[glob::Pattern]) -> bool {
patterns.iter().any(|pattern| {
if pattern.as_str().contains('/') {
rel.ancestors().any(|a| pattern.matches_path(a))
} else {
rel.components()
.any(|c| pattern.matches(&c.as_os_str().to_string_lossy()))
}
})
}
fn walk_source_files(req: &FileRequest) -> Result<Vec<(PathBuf, PathBuf)>> {
let mut out = vec![];
let mut walk = walkdir::WalkDir::new(&req.source)
.sort_by_file_name()
.into_iter();
while let Some(entry) = walk.next() {
let entry = entry?;
let rel = entry.path().strip_prefix(&req.source)?;
if !rel.as_os_str().is_empty() && is_excluded(rel, &req.exclude) {
if entry.file_type().is_dir() {
walk.skip_current_dir();
}
continue;
}
if entry.file_type().is_dir() {
continue;
}
out.push((entry.path().to_path_buf(), req.target.join(rel)));
}
Ok(out)
}
pub struct ApplyOpts {
pub dry_run: bool,
pub verbose: bool,
pub force: bool,
pub force_hint: &'static str,
pub yes: bool,
}
pub fn apply(config: &Config, requests: &[FileRequest], opts: &ApplyOpts) -> Result<bool> {
let mut todo: Vec<(&FileRequest, Option<String>)> = vec![];
let mut missing_sources = vec![];
let mut broken = vec![];
let mut conflicts = vec![];
for req in requests {
if !req.source.exists() {
missing_sources.push(format!(
" [dotfiles].\"{}\": {}",
req.target_raw,
req.source.display_user()
));
continue;
}
if opts.dry_run && req.mode == FileMode::Template {
conflicts.extend(find_conflicts(req)?);
todo.push((req, None));
continue;
}
let rendered = match req.mode {
FileMode::Template => match render_template(config, req) {
Ok(rendered) => Some(rendered),
Err(err) => {
broken.push(format!(" {err}"));
continue;
}
},
_ => None,
};
match check_rendered(req, rendered.as_deref()) {
Ok(FileState::Applied) => continue,
Ok(_) => {}
Err(err) => {
broken.push(format!(" [dotfiles].\"{}\": {err}", req.target_raw));
continue;
}
}
conflicts.extend(find_conflicts(req)?);
todo.push((req, rendered));
}
let mut problems = vec![];
if !missing_sources.is_empty() {
problems.push(format!(
"sources do not exist:\n{}",
missing_sources.join("\n")
));
}
if !broken.is_empty() {
problems.push(format!("entries with errors:\n{}", broken.join("\n")));
}
if !conflicts.is_empty() && !opts.force {
problems.push(format!(
"refusing to overwrite existing files ({}):\n{}",
opts.force_hint,
conflicts
.iter()
.map(|p| format!(" {}", p.display_user()))
.collect::<Vec<_>>()
.join("\n")
));
}
if !problems.is_empty() {
bail!("files: {}", problems.join("\nfiles: "));
}
if todo.is_empty() {
info!("files: all files are applied");
return Ok(true);
}
if opts.dry_run {
for (req, rendered) in &todo {
let conditional = req.mode == FileMode::Template && rendered.is_none();
let suffix = if conditional { " (if changed)" } else { "" };
miseprintln!("{}{suffix}", describe(req)?);
if opts.verbose && !conditional {
print_diff(req, rendered.as_deref())?;
}
}
return Ok(true);
}
if !opts.yes && console::user_attended_stderr() {
let list = todo
.iter()
.map(|(r, _)| r.target_raw.clone())
.collect::<Vec<_>>()
.join(", ");
if !prompt::confirm(format!("files: apply {list}?"))? {
info!("files: skipped");
return Ok(false);
}
}
for (req, rendered) in &todo {
apply_one(req, rendered.as_deref())?;
}
info!(
"files: applied {}",
todo.iter()
.map(|(r, _)| r.target_raw.clone())
.collect::<Vec<_>>()
.join(", ")
);
Ok(true)
}
pub struct UnapplyOpts {
pub dry_run: bool,
pub verbose: bool,
pub force: bool,
pub yes: bool,
}
#[derive(Debug)]
pub struct UnapplyPlan<'a> {
req: &'a FileRequest,
paths: Vec<PathBuf>,
cleanup_empty_dirs: bool,
conditional: bool,
}
pub fn plan_unapply<'a>(
requests: &'a [FileRequest],
opts: &UnapplyOpts,
) -> Result<Vec<UnapplyPlan<'a>>> {
let mut todo = vec![];
let mut problems = vec![];
for req in requests {
match plan_unapply_one(req, opts) {
Ok(Some(plan)) => todo.push(plan),
Ok(None) => {}
Err(err) => problems.push(format!(" [dotfiles].\"{}\": {err}", req.target_raw)),
}
}
if !problems.is_empty() {
bail!(
"files: cannot unapply these entries:\n{}",
problems.join("\n")
);
}
Ok(todo)
}
pub fn resolve_unapply(
config: &Config,
plans: &mut Vec<UnapplyPlan<'_>>,
opts: &UnapplyOpts,
) -> Result<()> {
if opts.dry_run {
return Ok(());
}
let mut problems = vec![];
let rendered = plans
.iter()
.map(|plan| {
if plan.conditional {
match render_template(config, plan.req) {
Ok(rendered) => Some(rendered),
Err(err) => {
problems.push(format!(" [dotfiles].\"{}\": {err}", plan.req.target_raw));
None
}
}
} else {
None
}
})
.collect::<Vec<_>>();
if !problems.is_empty() {
bail!(
"files: cannot unapply these entries:\n{}",
problems.join("\n")
);
}
let mut refreshed = Vec::with_capacity(plans.len());
for (plan, rendered) in std::mem::take(plans).into_iter().zip(rendered) {
let result = if let Some(rendered) = rendered {
let mut paths = IndexMap::new();
plan_expected_content(rendered.as_bytes(), &plan.req.target, false, &mut paths).map(
|_| {
Some(UnapplyPlan {
req: plan.req,
paths: paths.into_keys().collect(),
cleanup_empty_dirs: false,
conditional: false,
})
},
)
} else {
plan_unapply_one(plan.req, opts)
};
match result {
Ok(Some(plan)) => refreshed.push(plan),
Ok(None) => {}
Err(err) => problems.push(format!(" [dotfiles].\"{}\": {err}", plan.req.target_raw)),
}
}
if !problems.is_empty() {
bail!(
"files: cannot unapply these entries:\n{}",
problems.join("\n")
);
}
*plans = refreshed;
Ok(())
}
pub fn execute_unapply(plans: &[UnapplyPlan<'_>], opts: &UnapplyOpts) -> Result<()> {
let todo = plans;
if todo.is_empty() {
info!("files: all files are unapplied");
return Ok(());
}
if opts.dry_run {
for plan in todo {
for path in &plan.paths {
let suffix = if plan.conditional {
" (if unchanged)"
} else {
""
};
miseprintln!("rm {}{suffix}", path.display_user());
}
if plan.cleanup_empty_dirs {
miseprintln!("rmdir {} (if empty)", plan.req.target.display_user());
}
if opts.verbose && plan.cleanup_empty_dirs {
miseprintln!(
" preserve unmanaged files under {}",
plan.req.target.display_user()
);
}
}
return Ok(());
}
if !opts.yes && console::user_attended_stderr() {
let list = todo
.iter()
.map(|plan| plan.req.target_raw.clone())
.join(", ");
if !prompt::confirm(format!("files: unapply {list}?"))? {
info!("files: skipped");
return Ok(());
}
}
for plan in todo {
unapply_one(plan)?;
}
info!(
"files: unapplied {}",
todo.iter()
.map(|plan| plan.req.target_raw.clone())
.join(", ")
);
Ok(())
}
fn plan_unapply_one<'a>(
req: &'a FileRequest,
opts: &UnapplyOpts,
) -> Result<Option<UnapplyPlan<'a>>> {
let mut paths = IndexMap::<PathBuf, ()>::new();
let mut cleanup_empty_dirs = false;
let mut conditional = false;
match req.mode {
FileMode::Symlink if !(cfg!(windows) && req.source.is_file()) => {
if req.target.is_symlink() {
let dest = std::fs::read_link(&req.target)?;
if opts.force || dest == req.source || points_at_same_file(&req.target, &req.source)
{
paths.insert(req.target.clone(), ());
} else {
bail!(
"target symlink points to {}, use --force to remove it",
dest.display_user()
);
}
} else if req.target.exists() {
if opts.force {
paths.insert(req.target.clone(), ());
} else {
bail!("target is not the managed symlink, use --force to remove it");
}
}
}
FileMode::SymlinkEach if !cfg!(windows) => {
if req.target.is_symlink() || (req.target.exists() && !req.target.is_dir()) {
if opts.force {
paths.insert(req.target.clone(), ());
} else {
bail!("target is not the managed directory, use --force to remove it");
}
} else if req.target.is_dir() {
for path in owned_links(req)? {
paths.insert(path, ());
}
cleanup_empty_dirs = true;
}
}
FileMode::Copy | FileMode::SymlinkEach if req.source.is_dir() => {
if req.target.is_symlink() || (req.target.exists() && !req.target.is_dir()) {
if opts.force {
paths.insert(req.target.clone(), ());
} else {
bail!("target is not the managed directory, use --force to remove it");
}
} else if req.target.is_dir() {
for (source, target) in walk_source_files(req)? {
plan_regular_file(&source, &target, opts.force, &mut paths)?;
}
cleanup_empty_dirs = true;
}
}
FileMode::Copy => {
if req.target.is_dir() && !req.source.exists() {
bail!("source directory is missing, so managed children cannot be identified");
}
plan_single_file(req, opts, &mut paths)?;
}
FileMode::Symlink => {
plan_single_file(req, opts, &mut paths)?;
}
FileMode::SymlinkEach => {
bail!("mode symlink-each requires the source to be a directory");
}
FileMode::Template => {
if !req.target.exists() && !req.target.is_symlink() {
return Ok(None);
}
if opts.force {
paths.insert(req.target.clone(), ());
} else if opts.dry_run {
paths.insert(req.target.clone(), ());
conditional = true;
} else {
if !req.source.exists() {
bail!("source is missing; use --force to remove the target");
}
conditional = true;
}
}
}
if paths.is_empty() && !cleanup_empty_dirs && !conditional {
Ok(None)
} else {
Ok(Some(UnapplyPlan {
req,
paths: paths.into_keys().collect(),
cleanup_empty_dirs,
conditional,
}))
}
}
fn plan_single_file(
req: &FileRequest,
opts: &UnapplyOpts,
paths: &mut IndexMap<PathBuf, ()>,
) -> Result<()> {
if !req.source.exists() && req.target.exists() && !opts.force {
bail!("source is missing; use --force to remove the target");
}
if opts.force && (req.target.exists() || req.target.is_symlink()) {
paths.insert(req.target.clone(), ());
} else if req.source.exists() {
plan_regular_file(&req.source, &req.target, false, paths)?;
}
Ok(())
}
fn plan_regular_file(
source: &Path,
target: &Path,
force: bool,
paths: &mut IndexMap<PathBuf, ()>,
) -> Result<()> {
if !target.exists() && !target.is_symlink() {
return Ok(());
}
if source.is_file() {
plan_expected_content(&file::read(source)?, target, force, paths)
} else if force {
paths.insert(target.to_path_buf(), ());
Ok(())
} else {
bail!(
"cannot verify {}; use --force to remove it",
target.display_user()
)
}
}
fn plan_expected_content(
expected: &[u8],
target: &Path,
force: bool,
paths: &mut IndexMap<PathBuf, ()>,
) -> Result<()> {
if force || (target.is_file() && !target.is_symlink() && file::read(target)? == expected) {
paths.insert(target.to_path_buf(), ());
Ok(())
} else {
bail!(
"{} differs from its managed source; use --force to remove it",
target.display_user()
)
}
}
fn owned_links(req: &FileRequest) -> Result<Vec<PathBuf>> {
if !req.target.is_dir() || req.target.is_symlink() {
return Ok(vec![]);
}
let mut out = vec![];
for entry in walkdir::WalkDir::new(&req.target).sort_by_file_name() {
let entry = match entry {
Ok(entry) => entry,
Err(err) => {
debug!("files: walking {}: {err}", req.target.display_user());
continue;
}
};
if !entry.file_type().is_symlink() {
continue;
}
let Ok(rel) = entry.path().strip_prefix(&req.target) else {
continue;
};
if is_excluded(rel, &req.exclude) {
continue;
}
let dest = match std::fs::read_link(entry.path()) {
Ok(dest) => dest,
Err(err) => {
debug!("files: reading {}: {err}", entry.path().display_user());
continue;
}
};
let expected = req.source.join(rel);
if dest == expected || points_at_same_file(entry.path(), &expected) {
out.push(entry.path().to_path_buf());
}
}
Ok(out)
}
fn unapply_one(plan: &UnapplyPlan<'_>) -> Result<()> {
let mut parents = vec![];
for path in &plan.paths {
debug!("files: removing {}", path.display_user());
if path.is_symlink() || path.is_file() {
file::remove_file(path)?;
} else if path.is_dir() {
file::remove_all(path)?;
}
if let Some(parent) = path.parent() {
parents.push(parent.to_path_buf());
}
}
if plan.cleanup_empty_dirs && plan.req.target.is_dir() {
parents.push(plan.req.target.clone());
for start in parents
.into_iter()
.sorted_by_key(|p| std::cmp::Reverse(p.components().count()))
.unique()
{
let mut dir = start.as_path();
while dir.starts_with(&plan.req.target) {
if !dir.is_dir() || dir.read_dir()?.next().is_some() {
break;
}
file::remove_dir(dir)?;
if dir == plan.req.target {
break;
}
let Some(parent) = dir.parent() else {
break;
};
dir = parent;
}
}
}
Ok(())
}
fn find_conflicts(req: &FileRequest) -> Result<Vec<PathBuf>> {
let regular_files_have_same_content = |source: &Path, target: &Path| -> Result<bool> {
if !source.is_file() || !target.is_file() {
return Ok(false);
}
if source.metadata()?.len() != target.metadata()?.len() {
return Ok(false);
}
Ok(file::read(source)? == file::read(target)?)
};
let file_link_conflicts = |source: &Path, target: &Path| -> Result<bool> {
if cfg!(windows) && source.is_file() {
Ok(target.exists() && target.is_dir())
} else {
if !target.exists() || target.is_symlink() {
return Ok(false);
}
Ok(!regular_files_have_same_content(source, target).unwrap_or(false))
}
};
let mut out = vec![];
match req.mode {
FileMode::Symlink => {
if file_link_conflicts(&req.source, &req.target)? {
out.push(req.target.clone());
}
}
FileMode::SymlinkEach => {
for dir in needed_dirs(req)? {
if dir.exists() && !dir.is_dir() {
out.push(dir);
}
}
for (source, target) in walk_source_files(req)? {
if file_link_conflicts(&source, &target)? {
out.push(target);
}
}
}
FileMode::Copy | FileMode::Template => {
if req.target.exists() && req.target.is_dir() != req.source.is_dir() {
out.push(req.target.clone());
}
}
}
Ok(out)
}
fn describe(req: &FileRequest) -> Result<String> {
let src = req.source.display_user();
let tgt = req.target.display_user();
Ok(match req.mode {
FileMode::Symlink => format!("ln -sf {src} {tgt}"),
FileMode::SymlinkEach => {
let stale = stale_links(req)?.len();
let removals = match stale {
0 => String::new(),
n => format!(", rm {n} stale link(s)"),
};
format!(
"ln -sf {src}/* into {tgt}/ ({} files){removals}",
walk_source_files(req)?.len()
)
}
FileMode::Copy if req.source.is_dir() => format!("cp -r {src} {tgt}"),
FileMode::Copy => format!("cp {src} {tgt}"),
FileMode::Template => format!("render {src} -> {tgt}"),
})
}
fn print_diff(req: &FileRequest, rendered: Option<&str>) -> Result<()> {
match req.mode {
FileMode::Symlink => {
if req.target.is_symlink() {
let dest = std::fs::read_link(&req.target)?;
miseprintln!(
" current symlink: {} -> {}",
req.target.display_user(),
dest.display_user()
);
} else if req.target.exists() {
miseprintln!(" current: {} exists", req.target.display_user());
} else {
miseprintln!(" current: {} missing", req.target.display_user());
}
miseprintln!(
" desired symlink: {} -> {}",
req.target.display_user(),
req.source.display_user()
);
}
FileMode::SymlinkEach => {
miseprintln!(
" desired symlink-each: {} files from {}",
walk_source_files(req)?.len(),
req.source.display_user()
);
for path in stale_links(req)? {
miseprintln!(" stale link to remove: {}", path.display_user());
}
}
FileMode::Copy | FileMode::Template if req.source.is_file() => {
let desired = match req.mode {
FileMode::Template => rendered.unwrap_or_default().as_bytes().to_vec(),
_ => file::read(&req.source)?,
};
let current = if req.target.exists() && req.target.is_file() {
file::read(&req.target)?
} else {
vec![]
};
if current != desired {
miseprintln!(
" content differs: {} -> {}",
req.source.display_user(),
req.target.display_user()
);
}
}
FileMode::Copy | FileMode::Template => {
miseprintln!(
" desired directory contents: {} -> {}",
req.source.display_user(),
req.target.display_user()
);
}
}
Ok(())
}
fn apply_one(req: &FileRequest, rendered: Option<&str>) -> Result<()> {
debug!("files: {}", describe(req)?);
if let Some(parent) = req.target.parent() {
file::create_dir_all(parent)?;
}
match req.mode {
FileMode::Symlink => {
remove_existing(&req.target)?;
link_path(&req.source, &req.target)?;
}
FileMode::SymlinkEach => {
for dir in needed_dirs(req)? {
if dir.exists() && !dir.is_dir() {
remove_existing(&dir)?;
}
}
file::create_dir_all(&req.target)?;
for (source, target) in walk_source_files(req)? {
if check_symlink(&source, &target)? == FileState::Applied {
continue;
}
if let Some(parent) = target.parent() {
file::create_dir_all(parent)?;
}
remove_existing(&target)?;
link_path(&source, &target)?;
}
prune_stale_links(req)?;
}
FileMode::Copy => {
if req.source.is_dir() {
if req.target.exists() && !req.target.is_dir() {
remove_existing(&req.target)?;
}
file::create_dir_all(&req.target)?;
for (source, target) in walk_source_files(req)? {
if let Some(parent) = target.parent() {
file::create_dir_all(parent)?;
}
if target.is_symlink() {
file::remove_file(&target)?;
}
file::copy(&source, &target)?;
}
} else {
remove_existing(&req.target)?;
file::copy(&req.source, &req.target)?;
}
}
FileMode::Template => {
let rendered = rendered.expect("rendered template content");
remove_existing(&req.target)?;
file::write(&req.target, rendered)?;
#[cfg(unix)]
std::fs::set_permissions(&req.target, req.source.metadata()?.permissions())?;
}
}
Ok(())
}
fn prune_stale_links(req: &FileRequest) -> Result<()> {
let stale = stale_links(req)?;
if stale.is_empty() {
return Ok(());
}
for path in &stale {
debug!("files: removing stale link {}", path.display_user());
file::remove_file(path)?;
}
let needed: std::collections::HashSet<PathBuf> = needed_dirs(req)?.into_iter().collect();
for dir in stale
.iter()
.filter_map(|p| p.parent())
.sorted_by_key(|d| std::cmp::Reverse(d.components().count()))
.unique()
{
let mut dir = dir;
while dir != req.target && dir.starts_with(&req.target) && !needed.contains(dir) {
if dir.read_dir()?.next().is_some() {
break;
}
file::remove_dir(dir)?;
match dir.parent() {
Some(parent) => dir = parent,
None => break,
}
}
}
Ok(())
}
fn remove_existing(path: &Path) -> Result<()> {
if path.is_symlink() || path.is_file() {
file::remove_file(path)?;
} else if path.is_dir() {
file::remove_all(path)?;
}
Ok(())
}
fn link_path(source: &Path, target: &Path) -> Result<()> {
if cfg!(windows) && source.is_file() {
file::copy(source, target)?;
} else {
file::make_symlink(source, target)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_mode_parse() {
assert_eq!(FileMode::parse("symlink"), Some(FileMode::Symlink));
assert_eq!(FileMode::parse("symlink-each"), Some(FileMode::SymlinkEach));
assert_eq!(FileMode::parse("copy"), Some(FileMode::Copy));
assert_eq!(FileMode::parse("template"), Some(FileMode::Template));
assert_eq!(FileMode::parse("hardlink"), None);
}
fn patterns(patterns: &[&str]) -> Vec<glob::Pattern> {
patterns
.iter()
.map(|p| glob::Pattern::new(p).unwrap())
.collect()
}
#[test]
fn test_exclude_bare_pattern_matches_any_component() {
let pats = patterns(&["mise.toml"]);
assert!(is_excluded(Path::new("mise.toml"), &pats));
assert!(is_excluded(Path::new("nested/mise.toml"), &pats));
assert!(!is_excluded(Path::new("mise.toml.bak"), &pats));
assert!(!is_excluded(Path::new("other.toml"), &pats));
}
#[test]
fn test_exclude_wildcard_matches_by_component() {
let pats = patterns(&["*.md"]);
assert!(is_excluded(Path::new("README.md"), &pats));
assert!(is_excluded(Path::new("docs/guide.md"), &pats));
assert!(!is_excluded(Path::new("README.txt"), &pats));
}
#[test]
fn test_exclude_slash_pattern_is_anchored() {
let pats = patterns(&["nvim/spell"]);
assert!(is_excluded(Path::new("nvim/spell"), &pats));
assert!(is_excluded(Path::new("nvim/spell/en.add"), &pats));
assert!(!is_excluded(Path::new("config/nvim/spell"), &pats));
assert!(!is_excluded(Path::new("spell"), &pats));
}
#[test]
fn test_exclude_directory_component_takes_children() {
let pats = patterns(&[".git"]);
assert!(is_excluded(Path::new(".git"), &pats));
assert!(is_excluded(Path::new(".git/config"), &pats));
assert!(!is_excluded(Path::new("git/config"), &pats));
}
#[test]
fn test_exclude_empty_matches_nothing() {
assert!(!is_excluded(Path::new("mise.toml"), &[]));
}
#[test]
fn test_wildcard_target_expansion() {
let captures = wildcard_captures(
"/repo/dotfiles/config/*.toml",
Path::new("/repo/dotfiles/config/starship.toml"),
)
.unwrap();
let target = expand_target_pattern("/home/me/.config/*.toml", &captures).unwrap();
assert_eq!(target, PathBuf::from("/home/me/.config/starship.toml"));
}
#[test]
fn test_recursive_wildcard_target_expansion() {
let captures = wildcard_captures(
"/repo/dotfiles/config/**/*.toml",
Path::new("/repo/dotfiles/config/a/b/tool.toml"),
)
.unwrap();
let target = expand_target_pattern("/home/me/.config/**/*.toml", &captures).unwrap();
assert_eq!(target, PathBuf::from("/home/me/.config/a/b/tool.toml"));
}
#[test]
fn test_recursive_wildcard_matches_zero_directories() {
let captures = wildcard_captures(
"/repo/dotfiles/config/**/*.toml",
Path::new("/repo/dotfiles/config/tool.toml"),
)
.unwrap();
let target = expand_target_pattern("/home/me/.config/**/*.toml", &captures).unwrap();
assert_eq!(target, PathBuf::from("/home/me/.config/tool.toml"));
}
#[test]
fn test_question_mark_target_expansion() {
let captures = wildcard_captures(
"/repo/dotfiles/config/app?.toml",
Path::new("/repo/dotfiles/config/app1.toml"),
)
.unwrap();
let target = expand_target_pattern("/home/me/.config/app?.toml", &captures).unwrap();
assert_eq!(target, PathBuf::from("/home/me/.config/app1.toml"));
}
#[test]
fn test_character_class_target_expansion() {
let captures = wildcard_captures(
"/repo/dotfiles/config/theme-[ab].toml",
Path::new("/repo/dotfiles/config/theme-a.toml"),
)
.unwrap();
let target = expand_target_pattern("/home/me/.config/theme-[ab].toml", &captures).unwrap();
assert_eq!(target, PathBuf::from("/home/me/.config/theme-a.toml"));
}
#[test]
fn test_windows_separator_wildcard_expansion() {
let captures = wildcard_captures(
r"C:\repo\dotfiles\config\*.toml",
Path::new(r"C:\repo\dotfiles\config\starship.toml"),
)
.unwrap();
let target = expand_target_pattern(r"C:\Users\me\.config\*.toml", &captures).unwrap();
assert_eq!(
target,
PathBuf::from(native_path_separators("C:/Users/me/.config/starship.toml"))
);
}
#[test]
fn test_windows_separator_recursive_wildcard_expansion() {
let captures = wildcard_captures(
r"C:\repo\dotfiles\config\**\*.toml",
Path::new(r"C:\repo\dotfiles\config\tool.toml"),
)
.unwrap();
let target = expand_target_pattern(r"C:\Users\me\.config\**\*.toml", &captures).unwrap();
assert_eq!(
target,
PathBuf::from(native_path_separators("C:/Users/me/.config/tool.toml"))
);
}
}