1use std::collections::HashSet;
21
22use serde_json::{Map, Value};
23
24use crate::consent::{load_consent, now_epoch, token_hex, utc_isoformat_micros, xdg_rac_file};
25use crate::pycompat::{py_splitlines, py_strip, quote_plus_urlencode};
26use crate::pyjson;
27use crate::telemetry::{summary_value, LogNotUtf8, TelemetrySummary};
28
29pub const SCHEMA_VERSION: &str = "1";
30const USAGE_FILENAME: &str = "decided-usage.jsonl";
31pub const OUTCOME_OK: &str = "ok";
32pub const OUTCOME_ERROR: &str = "error";
33
34const RECENT_DAYS: usize = 7;
36
37pub const SHARE_ISSUE_URL: &str = "https://github.com/asdecided/core/issues/new";
38pub const SHARE_TEMPLATE: &str = "guide-usage-report.yml";
39pub const SHARE_FIELD: &str = "report";
40
41pub struct CommandUsage {
42 pub command: String,
43 pub calls: i64,
44 pub errors: i64,
45}
46
47pub struct UsageSummary {
48 pub total: i64,
49 pub sessions: i64,
50 pub commands: Vec<CommandUsage>,
51 pub recent: Vec<(String, i64)>,
53}
54
55pub fn usage_path() -> String {
57 xdg_rac_file("XDG_STATE_HOME", &[".local", "state"], USAGE_FILENAME)
58}
59
60fn read_usage(path: &str) -> Result<Vec<Map<String, Value>>, LogNotUtf8> {
64 let Ok(bytes) = std::fs::read(path) else {
65 return Ok(Vec::new());
66 };
67 let Ok(text) = String::from_utf8(bytes) else {
68 return Err(LogNotUtf8);
69 };
70 let mut events = Vec::new();
71 for line in py_splitlines(&text) {
72 let line = py_strip(line);
73 if line.is_empty() {
74 continue;
75 }
76 if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(line) {
77 events.push(map);
78 }
79 }
80 Ok(events)
81}
82
83pub fn summarize_usage() -> Result<UsageSummary, LogNotUtf8> {
85 let events = read_usage(&usage_path())?;
86 let sessions: HashSet<&str> = events
87 .iter()
88 .filter_map(|ev| ev.get("session").and_then(Value::as_str))
89 .collect();
90 let mut by_command: std::collections::BTreeMap<&str, Vec<&Map<String, Value>>> =
91 std::collections::BTreeMap::new();
92 for ev in &events {
93 if let Some(command) = ev.get("command").and_then(Value::as_str) {
94 by_command.entry(command).or_default().push(ev);
95 }
96 }
97 let commands = by_command
98 .iter()
99 .map(|(command, rows)| CommandUsage {
100 command: (*command).to_string(),
101 calls: rows.len() as i64,
102 errors: rows
103 .iter()
104 .filter(|ev| {
105 matches!(
106 ev.get("outcome").and_then(Value::as_str),
107 Some("error") | Some("exception")
108 )
109 })
110 .count() as i64,
111 })
112 .collect();
113 let mut day_counts: std::collections::BTreeMap<String, i64> = std::collections::BTreeMap::new();
116 for ev in &events {
117 if let Some(ts) = ev.get("ts").and_then(Value::as_str) {
118 if ts.chars().count() >= 10 {
119 let day: String = ts.chars().take(10).collect();
120 *day_counts.entry(day).or_insert(0) += 1;
121 }
122 }
123 }
124 let skip = day_counts.len().saturating_sub(RECENT_DAYS);
125 let recent = day_counts.into_iter().skip(skip).collect();
126 Ok(UsageSummary {
127 total: events.len() as i64,
128 sessions: sessions.len() as i64,
129 commands,
130 recent,
131 })
132}
133
134pub fn cli_value(summary: &UsageSummary) -> Value {
136 let mut m = Map::new();
137 m.insert("schema_version".into(), Value::String(SCHEMA_VERSION.into()));
138 m.insert("total".into(), Value::from(summary.total));
139 m.insert("sessions".into(), Value::from(summary.sessions));
140 m.insert(
141 "commands".into(),
142 Value::Array(
143 summary
144 .commands
145 .iter()
146 .map(|c| {
147 let mut cm = Map::new();
148 cm.insert("command".into(), Value::String(c.command.clone()));
149 cm.insert("calls".into(), Value::from(c.calls));
150 cm.insert("errors".into(), Value::from(c.errors));
151 Value::Object(cm)
152 })
153 .collect(),
154 ),
155 );
156 let mut recent = Map::new();
157 for (day, count) in &summary.recent {
158 recent.insert(day.clone(), Value::from(*count));
159 }
160 m.insert("recent".into(), Value::Object(recent));
161 Value::Object(m)
162}
163
164pub fn combined_value(cli: &UsageSummary, guide: &TelemetrySummary) -> Value {
167 let mut m = Map::new();
168 m.insert("schema_version".into(), Value::String(SCHEMA_VERSION.into()));
169 m.insert("cli".into(), cli_value(cli));
170 m.insert("guide".into(), summary_value(guide));
171 Value::Object(m)
172}
173
174pub fn share_url(cli: &UsageSummary, guide: &TelemetrySummary) -> String {
177 let report = pyjson::dumps_indent2_no_ascii(&combined_value(cli, guide));
178 let query = quote_plus_urlencode(&[("template", SHARE_TEMPLATE), (SHARE_FIELD, &report)]);
179 format!("{SHARE_ISSUE_URL}?{query}")
180}
181
182fn session_id() -> &'static str {
189 static SESSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
190 SESSION.get_or_init(|| token_hex(8))
191}
192
193pub fn record_command(command: &str, outcome: &str, duration_ms: i64) {
197 if command.is_empty() {
198 return;
199 }
200 if !load_consent().share_usage {
201 return;
202 }
203 let path = usage_path();
204 if let Some(parent) = std::path::Path::new(&path).parent() {
205 if std::fs::create_dir_all(parent).is_err() {
206 return;
207 }
208 }
209 let (secs, micros) = now_epoch();
210 let mut event = Map::new();
211 event.insert("schema_version".into(), Value::String(SCHEMA_VERSION.into()));
212 event.insert(
213 "ts".into(),
214 Value::String(utc_isoformat_micros(secs, micros)),
215 );
216 event.insert("session".into(), Value::String(session_id().to_string()));
217 event.insert("command".into(), Value::String(command.to_string()));
218 event.insert("outcome".into(), Value::String(outcome.to_string()));
219 event.insert("duration_ms".into(), Value::from(duration_ms));
220 let line = pyjson::dumps_compact(&Value::Object(event)) + "\n";
221 use std::io::Write as _;
222 if let Ok(mut handle) = std::fs::OpenOptions::new()
223 .append(true)
224 .create(true)
225 .open(&path)
226 {
227 let _ = handle.write_all(line.as_bytes());
228 }
229}