use std::fs;
use std::path::Path;
use jsonc_parser::cst::CstRootNode;
use jsonc_parser::ParseOptions;
use crate::error::{
OlError, ERR_HOOK_MALFORMED_JSONC, ERR_HOOK_MALFORMED_TOML, ERR_HOOK_WRITE_FAILED,
ERR_MODEL_RELAY_STATE_FILE,
};
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)?;
write_replacing(&real_path, root.to_string().as_bytes())
}
fn write_replacing(real_path: &Path, body: &[u8]) -> Result<(), OlError> {
crate::fs_secure::write_preserving_mode(real_path, body).map_err(|e| {
OlError::new(
ERR_ATOMIC_WRITE_FAILED,
format!(
"Cannot replace '{}': {e}",
crate::core::path_compat::display_path(real_path)
),
)
})
}
#[derive(Clone, Copy, Debug)]
pub struct RewriteLimits {
pub max_bytes: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RewriteOutcome {
Unchanged,
Written,
Contended,
Absent,
}
pub fn rewrite_existing_json<F>(
path: &Path,
limits: RewriteLimits,
mutate: F,
) -> Result<RewriteOutcome, OlError>
where
F: FnOnce(&CstRootNode) -> Result<(), OlError>,
{
let real_path = match fs::canonicalize(path) {
Ok(p) => p,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(RewriteOutcome::Absent),
Err(e) => {
return Err(OlError::new(
ERR_SYMLINK_CANONICALIZE_FAILED,
format!(
"Cannot resolve '{}': {e}",
crate::core::path_compat::display_path(path)
),
))
}
};
let display = crate::core::path_compat::display_path(&real_path);
let too_large = |size: u64| {
OlError::new(
ERR_MODEL_RELAY_STATE_FILE,
format!(
"'{display}' is {size} bytes, over the {} byte limit for an agent state file \
OpenLatch edits",
limits.max_bytes
),
)
};
let seen = match crate::fs_secure::fingerprint(&real_path) {
Ok(fp) => fp,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(RewriteOutcome::Absent),
Err(e) => {
return Err(OlError::new(
ERR_HOOK_WRITE_FAILED,
format!("Cannot read '{display}': {e}"),
))
}
};
if seen.size() > limits.max_bytes {
return Err(too_large(seen.size()));
}
let raw = match fs::read(&real_path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(RewriteOutcome::Absent),
Err(e) => {
return Err(OlError::new(
ERR_HOOK_WRITE_FAILED,
format!("Cannot read '{display}': {e}"),
))
}
};
if raw.len() as u64 != seen.size() {
return Ok(RewriteOutcome::Contended);
}
let raw = String::from_utf8(raw).map_err(|_| {
OlError::new(
ERR_HOOK_MALFORMED_JSONC,
format!("'{display}' is not UTF-8 text"),
)
})?;
let root = CstRootNode::parse(&raw, &ParseOptions::default()).map_err(|e| {
OlError::new(
ERR_HOOK_MALFORMED_JSONC,
format!("Cannot parse '{display}' as JSON: {e}"),
)
})?;
mutate(&root)?;
let edited = root.to_string();
if edited == raw {
return Ok(RewriteOutcome::Unchanged);
}
match crate::fs_secure::replace_preserving(&real_path, edited.as_bytes(), &seen) {
Ok(crate::fs_secure::ReplaceOutcome::Replaced) => Ok(RewriteOutcome::Written),
Ok(crate::fs_secure::ReplaceOutcome::Contended) => Ok(RewriteOutcome::Contended),
Err(e) => Err(OlError::new(
ERR_ATOMIC_WRITE_FAILED,
format!("Cannot replace '{display}': {e}"),
)),
}
}
pub fn atomic_rewrite_toml<F>(path: &Path, mutate: F) -> Result<(), OlError>
where
F: FnOnce(&mut toml_edit::DocumentMut) -> 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 config 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 config directory: {e}"),
)
})?;
}
let raw_toml = if real_path.exists() {
fs::read_to_string(&real_path).map_err(|e| {
OlError::new(
ERR_HOOK_WRITE_FAILED,
format!("Cannot read config file: {e}"),
)
})?
} else {
String::new()
};
let mut doc = raw_toml.parse::<toml_edit::DocumentMut>().map_err(|e| {
OlError::new(
ERR_HOOK_MALFORMED_TOML,
format!(
"Cannot parse '{}' as TOML: {e}",
crate::core::path_compat::display_path(&real_path)
),
)
.with_suggestion("Fix the TOML syntax in the file, then re-run the command.")
})?;
mutate(&mut doc)?;
write_replacing(&real_path, doc.to_string().as_bytes())
}
#[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());
}
const LIMITS: RewriteLimits = RewriteLimits { max_bytes: 1 << 16 };
fn set_key(root: &CstRootNode, key: &str, value: &str) {
let obj = root.object_value_or_set();
let v = jsonc_parser::cst::CstInputValue::String(value.into());
match obj.get(key) {
Some(prop) => prop.set_value(v),
None => {
obj.append(key, v);
}
}
}
#[test]
fn rewrite_never_creates_a_missing_file_or_parent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("data").join("globalState.json");
let outcome = rewrite_existing_json(&path, LIMITS, |root| {
set_key(root, "ollamaBaseUrl", "http://127.0.0.1:7601");
Ok(())
})
.unwrap();
assert_eq!(outcome, RewriteOutcome::Absent);
assert!(!path.exists());
assert!(
!path.parent().unwrap().exists(),
"no parent directory either"
);
}
#[test]
fn rewrite_refuses_a_file_over_the_cap_with_a_code() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("globalState.json");
let big = format!("{{\"taskHistory\":\"{}\"}}", "x".repeat(200));
fs::write(&path, &big).unwrap();
let err = rewrite_existing_json(&path, RewriteLimits { max_bytes: 64 }, |root| {
set_key(root, "ollamaBaseUrl", "http://127.0.0.1:7601");
Ok(())
})
.expect_err("over the cap");
assert_eq!(err.code, ERR_MODEL_RELAY_STATE_FILE);
assert_eq!(fs::read_to_string(&path).unwrap(), big, "never truncated");
}
#[test]
fn rewrite_is_a_noop_when_bytes_are_unchanged() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("globalState.json");
fs::write(&path, "{\"ollamaBaseUrl\":\"http://127.0.0.1:7601\"}").unwrap();
let before = crate::fs_secure::fingerprint(&path).unwrap();
let outcome = rewrite_existing_json(&path, LIMITS, |root| {
set_key(root, "ollamaBaseUrl", "http://127.0.0.1:7601");
Ok(())
})
.unwrap();
assert_eq!(outcome, RewriteOutcome::Unchanged);
assert_eq!(
crate::fs_secure::fingerprint(&path).unwrap(),
before,
"mtime, size and inode untouched"
);
}
#[test]
fn rewrite_aborts_when_the_file_changed_under_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("globalState.json");
fs::write(&path, "{\"actModeApiProvider\":\"ollama\"}").unwrap();
let theirs = "{\"actModeApiProvider\":\"gemini\",\"geminiBaseUrl\":\"\"}";
let outcome = rewrite_existing_json(&path, LIMITS, |root| {
let tmp = dir.path().join("editor-save");
fs::write(&tmp, theirs).unwrap();
fs::rename(&tmp, &path).unwrap();
set_key(root, "ollamaBaseUrl", "http://127.0.0.1:7601");
Ok(())
})
.unwrap();
assert_eq!(outcome, RewriteOutcome::Contended);
assert_eq!(fs::read_to_string(&path).unwrap(), theirs);
}
#[test]
#[cfg(unix)]
fn rewrite_preserves_mode_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("providers.json");
fs::write(&path, "{}").unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
let outcome = rewrite_existing_json(&path, LIMITS, |root| {
set_key(root, "lastUsedProvider", "ollama");
Ok(())
})
.unwrap();
assert_eq!(outcome, RewriteOutcome::Written);
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
}
#[test]
#[cfg(unix)]
fn atomic_rewrite_jsonc_keeps_an_existing_files_mode() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("providers.json");
fs::write(&path, "{}").unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
atomic_rewrite_jsonc(&path, |root| {
set_key(root, "k", "v");
Ok(())
})
.unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
}
#[test]
#[cfg(unix)]
fn a_planted_temp_symlink_is_not_followed() {
let dir = tempfile::tempdir().unwrap();
let victim = dir.path().join("victim");
fs::write(&victim, "PRECIOUS").unwrap();
let path = dir.path().join("settings.json");
fs::write(&path, "{}").unwrap();
std::os::unix::fs::symlink(&victim, dir.path().join("settings.json.openlatch-tmp"))
.unwrap();
atomic_rewrite_jsonc(&path, |root| {
set_key(root, "k", "v");
Ok(())
})
.unwrap();
assert_eq!(fs::read_to_string(&victim).unwrap(), "PRECIOUS");
std::os::unix::fs::symlink(&victim, dir.path().join("settings.json.openlatch-tmp"))
.unwrap();
rewrite_existing_json(&path, LIMITS, |root| {
set_key(root, "k", "w");
Ok(())
})
.unwrap();
assert_eq!(fs::read_to_string(&victim).unwrap(), "PRECIOUS");
assert!(fs::read_to_string(&path).unwrap().contains("\"w\""));
}
#[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());
}
}