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)
}
#[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 }
}
}
#[derive(Debug, Default, Deserialize)]
struct PartialConfig {
#[serde(default)]
crashreport: Option<CrashreportSection>,
}
#[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}"),
}
}
}
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::*;
#[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 })
);
}
#[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() {
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);
}
}