Skip to main content

rtb_cli/
telemetry.rs

1//! `telemetry` CLI subtree — `status / enable / disable / reset`.
2//!
3//! Backed by [`rtb_telemetry::consent`]'s persisted-consent file
4//! primitives. The file lives at
5//! `<ProjectDirs::config_dir()>/<tool>/consent.toml`.
6//!
7//! # Runtime policy resolution
8//!
9//! Per the v0.4 scope addendum §3.2, the runtime [`CollectionPolicy`]
10//! resolves from this chain (each step short-circuits):
11//!
12//! 1. **Hardcoded compile-time disable** — when the `telemetry`
13//!    Cargo feature on `rtb` is off, the policy is unconditionally
14//!    `Disabled`. The subtree is not registered; `telemetry status`
15//!    is unreachable from the CLI.
16//! 2. **Consent file** — `<config_dir>/<tool>/consent.toml`. State
17//!    `enabled` → `Enabled`; `disabled` → `Disabled`; `unset` or
18//!    file missing → step 3.
19//! 3. **`MYTOOL_TELEMETRY` env var** — `1` / `true` / `on` →
20//!    `Enabled`; `0` / `false` / `off` → `Disabled`; absent → step 4.
21//! 4. **Default** — `Disabled`. Opt-in is the standing rule.
22//!
23//! `telemetry status` reports which step decided the current state.
24//!
25//! [`CollectionPolicy`]: rtb_telemetry::CollectionPolicy
26//!
27//! # Lint exception
28//!
29//! `linkme::distributed_slice` emits `#[link_section]` which Rust
30//! 1.95+ flags under `unsafe_code`. Allowed at module level — no
31//! hand-rolled `unsafe` blocks anywhere in the module.
32
33#![allow(unsafe_code)]
34
35use std::ffi::OsString;
36use std::path::PathBuf;
37
38use async_trait::async_trait;
39use clap::{Parser, Subcommand};
40use directories::ProjectDirs;
41use linkme::distributed_slice;
42use miette::miette;
43use rtb_app::app::App;
44use rtb_app::command::{Command, CommandSpec, BUILTIN_COMMANDS};
45use rtb_app::features::Feature;
46use rtb_telemetry::consent::{self, Consent, ConsentState};
47use rtb_telemetry::CollectionPolicy;
48use serde::Serialize;
49use tabled::Tabled;
50
51use crate::render::{output, strip_global_output, OutputMode};
52
53/// The `telemetry` subcommand.
54pub struct TelemetryCmd;
55
56#[async_trait]
57impl Command for TelemetryCmd {
58    fn spec(&self) -> &CommandSpec {
59        static SPEC: CommandSpec = CommandSpec {
60            name: "telemetry",
61            about: "Manage opt-in telemetry consent (status / enable / disable / reset)",
62            feature: Some(Feature::Telemetry),
63            ..CommandSpec::DEFAULT
64        };
65        &SPEC
66    }
67
68    fn subcommand_passthrough(&self) -> bool {
69        true
70    }
71
72    async fn run(&self, app: App) -> miette::Result<()> {
73        let mut args: Vec<OsString> = std::env::args_os().collect();
74        if args.len() >= 2 {
75            args.drain(..2);
76        }
77        args.insert(0, OsString::from("telemetry"));
78        args = strip_global_output(args);
79
80        let cli = match TelemetryCli::try_parse_from(args) {
81            Ok(c) => c,
82            Err(e) => {
83                use clap::error::ErrorKind;
84                if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) {
85                    print!("{e}");
86                    return Ok(());
87                }
88                return Err(miette!("{e}"));
89            }
90        };
91
92        let mode = OutputMode::from_args_os();
93        match cli.command {
94            TelemetrySub::Status => run_status(&app, mode),
95            TelemetrySub::Enable => run_enable(&app),
96            TelemetrySub::Disable => run_disable(&app),
97            TelemetrySub::Reset => run_reset(&app),
98        }
99    }
100}
101
102#[distributed_slice(BUILTIN_COMMANDS)]
103fn __register_telemetry() -> Box<dyn Command> {
104    Box::new(TelemetryCmd)
105}
106
107// ---------------------------------------------------------------------
108// clap surface
109// ---------------------------------------------------------------------
110
111#[derive(Debug, Parser)]
112#[command(name = "telemetry", about = "Manage opt-in telemetry consent")]
113struct TelemetryCli {
114    #[command(subcommand)]
115    command: TelemetrySub,
116}
117
118#[derive(Debug, Subcommand)]
119enum TelemetrySub {
120    /// Print current state, decision timestamp, and consent-file path.
121    Status,
122    /// Opt in. Refused under `CI=true` (per C3 resolution).
123    Enable,
124    /// Opt out.
125    Disable,
126    /// Remove the consent file. Idempotent.
127    Reset,
128}
129
130// ---------------------------------------------------------------------
131// `status`
132// ---------------------------------------------------------------------
133
134#[derive(Tabled, Serialize)]
135struct StatusRow {
136    state: &'static str,
137    source: &'static str,
138    decided_at: String,
139    policy: &'static str,
140    consent_file: String,
141}
142
143fn run_status(app: &App, mode: OutputMode) -> miette::Result<()> {
144    let path = consent_path(app)?;
145
146    // Step 2: consent file.
147    if let Some(consent) = consent::read(&path).map_err(|e| miette!("read consent: {e}"))? {
148        let row = StatusRow {
149            state: state_label(consent.state),
150            source: "consent-file",
151            decided_at: consent.decided_at.unwrap_or_else(|| "-".to_string()),
152            policy: policy_label(CollectionPolicy::from(consent.state)),
153            consent_file: path.display().to_string(),
154        };
155        return output(mode, &[row]).map_err(|e| miette!("render: {e}"));
156    }
157
158    // Step 3: MYTOOL_TELEMETRY env override.
159    let env_var = format!("{}_TELEMETRY", app.metadata.name.to_uppercase());
160    if let Ok(raw) = std::env::var(&env_var) {
161        if let Some(state) = parse_env_override(&raw) {
162            let row = StatusRow {
163                state: state_label(state),
164                source: "env-override",
165                decided_at: format!("via {env_var}"),
166                policy: policy_label(CollectionPolicy::from(state)),
167                consent_file: path.display().to_string(),
168            };
169            return output(mode, &[row]).map_err(|e| miette!("render: {e}"));
170        }
171    }
172
173    // Step 4: default Disabled.
174    let row = StatusRow {
175        state: state_label(ConsentState::Unset),
176        source: "default",
177        decided_at: "-".to_string(),
178        policy: policy_label(CollectionPolicy::Disabled),
179        consent_file: path.display().to_string(),
180    };
181    output(mode, &[row]).map_err(|e| miette!("render: {e}"))
182}
183
184fn parse_env_override(raw: &str) -> Option<ConsentState> {
185    match raw.to_ascii_lowercase().as_str() {
186        "1" | "true" | "on" => Some(ConsentState::Enabled),
187        "0" | "false" | "off" => Some(ConsentState::Disabled),
188        _ => None,
189    }
190}
191
192const fn state_label(state: ConsentState) -> &'static str {
193    match state {
194        ConsentState::Enabled => "enabled",
195        ConsentState::Disabled => "disabled",
196        ConsentState::Unset => "unset",
197    }
198}
199
200const fn policy_label(policy: CollectionPolicy) -> &'static str {
201    match policy {
202        CollectionPolicy::Enabled => "Enabled",
203        CollectionPolicy::Disabled => "Disabled",
204    }
205}
206
207// ---------------------------------------------------------------------
208// `enable`
209// ---------------------------------------------------------------------
210
211fn run_enable(app: &App) -> miette::Result<()> {
212    // C3 resolution — refuse under CI. Operators enabling telemetry
213    // interactively want a real prompt; a build pipeline silently
214    // flipping it on is the wrong default.
215    if is_ci() {
216        return Err(miette!(
217            help = "interactive opt-in only — a CI=true environment may not flip it on",
218            "telemetry enable refused under CI=true"
219        ));
220    }
221
222    let path = consent_path(app)?;
223    consent::write(&path, &Consent::enabled_now()).map_err(|e| miette!("write consent: {e}"))?;
224
225    if let Some(notice) = app.metadata.telemetry_notice {
226        println!("{notice}");
227    } else {
228        println!("telemetry enabled. {} now records anonymised usage events.", app.metadata.name);
229        println!("(no privacy notice configured; set ToolMetadata::telemetry_notice for a tool-specific message.)");
230    }
231    println!("consent file: {}", path.display());
232    Ok(())
233}
234
235// ---------------------------------------------------------------------
236// `disable`
237// ---------------------------------------------------------------------
238
239fn run_disable(app: &App) -> miette::Result<()> {
240    let path = consent_path(app)?;
241    consent::write(&path, &Consent::disabled_now()).map_err(|e| miette!("write consent: {e}"))?;
242    println!("telemetry disabled. consent file: {}", path.display());
243    Ok(())
244}
245
246// ---------------------------------------------------------------------
247// `reset`
248// ---------------------------------------------------------------------
249
250fn run_reset(app: &App) -> miette::Result<()> {
251    let path = consent_path(app)?;
252    consent::reset(&path).map_err(|e| miette!("reset consent: {e}"))?;
253    println!("telemetry consent reset (state → unset). path: {}", path.display());
254    Ok(())
255}
256
257// ---------------------------------------------------------------------
258// shared
259// ---------------------------------------------------------------------
260
261fn consent_path(app: &App) -> miette::Result<PathBuf> {
262    // Match the existing rtb-cli pattern: ProjectDirs("dev", "", &name).
263    // Tools that override the qualifier reach down through their own
264    // builder; v0.4 keeps the default consistent.
265    let dirs = ProjectDirs::from("dev", "", &app.metadata.name).ok_or_else(|| {
266        miette!(
267            help = "rtb-cli could not derive a config directory; HOME may be unset",
268            "no config directory available for tool `{}`",
269            app.metadata.name
270        )
271    })?;
272    Ok(dirs.config_dir().join("consent.toml"))
273}
274
275fn is_ci() -> bool {
276    std::env::var("CI").as_deref() == Ok("true")
277}