1#![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
53pub 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#[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 Status,
122 Enable,
124 Disable,
126 Reset,
128}
129
130#[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 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 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 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
207fn run_enable(app: &App) -> miette::Result<()> {
212 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
235fn 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
246fn 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
257fn consent_path(app: &App) -> miette::Result<PathBuf> {
262 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}