rusty_stack/telemetry/
opt_in.rs1use anyhow::{Context, Result};
17use serde::{Deserialize, Serialize};
18use std::fs;
19use std::path::PathBuf;
20
21const TELEMETRY_CONFIG_FILENAME: &str = "telemetry.json";
23
24const MLSTACK_CONFIG_DIR: &str = ".mlstack/config";
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
29pub struct TelemetryConfig {
30 pub enabled: bool,
32 #[serde(default)]
34 pub last_toggled_at: Option<String>,
35}
36
37#[derive(Debug, Clone)]
41pub struct OptInGate {
42 config: TelemetryConfig,
43 config_path: PathBuf,
44}
45
46impl OptInGate {
47 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 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 pub fn is_enabled(&self) -> bool {
73 self.config.enabled
74 }
75
76 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 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 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 pub fn config(&self) -> &TelemetryConfig {
106 &self.config
107 }
108
109 pub fn config_path(&self) -> &PathBuf {
111 &self.config_path
112 }
113
114 pub fn reload(&mut self) -> Result<()> {
116 self.config = Self::load_config(&self.config_path).unwrap_or_default();
117 Ok(())
118 }
119
120 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
148fn chrono_now_rfc3339() -> String {
150 chrono::Local::now().to_rfc3339()
151}
152
153pub const TELEMETRY_DESCRIPTION: &str =
160 "Share anonymous hardware metrics to help improve GPU support.";
161
162pub const TELEMETRY_ENABLE_LABEL: &str = "Enable anonymous telemetry";
164
165pub const TELEMETRY_DISABLE_LABEL: &str = "Disable telemetry";
167
168pub const TELEMETRY_ENABLED_CONFIRMATION: &str = "Telemetry enabled. Thank you for contributing.";
170
171pub const TELEMETRY_DISABLED_CONFIRMATION: &str = "Telemetry disabled.";
173
174pub const TELEMETRY_STATUS_ENABLED: &str = "Telemetry: enabled";
176
177pub const TELEMETRY_STATUS_DISABLED: &str = "Telemetry: disabled";
179
180pub const TELEMETRY_DATA_DESCRIPTION: &str =
182 "Collected data: GPU utilization, VRAM usage, temperature, clock speed, throttling status.";
183
184pub const TELEMETRY_PRIVACY_NOTE: &str =
186 "No personal data is collected. Payloads contain only hardware metrics.";
187
188#[cfg(test)]
193mod tests {
194 use super::*;
195 use tempfile::TempDir;
196
197 #[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 let gate = OptInGate::with_config_path(config_path);
224 assert!(!gate.is_enabled());
225 }
226
227 #[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 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 let mut gate = OptInGate::with_config_path(config_path.clone());
310 assert!(!gate.is_enabled());
311
312 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 gate.reload().unwrap();
325 assert!(
326 gate.is_enabled(),
327 "Reload should detect external config change"
328 );
329 }
330
331 #[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 #[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}