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 percentile: Some(99),
199 }
200 }
201
202 #[test]
203 fn svg_is_well_formed_and_branded() {
204 let svg = sample().to_svg();
205 assert!(svg.starts_with("<svg"), "must be an SVG document");
206 assert!(svg.trim_end().ends_with("</svg>"), "must close the svg tag");
207 assert!(svg.contains("leanctx.com"), "must carry the brand footer");
208 assert!(svg.contains("Wrapped"));
209 assert!(svg.contains("tokens saved"));
210 assert!(svg.contains("348.3M"), "must render formatted tokens saved");
212 }
213
214 #[test]
215 fn svg_states_methodology_and_model() {
216 let svg = sample().to_svg();
217 assert!(
218 svg.contains("upper bound"),
219 "must state USD is an upper bound"
220 );
221 assert!(
222 svg.contains("claude-3.5-sonnet"),
223 "must name the pricing model"
224 );
225 }
226
227 #[test]
228 fn svg_escapes_command_names() {
229 let svg = sample().to_svg();
230 assert!(
232 svg.contains("cli_grep <x>"),
233 "command names must be escaped"
234 );
235 }
236
237 #[test]
238 fn svg_minimal_card_shows_energy_and_omits_empty_fields() {
239 let mut r = sample();
242 r.total_commands = 0;
243 r.sessions_count = 0;
244 r.model_key = String::new();
245 r.top_commands = vec![];
246 let svg = r.to_svg();
247 assert!(
248 svg.contains(">energy saved<"),
249 "energy is always shown:\n{svg}"
250 );
251 assert!(svg.contains(">compression<"), "compression is always shown");
252 assert!(!svg.contains(">commands<"), "no commands label when zero");
253 assert!(!svg.contains(">sessions<"), "no sessions label when zero");
254 assert!(!svg.contains("priced at"), "no model line when model empty");
255 }
256
257 #[test]
258 fn svg_omits_sparkline_without_history() {
259 let mut r = sample();
260 r.daily_savings = vec![0];
261 let svg = r.to_svg();
262 assert!(
263 !svg.contains("<polyline"),
264 "no sparkline without enough history"
265 );
266 assert!(svg.contains("</svg>"));
268 }
269}