1use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum ChartKind {
13 Bar,
14 StackedBar,
15 Line,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct SeriesPoint {
23 pub x: String,
24 pub y_num: i64,
25 pub y_denom: i64,
26}
27
28impl SeriesPoint {
29 #[must_use]
33 pub fn y_f64(&self) -> f64 {
34 if self.y_denom == 0 {
35 return 0.0;
36 }
37 self.y_num as f64 / self.y_denom as f64
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct Series {
43 pub label: String,
44 pub commodity_symbol: String,
46 pub points: Vec<SeriesPoint>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct ChartSpec {
54 pub title: String,
55 pub kind: ChartKind,
56 pub x_label: String,
57 pub y_label: String,
58 pub series: Vec<Series>,
59 pub notes: Vec<String>,
62}
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67
68 fn sample() -> ChartSpec {
69 ChartSpec {
70 title: "Income vs Expense".to_string(),
71 kind: ChartKind::StackedBar,
72 x_label: "Month".to_string(),
73 y_label: "Amount".to_string(),
74 series: vec![
75 Series {
76 label: "Income".to_string(),
77 commodity_symbol: "USD".to_string(),
78 points: vec![
79 SeriesPoint {
80 x: "2026-01".to_string(),
81 y_num: 3200,
82 y_denom: 1,
83 },
84 SeriesPoint {
85 x: "2026-02".to_string(),
86 y_num: 2800,
87 y_denom: 1,
88 },
89 ],
90 },
91 Series {
92 label: "Expense".to_string(),
93 commodity_symbol: "USD".to_string(),
94 points: vec![
95 SeriesPoint {
96 x: "2026-01".to_string(),
97 y_num: 2100,
98 y_denom: 1,
99 },
100 SeriesPoint {
101 x: "2026-02".to_string(),
102 y_num: 1900,
103 y_denom: 1,
104 },
105 ],
106 },
107 ],
108 notes: vec!["Showing USD only; 1 other commodity hidden".to_string()],
109 }
110 }
111
112 #[test]
113 fn spec_round_trips_through_json() {
114 let original = sample();
115 let encoded = serde_json::to_string(&original).expect("serialise");
116 let decoded: ChartSpec = serde_json::from_str(&encoded).expect("deserialise");
117 assert_eq!(decoded, original, "wire format must be lossless");
118 }
119
120 #[test]
121 fn chart_kind_serialises_as_lowercase() {
122 let s = serde_json::to_string(&ChartKind::StackedBar).expect("serialise");
123 assert_eq!(s, "\"stackedbar\"");
124 }
125
126 #[test]
127 fn series_point_converts_to_f64() {
128 let p = SeriesPoint {
129 x: "x".to_string(),
130 y_num: 3,
131 y_denom: 2,
132 };
133 assert!((p.y_f64() - 1.5).abs() < f64::EPSILON);
134 }
135
136 #[test]
137 fn series_point_zero_denominator_is_safe() {
138 let p = SeriesPoint {
139 x: "x".to_string(),
140 y_num: 42,
141 y_denom: 0,
142 };
143 assert_eq!(p.y_f64(), 0.0);
144 }
145}