Skip to main content

rtb_telemetry/
consent.rs

1//! Persisted user consent for telemetry collection.
2//!
3//! Backs the v0.4 `rtb-cli telemetry status / enable / disable / reset`
4//! subtree. The file lives at `<config_dir>/<tool>/consent.toml`:
5//!
6//! ```toml
7//! version = 1
8//! state = "enabled"   # or "disabled" or "unset"
9//! decided_at = "2026-05-08T12:34:56Z"
10//! ```
11//!
12//! `Application::builder().read_telemetry_consent()` (in `rtb-cli`)
13//! threads the resulting [`CollectionPolicy`] into the
14//! [`crate::TelemetryContext`] it builds.
15//!
16//! # Why a separate file (not inside `config.yaml`)?
17//!
18//! The v0.4 scope addendum (§2.5, O3 resolution) keeps consent in a
19//! dedicated file so:
20//!
21//! - The state is unambiguously CLI-managed; `config.yaml` stays a
22//!   user-edited artefact.
23//! - `telemetry reset` can `unlink(2)` rather than rewrite YAML.
24//! - The schema is versioned independently, so a future consent
25//!   format change doesn't force a config-file migration.
26
27use std::path::Path;
28
29use serde::{Deserialize, Serialize};
30
31use crate::context::CollectionPolicy;
32use crate::error::TelemetryError;
33
34/// User-recorded consent state. Maps onto [`CollectionPolicy`] via
35/// [`From`]: `Enabled` → `Enabled`; `Disabled` and `Unset` both
36/// → `Disabled` (opt-in is the default).
37#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "lowercase")]
39pub enum ConsentState {
40    /// User has explicitly opted in.
41    Enabled,
42    /// User has explicitly opted out.
43    Disabled,
44    /// No decision recorded — falls back to opt-in default
45    /// (`Disabled` policy).
46    #[default]
47    Unset,
48}
49
50impl From<ConsentState> for CollectionPolicy {
51    fn from(state: ConsentState) -> Self {
52        match state {
53            ConsentState::Enabled => Self::Enabled,
54            ConsentState::Disabled | ConsentState::Unset => Self::Disabled,
55        }
56    }
57}
58
59/// On-disk consent record. Version-tagged so a future schema change
60/// is non-breaking.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct Consent {
63    /// Schema version. Always [`Self::SCHEMA_VERSION`] at v0.4.
64    /// A future bump would let `read` decide whether to upgrade
65    /// in-place or refuse.
66    pub version: u32,
67    /// User decision.
68    pub state: ConsentState,
69    /// ISO-8601 timestamp of the most recent decision. Stored as a
70    /// pre-formatted string rather than a `time::OffsetDateTime` so
71    /// future schema versions can carry richer formats without
72    /// breaking deserialisation here.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub decided_at: Option<String>,
75}
76
77impl Consent {
78    /// Current on-disk schema version.
79    pub const SCHEMA_VERSION: u32 = 1;
80
81    /// Construct a record at [`ConsentState::Unset`] with no
82    /// timestamp. Equivalent to "no consent file on disk."
83    #[must_use]
84    pub const fn unset() -> Self {
85        Self { version: Self::SCHEMA_VERSION, state: ConsentState::Unset, decided_at: None }
86    }
87
88    /// Construct an enabled record stamped with the current UTC time.
89    #[must_use]
90    pub fn enabled_now() -> Self {
91        Self::with_state_now(ConsentState::Enabled)
92    }
93
94    /// Construct a disabled record stamped with the current UTC time.
95    #[must_use]
96    pub fn disabled_now() -> Self {
97        Self::with_state_now(ConsentState::Disabled)
98    }
99
100    fn with_state_now(state: ConsentState) -> Self {
101        let decided_at = time::OffsetDateTime::now_utc()
102            .format(&time::format_description::well_known::Rfc3339)
103            .ok();
104        Self { version: Self::SCHEMA_VERSION, state, decided_at }
105    }
106}
107
108/// Read the consent file. `Ok(None)` when the file does not exist —
109/// callers should treat that as [`ConsentState::Unset`].
110///
111/// # Errors
112///
113/// - [`TelemetryError::Io`] for filesystem failures other than
114///   "not found."
115/// - [`TelemetryError::Serde`] for malformed TOML or unknown schema
116///   versions.
117pub fn read(path: &Path) -> Result<Option<Consent>, TelemetryError> {
118    let body = match std::fs::read_to_string(path) {
119        Ok(body) => body,
120        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
121        Err(err) => return Err(TelemetryError::Io(err)),
122    };
123    let consent: Consent =
124        toml::from_str(&body).map_err(|e| TelemetryError::Serde(format!("consent: {e}")))?;
125    if consent.version != Consent::SCHEMA_VERSION {
126        return Err(TelemetryError::Serde(format!(
127            "consent: unsupported schema version {} (expected {})",
128            consent.version,
129            Consent::SCHEMA_VERSION
130        )));
131    }
132    Ok(Some(consent))
133}
134
135/// Write the consent file. Parent directories are created on demand.
136/// The write is not atomic at v0.4 — callers concerned about torn
137/// writes implement their own staging on top.
138///
139/// # Errors
140///
141/// - [`TelemetryError::Io`] for filesystem failures (parent-dir
142///   creation, file write).
143/// - [`TelemetryError::Serde`] for serialisation failures.
144pub fn write(path: &Path, consent: &Consent) -> Result<(), TelemetryError> {
145    let body = toml::to_string_pretty(consent)
146        .map_err(|e| TelemetryError::Serde(format!("consent: {e}")))?;
147    if let Some(parent) = path.parent() {
148        if !parent.as_os_str().is_empty() {
149            std::fs::create_dir_all(parent).map_err(TelemetryError::Io)?;
150        }
151    }
152    std::fs::write(path, body).map_err(TelemetryError::Io)?;
153    Ok(())
154}
155
156/// Convenience: delete the consent file. `Ok(())` if the file was
157/// already absent — `telemetry reset` is idempotent.
158///
159/// # Errors
160///
161/// [`TelemetryError::Io`] for filesystem failures other than
162/// "not found."
163pub fn reset(path: &Path) -> Result<(), TelemetryError> {
164    match std::fs::remove_file(path) {
165        Ok(()) => Ok(()),
166        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
167        Err(err) => Err(TelemetryError::Io(err)),
168    }
169}