1use super::client::DiscourseClient;
10use super::error::http_error;
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15#[derive(Debug, Deserialize, Serialize, Clone)]
17pub struct ReportPoint {
18 #[serde(default)]
19 pub x: String,
20 #[serde(default)]
21 pub y: f64,
22}
23
24fn deserialize_lenient_optional_f64<'de, D>(de: D) -> Result<Option<f64>, D::Error>
28where
29 D: serde::Deserializer<'de>,
30{
31 let v = serde_json::Value::deserialize(de)?;
32 match v {
33 serde_json::Value::Number(n) => Ok(n.as_f64()),
34 serde_json::Value::String(s) => Ok(s.parse::<f64>().ok()),
35 _ => Ok(None),
36 }
37}
38
39#[derive(Debug, Deserialize, Serialize, Clone)]
54pub struct AdminReport {
55 #[serde(default, alias = "type")]
56 pub report_type: String,
57 #[serde(default)]
58 pub data: Value,
59 #[serde(default)]
60 pub prev_data: Option<Value>,
61 #[serde(default)]
62 pub start_date: Option<String>,
63 #[serde(default)]
64 pub end_date: Option<String>,
65 #[serde(default)]
66 pub prev_start_date: Option<String>,
67 #[serde(default)]
68 pub prev_end_date: Option<String>,
69 #[serde(default, deserialize_with = "deserialize_lenient_optional_f64")]
74 pub average: Option<f64>,
75 #[serde(default)]
79 pub higher_is_better: Option<bool>,
80}
81
82#[derive(Debug, Deserialize)]
83struct ReportEnvelope {
84 report: AdminReport,
85}
86
87impl AdminReport {
88 pub fn current_total(&self) -> f64 {
95 sum_data(&self.data)
96 }
97
98 pub fn previous_total(&self) -> Option<f64> {
100 self.prev_data.as_ref().map(sum_data)
101 }
102}
103
104fn sum_data(v: &Value) -> f64 {
105 match v {
106 Value::Array(items) => items
107 .iter()
108 .map(|item| {
109 if let Some(inner) = item.get("data") {
112 sum_data(inner)
113 } else if let Some(y) = item.get("y") {
114 coerce_f64(y)
115 } else {
116 0.0
117 }
118 })
119 .sum(),
120 _ => 0.0,
121 }
122}
123
124fn coerce_f64(v: &Value) -> f64 {
125 match v {
126 Value::Number(n) => n.as_f64().unwrap_or(0.0),
127 Value::String(s) => s.parse().unwrap_or(0.0),
128 Value::Bool(_) | Value::Null => 0.0,
129 _ => 0.0,
130 }
131}
132
133impl DiscourseClient {
134 pub fn fetch_admin_report(
138 &self,
139 report_id: &str,
140 start: &str,
141 end: &str,
142 ) -> Result<AdminReport> {
143 if !report_id_is_valid(report_id) {
146 return Err(anyhow::anyhow!(
147 "invalid report id {:?} — must match /^[a-z0-9_]+$/",
148 report_id
149 ));
150 }
151 let path = format!(
152 "/admin/reports/{}.json?start_date={}&end_date={}",
153 report_id, start, end
154 );
155 let response = self.get(&path)?;
156 let status = response.status();
157 let text = response.text().context("reading admin report response")?;
158 if !status.is_success() {
159 return Err(http_error(
160 &format!("admin report {} request", report_id),
161 status,
162 &text,
163 ));
164 }
165 let env: ReportEnvelope =
166 serde_json::from_str(&text).with_context(|| {
167 format!("parsing admin report {} response", report_id)
168 })?;
169 Ok(env.report)
170 }
171}
172
173fn report_id_is_valid(id: &str) -> bool {
174 !id.is_empty()
175 && id
176 .bytes()
177 .all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_'))
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn report_id_validation() {
186 assert!(report_id_is_valid("signups"));
187 assert!(report_id_is_valid("time_to_first_response"));
188 assert!(!report_id_is_valid(""));
189 assert!(!report_id_is_valid("Signups"));
190 assert!(!report_id_is_valid("../../../../etc/passwd"));
191 assert!(!report_id_is_valid("topics; rm -rf"));
192 }
193
194 fn parse_report(json: &str) -> AdminReport {
195 let envelope: serde_json::Value = serde_json::from_str(json).unwrap();
196 let report = envelope.get("report").cloned().unwrap_or(envelope);
197 serde_json::from_value(report).unwrap()
198 }
199
200 #[test]
201 fn current_total_sums_flat_data() {
202 let r = parse_report(
203 r#"{
204 "type": "signups",
205 "data": [{"x":"2026-04-01","y":3},{"x":"2026-04-02","y":5},{"x":"2026-04-03","y":0}]
206 }"#,
207 );
208 assert_eq!(r.current_total(), 8.0);
209 assert_eq!(r.previous_total(), None);
210 }
211
212 #[test]
213 fn previous_total_when_prev_data_present() {
214 let r = parse_report(
215 r#"{
216 "type": "posts",
217 "data": [{"x":"2026-04-01","y":10}],
218 "prev_data": [{"x":"2026-03-01","y":4},{"x":"2026-03-02","y":6}]
219 }"#,
220 );
221 assert_eq!(r.current_total(), 10.0);
222 assert_eq!(r.previous_total(), Some(10.0));
223 }
224
225 #[test]
226 fn current_total_handles_stacked_chart() {
227 let r = parse_report(
230 r#"{
231 "type": "trust_level_growth",
232 "data": [
233 {"req": "tl1_reached", "label": "TL1", "data": [{"x":"2026-04-01","y":2},{"x":"2026-04-02","y":3}]},
234 {"req": "tl2_reached", "label": "TL2", "data": [{"x":"2026-04-01","y":1}]},
235 {"req": "tl3_reached", "label": "TL3", "data": []},
236 {"req": "tl4_reached", "label": "TL4", "data": [{"x":"2026-04-02","y":1}]}
237 ]
238 }"#,
239 );
240 assert_eq!(r.current_total(), 7.0);
241 }
242
243 #[test]
244 fn current_total_zero_for_non_array_data() {
245 let r = parse_report(r#"{"type": "x", "data": false}"#);
248 assert_eq!(r.current_total(), 0.0);
249 let r = parse_report(r#"{"type": "x", "data": null}"#);
250 assert_eq!(r.current_total(), 0.0);
251 }
252
253 #[test]
254 fn current_total_coerces_non_numeric_y() {
255 let r = parse_report(
256 r#"{
257 "type": "x",
258 "data": [{"x":"2026-04-01","y":false},{"x":"2026-04-02","y":"5"},{"x":"2026-04-03","y":null}]
259 }"#,
260 );
261 assert_eq!(r.current_total(), 5.0);
262 }
263}