1use crate::core::wrapped::{format_tokens, WrappedReport};
9
10const CARD_W: u32 = 1200;
12const CARD_H: u32 = 630;
13
14impl WrappedReport {
15 pub fn to_svg(&self) -> String {
17 let period_label = match self.period.as_str() {
18 "week" => format!("Week of {}", chrono::Utc::now().format("%b %d, %Y")),
19 "month" => format!("Month of {}", chrono::Utc::now().format("%B %Y")),
20 _ => "All Time".to_string(),
21 };
22
23 let saved = format_tokens(self.tokens_saved);
24 let cost = format!("${:.2}", self.cost_avoided_usd);
25 let est = if self.pricing_estimated {
26 " (est.)"
27 } else {
28 ""
29 };
30 let secondary = self.svg_secondary_metrics();
31 let model_line = if self.model_key.is_empty() {
33 String::new()
34 } else {
35 format!(
36 r##" <text x="70" y="606" fill="#475569" font-size="17">priced at {}{}</text>"##,
37 escape(&self.model_key),
38 est
39 )
40 };
41
42 let spark = self.svg_sparkline();
43 let top = self.svg_top_commands();
44 let bounce_note = if self.bounce_tokens > 0 {
45 format!(
46 " - {} bounce",
47 crate::core::wrapped::format_tokens(self.bounce_tokens)
48 )
49 } else {
50 String::new()
51 };
52
53 format!(
54 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{CARD_W}" height="{CARD_H}" viewBox="0 0 {CARD_W} {CARD_H}" font-family="Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
55 <defs>
56 <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
57 <stop offset="0" stop-color="#0b1020"/>
58 <stop offset="1" stop-color="#131a2e"/>
59 </linearGradient>
60 <linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
61 <stop offset="0" stop-color="#34d399"/>
62 <stop offset="1" stop-color="#22d3ee"/>
63 </linearGradient>
64 </defs>
65 <rect width="{CARD_W}" height="{CARD_H}" fill="url(#bg)"/>
66 <rect x="0" y="0" width="{CARD_W}" height="8" fill="url(#accent)"/>
67
68 <text x="70" y="92" fill="#e5e7eb" font-size="34" font-weight="700">lean-ctx <tspan fill="#34d399">Wrapped</tspan></text>
69 <text x="70" y="130" fill="#94a3b8" font-size="24">{period}</text>
70
71 <text x="70" y="300" fill="#34d399" font-size="138" font-weight="800" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">{saved}</text>
72 <text x="76" y="346" fill="#94a3b8" font-size="26">tokens saved</text>
73
74 <text x="730" y="252" fill="#22d3ee" font-size="84" font-weight="800" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">{cost}</text>
75 <text x="734" y="292" fill="#94a3b8" font-size="24">cost avoided{est}</text>
76
77{secondary}
78{spark}
79{top}
80 <text x="70" y="582" fill="#64748b" font-size="19">Savings = measured original - compressed{bounce_note} tokens · USD = upper bound</text>
81{model_line}
82 <text x="1130" y="592" text-anchor="end" fill="#34d399" font-size="26" font-weight="700">leanctx.com</text>
83</svg>"##,
84 period = escape(&period_label),
85 )
86 }
87
88 fn svg_secondary_metrics(&self) -> String {
93 let mut items: Vec<(String, &str)> = vec![
94 (format!("{:.1}%", self.compression_rate_pct), "compression"),
95 (
96 crate::core::energy::format_for_tokens(self.tokens_saved),
97 "energy saved",
98 ),
99 ];
100 if self.total_commands > 0 {
101 items.push((self.total_commands.to_string(), "commands"));
102 }
103 if self.sessions_count > 0 {
104 items.push((self.sessions_count.to_string(), "sessions"));
105 }
106 let xs = [70, 360, 650, 940];
107 let mut out = String::new();
108 for (i, (val, label)) in items.iter().take(xs.len()).enumerate() {
109 let x = xs[i];
110 out.push_str(&format!(
111 " <text x=\"{x}\" y=\"412\" fill=\"#e5e7eb\" font-size=\"44\" font-weight=\"700\" font-family=\"ui-monospace, SFMono-Regular, Menlo, monospace\">{val}</text>\n <text x=\"{lx}\" y=\"442\" fill=\"#94a3b8\" font-size=\"22\">{label}</text>",
112 lx = x + 2,
113 val = escape(val),
114 ));
115 if i + 1 < items.len() {
116 out.push('\n');
117 }
118 }
119 out
120 }
121
122 fn svg_sparkline(&self) -> String {
125 let vals = &self.daily_savings;
126 if vals.iter().filter(|v| **v > 0).count() < 2 {
127 return String::new();
128 }
129 let max = (*vals.iter().max().unwrap_or(&1)).max(1) as f64;
130 let (x0, x1) = (70.0_f64, 1130.0_f64);
131 let baseline = 515.0_f64;
132 let height = 55.0_f64;
133 let n = vals.len().max(2);
134 let dx = (x1 - x0) / (n as f64 - 1.0);
135 let mut points = String::new();
136 for (i, v) in vals.iter().enumerate() {
137 let x = x0 + dx * i as f64;
138 let y = baseline - (*v as f64 / max) * height;
139 points.push_str(&format!("{x:.1},{y:.1} "));
140 }
141 format!(
142 " <polyline fill=\"none\" stroke=\"url(#accent)\" stroke-width=\"3\" stroke-linejoin=\"round\" stroke-linecap=\"round\" points=\"{}\"/>",
143 points.trim()
144 )
145 }
146
147 fn svg_top_commands(&self) -> String {
149 if self.top_commands.is_empty() {
150 return String::new();
151 }
152 let joined = self
153 .top_commands
154 .iter()
155 .take(3)
156 .map(|(cmd, _, pct)| format!("{cmd} {pct:.0}%"))
157 .collect::<Vec<_>>()
158 .join(" · ");
159 format!(
160 " <text x=\"70\" y=\"548\" fill=\"#cbd5e1\" font-size=\"22\">top {}</text>",
161 escape(&joined)
162 )
163 }
164}
165
166fn escape(s: &str) -> String {
168 s.replace('&', "&")
169 .replace('<', "<")
170 .replace('>', ">")
171 .replace('"', """)
172 .replace('\'', "'")
173}
174
175#[cfg(test)]
176mod tests {
177 use crate::core::wrapped::WrappedReport;
178
179 fn sample() -> WrappedReport {
180 WrappedReport {
181 period: "all".into(),
182 tokens_saved: 348_300_000,
183 tokens_input: 580_000_000,
184 cost_avoided_usd: 870.81,
185 total_commands: 17_055,
186 sessions_count: 67,
187 top_commands: vec![
188 ("ctx_search".into(), 100, 60.0),
189 ("cli_grep <x>".into(), 80, 85.0),
190 ("cli_shell".into(), 50, 37.0),
191 ],
192 compression_rate_pct: 60.2,
193 files_touched: 1_234,
194 daily_savings: vec![10, 50, 30, 30, 80, 80, 20, 5, 5, 40, 60, 40, 5, 50, 15],
195 bounce_tokens: 0,
196 model_key: "claude-3.5-sonnet".into(),
197 pricing_estimated: false,
198 }
199 }
200
201 #[test]
202 fn svg_is_well_formed_and_branded() {
203 let svg = sample().to_svg();
204 assert!(svg.starts_with("<svg"), "must be an SVG document");
205 assert!(svg.trim_end().ends_with("</svg>"), "must close the svg tag");
206 assert!(svg.contains("leanctx.com"), "must carry the brand footer");
207 assert!(svg.contains("Wrapped"));
208 assert!(svg.contains("tokens saved"));
209 assert!(svg.contains("348.3M"), "must render formatted tokens saved");
211 }
212
213 #[test]
214 fn svg_states_methodology_and_model() {
215 let svg = sample().to_svg();
216 assert!(
217 svg.contains("upper bound"),
218 "must state USD is an upper bound"
219 );
220 assert!(
221 svg.contains("claude-3.5-sonnet"),
222 "must name the pricing model"
223 );
224 }
225
226 #[test]
227 fn svg_escapes_command_names() {
228 let svg = sample().to_svg();
229 assert!(
231 svg.contains("cli_grep <x>"),
232 "command names must be escaped"
233 );
234 }
235
236 #[test]
237 fn svg_minimal_card_shows_energy_and_omits_empty_fields() {
238 let mut r = sample();
241 r.total_commands = 0;
242 r.sessions_count = 0;
243 r.model_key = String::new();
244 r.top_commands = vec![];
245 let svg = r.to_svg();
246 assert!(
247 svg.contains(">energy saved<"),
248 "energy is always shown:\n{svg}"
249 );
250 assert!(svg.contains(">compression<"), "compression is always shown");
251 assert!(!svg.contains(">commands<"), "no commands label when zero");
252 assert!(!svg.contains(">sessions<"), "no sessions label when zero");
253 assert!(!svg.contains("priced at"), "no model line when model empty");
254 }
255
256 #[test]
257 fn svg_omits_sparkline_without_history() {
258 let mut r = sample();
259 r.daily_savings = vec![0];
260 let svg = r.to_svg();
261 assert!(
262 !svg.contains("<polyline"),
263 "no sparkline without enough history"
264 );
265 assert!(svg.contains("</svg>"));
267 }
268}