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 = serde_json::from_str(&text)
166 .with_context(|| format!("parsing admin report {} response", report_id))?;
167 Ok(env.report)
168 }
169}
170
171fn report_id_is_valid(id: &str) -> bool {
172 !id.is_empty()
173 && id
174 .bytes()
175 .all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_'))
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn report_id_validation() {
184 assert!(report_id_is_valid("signups"));
185 assert!(report_id_is_valid("time_to_first_response"));
186 assert!(!report_id_is_valid(""));
187 assert!(!report_id_is_valid("Signups"));
188 assert!(!report_id_is_valid("../../../../etc/passwd"));
189 assert!(!report_id_is_valid("topics; rm -rf"));
190 }
191
192 fn parse_report(json: &str) -> AdminReport {
193 let envelope: serde_json::Value = serde_json::from_str(json).unwrap();
194 let report = envelope.get("report").cloned().unwrap_or(envelope);
195 serde_json::from_value(report).unwrap()
196 }
197
198 #[test]
199 fn current_total_sums_flat_data() {
200 let r = parse_report(
201 r#"{
202 "type": "signups",
203 "data": [{"x":"2026-04-01","y":3},{"x":"2026-04-02","y":5},{"x":"2026-04-03","y":0}]
204 }"#,
205 );
206 assert_eq!(r.current_total(), 8.0);
207 assert_eq!(r.previous_total(), None);
208 }
209
210 #[test]
211 fn previous_total_when_prev_data_present() {
212 let r = parse_report(
213 r#"{
214 "type": "posts",
215 "data": [{"x":"2026-04-01","y":10}],
216 "prev_data": [{"x":"2026-03-01","y":4},{"x":"2026-03-02","y":6}]
217 }"#,
218 );
219 assert_eq!(r.current_total(), 10.0);
220 assert_eq!(r.previous_total(), Some(10.0));
221 }
222
223 #[test]
224 fn current_total_handles_stacked_chart() {
225 let r = parse_report(
228 r#"{
229 "type": "trust_level_growth",
230 "data": [
231 {"req": "tl1_reached", "label": "TL1", "data": [{"x":"2026-04-01","y":2},{"x":"2026-04-02","y":3}]},
232 {"req": "tl2_reached", "label": "TL2", "data": [{"x":"2026-04-01","y":1}]},
233 {"req": "tl3_reached", "label": "TL3", "data": []},
234 {"req": "tl4_reached", "label": "TL4", "data": [{"x":"2026-04-02","y":1}]}
235 ]
236 }"#,
237 );
238 assert_eq!(r.current_total(), 7.0);
239 }
240
241 #[test]
242 fn current_total_zero_for_non_array_data() {
243 let r = parse_report(r#"{"type": "x", "data": false}"#);
246 assert_eq!(r.current_total(), 0.0);
247 let r = parse_report(r#"{"type": "x", "data": null}"#);
248 assert_eq!(r.current_total(), 0.0);
249 }
250
251 #[test]
252 fn current_total_coerces_non_numeric_y() {
253 let r = parse_report(
254 r#"{
255 "type": "x",
256 "data": [{"x":"2026-04-01","y":false},{"x":"2026-04-02","y":"5"},{"x":"2026-04-03","y":null}]
257 }"#,
258 );
259 assert_eq!(r.current_total(), 5.0);
260 }
261}