use std::fs::File;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::error::ConfigError;
fn pid_liveness(pid: u32) -> Option<bool> {
#[cfg(target_os = "linux")]
{
Some(std::fs::metadata(format!("/proc/{pid}")).is_ok())
}
#[cfg(not(target_os = "linux"))]
{
let _ = pid;
None
}
}
const LOCK_STALE_SECS: u64 = 60;
struct LockGuard(PathBuf);
impl LockGuard {
fn acquire(config_path: &Path) -> Result<Self, ConfigError> {
let lock_path = PathBuf::from(format!("{}.lock", config_path.display()));
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| ConfigError::Write {
path: parent.to_path_buf(),
source: e,
})?;
}
const MAX_ATTEMPTS: u32 = 10;
let mut attempts = 0u32;
loop {
match File::create_new(&lock_path) {
Ok(mut f) => {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let _ = write!(f, "{} {}", std::process::id(), now);
let _ = f.sync_all();
return Ok(Self(lock_path));
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
if lock_is_stale(&lock_path) {
let _ = std::fs::remove_file(&lock_path);
continue;
}
attempts += 1;
if attempts >= MAX_ATTEMPTS {
return Err(ConfigError::Write {
path: lock_path,
source: e,
});
}
std::thread::sleep(Duration::from_millis(10));
}
Err(e) => {
return Err(ConfigError::Write {
path: lock_path,
source: e,
});
}
}
}
}
}
fn lock_is_stale(lock_path: &Path) -> bool {
if let Ok(meta) = std::fs::metadata(lock_path)
&& let Ok(mod_time) = meta.modified()
&& let Ok(elapsed) = mod_time.elapsed()
&& elapsed.as_secs() > LOCK_STALE_SECS
{
return true;
}
let mut contents = String::new();
if File::open(lock_path)
.and_then(|mut f| f.read_to_string(&mut contents))
.is_ok()
{
if let Some(pid_str) = contents.split_whitespace().next()
&& let Ok(pid) = pid_str.parse::<u32>()
{
return matches!(pid_liveness(pid), Some(false));
}
return true;
}
true
}
impl Drop for LockGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
fn temp_path_for(config_path: &Path) -> PathBuf {
let dir = config_path.parent().unwrap_or(Path::new("."));
let file_name = config_path
.file_name()
.unwrap_or(std::ffi::OsStr::new("config.toml"));
let temp_name = format!(
".{}.hjkl-tmp.{}",
file_name.to_string_lossy(),
std::process::id()
);
dir.join(temp_name)
}
fn atomic_write(path: &Path, contents: &str) -> Result<(), ConfigError> {
let temp_path = temp_path_for(path);
let mut file = File::create_new(&temp_path).map_err(|e| ConfigError::Write {
path: temp_path.clone(),
source: e,
})?;
file.write_all(contents.as_bytes()).map_err(|e| {
let _ = std::fs::remove_file(&temp_path);
ConfigError::Write {
path: temp_path.clone(),
source: e,
}
})?;
file.sync_all().map_err(|e| {
let _ = std::fs::remove_file(&temp_path);
ConfigError::Write {
path: temp_path.clone(),
source: e,
}
})?;
std::fs::rename(&temp_path, path).map_err(|e| {
let _ = std::fs::remove_file(&temp_path);
ConfigError::Write {
path: path.to_path_buf(),
source: e,
}
})?;
if let Some(parent) = path.parent()
&& let Ok(pdir) = File::open(parent)
{
let _ = pdir.sync_all();
}
Ok(())
}
pub fn write_key_at(
path: &Path,
dotted_path: &str,
value: impl Into<toml_edit::Value>,
) -> Result<(), ConfigError> {
let _lock = LockGuard::acquire(path)?;
let existing = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => {
return Err(ConfigError::Io {
path: path.to_path_buf(),
source: e,
});
}
};
let mut doc = existing
.parse::<toml_edit::DocumentMut>()
.map_err(|e| ConfigError::Invalid {
path: path.to_path_buf(),
message: format!("existing config is not valid TOML: {e}"),
})?;
let segments: Vec<&str> = dotted_path.split('.').collect();
let (last, parents) = segments
.split_last()
.expect("dotted_path must have at least one segment");
let mut table: &mut toml_edit::Table = doc.as_table_mut();
for seg in parents {
let item = table
.entry(seg)
.or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()));
table = item.as_table_mut().ok_or_else(|| ConfigError::Invalid {
path: path.to_path_buf(),
message: format!("`{seg}` (in `{dotted_path}`) exists but is not a table"),
})?;
}
table.insert(last, toml_edit::Item::Value(value.into()));
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| ConfigError::Write {
path: parent.to_path_buf(),
source: e,
})?;
}
atomic_write(path, &doc.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lock_is_not_stale_for_live_owner() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml.lock");
std::fs::write(&path, format!("{} 0", std::process::id())).unwrap();
assert!(!lock_is_stale(&path));
}
#[test]
fn lock_is_stale_for_corrupt_body() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml.lock");
std::fs::write(&path, "not-a-pid").unwrap();
assert!(lock_is_stale(&path));
}
#[test]
fn creates_file_when_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("config.toml");
write_key_at(&path, "explorer.width", 42i64).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("[explorer]"));
assert!(text.contains("width = 42"));
}
#[test]
fn preserves_unrelated_content_and_comments() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"# leader comment\n[editor]\nleader = \" \"\n\n[explorer]\nwidth = 36\n",
)
.unwrap();
write_key_at(&path, "explorer.width", 50i64).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("# leader comment"));
assert!(text.contains("leader = \" \""));
assert!(text.contains("width = 50"));
assert!(!text.contains("width = 36"));
}
#[test]
fn creates_missing_parent_table() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[editor]\nleader = \" \"\n").unwrap();
write_key_at(&path, "panel.height", 12i64).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("[panel]"));
assert!(text.contains("height = 12"));
assert!(text.contains("[editor]"));
}
#[test]
fn overwrites_existing_key_in_place() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[explorer]\nwidth = 20\n").unwrap();
write_key_at(&path, "explorer.width", 60i64).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
let width_lines: Vec<&str> = text.lines().filter(|l| l.contains("width")).collect();
assert_eq!(width_lines.len(), 1, "must not duplicate the key");
assert!(width_lines[0].contains("60"));
}
#[test]
fn errors_when_segment_is_not_a_table() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "explorer = \"oops\"\n").unwrap();
let err = write_key_at(&path, "explorer.width", 40i64).unwrap_err();
assert!(matches!(err, ConfigError::Invalid { .. }));
}
#[test]
fn invalid_existing_toml_errors() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "not [ valid toml").unwrap();
let err = write_key_at(&path, "explorer.width", 40i64).unwrap_err();
assert!(matches!(err, ConfigError::Invalid { .. }));
}
}