openlatch-client 0.5.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
//! 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)
}

// ---------------------------------------------------------------------------
// `[crashreport]` in ~/.openlatch/config.toml.
//
// Crash reporting does NOT have its own sidecar JSON the way product telemetry
// does. The toggle lives in the main TOML config because (a) it is a single
// boolean and (b) it shares the same per-user directory lifecycle.
//
// The SECTION NAME IS UNCHANGED by the PostHog migration. Two independent
// consent gates keep two independent sections; folding this key under
// `[telemetry]` would imply one switch where there are two. Only the key's
// meaning is restated: it now gates PostHog error tracking.
//
// Default when the section is absent: ENABLED. This is the single exception in
// the product's consent table — crash reports are diagnostic, not behavioural.
// ---------------------------------------------------------------------------

/// On-disk shape of `[crashreport]`. All fields are optional so a missing or
/// partially-written section still parses.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct CrashreportSection {
    pub enabled: bool,
}

impl Default for CrashreportSection {
    fn default() -> Self {
        Self { enabled: true }
    }
}

/// Outer shape for the partial TOML parse — we care about one section and
/// ignore every other top-level key.
#[derive(Debug, Default, Deserialize)]
struct PartialConfig {
    #[serde(default)]
    crashreport: Option<CrashreportSection>,
}

/// Why reading `[crashreport]` failed.
///
/// Deliberately its own type rather than an `OlError`: every caller treats a
/// failure identically (crash reporting stays ON), so this never reaches a user
/// as a diagnostic and never needs a stable `OL-XXXX` code.
#[derive(Debug)]
pub enum CrashreportReadError {
    Io(String),
    Parse(String),
}

impl std::fmt::Display for CrashreportReadError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            CrashreportReadError::Io(s) => write!(f, "config.toml read error: {s}"),
            CrashreportReadError::Parse(s) => write!(f, "config.toml parse error: {s}"),
        }
    }
}

/// Parse the `[crashreport]` section from the given `config.toml` path.
///
/// Returns:
/// - `Ok(Some(section))` when the section is present
/// - `Ok(None)` when the file does not exist OR the section is absent — the consent
///   layer treats both as "default on"
/// - `Err(...)` on I/O or TOML parse failure of the whole file, which the consent layer
///   ALSO treats as "default on": a corrupt file must not silently stop crash
///   diagnostics
pub fn read_crashreport_section(
    path: &Path,
) -> Result<Option<CrashreportSection>, CrashreportReadError> {
    let raw = match std::fs::read_to_string(path) {
        Ok(s) => s,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(e) => return Err(CrashreportReadError::Io(e.to_string())),
    };
    let parsed: PartialConfig =
        toml::from_str(&raw).map_err(|e| CrashreportReadError::Parse(e.to_string()))?;
    Ok(parsed.crashreport)
}

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

    // --- [crashreport] -----------------------------------------------------
    // These two came across with `read_crashreport_section`. They pin the
    // default that the consent chain's "section present" rung depends on, and
    // neither is reachable through the consent tests: one needs a bare section
    // header, the other needs a file that EXISTS with the section absent.

    /// A bare `[crashreport]` header with no keys means ENABLED. Delete the manual
    /// `Default` impl and this is the only test that notices — while a hand-written
    /// section silently turns crash reporting off.
    #[test]
    fn crashreport_bare_section_defaults_to_enabled() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("config.toml");
        std::fs::write(
            &path,
            "[crashreport]
",
        )
        .unwrap();
        assert_eq!(
            read_crashreport_section(&path).unwrap(),
            Some(CrashreportSection { enabled: true })
        );
    }

    /// A file that exists, parses, and simply has no `[crashreport]` section. Distinct
    /// from a missing file: this one reaches the TOML parse, the missing one exits at
    /// the `NotFound` branch.
    #[test]
    fn crashreport_absent_section_in_an_existing_file_is_none() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("config.toml");
        std::fs::write(
            &path,
            "[other]
key = \"value\"
",
        )
        .unwrap();
        assert!(read_crashreport_section(&path).unwrap().is_none());
    }

    #[test]
    fn crashreport_missing_file_is_none() {
        let tmp = tempfile::TempDir::new().unwrap();
        assert!(read_crashreport_section(&tmp.path().join("config.toml"))
            .unwrap()
            .is_none());
    }

    #[test]
    fn crashreport_enabled_false_round_trips() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("config.toml");
        std::fs::write(
            &path,
            "[crashreport]
enabled = false
",
        )
        .unwrap();
        assert_eq!(
            read_crashreport_section(&path).unwrap(),
            Some(CrashreportSection { enabled: false })
        );
    }

    #[test]
    fn crashreport_corrupt_file_is_an_error_not_a_default() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("config.toml");
        std::fs::write(
            &path,
            "[crashreport
enabled = ",
        )
        .unwrap();
        assert!(read_crashreport_section(&path).is_err());
    }

    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);
    }
}