Skip to main content

zoi_cli/cmd/
telemetry.rs

1//! Telemetry command implementation.
2
3use std::path::PathBuf;
4
5use anyhow::{Context, Result};
6use colored::Colorize;
7
8/// Crash-report subcommands (`zoi telemetry crash ...`).
9#[derive(Debug, Clone)]
10pub enum CrashCommand {
11    /// List locally stored crash reports.
12    List,
13    /// Print a crash report envelope to stdout.
14    Show {
15        /// Crash report file name or full path.
16        file: PathBuf
17    },
18    /// Upload a crash report to Sentry (requires telemetry opt-in).
19    Send {
20        /// Crash report file name or full path.
21        file: PathBuf,
22        /// Skip the confirmation prompt.
23        yes: bool
24    },
25    /// Delete all locally stored crash reports.
26    Clear {
27        /// Skip the confirmation prompt.
28        yes: bool
29    }
30}
31
32/// Telemetry subcommands.
33#[derive(Debug, Clone)]
34pub enum TelemetryCommand {
35    /// Show telemetry status.
36    Status,
37    /// Enable telemetry.
38    Enable,
39    /// Disable telemetry.
40    Disable,
41    /// Manage local crash reports.
42    Crash(CrashCommand),
43    /// Deliver queued analytics events (internal flusher entry point).
44    Flush
45}
46
47/// Resolves a user-supplied crash file (bare name or path) against the crash
48/// directory.
49///
50/// # Errors
51///
52/// Returns an error if the file does not exist.
53fn resolve_crash_file(file: &PathBuf) -> Result<PathBuf> {
54    if file.exists() {
55        return Ok(file.clone());
56    }
57    let dir = zoi_telemetry::crash::list_crashes();
58    for known in dir {
59        if known.ends_with(file)
60            || known.file_name().is_some_and(|n| n == file.as_os_str())
61        {
62            return Ok(known);
63        }
64    }
65    Err(anyhow::anyhow!(
66        "Crash report not found: {}. Run 'zoi telemetry crash list' to see \
67         available reports.",
68        file.display()
69    ))
70}
71
72/// Run the telemetry command.
73///
74/// # Errors
75///
76/// Returns an error if the configuration cannot be read or written, or if a
77/// crash-report operation fails.
78pub fn run(cmd: TelemetryCommand) -> Result<()> {
79    match cmd {
80        TelemetryCommand::Status => {
81            let cfg = crate::pkg::config::read_config()?;
82            let status = if cfg.telemetry_enabled {
83                "Enabled".green()
84            } else {
85                "Disabled".yellow()
86            };
87            println!(
88                "{} telemetry is currently {}.",
89                "::".bold().blue(),
90                status
91            );
92
93            if cfg.telemetry_enabled {
94                let id = crate::pkg::telemetry::get_anonymous_id();
95                println!("Anonymous Client ID: {}", id.cyan());
96                println!(
97                    "\nThank you for helping us improve Zoi! We collect \
98                     minimal, anonymous data about:"
99                );
100                println!(
101                    "- {} (OS, Arch, Distro, Shell)",
102                    "Environment".bold()
103                );
104                println!("- {} (Action, Scope, Reason)", "Operations".bold());
105                println!(
106                    "- {} (Name, Version, Repo, License)",
107                    "Package Metadata".bold()
108                );
109                let crashes = crate::pkg::telemetry::crash::list_crashes();
110                if crashes.is_empty() {
111                    println!(
112                        "- {}: none stored locally",
113                        "Crash Reports".bold()
114                    );
115                } else {
116                    println!(
117                        "- {}: {} stored locally (see 'zoi telemetry crash \
118                         list')",
119                        "Crash Reports".bold(),
120                        crashes.len()
121                    );
122                }
123            } else {
124                println!(
125                    "\nTelemetry is anonymous and helps us prioritize \
126                     features and platforms."
127                );
128                println!("Run 'zoi telemetry enable' to help the project.");
129                println!(
130                    "Note: crash reports are still saved locally to \
131                     $XDG_STATE_HOME/zoi/crash/ but never uploaded while \
132                     telemetry is disabled."
133                );
134            }
135        }
136        TelemetryCommand::Enable => {
137            let mut cfg = crate::pkg::config::read_user_config()?;
138
139            println!(
140                "{}",
141                "Notice: Enabling telemetry shares anonymous usage data to \
142                 help improve Zoi."
143                    .dimmed()
144            );
145            println!(
146                "{}",
147                "Success analytics contain no personal data or IP addresses. \
148                 Failure reports may include error text with local paths."
149                    .dimmed()
150            );
151            println!(
152                "{} {}",
153                "Full Privacy Policy:".dimmed(),
154                "https://zillowe.qzz.io/legal/privacy".cyan()
155            );
156
157            cfg.telemetry_enabled = true;
158            crate::pkg::config::write_user_config(&cfg)?;
159            println!("{} telemetry enabled", "Success:".green());
160        }
161        TelemetryCommand::Disable => {
162            let mut cfg = crate::pkg::config::read_user_config()?;
163            cfg.telemetry_enabled = false;
164            crate::pkg::config::write_user_config(&cfg)?;
165            println!("{} telemetry disabled", "Success:".green());
166        }
167        TelemetryCommand::Crash(crash_cmd) => run_crash(crash_cmd)?,
168        TelemetryCommand::Flush => {
169            let summary = crate::pkg::telemetry::queue::flush_queue();
170            println!(
171                "Telemetry flush: {} sent, {} failed, {} skipped.",
172                summary.sent, summary.failed, summary.skipped
173            );
174        }
175    }
176    Ok(())
177}
178
179/// Runs a `zoi telemetry crash ...` subcommand.
180///
181/// # Errors
182///
183/// Returns an error if a crash file cannot be read, uploaded, or deleted.
184fn run_crash(cmd: CrashCommand) -> Result<()> {
185    match cmd {
186        CrashCommand::List => {
187            let crashes = crate::pkg::telemetry::crash::list_crashes();
188            if crashes.is_empty() {
189                println!("No crash reports stored locally.");
190                println!(
191                    "Crash reports are saved to $XDG_STATE_HOME/zoi/crash/ \
192                     (default ~/.local/state/zoi/crash/) with a .zoicrash \
193                     extension."
194                );
195            } else {
196                println!("Stored crash reports:");
197                for path in crashes {
198                    println!("  {}", path.display());
199                }
200            }
201        }
202        CrashCommand::Show { file } => {
203            let path = resolve_crash_file(&file)?;
204            let contents =
205                std::fs::read_to_string(&path).with_context(|| {
206                    format!("Failed to read {}", path.display())
207                })?;
208            println!("{contents}");
209        }
210        CrashCommand::Send { file, yes } => {
211            let path = resolve_crash_file(&file)?;
212            if !yes
213                && !crate::pkg::utils::ask_for_confirmation(
214                    &format!(
215                        "Upload {} to Sentry? Crash reports may contain stack \
216                         memory snapshots.",
217                        path.display()
218                    ),
219                    false
220                )
221            {
222                println!("Upload cancelled.");
223                return Ok(());
224            }
225            let sent = crate::pkg::telemetry::crash::send_crash_report(&path)
226                .map_err(|e| anyhow::anyhow!("{e}"))?;
227            if sent {
228                println!("{} crash report uploaded", "Success:".green());
229                std::fs::remove_file(&path).with_context(|| {
230                    format!("Uploaded but failed to remove {}", path.display())
231                })?;
232            } else {
233                println!(
234                    "{} telemetry is disabled; crash report was not uploaded. \
235                     Enable with 'zoi telemetry enable' first.",
236                    "Skipped:".yellow()
237                );
238            }
239        }
240        CrashCommand::Clear { yes } => {
241            let crashes = crate::pkg::telemetry::crash::list_crashes();
242            if crashes.is_empty() {
243                println!("No crash reports to delete.");
244                return Ok(());
245            }
246            if !yes
247                && !crate::pkg::utils::ask_for_confirmation(
248                    &format!(
249                        "Delete {} stored crash report(s)?",
250                        crashes.len()
251                    ),
252                    false
253                )
254            {
255                println!("Cancelled.");
256                return Ok(());
257            }
258            for path in crashes {
259                std::fs::remove_file(&path).with_context(|| {
260                    format!("Failed to remove {}", path.display())
261                })?;
262            }
263            println!("{} crash reports cleared", "Success:".green());
264        }
265    }
266    Ok(())
267}