openlatch-client 0.1.6

The open-source security layer for AI agents — client forwarder
//! Read and write the `~/.openlatch/telemetry.json` consent file.
//!
//! Schema (v1):
//! ```json
//! {
//!   "enabled": true,
//!   "notice_shown_at": "2026-04-13T14:21:08Z",
//!   "schema_version": 1
//! }
//! ```
//!
//! Invariants (brainstorm §4.4):
//! - I9: upgrading a disabled install never flips it to enabled. A missing or
//!   unknown `schema_version` is treated as v1; future migrations must preserve
//!   `enabled: false` explicitly.
//! - Corrupt files resolve to "disabled" in the consent layer — this module
//!   surfaces the parse error so callers (status command) can explain it.

use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::error::{OlError, ERR_TELEMETRY_CONFIG_CORRUPT, ERR_TELEMETRY_WRITE_FAILED};

/// On-disk shape of `telemetry.json`. `schema_version` lets us evolve the file
/// without silently re-enabling consent on upgrade (I9).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ConsentFile {
    pub enabled: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub notice_shown_at: Option<String>,
    #[serde(default = "default_schema_version")]
    pub schema_version: u32,
}

fn default_schema_version() -> u32 {
    1
}

/// Read the consent file if it exists.
///
/// Returns:
/// - `Ok(Some(file))` when the file exists and parses
/// - `Ok(None)` when the file is absent — caller resolves this as "unconsented"
/// - `Err(OlError)` when the file exists but is malformed JSON or missing fields
pub fn read_consent(path: &Path) -> Result<Option<ConsentFile>, OlError> {
    match std::fs::read_to_string(path) {
        Ok(raw) => {
            let parsed: ConsentFile = serde_json::from_str(&raw).map_err(|e| {
                OlError::new(
                    ERR_TELEMETRY_CONFIG_CORRUPT,
                    format!("telemetry.json at '{}' is malformed: {e}", path.display()),
                )
                .with_suggestion(
                    "Delete the file and re-run `openlatch init` to restore consent state.",
                )
            })?;
            Ok(Some(parsed))
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(OlError::new(
            ERR_TELEMETRY_CONFIG_CORRUPT,
            format!("cannot read telemetry.json: {e}"),
        )),
    }
}

/// Atomically write a consent decision to disk with the current UTC timestamp.
///
/// Writes to `{path}.tmp` first, then renames over `path` — prevents partial
/// writes from corrupting consent state if the process is killed mid-write.
pub fn write_consent(path: &Path, enabled: bool) -> Result<(), OlError> {
    let file = ConsentFile {
        enabled,
        notice_shown_at: Some(now_iso8601()),
        schema_version: 1,
    };
    write_consent_file(path, &file)
}

fn write_consent_file(path: &Path, file: &ConsentFile) -> Result<(), OlError> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| {
            OlError::new(
                ERR_TELEMETRY_WRITE_FAILED,
                format!("cannot create parent directory '{}': {e}", parent.display()),
            )
        })?;
    }

    let serialized = serde_json::to_string_pretty(file).map_err(|e| {
        OlError::new(
            ERR_TELEMETRY_WRITE_FAILED,
            format!("cannot serialize telemetry.json: {e}"),
        )
    })?;

    let tmp = path.with_extension("json.tmp");
    std::fs::write(&tmp, serialized.as_bytes()).map_err(|e| {
        OlError::new(
            ERR_TELEMETRY_WRITE_FAILED,
            format!("cannot write telemetry.json: {e}"),
        )
    })?;
    std::fs::rename(&tmp, path).map_err(|e| {
        OlError::new(
            ERR_TELEMETRY_WRITE_FAILED,
            format!("cannot atomically rename telemetry.json into place: {e}"),
        )
    })?;
    Ok(())
}

fn now_iso8601() -> String {
    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn test_read_missing_file_returns_none() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("telemetry.json");
        assert!(read_consent(&path).unwrap().is_none());
    }

    #[test]
    fn test_write_then_read_roundtrip_enabled() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("telemetry.json");
        write_consent(&path, true).unwrap();

        let loaded = read_consent(&path).unwrap().unwrap();

        assert!(loaded.enabled);
        assert_eq!(loaded.schema_version, 1);
        assert!(loaded.notice_shown_at.is_some());
    }

    #[test]
    fn test_write_then_read_roundtrip_disabled() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("telemetry.json");
        write_consent(&path, false).unwrap();

        let loaded = read_consent(&path).unwrap().unwrap();

        assert!(!loaded.enabled);
    }

    #[test]
    fn test_corrupt_file_returns_err() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("telemetry.json");
        std::fs::write(&path, b"{ broken").unwrap();

        let err = read_consent(&path).unwrap_err();
        assert_eq!(err.code, ERR_TELEMETRY_CONFIG_CORRUPT);
    }

    #[test]
    fn test_unknown_schema_version_preserves_disabled() {
        // I9: a future schema version with enabled=false must still resolve disabled.
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("telemetry.json");
        std::fs::write(
            &path,
            br#"{"enabled": false, "schema_version": 99, "extra": "future"}"#,
        )
        .unwrap();

        let loaded = read_consent(&path).unwrap().unwrap();
        assert!(!loaded.enabled);
        assert_eq!(loaded.schema_version, 99);
    }
}