Skip to main content

rusty_stack/telemetry/
opt_in.rs

1//! Opt-in gate for telemetry collection and submission.
2//!
3//! Telemetry is **disabled by default** (VAL-TELE-010). The user must
4//! explicitly enable it via config file or CLI toggle.
5//!
6//! # Immediate Effect (VAL-TELE-011)
7//!
8//! Changes to the opt-in setting take effect immediately without requiring
9//! a restart. The setting is persisted to the config file.
10//!
11//! # No Promotional Language (VAL-TELE-012)
12//!
13//! UI strings in this module contain no promotional language, upgrade nudges,
14//! or paid tier links.
15
16use anyhow::{Context, Result};
17use serde::{Deserialize, Serialize};
18use std::fs;
19use std::path::PathBuf;
20
21/// Config file name for telemetry settings.
22const TELEMETRY_CONFIG_FILENAME: &str = "telemetry.json";
23
24/// Directory name under .mlstack for config.
25const MLSTACK_CONFIG_DIR: &str = ".mlstack/config";
26
27/// Telemetry configuration persisted to disk.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
29pub struct TelemetryConfig {
30    /// Whether telemetry is enabled. Defaults to false.
31    pub enabled: bool,
32    /// Timestamp of when telemetry was last toggled.
33    #[serde(default)]
34    pub last_toggled_at: Option<String>,
35}
36
37/// Telemetry opt-in gate. Manages the enabled/disabled state.
38///
39/// The gate reads from and writes to a config file. Changes are immediate.
40#[derive(Debug, Clone)]
41pub struct OptInGate {
42    config: TelemetryConfig,
43    config_path: PathBuf,
44}
45
46impl OptInGate {
47    /// Create a new opt-in gate with the default config directory.
48    ///
49    /// Looks for config at `~/.mlstack/config/telemetry.json`.
50    /// If not found, defaults to disabled.
51    pub fn new() -> Result<Self> {
52        let config_path = Self::default_config_path()?;
53        let config = Self::load_config(&config_path).unwrap_or_default();
54        Ok(Self {
55            config,
56            config_path,
57        })
58    }
59
60    /// Create a new opt-in gate with a custom config path (for testing).
61    pub fn with_config_path(config_path: PathBuf) -> Self {
62        let config = Self::load_config(&config_path).unwrap_or_default();
63        Self {
64            config,
65            config_path,
66        }
67    }
68
69    /// Check if telemetry is currently enabled.
70    ///
71    /// VAL-TELE-010: Returns false by default.
72    pub fn is_enabled(&self) -> bool {
73        self.config.enabled
74    }
75
76    /// Enable telemetry.
77    ///
78    /// VAL-TELE-011: Takes effect immediately, no restart required.
79    pub fn enable(&mut self) -> Result<()> {
80        self.config.enabled = true;
81        self.config.last_toggled_at = Some(chrono_now_rfc3339());
82        self.persist()
83    }
84
85    /// Disable telemetry.
86    ///
87    /// VAL-TELE-011: Takes effect immediately, no restart required.
88    pub fn disable(&mut self) -> Result<()> {
89        self.config.enabled = false;
90        self.config.last_toggled_at = Some(chrono_now_rfc3339());
91        self.persist()
92    }
93
94    /// Toggle telemetry on/off. Returns the new state.
95    pub fn toggle(&mut self) -> Result<bool> {
96        if self.config.enabled {
97            self.disable()?;
98        } else {
99            self.enable()?;
100        }
101        Ok(self.config.enabled)
102    }
103
104    /// Get the current config.
105    pub fn config(&self) -> &TelemetryConfig {
106        &self.config
107    }
108
109    /// Get the config file path.
110    pub fn config_path(&self) -> &PathBuf {
111        &self.config_path
112    }
113
114    /// Reload config from disk (for detecting external changes).
115    pub fn reload(&mut self) -> Result<()> {
116        self.config = Self::load_config(&self.config_path).unwrap_or_default();
117        Ok(())
118    }
119
120    // -----------------------------------------------------------------------
121    // Private helpers
122    // -----------------------------------------------------------------------
123
124    fn default_config_path() -> Result<PathBuf> {
125        let home = dirs::home_dir().context("Cannot determine home directory")?;
126        Ok(home
127            .join(MLSTACK_CONFIG_DIR)
128            .join(TELEMETRY_CONFIG_FILENAME))
129    }
130
131    fn load_config(path: &PathBuf) -> Result<TelemetryConfig> {
132        let content = fs::read_to_string(path).context("Config file not found")?;
133        let config: TelemetryConfig =
134            serde_json::from_str(&content).context("Invalid telemetry config")?;
135        Ok(config)
136    }
137
138    fn persist(&self) -> Result<()> {
139        if let Some(parent) = self.config_path.parent() {
140            fs::create_dir_all(parent).context("Failed to create config directory")?;
141        }
142        let json = serde_json::to_string_pretty(&self.config)?;
143        fs::write(&self.config_path, json).context("Failed to write telemetry config")?;
144        Ok(())
145    }
146}
147
148/// Get the current time as an RFC 3339 string.
149fn chrono_now_rfc3339() -> String {
150    chrono::Local::now().to_rfc3339()
151}
152
153// ---------------------------------------------------------------------------
154// UI Strings (VAL-TELE-012: No promotional language)
155// ---------------------------------------------------------------------------
156
157/// Description of telemetry for user-facing display.
158/// No promotional language, upgrade nudges, or paid tier links.
159pub const TELEMETRY_DESCRIPTION: &str =
160    "Share anonymous hardware metrics to help improve GPU support.";
161
162/// Label for the enable toggle.
163pub const TELEMETRY_ENABLE_LABEL: &str = "Enable anonymous telemetry";
164
165/// Label for the disable toggle.
166pub const TELEMETRY_DISABLE_LABEL: &str = "Disable telemetry";
167
168/// Confirmation message when telemetry is enabled.
169pub const TELEMETRY_ENABLED_CONFIRMATION: &str = "Telemetry enabled. Thank you for contributing.";
170
171/// Confirmation message when telemetry is disabled.
172pub const TELEMETRY_DISABLED_CONFIRMATION: &str = "Telemetry disabled.";
173
174/// Status text when telemetry is enabled.
175pub const TELEMETRY_STATUS_ENABLED: &str = "Telemetry: enabled";
176
177/// Status text when telemetry is disabled.
178pub const TELEMETRY_STATUS_DISABLED: &str = "Telemetry: disabled";
179
180/// What data is collected (displayed to user).
181pub const TELEMETRY_DATA_DESCRIPTION: &str =
182    "Collected data: GPU utilization, VRAM usage, temperature, clock speed, throttling status.";
183
184/// Privacy note for user display.
185pub const TELEMETRY_PRIVACY_NOTE: &str =
186    "No personal data is collected. Payloads contain only hardware metrics.";
187
188// ===========================================================================
189// Tests
190// ===========================================================================
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use tempfile::TempDir;
196
197    // =======================================================================
198    // VAL-TELE-010: Opt-In Gate — Default Off
199    // =======================================================================
200
201    #[test]
202    fn test_telemetry_disabled_by_default() {
203        let dir = TempDir::new().unwrap();
204        let config_path = dir.path().join("telemetry.json");
205        let gate = OptInGate::with_config_path(config_path);
206        assert!(
207            !gate.is_enabled(),
208            "Telemetry should be disabled by default"
209        );
210    }
211
212    #[test]
213    fn test_telemetry_config_default_disabled() {
214        let config = TelemetryConfig::default();
215        assert!(!config.enabled, "Default config should have enabled=false");
216    }
217
218    #[test]
219    fn test_fresh_config_has_no_file() {
220        let dir = TempDir::new().unwrap();
221        let config_path = dir.path().join("telemetry.json");
222        // File doesn't exist — should default to disabled
223        let gate = OptInGate::with_config_path(config_path);
224        assert!(!gate.is_enabled());
225    }
226
227    // =======================================================================
228    // VAL-TELE-011: Opt-In Gate — Explicit Toggle
229    // =======================================================================
230
231    #[test]
232    fn test_enable_takes_effect_immediately() {
233        let dir = TempDir::new().unwrap();
234        let config_path = dir.path().join("telemetry.json");
235        let mut gate = OptInGate::with_config_path(config_path);
236
237        assert!(!gate.is_enabled());
238        gate.enable().unwrap();
239        assert!(gate.is_enabled(), "Enable should take effect immediately");
240    }
241
242    #[test]
243    fn test_disable_takes_effect_immediately() {
244        let dir = TempDir::new().unwrap();
245        let config_path = dir.path().join("telemetry.json");
246        let mut gate = OptInGate::with_config_path(config_path);
247
248        gate.enable().unwrap();
249        assert!(gate.is_enabled());
250
251        gate.disable().unwrap();
252        assert!(!gate.is_enabled(), "Disable should take effect immediately");
253    }
254
255    #[test]
256    fn test_toggle_cycles_state() {
257        let dir = TempDir::new().unwrap();
258        let config_path = dir.path().join("telemetry.json");
259        let mut gate = OptInGate::with_config_path(config_path);
260
261        assert!(!gate.is_enabled());
262        let new_state = gate.toggle().unwrap();
263        assert!(new_state, "First toggle should enable");
264        assert!(gate.is_enabled());
265
266        let new_state = gate.toggle().unwrap();
267        assert!(!new_state, "Second toggle should disable");
268        assert!(!gate.is_enabled());
269    }
270
271    #[test]
272    fn test_toggle_persists_to_disk() {
273        let dir = TempDir::new().unwrap();
274        let config_path = dir.path().join("telemetry.json");
275        let mut gate = OptInGate::with_config_path(config_path.clone());
276
277        gate.enable().unwrap();
278
279        // Create a new gate reading from the same path
280        let gate2 = OptInGate::with_config_path(config_path);
281        assert!(
282            gate2.is_enabled(),
283            "Persisted enable should be read by new gate instance"
284        );
285    }
286
287    #[test]
288    fn test_disable_persists_to_disk() {
289        let dir = TempDir::new().unwrap();
290        let config_path = dir.path().join("telemetry.json");
291        let mut gate = OptInGate::with_config_path(config_path.clone());
292
293        gate.enable().unwrap();
294        gate.disable().unwrap();
295
296        let gate2 = OptInGate::with_config_path(config_path);
297        assert!(
298            !gate2.is_enabled(),
299            "Persisted disable should be read by new gate instance"
300        );
301    }
302
303    #[test]
304    fn test_reload_detects_external_changes() {
305        let dir = TempDir::new().unwrap();
306        let config_path = dir.path().join("telemetry.json");
307
308        // Create initial disabled gate
309        let mut gate = OptInGate::with_config_path(config_path.clone());
310        assert!(!gate.is_enabled());
311
312        // Externally write an enabled config
313        let enabled_config = TelemetryConfig {
314            enabled: true,
315            last_toggled_at: None,
316        };
317        fs::write(
318            &config_path,
319            serde_json::to_string(&enabled_config).unwrap(),
320        )
321        .unwrap();
322
323        // Reload should pick up the change
324        gate.reload().unwrap();
325        assert!(
326            gate.is_enabled(),
327            "Reload should detect external config change"
328        );
329    }
330
331    // =======================================================================
332    // VAL-TELE-012: Governance — No Direct Promotion
333    // =======================================================================
334
335    #[test]
336    fn test_ui_strings_no_promotional_language() {
337        let promotional_terms = [
338            r"\bupgrade\b",
339            r"\bpremium\b",
340            r"\bpro\b",
341            r"\bpaid\b",
342            r"\bsubscribe\b",
343            r"\btrial\b",
344            r"\bdiscount\b",
345            r"\boffer\b",
346            r"\bdeal\b",
347            r"\bfree trial\b",
348            r"\blimited time\b",
349            r"\bexclusive\b",
350            r"\bunlock\b",
351            r"\bbuy\b",
352            r"\bpurchase\b",
353        ];
354
355        let all_strings = [
356            TELEMETRY_DESCRIPTION,
357            TELEMETRY_ENABLE_LABEL,
358            TELEMETRY_DISABLE_LABEL,
359            TELEMETRY_ENABLED_CONFIRMATION,
360            TELEMETRY_DISABLED_CONFIRMATION,
361            TELEMETRY_STATUS_ENABLED,
362            TELEMETRY_STATUS_DISABLED,
363            TELEMETRY_DATA_DESCRIPTION,
364            TELEMETRY_PRIVACY_NOTE,
365        ];
366
367        for string in &all_strings {
368            let lower = string.to_lowercase();
369            for pattern in &promotional_terms {
370                let re = regex::Regex::new(pattern).unwrap();
371                assert!(
372                    !re.is_match(&lower),
373                    "UI string '{}' contains promotional term matching '{}'",
374                    string,
375                    pattern
376                );
377            }
378        }
379    }
380
381    // =======================================================================
382    // Config serde
383    // =======================================================================
384
385    #[test]
386    fn test_telemetry_config_serde_roundtrip() {
387        let config = TelemetryConfig {
388            enabled: true,
389            last_toggled_at: Some("2026-05-01T00:00:00+00:00".to_string()),
390        };
391        let json = serde_json::to_string(&config).unwrap();
392        let back: TelemetryConfig = serde_json::from_str(&json).unwrap();
393        assert_eq!(config, back);
394    }
395
396    #[test]
397    fn test_telemetry_config_default_serde_roundtrip() {
398        let config = TelemetryConfig::default();
399        let json = serde_json::to_string(&config).unwrap();
400        let back: TelemetryConfig = serde_json::from_str(&json).unwrap();
401        assert_eq!(config, back);
402    }
403
404    #[test]
405    fn test_last_toggled_at_set_on_enable() {
406        let dir = TempDir::new().unwrap();
407        let config_path = dir.path().join("telemetry.json");
408        let mut gate = OptInGate::with_config_path(config_path);
409        gate.enable().unwrap();
410        assert!(
411            gate.config().last_toggled_at.is_some(),
412            "last_toggled_at should be set after enable"
413        );
414    }
415
416    #[test]
417    fn test_last_toggled_at_set_on_disable() {
418        let dir = TempDir::new().unwrap();
419        let config_path = dir.path().join("telemetry.json");
420        let mut gate = OptInGate::with_config_path(config_path);
421        gate.enable().unwrap();
422        gate.disable().unwrap();
423        assert!(
424            gate.config().last_toggled_at.is_some(),
425            "last_toggled_at should be set after disable"
426        );
427    }
428}