Skip to main content

systemprompt_cli/commands/analytics/shared/
output.rs

1//! Reusable analytics output shapes and human-readable formatters.
2//!
3//! Provides the domain-agnostic render structs ([`TrendData`],
4//! [`StatsSummary`], [`BreakdownData`], [`MetricCard`]) plus the formatting
5//! functions ([`format_number`], [`format_cost`], [`format_percent`],
6//! [`format_change`], [`format_tokens`]) that turn raw metrics into the strings
7//! shown in tables.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
16pub struct TrendPoint {
17    pub timestamp: String,
18    pub value: f64,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub label: Option<String>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
24pub struct TrendData {
25    pub points: Vec<TrendPoint>,
26    pub period: String,
27    pub metric: String,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
31pub struct StatsSummary {
32    pub total: i64,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub change_percent: Option<f64>,
35    pub period: String,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
39pub struct BreakdownItem {
40    pub name: String,
41    pub count: i64,
42    pub percentage: f64,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
46pub struct BreakdownData {
47    pub items: Vec<BreakdownItem>,
48    pub total: i64,
49    pub label: String,
50}
51
52impl BreakdownData {
53    pub fn new(label: impl Into<String>) -> Self {
54        Self {
55            items: Vec::new(),
56            total: 0,
57            label: label.into(),
58        }
59    }
60
61    pub fn add(&mut self, name: impl Into<String>, count: i64) {
62        self.total += count;
63        self.items.push(BreakdownItem {
64            name: name.into(),
65            count,
66            percentage: 0.0,
67        });
68    }
69
70    pub fn finalize(&mut self) {
71        if self.total > 0 {
72            self.items.iter_mut().for_each(|item| {
73                item.percentage = (item.count as f64 / self.total as f64) * 100.0;
74            });
75        }
76        self.items.sort_by_key(|x| std::cmp::Reverse(x.count));
77    }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
81pub struct MetricCard {
82    pub label: String,
83    pub value: String,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub change: Option<String>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub secondary: Option<String>,
88}
89
90impl MetricCard {
91    pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
92        Self {
93            label: label.into(),
94            value: value.into(),
95            change: None,
96            secondary: None,
97        }
98    }
99
100    pub fn with_change(mut self, change: impl Into<String>) -> Self {
101        self.change = Some(change.into());
102        self
103    }
104
105    pub fn with_secondary(mut self, secondary: impl Into<String>) -> Self {
106        self.secondary = Some(secondary.into());
107        self
108    }
109}
110
111pub fn format_number(n: i64) -> String {
112    let s = n.abs().to_string();
113    let chars: Vec<char> = s.chars().collect();
114    let formatted: String = chars
115        .iter()
116        .rev()
117        .enumerate()
118        .flat_map(|(i, c)| {
119            if i > 0 && i % 3 == 0 {
120                vec![',', *c]
121            } else {
122                vec![*c]
123            }
124        })
125        .collect::<Vec<_>>()
126        .into_iter()
127        .rev()
128        .collect();
129
130    if n < 0 {
131        format!("-{}", formatted)
132    } else {
133        formatted
134    }
135}
136
137pub fn format_cost(microdollars: i64) -> String {
138    let dollars = microdollars as f64 / 1_000_000.0;
139    match dollars {
140        d if d < 0.01 && microdollars > 0 => format!("${:.4}", d),
141        d if d < 100.0 => format!("${:.2}", d),
142        _ => format!("${:.0}", dollars),
143    }
144}
145
146pub fn format_percent(value: f64) -> String {
147    match value.abs() {
148        v if v < 0.1 => format!("{:.2}%", value),
149        v if v < 10.0 => format!("{:.1}%", value),
150        _ => format!("{:.0}%", value),
151    }
152}
153
154pub fn format_change(current: i64, previous: i64) -> Option<String> {
155    (previous != 0).then(|| {
156        let change = ((current - previous) as f64 / previous as f64) * 100.0;
157        let sign = if change >= 0.0 { "+" } else { "" };
158        format!("{}{:.1}%", sign, change)
159    })
160}
161
162pub fn format_tokens(tokens: i64) -> String {
163    match tokens {
164        t if t < 1000 => format!("{}", t),
165        t if t < 1_000_000 => format!("{:.1}K", t as f64 / 1000.0),
166        _ => format!("{:.1}M", tokens as f64 / 1_000_000.0),
167    }
168}