1use crate::event::TokenUsage;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Competitor {
17 ClaudeCode, Codex, OpenCode, Cursor, Copilot, Aider, }
24
25impl Competitor {
26 pub fn display_name(&self) -> &str {
28 match self {
29 Competitor::ClaudeCode => "Claude Code",
30 Competitor::Codex => "Codex CLI",
31 Competitor::OpenCode => "OpenCode",
32 Competitor::Cursor => "Cursor",
33 Competitor::Copilot => "GitHub Copilot",
34 Competitor::Aider => "Aider",
35 }
36 }
37
38 pub fn price_per_mtok(&self) -> (f64, f64) {
41 match self {
42 Competitor::ClaudeCode => (3.0, 15.0),
44 Competitor::Codex => (2.5, 10.0),
46 Competitor::OpenCode => (3.0, 15.0),
48 Competitor::Cursor => (2.5, 10.0),
50 Competitor::Copilot => (2.5, 10.0),
52 Competitor::Aider => (3.0, 15.0),
54 }
55 }
56
57 pub fn estimate_cost(&self, tokens: &TokenUsage) -> f64 {
59 let (in_price, out_price) = self.price_per_mtok();
60 let input_cost = tokens.input as f64 * in_price / 1_000_000.0;
61 let output_cost = tokens.output as f64 * out_price / 1_000_000.0;
62 input_cost + output_cost
63 }
64}
65
66#[derive(Debug, Clone)]
70pub struct CostLine {
71 pub competitor: Competitor,
72 pub estimated_cost: f64,
73 pub savings: f64, pub savings_percent: f64, }
76
77pub fn compare(sparrow_cost: f64, tokens: &TokenUsage) -> Vec<CostLine> {
82 let competitors = [
83 Competitor::ClaudeCode,
84 Competitor::Codex,
85 Competitor::OpenCode,
86 ];
87
88 competitors
89 .iter()
90 .map(|c| {
91 let estimated = c.estimate_cost(tokens);
92 let savings = sparrow_cost - estimated; let savings_percent = if estimated > 0.0 {
94 ((estimated - sparrow_cost) / estimated * 100.0).max(0.0)
95 } else {
96 0.0
97 };
98 CostLine {
99 competitor: *c,
100 estimated_cost: estimated,
101 savings,
102 savings_percent,
103 }
104 })
105 .collect()
106}
107
108fn fmt_usd(amount: f64) -> String {
111 if amount.abs() >= 0.01 {
112 format!("${:.2}", amount)
113 } else {
114 format!("${:.4}", amount)
115 }
116}
117
118pub fn format_comparison(sparrow_cost: f64, tokens: &TokenUsage) -> String {
127 let lines = compare(sparrow_cost, tokens);
128 let mut out = String::new();
129
130 out.push_str("── Cost ──────────────────────────────────────────\n");
131 out.push_str(&format!(
132 "Sparrow .............. ${:.4} ({} in / {} out)\n",
133 sparrow_cost, tokens.input, tokens.output
134 ));
135
136 for line in &lines {
137 if line.savings_percent > 0.0 {
138 out.push_str(&format!(
139 "{} .......... ~${:.4} est. (save {:.0}% — {} cheaper)\n",
140 line.competitor.display_name(),
141 line.estimated_cost,
142 line.savings_percent,
143 fmt_usd(-line.savings)
144 ));
145 } else {
146 out.push_str(&format!(
149 "{} .......... ~${:.4} est. (comparable on this run)\n",
150 line.competitor.display_name(),
151 line.estimated_cost
152 ));
153 }
154 }
155
156 if let Some(best) = lines
159 .iter()
160 .max_by(|a, b| a.savings_percent.partial_cmp(&b.savings_percent).unwrap())
161 {
162 if best.savings_percent > 50.0 {
163 out.push_str(&format!(
164 "\n💡 Same tokens on {} would have cost ~{} more (est. at list price).",
165 best.competitor.display_name(),
166 fmt_usd(-best.savings)
167 ));
168 }
169 }
170
171 out
172}
173
174pub fn format_comparison_oneliner(sparrow_cost: f64, tokens: &TokenUsage) -> String {
176 let lines = compare(sparrow_cost, tokens);
177 let best = lines
178 .iter()
179 .max_by(|a, b| a.savings_percent.partial_cmp(&b.savings_percent).unwrap());
180
181 match best {
182 Some(line) if line.savings_percent > 30.0 => format!(
183 "(vs {}: ~{} est. — save {:.0}%)",
184 line.competitor.display_name(),
185 fmt_usd(line.estimated_cost),
186 line.savings_percent
187 ),
188 _ => String::new(),
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn test_competitor_pricing_known() {
198 let cc = Competitor::ClaudeCode.price_per_mtok();
199 assert_eq!(cc, (3.0, 15.0));
200
201 let cx = Competitor::Codex.price_per_mtok();
202 assert_eq!(cx, (2.5, 10.0));
203 }
204
205 #[test]
206 fn test_estimate_cost_zero_tokens() {
207 let tokens = TokenUsage {
208 input: 0,
209 output: 0,
210 };
211 assert_eq!(Competitor::ClaudeCode.estimate_cost(&tokens), 0.0);
212 }
213
214 #[test]
215 fn test_estimate_cost_typical_run() {
216 let tokens = TokenUsage {
218 input: 5_000,
219 output: 1_000,
220 };
221 let cost = Competitor::ClaudeCode.estimate_cost(&tokens);
222 assert!((cost - 0.030).abs() < 0.001);
226 }
227
228 #[test]
229 fn test_compare_returns_three_competitors() {
230 let tokens = TokenUsage {
231 input: 10_000,
232 output: 2_000,
233 };
234 let lines = compare(0.05, &tokens);
235 assert_eq!(lines.len(), 3);
236 }
237
238 #[test]
239 fn test_format_comparison_shows_savings() {
240 let tokens = TokenUsage {
241 input: 10_000,
242 output: 2_000,
243 };
244 let report = format_comparison(0.02, &tokens);
245 assert!(report.contains("Sparrow"));
246 assert!(report.contains("Claude Code"));
247 assert!(report.contains("save"));
248 assert!(report.contains("cheaper"));
249 }
250
251 #[test]
252 fn test_oneliner_includes_savings() {
253 let tokens = TokenUsage {
254 input: 100_000,
255 output: 20_000,
256 };
257 let oneliner = format_comparison_oneliner(0.10, &tokens);
258 assert!(oneliner.contains("save"));
259 assert!(oneliner.contains("%"));
260 }
261}