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