1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use crate::analytics::plugin_usage::PluginAnalytics;
11use crate::models::session::SessionMetadata;
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub enum OptimizationCategory {
16 UnusedPlugin,
18 HighCostTool,
20 ModelDowngrade,
22 RedundantCalls,
24}
25
26impl OptimizationCategory {
27 pub fn label(&self) -> &'static str {
29 match self {
30 Self::UnusedPlugin => "Unused Plugin",
31 Self::HighCostTool => "High-Cost Tool",
32 Self::ModelDowngrade => "Model Downgrade",
33 Self::RedundantCalls => "Redundant Calls",
34 }
35 }
36
37 pub fn icon(&self) -> &'static str {
39 match self {
40 Self::UnusedPlugin => "🗑️",
41 Self::HighCostTool => "🔥",
42 Self::ModelDowngrade => "⬇️",
43 Self::RedundantCalls => "♻️",
44 }
45 }
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct CostSuggestion {
51 pub category: OptimizationCategory,
53 pub title: String,
55 pub description: String,
57 pub potential_savings: f64,
59 pub action: String,
61}
62
63pub fn generate_cost_suggestions(
72 plugin_analytics: &PluginAnalytics,
73 tool_token_usage: &HashMap<String, u64>,
74 total_monthly_cost: f64,
75) -> Vec<CostSuggestion> {
76 let mut suggestions: Vec<CostSuggestion> = Vec::new();
77
78 for dead_name in &plugin_analytics.dead_plugins {
80 suggestions.push(CostSuggestion {
81 category: OptimizationCategory::UnusedPlugin,
82 title: format!("Unused plugin: {}", dead_name),
83 description: format!(
84 "'{}' is defined in .claude/ but has never been invoked across all scanned sessions.",
85 dead_name
86 ),
87 potential_savings: 0.0,
88 action: format!(
89 "Remove or archive '{}' to reduce cognitive noise in your setup.",
90 dead_name
91 ),
92 });
93 }
94
95 if !tool_token_usage.is_empty() {
97 let total_tokens: u64 = tool_token_usage.values().sum();
98 if total_tokens > 0 {
99 let mut sorted_tools: Vec<(&String, &u64)> = tool_token_usage.iter().collect();
100 sorted_tools.sort_by(|a, b| b.1.cmp(a.1));
101
102 for (tool, &tokens) in &sorted_tools {
103 let pct = tokens as f64 / total_tokens as f64;
104 if pct > 0.20 {
105 let estimated_cost = total_monthly_cost * pct;
106 let savings = estimated_cost * 0.30; suggestions.push(CostSuggestion {
108 category: OptimizationCategory::HighCostTool,
109 title: format!("High-cost tool: {}", tool),
110 description: format!(
111 "'{}' consumes {:.1}% of your total token budget ({} tokens). \
112 This accounts for an estimated ${:.2}/month.",
113 tool,
114 pct * 100.0,
115 tokens,
116 estimated_cost
117 ),
118 potential_savings: savings,
119 action: format!(
120 "Review usage of '{}' — consider batching calls, caching results, \
121 or reducing call frequency.",
122 tool
123 ),
124 });
125 }
126 }
127 }
128 }
129
130 suggestions.sort_by(|a, b| {
132 b.potential_savings
133 .partial_cmp(&a.potential_savings)
134 .unwrap_or(std::cmp::Ordering::Equal)
135 });
136
137 suggestions
138}
139
140pub fn generate_model_recommendations(
148 sessions: &[Arc<SessionMetadata>],
149 total_monthly_cost: f64,
150) -> Vec<CostSuggestion> {
151 const MIN_SESSIONS: usize = 5;
152 const OPUS_THRESHOLD: f64 = 0.60;
153 const LOW_TOOL_CALLS: usize = 8;
154
155 if sessions.len() < MIN_SESSIONS || total_monthly_cost < 0.50 {
156 return vec![];
157 }
158
159 let mut opus_sessions = 0usize;
160 let mut total_tool_calls_opus = 0usize;
161
162 for session in sessions {
163 let uses_opus = session
164 .models_used
165 .iter()
166 .any(|m| m.to_lowercase().contains("opus"));
167 if uses_opus {
168 opus_sessions += 1;
169 total_tool_calls_opus += session.tool_usage.values().sum::<usize>();
170 }
171 }
172
173 if opus_sessions == 0 {
174 return vec![];
175 }
176
177 let opus_pct = opus_sessions as f64 / sessions.len() as f64;
178 let avg_tool_calls = total_tool_calls_opus as f64 / opus_sessions as f64;
179
180 if opus_pct > OPUS_THRESHOLD && avg_tool_calls < LOW_TOOL_CALLS as f64 {
181 let savings = total_monthly_cost * opus_pct * 0.70;
183 vec![CostSuggestion {
184 category: OptimizationCategory::ModelDowngrade,
185 title: format!("Switch {:.0}% of Opus sessions to Sonnet", opus_pct * 100.0),
186 description: format!(
187 "{:.0}% of sessions use Opus with only ~{:.0} avg tool calls — \
188 Sonnet handles most coding tasks at ~5× lower cost.",
189 opus_pct * 100.0,
190 avg_tool_calls
191 ),
192 potential_savings: savings,
193 action: "Use claude-sonnet-4-6 for routine tasks, reserve Opus 4.6 for complex \
194 multi-step reasoning."
195 .to_string(),
196 }]
197 } else {
198 vec![]
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use crate::analytics::plugin_usage::PluginAnalytics;
206 use crate::models::session::SessionMetadata;
207 use std::collections::HashMap;
208 use std::sync::Arc;
209
210 fn empty_analytics() -> PluginAnalytics {
211 PluginAnalytics::empty()
212 }
213
214 #[test]
215 fn test_no_suggestions_empty_data() {
216 let analytics = empty_analytics();
217 let tool_tokens: HashMap<String, u64> = HashMap::new();
218 let suggestions = generate_cost_suggestions(&analytics, &tool_tokens, 0.0);
219 assert!(suggestions.is_empty());
220 }
221
222 #[test]
223 fn test_unused_plugin_suggestion() {
224 let mut analytics = empty_analytics();
225 analytics.dead_plugins = vec!["unused-skill".to_string(), "old-command".to_string()];
226
227 let tool_tokens: HashMap<String, u64> = HashMap::new();
228 let suggestions = generate_cost_suggestions(&analytics, &tool_tokens, 10.0);
229
230 let unused: Vec<_> = suggestions
231 .iter()
232 .filter(|s| s.category == OptimizationCategory::UnusedPlugin)
233 .collect();
234 assert_eq!(unused.len(), 2);
235 }
236
237 #[test]
238 fn test_high_cost_tool_threshold() {
239 let analytics = empty_analytics();
240 let mut tool_tokens = HashMap::new();
241 tool_tokens.insert("Bash".to_string(), 500u64);
243 tool_tokens.insert("Read".to_string(), 500u64);
244
245 let suggestions = generate_cost_suggestions(&analytics, &tool_tokens, 20.0);
246
247 let high_cost: Vec<_> = suggestions
248 .iter()
249 .filter(|s| s.category == OptimizationCategory::HighCostTool)
250 .collect();
251 assert_eq!(high_cost.len(), 2);
253 assert!(high_cost.iter().all(|s| s.potential_savings > 0.0));
255 }
256
257 #[test]
258 fn test_below_threshold_no_high_cost() {
259 let analytics = empty_analytics();
260 let mut tool_tokens = HashMap::new();
261 for i in 0..5 {
263 tool_tokens.insert(format!("Tool{}", i), 200u64);
264 }
265
266 let suggestions = generate_cost_suggestions(&analytics, &tool_tokens, 10.0);
267 let high_cost: Vec<_> = suggestions
268 .iter()
269 .filter(|s| s.category == OptimizationCategory::HighCostTool)
270 .collect();
271 assert!(high_cost.is_empty(), "20% exactly is not above threshold");
272 }
273
274 #[test]
275 fn test_sorted_by_savings() {
276 let analytics = empty_analytics();
277 let mut tool_tokens = HashMap::new();
278 tool_tokens.insert("Bash".to_string(), 800u64);
280 tool_tokens.insert("Read".to_string(), 200u64);
281
282 let suggestions = generate_cost_suggestions(&analytics, &tool_tokens, 100.0);
283
284 if suggestions.len() >= 2 {
285 assert!(
286 suggestions[0].potential_savings >= suggestions[1].potential_savings,
287 "Should be sorted by potential savings descending"
288 );
289 }
290 }
291
292 fn make_session(id: &str, model: &str, tool_calls: usize) -> Arc<SessionMetadata> {
295 let mut tool_usage = HashMap::new();
296 if tool_calls > 0 {
297 tool_usage.insert("Bash".to_string(), tool_calls);
298 }
299 Arc::new(SessionMetadata {
300 id: id.into(),
301 file_path: std::path::PathBuf::from(format!("/tmp/{}.jsonl", id)),
302 project_path: "test".into(),
303 first_timestamp: Some(chrono::Utc::now()),
304 last_timestamp: Some(chrono::Utc::now()),
305 message_count: 5,
306 total_tokens: 10_000,
307 input_tokens: 5_000,
308 output_tokens: 5_000,
309 cache_creation_tokens: 0,
310 cache_read_tokens: 0,
311 models_used: vec![model.to_string()],
312 file_size_bytes: 1024,
313 first_user_message: None,
314 has_subagents: false,
315 duration_seconds: Some(60),
316 branch: None,
317 tool_usage,
318 tool_token_usage: HashMap::new(),
319 })
320 }
321
322 #[test]
323 fn test_model_recommendations_too_few_sessions() {
324 let sessions: Vec<_> = (0..4)
326 .map(|i| make_session(&format!("s{}", i), "claude-opus-4-6", 3))
327 .collect();
328 let recs = generate_model_recommendations(&sessions, 50.0);
329 assert!(recs.is_empty(), "Need at least 5 sessions");
330 }
331
332 #[test]
333 fn test_model_recommendations_low_cost() {
334 let sessions: Vec<_> = (0..10)
336 .map(|i| make_session(&format!("s{}", i), "claude-opus-4-6", 3))
337 .collect();
338 let recs = generate_model_recommendations(&sessions, 0.40);
339 assert!(recs.is_empty(), "Cost too low to recommend downgrade");
340 }
341
342 #[test]
343 fn test_model_recommendations_sonnet_heavy() {
344 let mut sessions: Vec<_> = (0..8)
346 .map(|i| make_session(&format!("sonnet_{}", i), "claude-sonnet-4-6", 5))
347 .collect();
348 sessions.extend((0..2).map(|i| make_session(&format!("opus_{}", i), "claude-opus-4-6", 3)));
349 let recs = generate_model_recommendations(&sessions, 50.0);
350 assert!(recs.is_empty(), "Mostly Sonnet — no downgrade needed");
351 }
352
353 #[test]
354 fn test_model_recommendations_opus_heavy_low_tools() {
355 let sessions: Vec<_> = (0..10)
357 .map(|i| make_session(&format!("s{}", i), "claude-opus-4-6", 4)) .collect();
359 let recs = generate_model_recommendations(&sessions, 100.0);
360 assert!(
361 !recs.is_empty(),
362 "Should recommend Sonnet for low-tool Opus sessions"
363 );
364 assert_eq!(recs[0].category, OptimizationCategory::ModelDowngrade);
365 assert!(recs[0].potential_savings > 0.0);
366 }
367
368 #[test]
369 fn test_model_recommendations_opus_heavy_high_tools() {
370 let sessions: Vec<_> = (0..10)
372 .map(|i| make_session(&format!("s{}", i), "claude-opus-4-6", 12)) .collect();
374 let recs = generate_model_recommendations(&sessions, 100.0);
375 assert!(recs.is_empty(), "High tool calls — Opus justified");
376 }
377}