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}