openlatch-provider 0.2.1

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Read and write `~/.openlatch/provider/telemetry.json` — the consent decision file.
//!
//! Schema (v1):
//! ```json
//! {
//!   "enabled": true,
//!   "notice_shown_at": "2026-04-13T14:21:08Z",
//!   "schema_version": 1
//! }
//! ```
//!
//! Invariant 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 [`super::consent::resolve`] —
//! this module surfaces the parse error so `config status` can explain it.

use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::error::{OlError, OL_4262_CONSENT_FILE_CORRUPT, OL_4263_CONSENT_WRITE_FAILED};

#[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
}

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(
                    OL_4262_CONSENT_FILE_CORRUPT,
                    format!("telemetry.json at '{}' is malformed: {e}", path.display()),
                )
                .with_suggestion(
                    "Delete the file and re-run `openlatch-provider init` to restore consent state.",
                )
            })?;
            Ok(Some(parsed))
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(OlError::new(
            OL_4262_CONSENT_FILE_CORRUPT,
            format!("cannot read telemetry.json: {e}"),
        )),
    }
}

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(
                OL_4263_CONSENT_WRITE_FAILED,
                format!("cannot create '{}': {e}", parent.display()),
            )
        })?;
    }
    let serialized = serde_json::to_string_pretty(file).map_err(|e| {
        OlError::new(
            OL_4263_CONSENT_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(
            OL_4263_CONSENT_WRITE_FAILED,
            format!("cannot write '{}': {e}", tmp.display()),
        )
    })?;
    std::fs::rename(&tmp, path).map_err(|e| {
        OlError::new(
            OL_4263_CONSENT_WRITE_FAILED,
            format!("cannot rename '{}' into place: {e}", tmp.display()),
        )
    })?;
    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 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 round_trip_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 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.code, "OL-4262");
    }

    #[test]
    fn unknown_schema_version_preserves_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);
    }
}