use std::fs;
use std::io::Write;
use std::path::Path;
use jsonc_parser::cst::CstRootNode;
use jsonc_parser::ParseOptions;
use crate::error::{OlError, ERR_HOOK_MALFORMED_JSONC, ERR_HOOK_WRITE_FAILED};
pub const ERR_ATOMIC_WRITE_FAILED: &str = "OL-1910";
pub const ERR_SYMLINK_CANONICALIZE_FAILED: &str = "OL-1911";
pub fn atomic_rewrite_jsonc<F>(path: &Path, mutate: F) -> Result<(), OlError>
where
F: FnOnce(&CstRootNode) -> Result<(), OlError>,
{
let real_path = if path.is_symlink() || path.exists() {
fs::canonicalize(path).map_err(|e| {
OlError::new(
ERR_SYMLINK_CANONICALIZE_FAILED,
format!("Cannot resolve settings path '{}': {e}", path.display()),
)
.with_suggestion("Check that the symlink target exists and is accessible.")
})?
} else {
path.to_path_buf()
};
if let Some(parent) = real_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
OlError::new(
ERR_HOOK_WRITE_FAILED,
format!("Cannot create settings directory: {e}"),
)
})?;
}
let raw_jsonc = if real_path.exists() {
fs::read_to_string(&real_path).map_err(|e| {
OlError::new(
ERR_HOOK_WRITE_FAILED,
format!("Cannot read settings file: {e}"),
)
})?
} else {
"{}".to_string()
};
let root = CstRootNode::parse(&raw_jsonc, &ParseOptions::default()).map_err(|e| {
OlError::new(
ERR_HOOK_MALFORMED_JSONC,
format!("Cannot parse settings.json as JSONC: {e}"),
)
.with_suggestion("Fix the JSON syntax in your settings.json file.")
})?;
mutate(&root)?;
let serialized = root.to_string();
let tmp_path = real_path.with_extension("json.openlatch-tmp");
let mut file = fs::File::create(&tmp_path).map_err(|e| {
OlError::new(
ERR_ATOMIC_WRITE_FAILED,
format!("Cannot create temp file: {e}"),
)
})?;
file.write_all(serialized.as_bytes()).map_err(|e| {
OlError::new(
ERR_ATOMIC_WRITE_FAILED,
format!("Cannot write temp file: {e}"),
)
})?;
file.sync_all().map_err(|e| {
OlError::new(
ERR_ATOMIC_WRITE_FAILED,
format!("Cannot fsync temp file: {e}"),
)
})?;
drop(file);
fs::rename(&tmp_path, &real_path).map_err(|e| {
let _ = fs::remove_file(&tmp_path);
OlError::new(
ERR_ATOMIC_WRITE_FAILED,
format!("Cannot rename temp file to target: {e}"),
)
})?;
#[cfg(unix)]
{
if let Some(parent) = real_path.parent() {
if let Ok(dir) = fs::File::open(parent) {
let _ = dir.sync_all();
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn atomic_rewrite_creates_file_if_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
atomic_rewrite_jsonc(&path, |root| {
let obj = root.object_value_or_set();
obj.append(
"test",
jsonc_parser::cst::CstInputValue::String("value".into()),
);
Ok(())
})
.unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("\"test\""));
assert!(content.contains("\"value\""));
}
#[test]
fn atomic_rewrite_preserves_existing_content() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
fs::write(&path, "{\n \"existing\": 42\n}").unwrap();
atomic_rewrite_jsonc(&path, |root| {
let obj = root.object_value_or_set();
obj.append("added", jsonc_parser::cst::CstInputValue::Bool(true));
Ok(())
})
.unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("\"existing\": 42"));
assert!(content.contains("\"added\""));
}
#[test]
fn atomic_rewrite_is_atomic_on_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
let original = "{\"keep\": true}";
fs::write(&path, original).unwrap();
let result = atomic_rewrite_jsonc(&path, |_root| {
Err(OlError::new("OL-TEST", "intentional failure"))
});
assert!(result.is_err());
let content = fs::read_to_string(&path).unwrap();
assert_eq!(content, original);
}
#[test]
fn no_temp_file_left_on_success() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
fs::write(&path, "{}").unwrap();
atomic_rewrite_jsonc(&path, |_root| Ok(())).unwrap();
let tmp = path.with_extension("json.openlatch-tmp");
assert!(!tmp.exists());
}
#[test]
#[cfg(unix)]
fn atomic_rewrite_through_symlink() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real.json");
let link = dir.path().join("link.json");
fs::write(&real, "{}").unwrap();
std::os::unix::fs::symlink(&real, &link).unwrap();
atomic_rewrite_jsonc(&link, |root| {
let obj = root.object_value_or_set();
obj.append("via_symlink", jsonc_parser::cst::CstInputValue::Bool(true));
Ok(())
})
.unwrap();
let content = fs::read_to_string(&real).unwrap();
assert!(content.contains("\"via_symlink\""));
assert!(link.is_symlink());
}
}