openlatch-client 0.3.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
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_MALFORMED_TOML, 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(())
}

/// The TOML sibling of [`atomic_rewrite_jsonc`], for the one agent that names
/// its model provider in a `config.toml` rather than in a JSONC settings file.
///
/// Same discipline, same temp-file-then-rename, same symlink resolution — the
/// only difference is the parser, and factoring that out is what stops a second
/// call site re-deriving the durability dance. The document handed to `mutate`
/// is `toml_edit`'s format-preserving one: comments, key order and spacing
/// outside the edited keys survive, which is the whole reason this exists
/// rather than a `toml::from_str` / `to_string` round trip.
///
/// A missing file starts from an empty document. **This never deletes one** —
/// removing the last of our keys leaves an empty `config.toml` behind, because
/// nothing records whether we created it and a customer's own empty file is
/// not ours to remove.
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)?;

    let serialized = doc.to_string();

    let tmp_path = real_path.with_extension("toml.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());
    }
}