Skip to main content

dsc/api/
reports.rs

1//! Wrapper around `/admin/reports/{report_id}.json`.
2//!
3//! Each Discourse report, when called with `start_date` + `end_date`, returns
4//! both the requested window (`data`) AND the immediately preceding window
5//! of equal length (`prev_data`) in a single response — so `--compare` does
6//! not require a second API call for metrics that come straight from a
7//! report.
8
9use super::client::DiscourseClient;
10use super::error::http_error;
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15/// One day-bucket from a flat (non-stacked) report's `data` array.
16#[derive(Debug, Deserialize, Serialize, Clone)]
17pub struct ReportPoint {
18    #[serde(default)]
19    pub x: String,
20    #[serde(default)]
21    pub y: f64,
22}
23
24/// Discourse's `average` emits as a number, `false`, or null depending on
25/// whether the report has a meaningful average. Coerce false/null/missing
26/// to `None`.
27fn 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/// Raw report payload, distilled from `/admin/reports/{id}.json`. Only the
40/// fields `dsc analytics` actually reads are deserialised — Discourse
41/// emits a lot more (axis labels, chart modes, descriptions) that we don't
42/// care about.
43///
44/// `data` and `prev_data` are kept as raw `serde_json::Value` because
45/// Discourse uses two different shapes:
46///
47/// 1. **Flat counter reports** (`signups`, `topics`, `posts`, `likes`,
48///    `flags`, `new_contributors`): `data: [{x: date, y: number}, ...]`.
49/// 2. **Stacked-chart reports** (`trust_level_growth`):
50///    `data: [{req: "tl1_reached", label: "...", data: [{x, y}, ...]}, ...]`.
51///
52/// `current_total()` walks both shapes and returns the right sum.
53#[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    /// Some reports (e.g. `time_to_first_response`) emit an `average`
70    /// scalar that is more meaningful than summing the daily points.
71    /// Discourse occasionally emits this as `false` when no average is
72    /// computable; we coerce that to None.
73    #[serde(default, deserialize_with = "deserialize_lenient_optional_f64")]
74    pub average: Option<f64>,
75    /// Whether higher-is-better — Discourse marks this on each report, and
76    /// we use it to set the default `desirable` direction when our spec
77    /// doesn't already pin one.
78    #[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    /// Total for the current window.
89    ///
90    /// Walks the `data` value and sums every `y` it can find. Handles both
91    /// the flat counter shape (`[{x, y}, ...]`) and the stacked-chart shape
92    /// (`[{data: [{x, y}, ...]}, ...]`). Coerces non-numeric `y` (Discourse
93    /// occasionally emits `false`/null/string for empty cells) to 0.
94    pub fn current_total(&self) -> f64 {
95        sum_data(&self.data)
96    }
97
98    /// Total for the previous-window, when present.
99    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                // Stacked-chart wrapper: {req, label, color, data: [{x, y}, ...]}
110                // → recurse into the inner data array.
111                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    /// Fetch a single admin report. `start` and `end` are ISO-8601 date
135    /// strings (YYYY-MM-DD) — the format Discourse's report controller
136    /// expects.
137    pub fn fetch_admin_report(
138        &self,
139        report_id: &str,
140        start: &str,
141        end: &str,
142    ) -> Result<AdminReport> {
143        // The report id is taken straight from the spec list; sanity-check
144        // it matches the same regex Discourse enforces server-side.
145        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        // trust_level_growth shape: data is an array of series, each with
228        // its own inner `data: [{x, y}, ...]`. Total is sum across all.
229        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        // Discourse occasionally emits `data: false` or `data: null` for
246        // genuinely-empty results (no permission, no data, etc.).
247        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}