use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use crate::analytics::plugin_usage::PluginAnalytics;
use crate::models::session::SessionMetadata;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum OptimizationCategory {
UnusedPlugin,
HighCostTool,
ModelDowngrade,
RedundantCalls,
}
impl OptimizationCategory {
pub fn label(&self) -> &'static str {
match self {
Self::UnusedPlugin => "Unused Plugin",
Self::HighCostTool => "High-Cost Tool",
Self::ModelDowngrade => "Model Downgrade",
Self::RedundantCalls => "Redundant Calls",
}
}
pub fn icon(&self) -> &'static str {
match self {
Self::UnusedPlugin => "🗑️",
Self::HighCostTool => "🔥",
Self::ModelDowngrade => "⬇️",
Self::RedundantCalls => "♻️",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostSuggestion {
pub category: OptimizationCategory,
pub title: String,
pub description: String,
pub potential_savings: f64,
pub action: String,
}
pub fn generate_cost_suggestions(
plugin_analytics: &PluginAnalytics,
tool_token_usage: &HashMap<String, u64>,
total_monthly_cost: f64,
) -> Vec<CostSuggestion> {
let mut suggestions: Vec<CostSuggestion> = Vec::new();
for dead_name in &plugin_analytics.dead_plugins {
suggestions.push(CostSuggestion {
category: OptimizationCategory::UnusedPlugin,
title: format!("Unused plugin: {}", dead_name),
description: format!(
"'{}' is defined in .claude/ but has never been invoked across all scanned sessions.",
dead_name
),
potential_savings: 0.0,
action: format!(
"Remove or archive '{}' to reduce cognitive noise in your setup.",
dead_name
),
});
}
if !tool_token_usage.is_empty() {
let total_tokens: u64 = tool_token_usage.values().sum();
if total_tokens > 0 {
let mut sorted_tools: Vec<(&String, &u64)> = tool_token_usage.iter().collect();
sorted_tools.sort_by(|a, b| b.1.cmp(a.1));
for (tool, &tokens) in &sorted_tools {
let pct = tokens as f64 / total_tokens as f64;
if pct > 0.20 {
let estimated_cost = total_monthly_cost * pct;
let savings = estimated_cost * 0.30; suggestions.push(CostSuggestion {
category: OptimizationCategory::HighCostTool,
title: format!("High-cost tool: {}", tool),
description: format!(
"'{}' consumes {:.1}% of your total token budget ({} tokens). \
This accounts for an estimated ${:.2}/month.",
tool,
pct * 100.0,
tokens,
estimated_cost
),
potential_savings: savings,
action: format!(
"Review usage of '{}' — consider batching calls, caching results, \
or reducing call frequency.",
tool
),
});
}
}
}
}
suggestions.sort_by(|a, b| {
b.potential_savings
.partial_cmp(&a.potential_savings)
.unwrap_or(std::cmp::Ordering::Equal)
});
suggestions
}
pub fn generate_model_recommendations(
sessions: &[Arc<SessionMetadata>],
total_monthly_cost: f64,
) -> Vec<CostSuggestion> {
const MIN_SESSIONS: usize = 5;
const OPUS_THRESHOLD: f64 = 0.60;
const LOW_TOOL_CALLS: usize = 8;
if sessions.len() < MIN_SESSIONS || total_monthly_cost < 0.50 {
return vec![];
}
let mut opus_sessions = 0usize;
let mut total_tool_calls_opus = 0usize;
for session in sessions {
let uses_opus = session
.models_used
.iter()
.any(|m| m.to_lowercase().contains("opus"));
if uses_opus {
opus_sessions += 1;
total_tool_calls_opus += session.tool_usage.values().sum::<usize>();
}
}
if opus_sessions == 0 {
return vec![];
}
let opus_pct = opus_sessions as f64 / sessions.len() as f64;
let avg_tool_calls = total_tool_calls_opus as f64 / opus_sessions as f64;
if opus_pct > OPUS_THRESHOLD && avg_tool_calls < LOW_TOOL_CALLS as f64 {
let savings = total_monthly_cost * opus_pct * 0.70;
vec![CostSuggestion {
category: OptimizationCategory::ModelDowngrade,
title: format!("Switch {:.0}% of Opus sessions to Sonnet", opus_pct * 100.0),
description: format!(
"{:.0}% of sessions use Opus with only ~{:.0} avg tool calls — \
Sonnet handles most coding tasks at ~5× lower cost.",
opus_pct * 100.0,
avg_tool_calls
),
potential_savings: savings,
action: "Use claude-sonnet-4-6 for routine tasks, reserve Opus 4.6 for complex \
multi-step reasoning."
.to_string(),
}]
} else {
vec![]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analytics::plugin_usage::PluginAnalytics;
use crate::models::session::SessionMetadata;
use std::collections::HashMap;
use std::sync::Arc;
fn empty_analytics() -> PluginAnalytics {
PluginAnalytics::empty()
}
#[test]
fn test_no_suggestions_empty_data() {
let analytics = empty_analytics();
let tool_tokens: HashMap<String, u64> = HashMap::new();
let suggestions = generate_cost_suggestions(&analytics, &tool_tokens, 0.0);
assert!(suggestions.is_empty());
}
#[test]
fn test_unused_plugin_suggestion() {
let mut analytics = empty_analytics();
analytics.dead_plugins = vec!["unused-skill".to_string(), "old-command".to_string()];
let tool_tokens: HashMap<String, u64> = HashMap::new();
let suggestions = generate_cost_suggestions(&analytics, &tool_tokens, 10.0);
let unused: Vec<_> = suggestions
.iter()
.filter(|s| s.category == OptimizationCategory::UnusedPlugin)
.collect();
assert_eq!(unused.len(), 2);
}
#[test]
fn test_high_cost_tool_threshold() {
let analytics = empty_analytics();
let mut tool_tokens = HashMap::new();
tool_tokens.insert("Bash".to_string(), 500u64);
tool_tokens.insert("Read".to_string(), 500u64);
let suggestions = generate_cost_suggestions(&analytics, &tool_tokens, 20.0);
let high_cost: Vec<_> = suggestions
.iter()
.filter(|s| s.category == OptimizationCategory::HighCostTool)
.collect();
assert_eq!(high_cost.len(), 2);
assert!(high_cost.iter().all(|s| s.potential_savings > 0.0));
}
#[test]
fn test_below_threshold_no_high_cost() {
let analytics = empty_analytics();
let mut tool_tokens = HashMap::new();
for i in 0..5 {
tool_tokens.insert(format!("Tool{}", i), 200u64);
}
let suggestions = generate_cost_suggestions(&analytics, &tool_tokens, 10.0);
let high_cost: Vec<_> = suggestions
.iter()
.filter(|s| s.category == OptimizationCategory::HighCostTool)
.collect();
assert!(high_cost.is_empty(), "20% exactly is not above threshold");
}
#[test]
fn test_sorted_by_savings() {
let analytics = empty_analytics();
let mut tool_tokens = HashMap::new();
tool_tokens.insert("Bash".to_string(), 800u64);
tool_tokens.insert("Read".to_string(), 200u64);
let suggestions = generate_cost_suggestions(&analytics, &tool_tokens, 100.0);
if suggestions.len() >= 2 {
assert!(
suggestions[0].potential_savings >= suggestions[1].potential_savings,
"Should be sorted by potential savings descending"
);
}
}
fn make_session(id: &str, model: &str, tool_calls: usize) -> Arc<SessionMetadata> {
let mut tool_usage = HashMap::new();
if tool_calls > 0 {
tool_usage.insert("Bash".to_string(), tool_calls);
}
Arc::new(SessionMetadata {
id: id.into(),
file_path: std::path::PathBuf::from(format!("/tmp/{}.jsonl", id)),
project_path: "test".into(),
first_timestamp: Some(chrono::Utc::now()),
last_timestamp: Some(chrono::Utc::now()),
message_count: 5,
total_tokens: 10_000,
input_tokens: 5_000,
output_tokens: 5_000,
cache_creation_tokens: 0,
cache_read_tokens: 0,
models_used: vec![model.to_string()],
file_size_bytes: 1024,
first_user_message: None,
has_subagents: false,
duration_seconds: Some(60),
branch: None,
tool_usage,
tool_token_usage: HashMap::new(),
})
}
#[test]
fn test_model_recommendations_too_few_sessions() {
let sessions: Vec<_> = (0..4)
.map(|i| make_session(&format!("s{}", i), "claude-opus-4-6", 3))
.collect();
let recs = generate_model_recommendations(&sessions, 50.0);
assert!(recs.is_empty(), "Need at least 5 sessions");
}
#[test]
fn test_model_recommendations_low_cost() {
let sessions: Vec<_> = (0..10)
.map(|i| make_session(&format!("s{}", i), "claude-opus-4-6", 3))
.collect();
let recs = generate_model_recommendations(&sessions, 0.40);
assert!(recs.is_empty(), "Cost too low to recommend downgrade");
}
#[test]
fn test_model_recommendations_sonnet_heavy() {
let mut sessions: Vec<_> = (0..8)
.map(|i| make_session(&format!("sonnet_{}", i), "claude-sonnet-4-6", 5))
.collect();
sessions.extend((0..2).map(|i| make_session(&format!("opus_{}", i), "claude-opus-4-6", 3)));
let recs = generate_model_recommendations(&sessions, 50.0);
assert!(recs.is_empty(), "Mostly Sonnet — no downgrade needed");
}
#[test]
fn test_model_recommendations_opus_heavy_low_tools() {
let sessions: Vec<_> = (0..10)
.map(|i| make_session(&format!("s{}", i), "claude-opus-4-6", 4)) .collect();
let recs = generate_model_recommendations(&sessions, 100.0);
assert!(
!recs.is_empty(),
"Should recommend Sonnet for low-tool Opus sessions"
);
assert_eq!(recs[0].category, OptimizationCategory::ModelDowngrade);
assert!(recs[0].potential_savings > 0.0);
}
#[test]
fn test_model_recommendations_opus_heavy_high_tools() {
let sessions: Vec<_> = (0..10)
.map(|i| make_session(&format!("s{}", i), "claude-opus-4-6", 12)) .collect();
let recs = generate_model_recommendations(&sessions, 100.0);
assert!(recs.is_empty(), "High tool calls — Opus justified");
}
}