From 18c59e869a7eb58d175eba600f7ef71ff52d6336 Mon Sep 17 00:00:00 2001 From: xqqmdy Date: Sat, 14 Nov 2026 18:51:09 +0800 Subject: [PATCH] fix: keep symlinked configuration and vocabulary in place --- src/config.rs | 28 +++++++++++++++++++--------- src/paths.rs | 33 ++++++++++++++++++++++++++++++++- src/vocabulary.rs | 36 ++++++++++++++++++++++-------------- 3 files changed, 73 insertions(+), 24 deletions(-) diff --git a/src/config.rs b/src/config.rs index 676387b..0bf9b3b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -127,15 +127,7 @@ impl Config { } pub fn save_to(&self, path: &Path) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let mut temporary = path.as_os_str().to_os_string(); - temporary.push(format!(".{}.tmp", std::process::id())); - let temporary = PathBuf::from(temporary); - fs::write(&temporary, toml::to_string_pretty(self)?)?; - fs::rename(temporary, path)?; - Ok(()) + paths::write_atomic(path, toml::to_string_pretty(self)?.as_bytes()) } } @@ -143,6 +135,24 @@ impl Config { mod tests { use super::*; + #[test] + fn saving_through_a_symlink_updates_the_link_target() { + let directory = + std::env::temp_dir().join(format!("whispers-config-symlink-{}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + assert!(fs::create_dir_all(&directory).is_ok()); + let target = directory.join("dotfiles-config.toml"); + let link = directory.join("config.toml"); + assert!(fs::write(&target, "version = 1\n").is_ok()); + assert!(std::os::unix::fs::symlink(&target, &link).is_ok()); + + assert!(Config::default().save_to(&link).is_ok()); + + assert!(link.is_symlink()); + assert!(fs::read_to_string(&target).is_ok_and(|text| text.contains("version = 2"))); + let _ = fs::remove_dir_all(&directory); + } + #[test] fn existing_unversioned_configuration_requires_migration() { let path = std::env::temp_dir().join(format!( diff --git a/src/paths.rs b/src/paths.rs index b8e845f..bb4c98e 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -1,10 +1,41 @@ use std::env; -use std::path::PathBuf; +use std::fs; +use std::path::{Path, PathBuf}; use crate::{Error, Result}; pub const MANAGED_LLAMA_RUNTIME: &str = "runtimes/llama.cpp/b10258-vulkan"; +/// Writes `contents` to `path` through an atomic rename, following symbolic +/// links so that dotfile-managed files stay links to their real location. +pub(crate) fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> { + let target = resolve_symlink(path); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + let mut temporary = target.as_os_str().to_os_string(); + temporary.push(format!(".{}.tmp", std::process::id())); + let temporary = PathBuf::from(temporary); + fs::write(&temporary, contents)?; + fs::rename(&temporary, &target)?; + Ok(()) +} + +fn resolve_symlink(path: &Path) -> PathBuf { + if !path.is_symlink() { + return path.to_path_buf(); + } + if let Ok(canonical) = fs::canonicalize(path) { + return canonical; + } + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + match fs::read_link(path) { + Ok(link) if link.is_absolute() => link, + Ok(link) => parent.join(link), + Err(_) => path.to_path_buf(), + } +} + pub fn config_dir() -> Result { xdg_path("XDG_CONFIG_HOME", ".config").map(|path| path.join("whispers")) } diff --git a/src/vocabulary.rs b/src/vocabulary.rs index 56d76a6..90eda61 100644 --- a/src/vocabulary.rs +++ b/src/vocabulary.rs @@ -1,5 +1,5 @@ use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; use serde::{Deserialize, Serialize}; @@ -57,13 +57,7 @@ impl Vocabulary { } pub fn save_to(&self, path: &Path) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let temporary = temporary_path(path); - fs::write(&temporary, toml::to_string_pretty(self)?)?; - fs::rename(temporary, path)?; - Ok(()) + paths::write_atomic(path, toml::to_string_pretty(self)?.as_bytes()) } pub fn add(&mut self, term: &str, aliases: Vec) -> Result<()> { @@ -185,16 +179,30 @@ fn word_character(character: char) -> bool { character.is_alphanumeric() || character == '_' } -fn temporary_path(path: &Path) -> PathBuf { - let mut temporary = path.as_os_str().to_os_string(); - temporary.push(format!(".{}.tmp", std::process::id())); - PathBuf::from(temporary) -} - #[cfg(test)] mod tests { use super::*; + #[test] + fn saving_through_a_symlink_updates_the_link_target() { + let directory = std::env::temp_dir().join(format!( + "whispers-vocabulary-symlink-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&directory); + assert!(fs::create_dir_all(&directory).is_ok()); + let target = directory.join("dotfiles-vocabulary.toml"); + let link = directory.join("vocabulary.toml"); + assert!(fs::write(&target, "version = 0\n").is_ok()); + assert!(std::os::unix::fs::symlink(&target, &link).is_ok()); + + assert!(Vocabulary::default().save_to(&link).is_ok()); + + assert!(link.is_symlink()); + assert!(fs::read_to_string(&target).is_ok_and(|text| text.contains("version = 1"))); + let _ = fs::remove_dir_all(&directory); + } + #[test] fn replaces_aliases_at_word_boundaries() { let vocabulary = Vocabulary {