use std::path::{Path, PathBuf};
use crate::datastore::DataStore;
use crate::fs::Fs;
use crate::handlers::{
ExecutionPhase, Handler, HandlerConfig, HandlerScope, HandlerStatus, MatchMode, HANDLER_SYMLINK,
};
use crate::operations::HandlerIntent;
use crate::paths::Pather;
use crate::rules::RuleMatch;
use crate::Result;
pub struct SymlinkHandler;
impl Handler for SymlinkHandler {
fn name(&self) -> &str {
HANDLER_SYMLINK
}
fn phase(&self) -> ExecutionPhase {
ExecutionPhase::Link
}
fn match_mode(&self) -> MatchMode {
MatchMode::Catchall
}
fn scope(&self) -> HandlerScope {
HandlerScope::Exclusive
}
fn to_intents(
&self,
matches: &[RuleMatch],
config: &HandlerConfig,
paths: &dyn Pather,
fs: &dyn Fs,
) -> Result<Vec<HandlerIntent>> {
let mut intents = Vec::new();
for m in matches {
let rel_str = m.relative_path.to_string_lossy();
if is_protected(&rel_str, &config.protected_paths) {
continue;
}
if m.is_dir {
intents.extend(dir_intents(m, config, paths, fs)?);
} else {
check_routing_conflict(&m.pack, &rel_str, config)?;
match resolve_target_full(&m.pack, &rel_str, config, paths) {
Resolution::Path(user_path) => intents.push(HandlerIntent::Link {
pack: m.pack.clone(),
handler: HANDLER_SYMLINK.into(),
source: m.absolute_path.clone(),
user_path,
}),
Resolution::Skip { .. } => {
}
}
}
}
Ok(intents)
}
fn warnings_for_matches(
&self,
matches: &[RuleMatch],
config: &HandlerConfig,
paths: &dyn Pather,
) -> Vec<String> {
if cfg!(target_os = "macos") {
return Vec::new();
}
let mut out = Vec::new();
for m in matches {
let rel_str = m.relative_path.to_string_lossy();
if is_protected(&rel_str, &config.protected_paths) {
continue;
}
let is_lib_dir = rel_str == "_lib" || rel_str.starts_with("_lib/");
let is_lib_file =
!m.is_dir && matches!(strip_file_prefix(&rel_str), Some((FilePrefix::Lib, _)));
if is_lib_dir || is_lib_file {
out.push(format!(
"warning: pack `{}` contains `{rel_str}` — \
macOS-only path, skipping on this platform",
m.pack
));
}
}
let _ = paths; out
}
fn check_status(
&self,
file: &Path,
pack: &str,
datastore: &dyn DataStore,
) -> Result<HandlerStatus> {
let has_state = datastore.has_handler_state(pack, HANDLER_SYMLINK)?;
Ok(HandlerStatus {
file: file.to_string_lossy().into_owned(),
handler: HANDLER_SYMLINK.into(),
deployed: has_state,
message: if has_state {
"symlink deployed".into()
} else {
"symlink pending".into()
},
})
}
}
fn dir_intents(
m: &RuleMatch,
config: &HandlerConfig,
paths: &dyn Pather,
fs: &dyn Fs,
) -> Result<Vec<HandlerIntent>> {
let rel_str = m.relative_path.to_string_lossy();
let dir_prefix = format!("{rel_str}/");
let has_override = config.protected_paths.iter().any(|p| {
let normalized = p.strip_prefix('.').unwrap_or(p);
normalized.starts_with(&dir_prefix)
|| p.starts_with(&dir_prefix)
|| normalized == rel_str
|| p == rel_str.as_ref()
}) || config
.targets
.keys()
.any(|k| k.starts_with(&dir_prefix) || k == rel_str.as_ref());
let is_escape_prefix_dir = matches!(rel_str.as_ref(), "_home" | "_xdg" | "_app" | "_lib");
if !has_override && !is_escape_prefix_dir {
let user_path = resolve_target(&m.pack, &rel_str, config, paths);
return Ok(vec![HandlerIntent::Link {
pack: m.pack.clone(),
handler: HANDLER_SYMLINK.into(),
source: m.absolute_path.clone(),
user_path,
}]);
}
let mut intents = Vec::new();
collect_per_file_intents(m, &m.absolute_path, config, paths, fs, &mut intents)?;
Ok(intents)
}
fn collect_per_file_intents(
m: &RuleMatch,
dir: &Path,
config: &HandlerConfig,
paths: &dyn Pather,
fs: &dyn Fs,
out: &mut Vec<HandlerIntent>,
) -> Result<()> {
let entries = fs.read_dir(dir)?;
for entry in entries {
if crate::rules::should_skip_entry(&entry.name, &config.pack_ignore) {
continue;
}
if entry.is_dir {
collect_per_file_intents(m, &entry.path, config, paths, fs, out)?;
continue;
}
let rel = entry
.path
.strip_prefix(&m.absolute_path)
.ok()
.map(|r| m.relative_path.join(r))
.unwrap_or_else(|| PathBuf::from(&entry.name));
let rel_str = rel.to_string_lossy();
if is_protected(&rel_str, &config.protected_paths) {
continue;
}
check_routing_conflict(&m.pack, &rel_str, config)?;
match resolve_target_full(&m.pack, &rel_str, config, paths) {
Resolution::Path(user_path) => out.push(HandlerIntent::Link {
pack: m.pack.clone(),
handler: HANDLER_SYMLINK.into(),
source: entry.path.clone(),
user_path,
}),
Resolution::Skip { .. } => continue,
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FilePrefix {
Home,
App,
Xdg,
Lib,
}
const FILE_PREFIXES: &[(&str, FilePrefix)] = &[
("home.", FilePrefix::Home),
("app.", FilePrefix::App),
("xdg.", FilePrefix::Xdg),
("lib.", FilePrefix::Lib),
];
const DIR_PREFIXES: &[&str] = &["_home/", "_xdg/", "_app/", "_lib/"];
const DIR_PREFIX_BARE: &[&str] = &["_home", "_xdg", "_app", "_lib"];
fn strip_file_prefix(rel_path: &str) -> Option<(FilePrefix, &str)> {
if rel_path.contains('/') {
return None;
}
for (lit, kind) in FILE_PREFIXES {
if let Some(rest) = rel_path.strip_prefix(lit) {
if !rest.is_empty() {
return Some((*kind, rest));
}
}
}
None
}
fn has_routing_prefix(rel_path: &str) -> bool {
if strip_file_prefix(rel_path).is_some() {
return true;
}
DIR_PREFIXES.iter().any(|p| rel_path.starts_with(p)) || DIR_PREFIX_BARE.contains(&rel_path)
}
fn check_routing_conflict(pack: &str, rel_path: &str, config: &HandlerConfig) -> Result<()> {
let Some(target) = config.targets.get(rel_path) else {
return Ok(());
};
if !has_routing_prefix(rel_path) {
return Ok(());
}
Err(crate::DodotError::RoutingOverrideConflict {
pack: pack.into(),
rel_path: rel_path.into(),
config_target: target.clone(),
})
}
#[derive(Debug, Clone)]
pub(crate) enum Resolution {
Path(PathBuf),
Skip {
#[allow(dead_code)]
reason: String,
},
}
pub(crate) fn resolve_target(
pack: &str,
rel_path: &str,
config: &HandlerConfig,
paths: &dyn Pather,
) -> PathBuf {
match resolve_target_full(pack, rel_path, config, paths) {
Resolution::Path(p) => p,
Resolution::Skip { .. } => {
paths.xdg_config_home().to_path_buf()
}
}
}
pub(crate) fn resolve_target_full(
pack: &str,
rel_path: &str,
config: &HandlerConfig,
paths: &dyn Pather,
) -> Resolution {
let pack = crate::packs::display_name_for(pack);
let home = paths.home_dir();
let xdg_config = paths.xdg_config_home();
let app_support = paths.app_support_dir();
if let Some(target) = config.targets.get(rel_path) {
if target.starts_with('/') {
return Resolution::Path(PathBuf::from(target));
}
return Resolution::Path(xdg_config.join(target));
}
if let Some((kind, rest)) = strip_file_prefix(rel_path) {
return match kind {
FilePrefix::Home => Resolution::Path(home.join(format!(".{rest}"))),
FilePrefix::App => Resolution::Path(app_support.join(rest)),
FilePrefix::Xdg => Resolution::Path(xdg_config.join(rest)),
FilePrefix::Lib => {
if cfg!(target_os = "macos") {
Resolution::Path(home.join("Library").join(rest))
} else {
Resolution::Skip {
reason: format!("lib.{rest} — macOS-only path, skipping on this platform"),
}
}
}
};
}
if let Some(stripped) = rel_path.strip_prefix("_home/") {
let parts: Vec<&str> = stripped.split('/').collect();
if let Some(first) = parts.first() {
if !first.is_empty() && !first.starts_with('.') {
let mut new_parts = vec![format!(".{first}")];
new_parts.extend(parts[1..].iter().map(|s| s.to_string()));
return Resolution::Path(home.join(new_parts.join("/")));
}
}
return Resolution::Path(home.join(stripped));
}
if let Some(stripped) = rel_path.strip_prefix("_xdg/") {
return Resolution::Path(xdg_config.join(stripped));
}
if let Some(stripped) = rel_path.strip_prefix("_app/") {
return Resolution::Path(app_support.join(stripped));
}
if let Some(stripped) = rel_path.strip_prefix("_lib/") {
if cfg!(target_os = "macos") {
return Resolution::Path(home.join("Library").join(stripped));
}
return Resolution::Skip {
reason: format!("_lib/{stripped} — macOS-only path, skipping on this platform"),
};
}
if is_force_home(rel_path, &config.force_home) {
if rel_path.contains('/') {
let parts: Vec<&str> = rel_path.split('/').collect();
let first = parts[0];
let dotted = if first.starts_with('.') {
first.to_string()
} else {
format!(".{first}")
};
let rest: Vec<&str> = parts[1..].to_vec();
let mut result = home.join(dotted);
for part in rest {
result = result.join(part);
}
return Resolution::Path(result);
}
let filename = Path::new(rel_path)
.file_name()
.unwrap_or_default()
.to_string_lossy();
let dotted = if filename.starts_with('.') {
filename.to_string()
} else {
format!(".{filename}")
};
return Resolution::Path(home.join(dotted));
}
if is_force_app(rel_path, &config.force_app) {
return Resolution::Path(app_support.join(rel_path));
}
if let Some(alias) = config.app_aliases.get(pack) {
return Resolution::Path(app_support.join(alias).join(rel_path));
}
Resolution::Path(xdg_config.join(pack).join(rel_path))
}
fn is_force_home(rel_path: &str, force_home: &[String]) -> bool {
let first_segment = rel_path.split('/').next().unwrap_or(rel_path);
let without_dot = first_segment.strip_prefix('.').unwrap_or(first_segment);
force_home.iter().any(|entry| {
let entry_without_dot = entry.strip_prefix('.').unwrap_or(entry);
entry_without_dot == without_dot
})
}
fn is_force_app(rel_path: &str, force_app: &[String]) -> bool {
let first_segment = rel_path.split('/').next().unwrap_or(rel_path);
force_app.iter().any(|entry| entry == first_segment)
}
fn is_protected(rel_path: &str, protected_paths: &[String]) -> bool {
let normalized = rel_path.strip_prefix("./").unwrap_or(rel_path);
let with_dot = if !normalized.starts_with('.') {
format!(".{normalized}")
} else {
normalized.to_string()
};
for protected in protected_paths {
if protected == normalized || protected == &with_dot {
return true;
}
if normalized.starts_with(&format!("{protected}/"))
|| with_dot.starts_with(&format!("{protected}/"))
{
return true;
}
}
false
}
#[cfg(test)]
mod tests;