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    let mut out = Vec::new();
387
388    out.push(
389        Metric::new(
390            "new contributors",
391            "new_contributors",
392            Direction::Up,
393            Unit::Count,
394            n,
395        )
396        .with_values(totals_for(cache, "new_contributors", n)),
397    );
398    out.push(
399        Metric::new(
400            "reactivated users",
401            "reactivated_users",
402            Direction::Up,
403            Unit::Count,
404            n,
405        )
406        .stub(),
407    );
408    out.push(
409        Metric::new(
410            "lost regulars",
411            "lost_regulars",
412            Direction::Down,
413            Unit::Count,
414            n,
415        )
416        .stub(),
417    );
418    out.push(
419        Metric::new(
420            "net active change",
421            "net_active_change",
422            Direction::Up,
423            Unit::Count,
424            n,
425        )
426        .stub(),
427    );
428    out.push(
429        Metric::new(
430            "trust-level promotions",
431            "trust_level_promotions",
432            Direction::Up,
433            Unit::Count,
434            n,
435        )
436        .with_values(totals_for(cache, "trust_level_growth", n)),
437    );
438
439    out
440}
441
442fn build_activity(cache: &ReportCache, n: usize) -> Vec<Metric> {
443    let mut out = Vec::new();
444
445    let topics = totals_for(cache, "topics", n);
446    let posts = totals_for(cache, "posts", n);
447    let no_response = totals_for(cache, "topics_with_no_response", n);
448
449    out.push(
450        Metric::new(
451            "topics created",
452            "topics_created",
453            Direction::Up,
454            Unit::Count,
455            n,
456        )
457        .with_values(topics.clone()),
458    );
459    out.push(
460        Metric::new(
461            "posts created",
462            "posts_created",
463            Direction::Up,
464            Unit::Count,
465            n,
466        )
467        .with_values(posts.clone()),
468    );
469    out.push(
470        Metric::new(
471            "posts per topic",
472            "posts_per_topic",
473            Direction::Up,
474            Unit::Ratio,
475            n,
476        )
477        .with_values(ratio_per_window(&posts, &topics)),
478    );
479    out.push(
480        Metric::new(
481            "unique posters",
482            "unique_posters",
483            Direction::Up,
484            Unit::Count,
485            n,
486        )
487        .stub(),
488    );
489    out.push(
490        Metric::new(
491            "top-10 share",
492            "top_10_share",
493            Direction::Down,
494            Unit::Percent,
495            n,
496        )
497        .stub(),
498    );
499
500    let coverage: Vec<Option<f64>> = topics
501        .iter()
502        .zip(no_response.iter())
503        .map(|(t, nr)| match (t, nr) {
504            (Some(t), Some(nr)) if *t > 0.0 => Some(((t - nr) / t) * 100.0),
505            _ => None,
506        })
507        .collect();
508    out.push(
509        Metric::new(
510            "reply coverage",
511            "reply_coverage",
512            Direction::Up,
513            Unit::Percent,
514            n,
515        )
516        .with_values(coverage),
517    );
518
519    out.push(
520        Metric::new(
521            "median time to first reply",
522            "median_time_to_first_reply",
523            Direction::Down,
524            Unit::Minutes,
525            n,
526        )
527        .with_values(averages_for(cache, "time_to_first_response", n)),
528    );
529
530    out
531}
532
533fn build_health(cache: &ReportCache, n: usize) -> Vec<Metric> {
534    let mut out = Vec::new();
535    let likes = totals_for(cache, "likes", n);
536    let posts = totals_for(cache, "posts", n);
537    let mods = totals_for(cache, "moderators_activity", n);
538
539    out.push(
540        Metric::new(
541            "likes per post",
542            "likes_per_post",
543            Direction::Up,
544            Unit::Ratio,
545            n,
546        )
547        .with_values(ratio_per_window(&likes, &posts)),
548    );
549    out.push(
550        Metric::new(
551            "returning poster rate",
552            "returning_poster_rate",
553            Direction::Up,
554            Unit::Percent,
555            n,
556        )
557        .stub(),
558    );
559    out.push(
560        Metric::new(
561            "flags raised",
562            "flags_raised",
563            Direction::Down,
564            Unit::Count,
565            n,
566        )
567        .with_values(totals_for(cache, "flags", n)),
568    );
569    out.push(
570        Metric::new(
571            "flag resolution time",
572            "flag_resolution_time",
573            Direction::Down,
574            Unit::Hours,
575            n,
576        )
577        .stub(),
578    );
579
580    let mar: Vec<Option<f64>> = mods
581        .iter()
582        .zip(posts.iter())
583        .map(|(m, p)| match (m, p) {
584            (Some(m), Some(p)) if *p > 0.0 => Some((m / p) * 1000.0),
585            _ => None,
586        })
587        .collect();
588    out.push(
589        Metric::new(
590            "moderator action rate",
591            "moderator_action_rate",
592            Direction::Neither,
593            Unit::PerThousandPosts,
594            n,
595        )
596        .with_values(mar),
597    );
598    out.push(
599        Metric::new(
600            "solo-thread rate",
601            "solo_thread_rate",
602            Direction::Down,
603            Unit::Percent,
604            n,
605        )
606        .stub(),
607    );
608
609    out
610}
611
612// ---------------------------------------------------------------------------
613// Render
614// ---------------------------------------------------------------------------
615
616fn render(report: &AnalyticsReport, format: AnalyticsFormat) -> Result<()> {
617    match format {
618        AnalyticsFormat::Text => render_text(report),
619        AnalyticsFormat::Table => render_table(report),
620        AnalyticsFormat::Json => render_json(report),
621        AnalyticsFormat::Yaml => render_yaml(report),
622        AnalyticsFormat::Markdown => render_markdown(report, false),
623        AnalyticsFormat::MarkdownTable => render_markdown(report, true),
624        AnalyticsFormat::Csv => render_csv(report),
625    }
626}
627
628fn render_text(report: &AnalyticsReport) -> Result<()> {
629    print_header_text(report);
630    let compare_mode = !report.snapshot && report.column_headers.len() == 2;
631    for (name, metrics) in iter_sections(report) {
632        println!();
633        println!("{}", name);
634        let label_w = metrics
635            .iter()
636            .map(|m| m.label.chars().count())
637            .max()
638            .unwrap_or(0)
639            .max(20);
640        let cols = report.column_headers.len();
641        let val_w = column_widths(metrics, cols);
642        for m in metrics {
643            print!("  {}", pad_right(&m.label, label_w));
644            for c in 0..cols {
645                let s = format_value(
646                    m.values.get(c).copied().flatten(),
647                    m.unit,
648                    m.not_implemented,
649                );
650                print!("  {}", right_align(&s, val_w[c]));
651            }
652            if compare_mode {
653                let pct = m
654                    .delta_pct()
655                    .map(|p| format!("({:+.0}%)", p))
656                    .unwrap_or_default();
657                print!("  {}", pct);
658            }
659            println!();
660        }
661    }
662    Ok(())
663}
664
665fn render_table(report: &AnalyticsReport) -> Result<()> {
666    print_header_text(report);
667    let cols = report.column_headers.len();
668    let compare_mode = !report.snapshot && cols == 2;
669
670    for (name, metrics) in iter_sections(report) {
671        println!();
672        println!("{}", name);
673
674        let label_w = metrics
675            .iter()
676            .map(|m| m.label.chars().count())
677            .max()
678            .unwrap_or(0)
679            .max(6)
680            .max("metric".len());
681        let mut col_w = column_widths(metrics, cols);
682        // Headers may be wider than any cell.
683        for (i, h) in report.column_headers.iter().enumerate() {
684            let hw = h.chars().count();
685            if hw > col_w[i] {
686                col_w[i] = hw;
687            }
688        }
689        let pct_w = if compare_mode { 7 } else { 0 };
690
691        // Top border
692        let mut widths: Vec<usize> = std::iter::once(label_w)
693            .chain(col_w.iter().copied())
694            .collect();
695        if compare_mode {
696            widths.push(pct_w);
697        }
698        println!("{}", border_line('┌', '┬', '┐', &widths));
699
700        // Header row
701        print!("│ {} ", pad_right("metric", label_w));
702        for (i, h) in report.column_headers.iter().enumerate() {
703            print!("│ {} ", center(h, col_w[i]));
704        }
705        if compare_mode {
706            print!("│ {} ", center("Δ", pct_w));
707        }
708        println!("│");
709
710        // Header separator
711        println!("{}", border_line('├', '┼', '┤', &widths));
712
713        for m in metrics {
714            print!("│ {} ", pad_right(&m.label, label_w));
715            for c in 0..cols {
716                let s = format_value(
717                    m.values.get(c).copied().flatten(),
718                    m.unit,
719                    m.not_implemented,
720                );
721                print!("│ {} ", right_align(&s, col_w[c]));
722            }
723            if compare_mode {
724                let pct = m
725                    .delta_pct()
726                    .map(|p| format!("{:+.0}%", p))
727                    .unwrap_or_else(|| "—".to_string());
728                print!("│ {} ", right_align(&pct, pct_w));
729            }
730            println!("│");
731        }
732
733        println!("{}", border_line('└', '┴', '┘', &widths));
734    }
735    Ok(())
736}
737
738fn print_header_text(report: &AnalyticsReport) {
739    if report.snapshot {
740        let now = Utc::now();
741        println!(
742            "analytics for {} — snapshot at {} UTC",
743            report.discourse,
744            now.format("%Y-%m-%d %H:%M")
745        );
746    } else {
747        let w = &report.windows[0];
748        println!(
749            "analytics for {} — {} ({} → {})",
750            report.discourse,
751            w.label,
752            w.iso_date_since(),
753            w.iso_date_until()
754        );
755        if w.clamped {
756            println!("(window clamped — install is younger than --since)");
757        }
758    }
759}
760
761fn render_json(report: &AnalyticsReport) -> Result<()> {
762    println!("{}", serde_json::to_string_pretty(&report_to_json(report))?);
763    Ok(())
764}
765
766fn render_yaml(report: &AnalyticsReport) -> Result<()> {
767    println!("{}", serde_yaml::to_string(&report_to_json(report))?);
768    Ok(())
769}
770
771fn render_markdown(report: &AnalyticsReport, table: bool) -> Result<()> {
772    let cols = report.column_headers.len();
773    let compare_mode = !report.snapshot && cols == 2;
774    println!("# analytics for {}", report.discourse);
775    println!();
776    if report.snapshot {
777        println!(
778            "Snapshot at **{}**",
779            Utc::now().format("%Y-%m-%d %H:%M UTC")
780        );
781    } else {
782        let w = &report.windows[0];
783        println!(
784            "Window: **{}** ({} → {})",
785            w.label,
786            w.iso_date_since(),
787            w.iso_date_until()
788        );
789    }
790
791    for (name, metrics) in iter_sections(report) {
792        println!();
793        println!("## {}", name);
794        println!();
795        if table {
796            print!("| metric |");
797            for h in &report.column_headers {
798                print!(" {} |", h);
799            }
800            if compare_mode {
801                print!(" Δ |");
802            }
803            println!();
804            print!("| --- |");
805            for _ in 0..cols {
806                print!(" ---: |");
807            }
808            if compare_mode {
809                print!(" ---: |");
810            }
811            println!();
812            for m in metrics {
813                print!("| {} |", m.label);
814                for c in 0..cols {
815                    let s = format_value(
816                        m.values.get(c).copied().flatten(),
817                        m.unit,
818                        m.not_implemented,
819                    );
820                    print!(" {} |", s);
821                }
822                if compare_mode {
823                    let pct = m
824                        .delta_pct()
825                        .map(|p| format!("{:+.0}%", p))
826                        .unwrap_or_else(|| "—".to_string());
827                    print!(" {} |", pct);
828                }
829                println!();
830            }
831        } else {
832            for m in metrics {
833                print!("- **{}** —", m.label);
834                for (i, h) in report.column_headers.iter().enumerate() {
835                    let s = format_value(
836                        m.values.get(i).copied().flatten(),
837                        m.unit,
838                        m.not_implemented,
839                    );
840                    if cols == 1 {
841                        print!(" {}", s);
842                    } else {
843                        print!(" {}: {}", h, s);
844                        if i + 1 < cols {
845                            print!(",");
846                        }
847                    }
848                }
849                if compare_mode && let Some(p) = m.delta_pct() {
850                    print!(" (`{:+.0}%`)", p);
851                }
852                println!();
853            }
854        }
855    }
856    Ok(())
857}
858
859fn render_csv(report: &AnalyticsReport) -> Result<()> {
860    let mut writer = csv::Writer::from_writer(io::stdout());
861    let mut header: Vec<String> = vec!["section".into(), "metric".into()];
862    for h in &report.column_headers {
863        header.push(h.clone());
864    }
865    header.push("desirable_direction".into());
866    header.push("unit".into());
867    writer.write_record(&header)?;
868
869    let cols = report.column_headers.len();
870    for (name, metrics) in iter_sections(report) {
871        for m in metrics {
872            let mut row: Vec<String> = vec![name.into(), m.label.clone()];
873            for c in 0..cols {
874                row.push(
875                    m.values
876                        .get(c)
877                        .copied()
878                        .flatten()
879                        .map(|v| format!("{}", v))
880                        .unwrap_or_default(),
881                );
882            }
883            row.push(
884                match m.desirable {
885                    Direction::Up => "up",
886                    Direction::Down => "down",
887                    Direction::Neither => "neither",
888                }
889                .into(),
890            );
891            row.push(unit_str(m.unit).into());
892            writer.write_record(&row)?;
893        }
894    }
895    writer.flush()?;
896    Ok(())
897}
898
899// ---------------------------------------------------------------------------
900// Render helpers
901// ---------------------------------------------------------------------------
902
903fn iter_sections(report: &AnalyticsReport) -> Vec<(&'static str, &[Metric])> {
904    let mut v: Vec<(&'static str, &[Metric])> = Vec::new();
905    if let Some(g) = &report.growth {
906        v.push(("growth", g));
907    }
908    if let Some(a) = &report.activity {
909        v.push(("activity", a));
910    }
911    if let Some(h) = &report.health {
912        v.push(("health", h));
913    }
914    v
915}
916
917fn fetch_optional(
918    client: &DiscourseClient,
919    report_id: &str,
920    start: &str,
921    end: &str,
922) -> Option<AdminReport> {
923    match client.fetch_admin_report(report_id, start, end) {
924        Ok(r) => Some(r),
925        Err(err) => {
926            let msg = err.to_string();
927            // Tolerate per-report 404/403/500. The cache slot stays None
928            // and the metric renders as `—`.
929            let known_missing = msg.contains(" 404 ")
930                || msg.contains(" 403 ")
931                || msg.contains(" 500 ")
932                || msg.contains("not found");
933            if known_missing {
934                None
935            } else {
936                eprintln!(
937                    "[analytics] warning fetching report '{}': {}",
938                    report_id, err
939                );
940                None
941            }
942        }
943    }
944}
945
946fn column_widths(metrics: &[Metric], cols: usize) -> Vec<usize> {
947    (0..cols)
948        .map(|c| {
949            metrics
950                .iter()
951                .map(|m| {
952                    visual_width(&format_value(
953                        m.values.get(c).copied().flatten(),
954                        m.unit,
955                        m.not_implemented,
956                    ))
957                })
958                .max()
959                .unwrap_or(0)
960                .max(6)
961        })
962        .collect()
963}
964
965fn visual_width(s: &str) -> usize {
966    s.chars().count()
967}
968
969fn pad_right(s: &str, width: usize) -> String {
970    let w = visual_width(s);
971    if w >= width {
972        s.to_string()
973    } else {
974        format!("{}{}", s, " ".repeat(width - w))
975    }
976}
977
978fn right_align(s: &str, width: usize) -> String {
979    let w = visual_width(s);
980    if w >= width {
981        s.to_string()
982    } else {
983        format!("{}{}", " ".repeat(width - w), s)
984    }
985}
986
987fn center(s: &str, width: usize) -> String {
988    let w = visual_width(s);
989    if w >= width {
990        return s.to_string();
991    }
992    let total = width - w;
993    let left = total / 2;
994    let right = total - left;
995    format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
996}
997
998fn border_line(start: char, mid: char, end: char, widths: &[usize]) -> String {
999    let mut out = String::new();
1000    out.push(start);
1001    for (i, w) in widths.iter().enumerate() {
1002        for _ in 0..(*w + 2) {
1003            out.push('─');
1004        }
1005        out.push(if i + 1 == widths.len() { end } else { mid });
1006    }
1007    out
1008}
1009
1010fn unit_str(u: Unit) -> &'static str {
1011    match u {
1012        Unit::Count => "count",
1013        Unit::Percent => "percent",
1014        Unit::Minutes => "minutes",
1015        Unit::Hours => "hours",
1016        Unit::Ratio => "ratio",
1017        Unit::PerThousandPosts => "per_1k_posts",
1018    }
1019}
1020
1021fn format_value(v: Option<f64>, unit: Unit, not_impl: bool) -> String {
1022    if not_impl {
1023        return "— (n/i)".to_string();
1024    }
1025    let v = v.map(|x| if x == 0.0 { 0.0 } else { x });
1026    match (v, unit) {
1027        (None, _) => "—".to_string(),
1028        (Some(x), Unit::Count) => format_count(x),
1029        (Some(x), Unit::Percent) => format!("{:.0}%", x),
1030        (Some(x), Unit::Minutes) => format_minutes(x),
1031        (Some(x), Unit::Hours) => format!("{:.1}h", x),
1032        (Some(x), Unit::Ratio) => format!("{:.1}", x),
1033        (Some(x), Unit::PerThousandPosts) => format!("{:.1} / 1k", x),
1034    }
1035}
1036
1037/// Integer count with thousand separators (commas) for readability.
1038fn format_count(x: f64) -> String {
1039    let n = x as i64;
1040    let neg = n < 0;
1041    let digits = n.unsigned_abs().to_string();
1042    // Walk the digit string from the right, inserting a comma every 3
1043    // characters except at the very start.
1044    let bytes: Vec<u8> = digits.into_bytes();
1045    let mut out = String::with_capacity(bytes.len() + bytes.len() / 3);
1046    let len = bytes.len();
1047    for (i, b) in bytes.iter().enumerate() {
1048        let from_right = len - i;
1049        if i > 0 && from_right.is_multiple_of(3) {
1050            out.push(',');
1051        }
1052        out.push(*b as char);
1053    }
1054    if neg {
1055        out.insert(0, '-');
1056    }
1057    out
1058}
1059
1060fn format_minutes(x: f64) -> String {
1061    if x >= 60.0 {
1062        let h = x / 60.0;
1063        format!("{:.1}h", h)
1064    } else {
1065        format!("{:.0}m", x)
1066    }
1067}
1068
1069fn format_yyyy_mm_dd(d: &DateTime<Utc>) -> String {
1070    format!("{:04}-{:02}-{:02}", d.year(), d.month(), d.day())
1071}
1072
1073// ---------------------------------------------------------------------------
1074// JSON serialisation
1075// ---------------------------------------------------------------------------
1076
1077fn report_to_json(report: &AnalyticsReport) -> Value {
1078    let mut top = Map::new();
1079    top.insert("schema".to_string(), json!(report.schema));
1080    top.insert("discourse".to_string(), json!(report.discourse));
1081    top.insert("snapshot".to_string(), json!(report.snapshot));
1082    top.insert(
1083        "windows".to_string(),
1084        Value::Array(
1085            report
1086                .windows
1087                .iter()
1088                .map(|w| {
1089                    json!({
1090                        "label": w.label,
1091                        "since": w.since.to_rfc3339(),
1092                        "until": w.until.to_rfc3339(),
1093                    })
1094                })
1095                .collect(),
1096        ),
1097    );
1098    for (name, metrics) in iter_sections(report) {
1099        top.insert(
1100            name.to_string(),
1101            section_to_json(metrics, &report.column_headers),
1102        );
1103    }
1104    Value::Object(top)
1105}
1106
1107fn section_to_json(metrics: &[Metric], headers: &[String]) -> Value {
1108    let mut out = Map::new();
1109    for m in metrics {
1110        let mut entry = Map::new();
1111        let mut values = Map::new();
1112        for (i, h) in headers.iter().enumerate() {
1113            values.insert(h.clone(), float_or_null(m.values.get(i).copied().flatten()));
1114        }
1115        entry.insert("values".to_string(), Value::Object(values));
1116        entry.insert(
1117            "desirable".to_string(),
1118            json!(match m.desirable {
1119                Direction::Up => "up",
1120                Direction::Down => "down",
1121                Direction::Neither => "neither",
1122            }),
1123        );
1124        entry.insert("unit".to_string(), json!(unit_str(m.unit)));
1125        if m.not_implemented {
1126            entry.insert("not_implemented".to_string(), json!(true));
1127        }
1128        out.insert(m.key.clone(), Value::Object(entry));
1129    }
1130    Value::Object(out)
1131}
1132
1133fn float_or_null(v: Option<f64>) -> Value {
1134    match v {
1135        None => Value::Null,
1136        Some(x) if x.is_finite() => json!(x),
1137        _ => Value::Null,
1138    }
1139}
1140
1141// ---------------------------------------------------------------------------
1142// Tests
1143// ---------------------------------------------------------------------------
1144
1145#[cfg(test)]
1146mod tests {
1147    use super::*;
1148
1149    #[test]
1150    fn metric_delta_pct_works_on_compare_layout() {
1151        let m = Metric::new("x", "x", Direction::Up, Unit::Count, 2)
1152            .with_values(vec![Some(80.0), Some(100.0)]);
1153        assert_eq!(m.delta_pct(), Some(-20.0));
1154    }
1155
1156    #[test]
1157    fn metric_delta_pct_none_when_previous_zero() {
1158        let m = Metric::new("x", "x", Direction::Up, Unit::Count, 2)
1159            .with_values(vec![Some(10.0), Some(0.0)]);
1160        assert!(m.delta_pct().is_none());
1161    }
1162
1163    #[test]
1164    fn metric_delta_pct_none_for_single_window() {
1165        let m = Metric::new("x", "x", Direction::Up, Unit::Count, 1).with_values(vec![Some(10.0)]);
1166        assert!(m.delta_pct().is_none());
1167    }
1168
1169    #[test]
1170    fn ratio_per_window_handles_zero_and_missing() {
1171        let n = vec![Some(10.0), Some(20.0), None];
1172        let d = vec![Some(2.0), Some(0.0), Some(5.0)];
1173        let r = ratio_per_window(&n, &d);
1174        assert_eq!(r, vec![Some(5.0), None, None]);
1175    }
1176
1177    #[test]
1178    fn format_value_em_dash_for_none() {
1179        assert_eq!(format_value(None, Unit::Count, false), "—");
1180        assert_eq!(format_value(Some(42.0), Unit::Count, true), "— (n/i)");
1181    }
1182
1183    #[test]
1184    fn format_count_inserts_thousand_separators() {
1185        assert_eq!(format_count(0.0), "0");
1186        assert_eq!(format_count(42.0), "42");
1187        assert_eq!(format_count(1_234.0), "1,234");
1188        assert_eq!(format_count(12_345.0), "12,345");
1189        assert_eq!(format_count(1_234_567.0), "1,234,567");
1190        assert_eq!(format_count(-1_500.0), "-1,500");
1191    }
1192
1193    #[test]
1194    fn format_minutes_rolls_to_hours() {
1195        assert_eq!(format_minutes(45.0), "45m");
1196        assert_eq!(format_minutes(90.0), "1.5h");
1197    }
1198
1199    #[test]
1200    fn parse_periods_default_set() {
1201        let now = Utc::now();
1202        let ws = parse_periods("24h,7d,30d,1y", now).unwrap();
1203        assert_eq!(ws.len(), 4);
1204        assert_eq!(ws[0].label, "24h");
1205        assert_eq!(ws[3].label, "1y");
1206    }
1207
1208    #[test]
1209    fn parse_periods_skips_blanks() {
1210        let now = Utc::now();
1211        let ws = parse_periods("7d, ,30d", now).unwrap();
1212        assert_eq!(ws.len(), 2);
1213    }
1214
1215    #[test]
1216    fn parse_periods_rejects_empty() {
1217        let now = Utc::now();
1218        assert!(parse_periods("", now).is_err());
1219    }
1220
1221    #[test]
1222    fn previous_window_is_immediately_preceding() {
1223        let now = Utc::now();
1224        let cur = window_from_since("7d", now).unwrap();
1225        let prev = previous_window_of(&cur);
1226        assert_eq!(prev.until, cur.since);
1227        assert_eq!(prev.duration(), cur.duration());
1228    }
1229
1230    #[test]
1231    fn border_line_lengths_match_widths() {
1232        let line = border_line('┌', '┬', '┐', &[6, 4]);
1233        // Each column is width+2 dashes, plus the four corners.
1234        let dashes = line.chars().filter(|c| *c == '─').count();
1235        assert_eq!(dashes, (6 + 2) + (4 + 2));
1236        assert!(line.starts_with('┌'));
1237        assert!(line.ends_with('┐'));
1238        assert!(line.contains('┬'));
1239    }
1240}