1use 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
30const 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#[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 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 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
104fn 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#[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 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 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
255type ReportCache = HashMap<(String, usize), Option<AdminReport>>;
263
264const 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 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
321fn 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
345fn 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
600fn 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 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 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 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 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
887fn 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 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
1025fn 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 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
1061fn 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#[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 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}