use std::time::{Duration, SystemTime};
use serde::{Deserialize, Serialize};
use teksilo_core::Signal;
use teksilo_core::telemetry::{ConsentScope, ConsentState};
use teksilo_settings::{
AppPaths, Migrator, SettingsFile, SettingsFileError, SettingsStore, Versioned,
};
use crate::scopes::{
TELEMETRY_ANONYMOUS_METRICS, TELEMETRY_CRASH_REPORTS, TELEMETRY_FEATURE_FLAGS,
};
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ConsentFile {
#[serde(default)]
pub version: u32,
#[serde(default)]
pub state: PersistedConsentState,
#[serde(default)]
pub decided_at: Option<SystemTime>,
#[serde(default)]
pub consented_to_event_schema: u32,
#[serde(default)]
pub endpoint_at_consent_time: String,
}
impl Versioned for ConsentFile {
const CURRENT_VERSION: u32 = 1;
fn version(&self) -> u32 {
self.version
}
fn set_version(&mut self, v: u32) {
self.version = v;
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum PersistedConsentState {
#[default]
Unknown,
Granted {
scope: PersistedConsentScope,
},
Denied,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct PersistedConsentScope {
#[serde(default)]
pub anonymous_metrics: bool,
#[serde(default)]
pub crash_reports: bool,
#[serde(default)]
pub feature_flags: bool,
#[serde(default)]
pub session_recording: bool,
}
impl From<ConsentScope> for PersistedConsentScope {
fn from(s: ConsentScope) -> Self {
Self {
anonymous_metrics: s.anonymous_metrics,
crash_reports: s.crash_reports,
feature_flags: s.feature_flags,
session_recording: s.session_recording,
}
}
}
impl From<PersistedConsentScope> for ConsentScope {
fn from(s: PersistedConsentScope) -> Self {
Self {
anonymous_metrics: s.anonymous_metrics,
crash_reports: s.crash_reports,
feature_flags: s.feature_flags,
session_recording: s.session_recording,
}
}
}
impl From<PersistedConsentState> for ConsentState {
fn from(p: PersistedConsentState) -> Self {
match p {
PersistedConsentState::Unknown => ConsentState::Unknown,
PersistedConsentState::Granted { scope } => ConsentState::Granted(scope.into()),
PersistedConsentState::Denied => ConsentState::Denied,
}
}
}
impl From<ConsentState> for PersistedConsentState {
fn from(c: ConsentState) -> Self {
match c {
ConsentState::Unknown => PersistedConsentState::Unknown,
ConsentState::Granted(scope) => PersistedConsentState::Granted {
scope: scope.into(),
},
ConsentState::Denied => PersistedConsentState::Denied,
}
}
}
#[derive(Clone)]
pub struct ConsentStore {
file: SettingsFile<ConsentFile>,
state: Signal<ConsentState>,
current_event_schema: u32,
settings_mirror: Option<SettingsStore>,
}
impl ConsentStore {
pub fn open(
paths: &AppPaths,
delay: Duration,
current_event_schema: u32,
current_endpoint: &str,
) -> Result<Self, SettingsFileError> {
let _ = delay;
let migrator = Migrator::<ConsentFile>::new();
let file = SettingsFile::load(paths.config_file("telemetry-consent"), migrator)?;
let snap = file.snapshot();
let needs_reprompt = snap.consented_to_event_schema < current_event_schema
|| (!snap.endpoint_at_consent_time.is_empty()
&& snap.endpoint_at_consent_time != current_endpoint);
if needs_reprompt {
file.mutate(|f| {
f.state = PersistedConsentState::Unknown;
f.decided_at = None;
f.consented_to_event_schema = current_event_schema;
f.endpoint_at_consent_time = current_endpoint.to_string();
})?;
}
let state = Signal::new(ConsentState::from(file.snapshot().state));
Ok(Self {
file,
state,
current_event_schema,
settings_mirror: None,
})
}
pub fn with_settings_mirror(mut self, settings: SettingsStore) -> Self {
self.settings_mirror = Some(settings);
self.write_mirror(self.state.get().scope().copied());
self
}
fn write_mirror(&self, scope: Option<ConsentScope>) {
let Some(store) = &self.settings_mirror else {
return;
};
let resolved = scope.unwrap_or_default();
store
.signal_for(&TELEMETRY_ANONYMOUS_METRICS)
.set(resolved.anonymous_metrics);
store
.signal_for(&TELEMETRY_CRASH_REPORTS)
.set(resolved.crash_reports);
store
.signal_for(&TELEMETRY_FEATURE_FLAGS)
.set(resolved.feature_flags);
}
pub fn state_signal(&self) -> Signal<ConsentState> {
self.state.clone()
}
pub fn is_granted(&self) -> bool {
self.state.get().is_granted()
}
pub fn grant(&self, scope: ConsentScope, endpoint: &str) -> Result<(), SettingsFileError> {
self.file.mutate(|f| {
f.state = PersistedConsentState::Granted {
scope: scope.into(),
};
f.decided_at = Some(SystemTime::now());
f.consented_to_event_schema = self.current_event_schema;
f.endpoint_at_consent_time = endpoint.to_string();
})?;
self.state.set(ConsentState::Granted(scope));
self.write_mirror(Some(scope));
Ok(())
}
pub fn deny(&self) -> Result<(), SettingsFileError> {
self.file.mutate(|f| {
f.state = PersistedConsentState::Denied;
f.decided_at = Some(SystemTime::now());
})?;
self.state.set(ConsentState::Denied);
self.write_mirror(None);
Ok(())
}
pub fn withdraw(&self) -> Result<(), SettingsFileError> {
self.deny()
}
pub fn reset(&self) -> Result<(), SettingsFileError> {
self.file.mutate(|f| {
f.state = PersistedConsentState::Unknown;
f.decided_at = None;
})?;
self.state.set(ConsentState::Unknown);
self.write_mirror(None);
Ok(())
}
pub fn set_or_grant_scope(
&self,
endpoint: &str,
f: impl FnOnce(&mut ConsentScope),
) -> Result<bool, SettingsFileError> {
let current = self.state.get();
match current {
ConsentState::Denied => Ok(false),
ConsentState::Granted(_) => self.set_scope(f),
ConsentState::Unknown => {
let mut scope = ConsentScope::none();
f(&mut scope);
self.grant(scope, endpoint)?;
Ok(true)
}
}
}
pub fn set_scope(&self, f: impl FnOnce(&mut ConsentScope)) -> Result<bool, SettingsFileError> {
let mut current = self.state.get();
let ConsentState::Granted(ref mut scope) = current else {
return Ok(false);
};
f(scope);
let scope = *scope;
self.file.mutate(|file| {
file.state = PersistedConsentState::Granted {
scope: scope.into(),
};
})?;
self.state.set(ConsentState::Granted(scope));
self.write_mirror(Some(scope));
Ok(true)
}
pub fn flush_now(&self) -> Result<(), SettingsFileError> {
self.file.flush_now()
}
pub fn path(&self) -> &std::path::Path {
self.file.path()
}
}
impl std::fmt::Debug for ConsentStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConsentStore")
.field("path", &self.file.path())
.field("state", &self.state.get())
.field("schema", &self.current_event_schema)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn open(dir: &std::path::Path, schema: u32, endpoint: &str) -> ConsentStore {
let paths = AppPaths::for_testing(dir);
ConsentStore::open(&paths, Duration::ZERO, schema, endpoint).unwrap()
}
#[test]
fn fresh_store_starts_unknown() {
let dir = tempdir().unwrap();
let store = open(dir.path(), 1, "stub://");
assert!(matches!(store.state_signal().get(), ConsentState::Unknown));
assert!(!store.is_granted());
}
#[test]
fn grant_persists_and_round_trips() {
let dir = tempdir().unwrap();
{
let store = open(dir.path(), 1, "stub://");
store
.grant(ConsentScope::anonymous_metrics_only(), "stub://")
.unwrap();
store.flush_now().unwrap();
}
let store = open(dir.path(), 1, "stub://");
let s = store.state_signal().get();
assert!(matches!(s, ConsentState::Granted(_)));
if let ConsentState::Granted(scope) = s {
assert!(scope.anonymous_metrics);
assert!(!scope.crash_reports);
}
}
#[test]
fn schema_bump_resets_to_unknown() {
let dir = tempdir().unwrap();
{
let store = open(dir.path(), 1, "stub://");
store.grant(ConsentScope::all(), "stub://").unwrap();
store.flush_now().unwrap();
}
let store = open(dir.path(), 2, "stub://");
assert!(matches!(store.state_signal().get(), ConsentState::Unknown));
}
#[test]
fn endpoint_change_resets_to_unknown() {
let dir = tempdir().unwrap();
{
let store = open(dir.path(), 1, "https://eu.example.com/");
store
.grant(ConsentScope::all(), "https://eu.example.com/")
.unwrap();
store.flush_now().unwrap();
}
let store = open(dir.path(), 1, "https://us.example.com/");
assert!(matches!(store.state_signal().get(), ConsentState::Unknown));
}
#[test]
fn deny_then_grant_works() {
let dir = tempdir().unwrap();
let store = open(dir.path(), 1, "stub://");
store.deny().unwrap();
assert!(matches!(store.state_signal().get(), ConsentState::Denied));
store
.grant(ConsentScope::anonymous_metrics_only(), "stub://")
.unwrap();
assert!(store.is_granted());
}
#[test]
fn set_scope_no_op_when_not_granted() {
let dir = tempdir().unwrap();
let store = open(dir.path(), 1, "stub://");
let applied = store.set_scope(|s| s.crash_reports = true).unwrap();
assert!(!applied, "set_scope must report skipped");
assert!(matches!(store.state_signal().get(), ConsentState::Unknown));
}
#[test]
fn settings_mirror_reflects_scope_changes() {
use teksilo_settings::SettingsStore;
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let store =
SettingsStore::open_with_delay(paths.config_file("general"), Duration::ZERO).unwrap();
let consent = ConsentStore::open(&paths, Duration::ZERO, 1, "stub://")
.unwrap()
.with_settings_mirror(store.clone());
assert!(!store.signal_for(&TELEMETRY_ANONYMOUS_METRICS).get());
consent.grant(ConsentScope::all(), "stub://").unwrap();
assert!(store.signal_for(&TELEMETRY_ANONYMOUS_METRICS).get());
assert!(store.signal_for(&TELEMETRY_CRASH_REPORTS).get());
assert!(store.signal_for(&TELEMETRY_FEATURE_FLAGS).get());
consent.set_scope(|s| s.crash_reports = false).unwrap();
assert!(!store.signal_for(&TELEMETRY_CRASH_REPORTS).get());
consent.deny().unwrap();
assert!(!store.signal_for(&TELEMETRY_ANONYMOUS_METRICS).get());
assert!(!store.signal_for(&TELEMETRY_CRASH_REPORTS).get());
}
#[test]
fn set_scope_updates_when_granted() {
let dir = tempdir().unwrap();
let store = open(dir.path(), 1, "stub://");
store
.grant(ConsentScope::anonymous_metrics_only(), "stub://")
.unwrap();
let applied = store.set_scope(|s| s.crash_reports = true).unwrap();
assert!(applied, "set_scope must report applied");
if let ConsentState::Granted(scope) = store.state_signal().get() {
assert!(scope.anonymous_metrics);
assert!(scope.crash_reports);
} else {
panic!("expected Granted");
}
}
}