Skip to main content

dsc/commands/
analytics.rs

1//! `dsc analytics` — community-health snapshot per `spec/analytics.md`.
2//!
3//! Three modes share one data path:
4//!
5//! - **single window** (`--since 30d` alone): one column.
6//! - **compare** (`--since 30d --compare`): two columns (current, previous).
7//! - **snapshot** (`--snapshot`): N columns, default `24h,7d,30d,1y`.
8//!
9//! Internally every mode is "list of windows + a report cache". The cache
10//! is populated by spawning one thread per `(report_id, window)` pair so
11//! a snapshot of N=4 windows × 9 reports completes in roughly the time
12//! of the slowest single call rather than 36× sequential.
13
14use crate::api::{AdminReport, DiscourseClient};
15use crate::cli::AnalyticsFormat;
16use crate::commands::common::{ensure_api_credentials, select_discourse};
17use crate::config::Config;
18use crate::utils::parse_since_cutoff;
19use anyhow::Result;
20use chrono::{DateTime, Datelike, Duration, Utc};
21use serde::Serialize;
22use serde_json::{Map, Value, json};
23use std::collections::HashMap;
24use std::io::{self, IsTerminal};
25use std::sync::{Arc, Mutex};
26use std::thread;
27
28const SCHEMA_VERSION: u32 = 1;
29
30/// All the report IDs the analytics command might fetch. Listed once so
31/// the cache populator can fan out without us forgetting one.
32const REPORT_IDS: &[&str] = &[
33    "topics",
34    "posts",
35    "likes",
36    "flags",
37    "new_contributors",
38    "trust_level_growth",
39    "time_to_first_response",
40    "topics_with_no_response",
41    "moderators_activity",
42];
43
44// ---------------------------------------------------------------------------
45// Public entry points
46// ---------------------------------------------------------------------------
47
48#[allow(clippy::too_many_arguments)]
49pub fn analytics(
50    config: &Config,
51    discourse_name: &str,
52    since: &str,
53    compare: bool,
54    snapshot: bool,
55    periods: Option<&str>,
56    section_filter: SectionFilter,
57    mut format: AnalyticsFormat,
58) -> Result<()> {
59    let discourse = select_discourse(config, Some(discourse_name))?;
60    ensure_api_credentials(discourse)?;
61    let client = DiscourseClient::new(discourse)?;
62    let now = Utc::now();
63
64    // Resolve windows per mode. Order matters: position 0 is the
65    // "primary" column (the one shown alone in single-window mode); the
66    // rest are comparison/snapshot columns in left-to-right reading order.
67    let windows = if snapshot {
68        let raw = periods.unwrap_or("24h,7d,30d,1y");
69        parse_periods(raw, now)?
70    } else if compare {
71        let cur = window_from_since(since, now)?;
72        let prev = previous_window_of(&cur);
73        vec![cur, prev]
74    } else {
75        vec![window_from_since(since, now)?]
76    };
77
78    let column_headers: Vec<String> = if snapshot {
79        windows.iter().map(|w| w.label.clone()).collect()
80    } else if compare {
81        vec!["current".to_string(), "previous".to_string()]
82    } else {
83        vec!["value".to_string()]
84    };
85
86    // Auto-fall-through `table` → `text` on non-TTY stdout so cron-piped
87    // output stays parseable.
88    if matches!(format, AnalyticsFormat::Table) && !io::stdout().is_terminal() {
89        format = AnalyticsFormat::Text;
90    }
91
92    let cache = populate_cache(&client, &windows)?;
93    let report = build_report(
94        discourse_name,
95        &windows,
96        &column_headers,
97        section_filter,
98        snapshot,
99        &cache,
100    );
101    render(&report, format)
102}
103
104// ---------------------------------------------------------------------------
105// Window helpers
106// ---------------------------------------------------------------------------
107
108fn window_from_since(since: &str, now: DateTime<Utc>) -> Result<Window> {
109    let cutoff = parse_since_cutoff(since)?;
110    let (start, end) = if cutoff <= now {
111        (cutoff, now)
112    } else {
113        (now, cutoff)
114    };
115    Ok(Window {
116        since: start,
117        until: end,
118        label: since.to_string(),
119        clamped: false,
120    })
121}
122
123fn previous_window_of(w: &Window) -> Window {
124    let len = w.duration();
125    Window {
126        since: w.since - len,
127        until: w.since,
128        label: w.label.clone(),
129        clamped: false,
130    }
131}
132
133fn parse_periods(raw: &str, now: DateTime<Utc>) -> Result<Vec<Window>> {
134    let mut out = Vec::new();
135    for piece in raw.split(',') {
136        let p = piece.trim();
137        if p.is_empty() {
138            continue;
139        }
140        out.push(window_from_since(p, now)?);
141    }
142    if out.is_empty() {
143        anyhow::bail!("--periods must contain at least one duration");
144    }
145    Ok(out)
146}
147
148// ---------------------------------------------------------------------------
149// Data model
150// ---------------------------------------------------------------------------
151
152#[derive(Clone, Copy, Debug, PartialEq, Eq)]
153pub enum SectionFilter {
154    All,
155    Growth,
156    Activity,
157    Health,
158}
159
160#[derive(Clone, Debug, Serialize)]
161struct Window {
162    since: DateTime<Utc>,
163    until: DateTime<Utc>,
164    label: String,
165    clamped: bool,
166}
167
168impl Window {
169    fn iso_date_since(&self) -> String {
170        format_yyyy_mm_dd(&self.since)
171    }
172    fn iso_date_until(&self) -> String {
173        format_yyyy_mm_dd(&self.until)
174    }
175    fn duration(&self) -> Duration {
176        self.until - self.since
177    }
178}
179
180#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
181#[serde(rename_all = "lowercase")]
182enum Direction {
183    Up,
184    Down,
185    Neither,
186}
187
188#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
189#[serde(rename_all = "snake_case")]
190enum Unit {
191    Count,
192    Percent,
193    Minutes,
194    Hours,
195    Ratio,
196    PerThousandPosts,
197}
198
199#[derive(Clone, Debug, Serialize)]
200struct Metric {
201    label: String,
202    key: String,
203    /// One slot per column. `None` means the metric is genuinely
204    /// undefined for that window (e.g. zero-topic divisor); see
205    /// `not_implemented` for "we haven't built this yet" markers.
206    values: Vec<Option<f64>>,
207    desirable: Direction,
208    unit: Unit,
209    not_implemented: bool,
210}
211
212impl Metric {
213    fn new(label: &str, key: &str, desirable: Direction, unit: Unit, n: usize) -> Self {
214        Self {
215            label: label.to_string(),
216            key: key.to_string(),
217            values: vec![None; n],
218            desirable,
219            unit,
220            not_implemented: false,
221        }
222    }
223    fn with_values(mut self, v: Vec<Option<f64>>) -> Self {
224        self.values = v;
225        self
226    }
227    fn stub(mut self) -> Self {
228        self.not_implemented = true;
229        self
230    }
231    /// % delta from values[1] → values[0]. Used in compare mode.
232    fn delta_pct(&self) -> Option<f64> {
233        match (
234            self.values.first().copied().flatten(),
235            self.values.get(1).copied().flatten(),
236        ) {
237            (Some(c), Some(p)) if p != 0.0 => Some(((c - p) / p) * 100.0),
238            _ => None,
239        }
240    }
241}
242
243#[derive(Clone, Debug, Serialize)]
244struct AnalyticsReport {
245    schema: u32,
246    discourse: String,
247    snapshot: bool,
248    windows: Vec<Window>,
249    column_headers: Vec<String>,
250    growth: Option<Vec<Metric>>,
251    activity: Option<Vec<Metric>>,
252    health: Option<Vec<Metric>>,
253}
254
255// ---------------------------------------------------------------------------
256// Concurrent report cache
257// ---------------------------------------------------------------------------
258
259/// Maps `(report_id, window_index)` to an optional `AdminReport`. None
260/// means Discourse returned a tolerable error (404/403/500) and the
261/// metric should render as `—`.
262type ReportCache = HashMap<(String, usize), Option<AdminReport>>;
263
264/// Max in-flight HTTP requests at any moment. Above this, observation on
265/// dhi-discourse showed nginx 429s even for single-window mode. The
266/// cross-cutting 429 retry would catch them but slow the run dramatically;
267/// staying below the burst limit is faster and more polite. 4 is empirical.
268const ANALYTICS_PARALLELISM: usize = 4;
269
270fn populate_cache(client: &DiscourseClient, windows: &[Window]) -> Result<ReportCache> {
271    let cache: Arc<Mutex<ReportCache>> = Arc::new(Mutex::new(HashMap::new()));
272
273    // Build the full task list, then dispatch with a bounded worker pool.
274    // We could do "parallel-within-window, sequential-between-window" but
275    // that lets each window finish its slowest call before the next one
276    // can start — pointless idle time. A flat task queue keeps the workers
277    // saturated.
278    let tasks: Vec<(String, usize, String, String)> = windows
279        .iter()
280        .enumerate()
281        .flat_map(|(w_idx, window)| {
282            let start = window.iso_date_since();
283            let end = window.iso_date_until();
284            REPORT_IDS
285                .iter()
286                .map(move |id| (id.to_string(), w_idx, start.clone(), end.clone()))
287        })
288        .collect();
289    let queue = Arc::new(Mutex::new(tasks.into_iter()));
290
291    thread::scope(|scope| {
292        for _ in 0..ANALYTICS_PARALLELISM {
293            let client = client.clone();
294            let cache = cache.clone();
295            let queue = queue.clone();
296            scope.spawn(move || {
297                loop {
298                    let next = { queue.lock().ok().and_then(|mut q| q.next()) };
299                    let Some((id, w_idx, start, end)) = next else {
300                        break;
301                    };
302                    let value = fetch_optional(&client, &id, &start, &end);
303                    if let Ok(mut guard) = cache.lock() {
304                        guard.insert((id, w_idx), value);
305                    }
306                }
307            });
308        }
309    });
310
311    Ok(Arc::try_unwrap(cache)
312        .map_err(|_| anyhow::anyhow!("cache still has live references"))?
313        .into_inner()
314        .unwrap_or_default())
315}
316
317fn report_at<'a>(cache: &'a ReportCache, id: &str, w: usize) -> Option<&'a AdminReport> {
318    cache.get(&(id.to_string(), w)).and_then(|opt| opt.as_ref())
319}
320
321/// Per-window total for a single report. None when the report was
322/// missing OR Discourse returned no data.
323fn totals_for(cache: &ReportCache, id: &str, n_windows: usize) -> Vec<Option<f64>> {
324    (0..n_windows)
325        .map(|w| report_at(cache, id, w).map(|r: &AdminReport| r.current_total()))
326        .collect()
327}
328
329fn averages_for(cache: &ReportCache, id: &str, n_windows: usize) -> Vec<Option<f64>> {
330    (0..n_windows)
331        .map(|w| report_at(cache, id, w).and_then(|r: &AdminReport| r.average))
332        .collect()
333}
334
335fn ratio_per_window(num: &[Option<f64>], den: &[Option<f64>]) -> Vec<Option<f64>> {
336    num.iter()
337        .zip(den.iter())
338        .map(|(n, d)| match (n, d) {
339            (Some(n), Some(d)) if *d > 0.0 => Some(n / d),
340            _ => None,
341        })
342        .collect()
343}
344
345// ---------------------------------------------------------------------------
346// Section construction
347// ---------------------------------------------------------------------------
348
349fn build_report(
350    discourse: &str,
351    windows: &[Window],
352    column_headers: &[String],
353    filter: SectionFilter,
354    snapshot: bool,
355    cache: &ReportCache,
356) -> AnalyticsReport {
357    let n = windows.len();
358    let growth = if matches!(filter, SectionFilter::All | SectionFilter::Growth) {
359        Some(build_growth(cache, n))
360    } else {
361        None
362    };
363    let activity = if matches!(filter, SectionFilter::All | SectionFilter::Activity) {
364        Some(build_activity(cache, n))
365    } else {
366        None
367    };
368    let health = if matches!(filter, SectionFilter::All | SectionFilter::Health) {
369        Some(build_health(cache, n))
370    } else {
371        None
372    };
373    AnalyticsReport {
374        schema: SCHEMA_VERSION,
375        discourse: discourse.to_string(),
376        snapshot,
377        windows: windows.to_vec(),
378        column_headers: column_headers.to_vec(),
379        growth,
380        activity,
381        health,
382    }
383}
384
385fn build_growth(cache: &ReportCache, n: usize) -> Vec<Metric> {
386    vec![
387        Metric::new(
388            "new contributors",
389            "new_contributors",
390            Direction::Up,
391            Unit::Count,
392            n,
393        )
394        .with_values(totals_for(cache, "new_contributors", n)),
395        Metric::new(
396            "reactivated users",
397            "reactivated_users",
398            Direction::Up,
399            Unit::Count,
400            n,
401        )
402        .stub(),
403        Metric::new(
404            "lost regulars",
405            "lost_regulars",
406            Direction::Down,
407            Unit::Count,
408            n,
409        )
410        .stub(),
411        Metric::new(
412            "net active change",
413            "net_active_change",
414            Direction::Up,
415            Unit::Count,
416            n,
417        )
418        .stub(),
419        Metric::new(
420            "trust-level promotions",
421            "trust_level_promotions",
422            Direction::Up,
423            Unit::Count,
424            n,
425        )
426        .with_values(totals_for(cache, "trust_level_growth", n)),
427    ]
428}
429
430fn build_activity(cache: &ReportCache, n: usize) -> Vec<Metric> {
431    let mut out = Vec::new();
432
433    let topics = totals_for(cache, "topics", n);
434    let posts = totals_for(cache, "posts", n);
435    let no_response = totals_for(cache, "topics_with_no_response", n);
436
437    out.push(
438        Metric::new(
439            "topics created",
440            "topics_created",
441            Direction::Up,
442            Unit::Count,
443            n,
444        )
445        .with_values(topics.clone()),
446    );
447    out.push(
448        Metric::new(
449            "posts created",
450            "posts_created",
451            Direction::Up,
452            Unit::Count,
453            n,
454        )
455        .with_values(posts.clone()),
456    );
457    out.push(
458        Metric::new(
459            "posts per topic",
460            "posts_per_topic",
461            Direction::Up,
462            Unit::Ratio,
463            n,
464        )
465        .with_values(ratio_per_window(&posts, &topics)),
466    );
467    out.push(
468        Metric::new(
469            "unique posters",
470            "unique_posters",
471            Direction::Up,
472            Unit::Count,
473            n,
474        )
475        .stub(),
476    );
477    out.push(
478        Metric::new(
479            "top-10 share",
480            "top_10_share",
481            Direction::Down,
482            Unit::Percent,
483            n,
484        )
485        .stub(),
486    );
487
488    let coverage: Vec<Option<f64>> = topics
489        .iter()
490        .zip(no_response.iter())
491        .map(|(t, nr)| match (t, nr) {
492            (Some(t), Some(nr)) if *t > 0.0 => Some(((t - nr) / t) * 100.0),
493            _ => None,
494        })
495        .collect();
496    out.push(
497        Metric::new(
498            "reply coverage",
499            "reply_coverage",
500            Direction::Up,
501            Unit::Percent,
502            n,
503        )
504        .with_values(coverage),
505    );
506
507    out.push(
508        Metric::new(
509            "median time to first reply",
510            "median_time_to_first_reply",
511            Direction::Down,
512            Unit::Minutes,
513            n,
514        )
515        .with_values(averages_for(cache, "time_to_first_response", n)),
516    );
517
518    out
519}
520
521fn build_health(cache: &ReportCache, n: usize) -> Vec<Metric> {
522    let mut out = Vec::new();
523    let likes = totals_for(cache, "likes", n);
524    let posts = totals_for(cache, "posts", n);
525    let mods = totals_for(cache, "moderators_activity", n);
526
527    out.push(
528        Metric::new(
529            "likes per post",
530            "likes_per_post",
531            Direction::Up,
532            Unit::Ratio,
533            n,
534        )
535        .with_values(ratio_per_window(&likes, &posts)),
536    );
537    out.push(
538        Metric::new(
539            "returning poster rate",
540            "returning_poster_rate",
541            Direction::Up,
542            Unit::Percent,
543            n,
544        )
545        .stub(),
546    );
547    out.push(
548        Metric::new(
549            "flags raised",
550            "flags_raised",
551            Direction::Down,
552            Unit::Count,
553            n,
554        )
555        .with_values(totals_for(cache, "flags", n)),
556    );
557    out.push(
558        Metric::new(
559            "flag resolution time",
560            "flag_resolution_time",
561            Direction::Down,
562            Unit::Hours,
563            n,
564        )
565        .stub(),
566    );
567
568    let mar: Vec<Option<f64>> = mods
569        .iter()
570        .zip(posts.iter())
571        .map(|(m, p)| match (m, p) {
572            (Some(m), Some(p)) if *p > 0.0 => Some((m / p) * 1000.0),
573            _ => None,
574        })
575        .collect();
576    out.push(
577        Metric::new(
578            "moderator action rate",
579            "moderator_action_rate",
580            Direction::Neither,
581            Unit::PerThousandPosts,
582            n,
583        )
584        .with_values(mar),
585    );
586    out.push(
587        Metric::new(
588            "solo-thread rate",
589            "solo_thread_rate",
590            Direction::Down,
591            Unit::Percent,
592            n,
593        )
594        .stub(),
595    );
596
597    out
598}
599
600// ---------------------------------------------------------------------------
601// Render
602// ---------------------------------------------------------------------------
603
604fn render(report: &AnalyticsReport, format: AnalyticsFormat) -> Result<()> {
605    match format {
606        AnalyticsFormat::Text => render_text(report),
607        AnalyticsFormat::Table => render_table(report),
608        AnalyticsFormat::Json => render_json(report),
609        AnalyticsFormat::Yaml => render_yaml(report),
610        AnalyticsFormat::Markdown => render_markdown(report, false),
611        AnalyticsFormat::MarkdownTable => render_markdown(report, true),
612        AnalyticsFormat::Csv => render_csv(report),
613    }
614}
615
616fn render_text(report: &AnalyticsReport) -> Result<()> {
617    print_header_text(report);
618    let compare_mode = !report.snapshot && report.column_headers.len() == 2;
619    for (name, metrics) in iter_sections(report) {
620        println!();
621        println!("{}", name);
622        let label_w = metrics
623            .iter()
624            .map(|m| m.label.chars().count())
625            .max()
626            .unwrap_or(0)
627            .max(20);
628        let cols = report.column_headers.len();
629        let val_w = column_widths(metrics, cols);
630        for m in metrics {
631            print!("  {}", pad_right(&m.label, label_w));
632            for (c, w) in val_w.iter().enumerate() {
633                let s = format_value(
634                    m.values.get(c).copied().flatten(),
635                    m.unit,
636                    m.not_implemented,
637                );
638                print!("  {}", right_align(&s, *w));
639            }
640            if compare_mode {
641                let pct = m
642                    .delta_pct()
643                    .map(|p| format!("({:+.0}%)", p))
644                    .unwrap_or_default();
645                print!("  {}", pct);
646            }
647            println!();
648        }
649    }
650    Ok(())
651}
652
653fn render_table(report: &AnalyticsReport) -> Result<()> {
654    print_header_text(report);
655    let cols = report.column_headers.len();
656    let compare_mode = !report.snapshot && cols == 2;
657
658    for (name, metrics) in iter_sections(report) {
659        println!();
660        println!("{}", name);
661
662        let label_w = metrics
663            .iter()
664            .map(|m| m.label.chars().count())
665            .max()
666            .unwrap_or(0)
667            .max(6)
668            .max("metric".len());
669        let mut col_w = column_widths(metrics, cols);
670        // Headers may be wider than any cell.
671        for (i, h) in report.column_headers.iter().enumerate() {
672            let hw = h.chars().count();
673            if hw > col_w[i] {
674                col_w[i] = hw;
675            }
676        }
677        let pct_w = if compare_mode { 7 } else { 0 };
678
679        // Top border
680        let mut widths: Vec<usize> = std::iter::once(label_w)
681            .chain(col_w.iter().copied())
682            .collect();
683        if compare_mode {
684            widths.push(pct_w);
685        }
686        println!("{}", border_line('┌', '┬', '┐', &widths));
687
688        // Header row
689        print!("│ {} ", pad_right("metric", label_w));
690        for (i, h) in report.column_headers.iter().enumerate() {
691            print!("│ {} ", center(h, col_w[i]));
692        }
693        if compare_mode {
694            print!("│ {} ", center("Δ", pct_w));
695        }
696        println!("│");
697
698        // Header separator
699        println!("{}", border_line('├', '┼', '┤', &widths));
700
701        for m in metrics {
702            print!("│ {} ", pad_right(&m.label, label_w));
703            for (c, w) in col_w.iter().enumerate() {
704                let s = format_value(
705                    m.values.get(c).copied().flatten(),
706                    m.unit,
707                    m.not_implemented,
708                );
709                print!("│ {} ", right_align(&s, *w));
710            }
711            if compare_mode {
712                let pct = m
713                    .delta_pct()
714                    .map(|p| format!("{:+.0}%", p))
715                    .unwrap_or_else(|| "—".to_string());
716                print!("│ {} ", right_align(&pct, pct_w));
717            }
718            println!("│");
719        }
720
721        println!("{}", border_line('└', '┴', '┘', &widths));
722    }
723    Ok(())
724}
725
726fn print_header_text(report: &AnalyticsReport) {
727    if report.snapshot {
728        let now = Utc::now();
729        println!(
730            "analytics for {} — snapshot at {} UTC",
731            report.discourse,
732            now.format("%Y-%m-%d %H:%M")
733        );
734    } else {
735        let w = &report.windows[0];
736        println!(
737            "analytics for {} — {} ({} → {})",
738            report.discourse,
739            w.label,
740            w.iso_date_since(),
741            w.iso_date_until()
742        );
743        if w.clamped {
744            println!("(window clamped — install is younger than --since)");
745        }
746    }
747}
748
749fn render_json(report: &AnalyticsReport) -> Result<()> {
750    println!("{}", serde_json::to_string_pretty(&report_to_json(report))?);
751    Ok(())
752}
753
754fn render_yaml(report: &AnalyticsReport) -> Result<()> {
755    println!("{}", serde_yaml::to_string(&report_to_json(report))?);
756    Ok(())
757}
758
759fn render_markdown(report: &AnalyticsReport, table: bool) -> Result<()> {
760    let cols = report.column_headers.len();
761    let compare_mode = !report.snapshot && cols == 2;
762    println!("# analytics for {}", report.discourse);
763    println!();
764    if report.snapshot {
765        println!(
766            "Snapshot at **{}**",
767            Utc::now().format("%Y-%m-%d %H:%M UTC")
768        );
769    } else {
770        let w = &report.windows[0];
771        println!(
772            "Window: **{}** ({} → {})",
773            w.label,
774            w.iso_date_since(),
775            w.iso_date_until()
776        );
777    }
778
779    for (name, metrics) in iter_sections(report) {
780        println!();
781        println!("## {}", name);
782        println!();
783        if table {
784            print!("| metric |");
785            for h in &report.column_headers {
786                print!(" {} |", h);
787            }
788            if compare_mode {
789                print!(" Δ |");
790            }
791            println!();
792            print!("| --- |");
793            for _ in 0..cols {
794                print!(" ---: |");
795            }
796            if compare_mode {
797                print!(" ---: |");
798            }
799            println!();
800            for m in metrics {
801                print!("| {} |", m.label);
802                for c in 0..cols {
803                    let s = format_value(
804                        m.values.get(c).copied().flatten(),
805                        m.unit,
806                        m.not_implemented,
807                    );
808                    print!(" {} |", s);
809                }
810                if compare_mode {
811                    let pct = m
812                        .delta_pct()
813                        .map(|p| format!("{:+.0}%", p))
814                        .unwrap_or_else(|| "—".to_string());
815                    print!(" {} |", pct);
816                }
817                println!();
818            }
819        } else {
820            for m in metrics {
821                print!("- **{}** —", m.label);
822                for (i, h) in report.column_headers.iter().enumerate() {
823                    let s = format_value(
824                        m.values.get(i).copied().flatten(),
825                        m.unit,
826                        m.not_implemented,
827                    );
828                    if cols == 1 {
829                        print!(" {}", s);
830                    } else {
831                        print!(" {}: {}", h, s);
832                        if i + 1 < cols {
833                            print!(",");
834                        }
835                    }
836                }
837                if compare_mode && let Some(p) = m.delta_pct() {
838                    print!(" (`{:+.0}%`)", p);
839                }
840                println!();
841            }
842        }
843    }
844    Ok(())
845}
846
847fn render_csv(report: &AnalyticsReport) -> Result<()> {
848    let mut writer = csv::Writer::from_writer(io::stdout());
849    let mut header: Vec<String> = vec!["section".into(), "metric".into()];
850    for h in &report.column_headers {
851        header.push(h.clone());
852    }
853    header.push("desirable_direction".into());
854    header.push("unit".into());
855    writer.write_record(&header)?;
856
857    let cols = report.column_headers.len();
858    for (name, metrics) in iter_sections(report) {
859        for m in metrics {
860            let mut row: Vec<String> = vec![name.into(), m.label.clone()];
861            for c in 0..cols {
862                row.push(
863                    m.values
864                        .get(c)
865                        .copied()
866                        .flatten()
867                        .map(|v| format!("{}", v))
868                        .unwrap_or_default(),
869                );
870            }
871            row.push(
872                match m.desirable {
873                    Direction::Up => "up",
874                    Direction::Down => "down",
875                    Direction::Neither => "neither",
876                }
877                .into(),
878            );
879            row.push(unit_str(m.unit).into());
880            writer.write_record(&row)?;
881        }
882    }
883    writer.flush()?;
884    Ok(())
885}
886
887// ---------------------------------------------------------------------------
888// Render helpers
889// ---------------------------------------------------------------------------
890
891fn iter_sections(report: &AnalyticsReport) -> Vec<(&'static str, &[Metric])> {
892    let mut v: Vec<(&'static str, &[Metric])> = Vec::new();
893    if let Some(g) = &report.growth {
894        v.push(("growth", g));
895    }
896    if let Some(a) = &report.activity {
897        v.push(("activity", a));
898    }
899    if let Some(h) = &report.health {
900        v.push(("health", h));
901    }
902    v
903}
904
905fn fetch_optional(
906    client: &DiscourseClient,
907    report_id: &str,
908    start: &str,
909    end: &str,
910) -> Option<AdminReport> {
911    match client.fetch_admin_report(report_id, start, end) {
912        Ok(r) => Some(r),
913        Err(err) => {
914            let msg = err.to_string();
915            // Tolerate per-report 404/403/500. The cache slot stays None
916            // and the metric renders as `—`.
917            let known_missing = msg.contains(" 404 ")
918                || msg.contains(" 403 ")
919                || msg.contains(" 500 ")
920                || msg.contains("not found");
921            if known_missing {
922                None
923            } else {
924                eprintln!(
925                    "[analytics] warning fetching report '{}': {}",
926                    report_id, err
927                );
928                None
929            }
930        }
931    }
932}
933
934fn column_widths(metrics: &[Metric], cols: usize) -> Vec<usize> {
935    (0..cols)
936        .map(|c| {
937            metrics
938                .iter()
939                .map(|m| {
940                    visual_width(&format_value(
941                        m.values.get(c).copied().flatten(),
942                        m.unit,
943                        m.not_implemented,
944                    ))
945                })
946                .max()
947                .unwrap_or(0)
948                .max(6)
949        })
950        .collect()
951}
952
953fn visual_width(s: &str) -> usize {
954    s.chars().count()
955}
956
957fn pad_right(s: &str, width: usize) -> String {
958    let w = visual_width(s);
959    if w >= width {
960        s.to_string()
961    } else {
962        format!("{}{}", s, " ".repeat(width - w))
963    }
964}
965
966fn right_align(s: &str, width: usize) -> String {
967    let w = visual_width(s);
968    if w >= width {
969        s.to_string()
970    } else {
971        format!("{}{}", " ".repeat(width - w), s)
972    }
973}
974
975fn center(s: &str, width: usize) -> String {
976    let w = visual_width(s);
977    if w >= width {
978        return s.to_string();
979    }
980    let total = width - w;
981    let left = total / 2;
982    let right = total - left;
983    format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
984}
985
986fn border_line(start: char, mid: char, end: char, widths: &[usize]) -> String {
987    let mut out = String::new();
988    out.push(start);
989    for (i, w) in widths.iter().enumerate() {
990        for _ in 0..(*w + 2) {
991            out.push('─');
992        }
993        out.push(if i + 1 == widths.len() { end } else { mid });
994    }
995    out
996}
997
998fn unit_str(u: Unit) -> &'static str {
999    match u {
1000        Unit::Count => "count",
1001        Unit::Percent => "percent",
1002        Unit::Minutes => "minutes",
1003        Unit::Hours => "hours",
1004        Unit::Ratio => "ratio",
1005        Unit::PerThousandPosts => "per_1k_posts",
1006    }
1007}
1008
1009fn format_value(v: Option<f64>, unit: Unit, not_impl: bool) -> String {
1010    if not_impl {
1011        return "— (n/i)".to_string();
1012    }
1013    let v = v.map(|x| if x == 0.0 { 0.0 } else { x });
1014    match (v, unit) {
1015        (None, _) => "—".to_string(),
1016        (Some(x), Unit::Count) => format_count(x),
1017        (Some(x), Unit::Percent) => format!("{:.0}%", x),
1018        (Some(x), Unit::Minutes) => format_minutes(x),
1019        (Some(x), Unit::Hours) => format!("{:.1}h", x),
1020        (Some(x), Unit::Ratio) => format!("{:.1}", x),
1021        (Some(x), Unit::PerThousandPosts) => format!("{:.1} / 1k", x),
1022    }
1023}
1024
1025/// Integer count with thousand separators (commas) for readability.
1026fn format_count(x: f64) -> String {
1027    let n = x as i64;
1028    let neg = n < 0;
1029    let digits = n.unsigned_abs().to_string();
1030    // Walk the digit string from the right, inserting a comma every 3
1031    // characters except at the very start.
1032    let bytes: Vec<u8> = digits.into_bytes();
1033    let mut out = String::with_capacity(bytes.len() + bytes.len() / 3);
1034    let len = bytes.len();
1035    for (i, b) in bytes.iter().enumerate() {
1036        let from_right = len - i;
1037        if i > 0 && from_right.is_multiple_of(3) {
1038            out.push(',');
1039        }
1040        out.push(*b as char);
1041    }
1042    if neg {
1043        out.insert(0, '-');
1044    }
1045    out
1046}
1047
1048fn format_minutes(x: f64) -> String {
1049    if x >= 60.0 {
1050        let h = x / 60.0;
1051        format!("{:.1}h", h)
1052    } else {
1053        format!("{:.0}m", x)
1054    }
1055}
1056
1057fn format_yyyy_mm_dd(d: &DateTime<Utc>) -> String {
1058    format!("{:04}-{:02}-{:02}", d.year(), d.month(), d.day())
1059}
1060
1061// ---------------------------------------------------------------------------
1062// JSON serialisation
1063// ---------------------------------------------------------------------------
1064
1065fn report_to_json(report: &AnalyticsReport) -> Value {
1066    let mut top = Map::new();
1067    top.insert("schema".to_string(), json!(report.schema));
1068    top.insert("discourse".to_string(), json!(report.discourse));
1069    top.insert("snapshot".to_string(), json!(report.snapshot));
1070    top.insert(
1071        "windows".to_string(),
1072        Value::Array(
1073            report
1074                .windows
1075                .iter()
1076                .map(|w| {
1077                    json!({
1078                        "label": w.label,
1079                        "since": w.since.to_rfc3339(),
1080                        "until": w.until.to_rfc3339(),
1081                    })
1082                })
1083                .collect(),
1084        ),
1085    );
1086    for (name, metrics) in iter_sections(report) {
1087        top.insert(
1088            name.to_string(),
1089            section_to_json(metrics, &report.column_headers),
1090        );
1091    }
1092    Value::Object(top)
1093}
1094
1095fn section_to_json(metrics: &[Metric], headers: &[String]) -> Value {
1096    let mut out = Map::new();
1097    for m in metrics {
1098        let mut entry = Map::new();
1099        let mut values = Map::new();
1100        for (i, h) in headers.iter().enumerate() {
1101            values.insert(h.clone(), float_or_null(m.values.get(i).copied().flatten()));
1102        }
1103        entry.insert("values".to_string(), Value::Object(values));
1104        entry.insert(
1105            "desirable".to_string(),
1106            json!(match m.desirable {
1107                Direction::Up => "up",
1108                Direction::Down => "down",
1109                Direction::Neither => "neither",
1110            }),
1111        );
1112        entry.insert("unit".to_string(), json!(unit_str(m.unit)));
1113        if m.not_implemented {
1114            entry.insert("not_implemented".to_string(), json!(true));
1115        }
1116        out.insert(m.key.clone(), Value::Object(entry));
1117    }
1118    Value::Object(out)
1119}
1120
1121fn float_or_null(v: Option<f64>) -> Value {
1122    match v {
1123        None => Value::Null,
1124        Some(x) if x.is_finite() => json!(x),
1125        _ => Value::Null,
1126    }
1127}
1128
1129// ---------------------------------------------------------------------------
1130// Tests
1131// ---------------------------------------------------------------------------
1132
1133#[cfg(test)]
1134mod tests {
1135    use super::*;
1136
1137    #[test]
1138    fn metric_delta_pct_works_on_compare_layout() {
1139        let m = Metric::new("x", "x", Direction::Up, Unit::Count, 2)
1140            .with_values(vec![Some(80.0), Some(100.0)]);
1141        assert_eq!(m.delta_pct(), Some(-20.0));
1142    }
1143
1144    #[test]
1145    fn metric_delta_pct_none_when_previous_zero() {
1146        let m = Metric::new("x", "x", Direction::Up, Unit::Count, 2)
1147            .with_values(vec![Some(10.0), Some(0.0)]);
1148        assert!(m.delta_pct().is_none());
1149    }
1150
1151    #[test]
1152    fn metric_delta_pct_none_for_single_window() {
1153        let m = Metric::new("x", "x", Direction::Up, Unit::Count, 1).with_values(vec![Some(10.0)]);
1154        assert!(m.delta_pct().is_none());
1155    }
1156
1157    #[test]
1158    fn ratio_per_window_handles_zero_and_missing() {
1159        let n = vec![Some(10.0), Some(20.0), None];
1160        let d = vec![Some(2.0), Some(0.0), Some(5.0)];
1161        let r = ratio_per_window(&n, &d);
1162        assert_eq!(r, vec![Some(5.0), None, None]);
1163    }
1164
1165    #[test]
1166    fn format_value_em_dash_for_none() {
1167        assert_eq!(format_value(None, Unit::Count, false), "—");
1168        assert_eq!(format_value(Some(42.0), Unit::Count, true), "— (n/i)");
1169    }
1170
1171    #[test]
1172    fn format_count_inserts_thousand_separators() {
1173        assert_eq!(format_count(0.0), "0");
1174        assert_eq!(format_count(42.0), "42");
1175        assert_eq!(format_count(1_234.0), "1,234");
1176        assert_eq!(format_count(12_345.0), "12,345");
1177        assert_eq!(format_count(1_234_567.0), "1,234,567");
1178        assert_eq!(format_count(-1_500.0), "-1,500");
1179    }
1180
1181    #[test]
1182    fn format_minutes_rolls_to_hours() {
1183        assert_eq!(format_minutes(45.0), "45m");
1184        assert_eq!(format_minutes(90.0), "1.5h");
1185    }
1186
1187    #[test]
1188    fn parse_periods_default_set() {
1189        let now = Utc::now();
1190        let ws = parse_periods("24h,7d,30d,1y", now).unwrap();
1191        assert_eq!(ws.len(), 4);
1192        assert_eq!(ws[0].label, "24h");
1193        assert_eq!(ws[3].label, "1y");
1194    }
1195
1196    #[test]
1197    fn parse_periods_skips_blanks() {
1198        let now = Utc::now();
1199        let ws = parse_periods("7d, ,30d", now).unwrap();
1200        assert_eq!(ws.len(), 2);
1201    }
1202
1203    #[test]
1204    fn parse_periods_rejects_empty() {
1205        let now = Utc::now();
1206        assert!(parse_periods("", now).is_err());
1207    }
1208
1209    #[test]
1210    fn previous_window_is_immediately_preceding() {
1211        let now = Utc::now();
1212        let cur = window_from_since("7d", now).unwrap();
1213        let prev = previous_window_of(&cur);
1214        assert_eq!(prev.until, cur.since);
1215        assert_eq!(prev.duration(), cur.duration());
1216    }
1217
1218    #[test]
1219    fn border_line_lengths_match_widths() {
1220        let line = border_line('┌', '┬', '┐', &[6, 4]);
1221        // Each column is width+2 dashes, plus the four corners.
1222        let dashes = line.chars().filter(|c| *c == '─').count();
1223        assert_eq!(dashes, (6 + 2) + (4 + 2));
1224        assert!(line.starts_with('┌'));
1225        assert!(line.ends_with('┐'));
1226        assert!(line.contains('┬'));
1227    }
1228}