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