use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::{OlError, ERR_TELEMETRY_CONFIG_CORRUPT, ERR_TELEMETRY_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(
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}"),
)),
}
}
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() {
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);
}
}