use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::models::session::SessionMetadata;
use crate::pricing::calculate_cost;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PluginType {
Skill,
Command,
Agent,
McpServer,
NativeTool,
}
impl PluginType {
pub fn icon(&self) -> &'static str {
match self {
Self::Skill => "🎓",
Self::Command => "⚡",
Self::Agent => "🤖",
Self::McpServer => "🔌",
Self::NativeTool => "🛠️",
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Skill => "Skill",
Self::Command => "Command",
Self::Agent => "Agent",
Self::McpServer => "MCP Server",
Self::NativeTool => "Native Tool",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginUsage {
pub name: String,
pub plugin_type: PluginType,
pub icon: String,
pub total_invocations: usize,
pub sessions_used: Vec<String>,
pub total_cost: f64,
pub avg_tokens_per_invocation: u64,
pub first_seen: DateTime<Utc>,
pub last_seen: DateTime<Utc>,
}
impl PluginUsage {
fn new(
name: String,
plugin_type: PluginType,
invocations: usize,
session_id: String,
cost: f64,
avg_tokens: u64,
timestamp: DateTime<Utc>,
) -> Self {
Self {
name,
icon: plugin_type.icon().to_string(),
plugin_type,
total_invocations: invocations,
sessions_used: vec![session_id],
total_cost: cost,
avg_tokens_per_invocation: avg_tokens,
first_seen: timestamp,
last_seen: timestamp,
}
}
#[allow(dead_code)]
fn merge(&mut self, other: &Self) {
self.total_invocations += other.total_invocations;
if !self.sessions_used.contains(&other.sessions_used[0]) {
self.sessions_used.push(other.sessions_used[0].clone());
}
self.total_cost += other.total_cost;
if other.first_seen < self.first_seen {
self.first_seen = other.first_seen;
}
if other.last_seen > self.last_seen {
self.last_seen = other.last_seen;
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginAnalytics {
pub total_plugins: usize,
pub active_plugins: usize,
pub dead_plugins: Vec<String>,
pub plugins: Vec<PluginUsage>,
pub top_by_usage: Vec<PluginUsage>,
pub top_by_cost: Vec<PluginUsage>,
pub computed_at: DateTime<Utc>,
}
impl PluginAnalytics {
pub fn empty() -> Self {
Self {
total_plugins: 0,
active_plugins: 0,
dead_plugins: Vec::new(),
plugins: Vec::new(),
top_by_usage: Vec::new(),
top_by_cost: Vec::new(),
computed_at: Utc::now(),
}
}
}
fn classify_plugin(name: &str, skills: &[String], commands: &[String]) -> PluginType {
if name.starts_with("mcp__") {
return PluginType::McpServer;
}
if name == "Task" {
return PluginType::Agent;
}
let lower_name = name.to_lowercase();
if skills
.iter()
.any(|s| lower_name.contains(&s.to_lowercase()))
{
return PluginType::Skill;
}
if commands
.iter()
.any(|c| lower_name.contains(&c.to_lowercase()))
{
return PluginType::Command;
}
const NATIVE_TOOLS: &[&str] = &[
"Read",
"Write",
"Edit",
"MultiEdit",
"Bash",
"Grep",
"Glob",
"WebSearch",
"WebFetch",
"NotebookEdit",
"AskUserQuestion",
"EnterPlanMode",
"ExitPlanMode",
"TaskCreate",
"TaskUpdate",
"TaskGet",
"TaskList",
"TeamCreate",
"TeamDelete",
"SendMessage",
"Skill",
];
if NATIVE_TOOLS.contains(&name) {
return PluginType::NativeTool;
}
PluginType::NativeTool
}
pub fn aggregate_plugin_usage(
sessions: &[Arc<SessionMetadata>],
available_skills: &[String],
available_commands: &[String],
) -> PluginAnalytics {
let mut usage_map: HashMap<String, PluginUsage> = HashMap::new();
for session in sessions {
let model = session
.models_used
.first()
.map(|s| s.as_str())
.unwrap_or("sonnet-4.5");
let session_cost = calculate_cost(
model,
session.input_tokens,
session.output_tokens,
session.cache_creation_tokens,
session.cache_read_tokens,
);
let session_tokens = session.total_tokens;
if session.tool_usage.is_empty() {
continue;
}
let total_calls: usize = session.tool_usage.values().sum();
if total_calls == 0 {
continue;
}
for (tool_name, call_count) in &session.tool_usage {
if tool_name.is_empty() {
continue;
}
let plugin_type = classify_plugin(tool_name, available_skills, available_commands);
let tool_cost = session_cost * (*call_count as f64 / total_calls as f64);
let avg_tokens = if *call_count > 0 {
session_tokens / *call_count as u64
} else {
0
};
let timestamp = session
.first_timestamp
.or(session.last_timestamp)
.unwrap_or_else(Utc::now);
usage_map
.entry(tool_name.clone())
.and_modify(|usage| {
usage.total_invocations += call_count;
if !usage.sessions_used.contains(&session.id.to_string()) {
usage.sessions_used.push(session.id.to_string());
}
usage.total_cost += tool_cost;
if let Some(first_ts) = session.first_timestamp {
if first_ts < usage.first_seen {
usage.first_seen = first_ts;
}
}
if let Some(last_ts) = session.last_timestamp {
if last_ts > usage.last_seen {
usage.last_seen = last_ts;
}
}
usage.avg_tokens_per_invocation = (usage.avg_tokens_per_invocation
* (usage.total_invocations - call_count) as u64
+ avg_tokens * *call_count as u64)
/ usage.total_invocations as u64;
})
.or_insert_with(|| {
PluginUsage::new(
tool_name.clone(),
plugin_type,
*call_count,
session.id.to_string(),
tool_cost,
avg_tokens,
timestamp,
)
});
}
}
let used_names: HashSet<_> = usage_map.keys().map(|s| s.to_lowercase()).collect();
let dead_plugins: Vec<String> = available_skills
.iter()
.chain(available_commands.iter())
.filter(|name| !used_names.contains(&name.to_lowercase()))
.cloned()
.collect();
let mut plugins: Vec<_> = usage_map.into_values().collect();
plugins.sort_by(|a, b| b.total_invocations.cmp(&a.total_invocations));
let top_by_usage = plugins.iter().take(10).cloned().collect();
let mut top_by_cost = plugins.clone();
top_by_cost.sort_by(|a, b| {
b.total_cost
.partial_cmp(&a.total_cost)
.unwrap_or(std::cmp::Ordering::Equal)
});
let top_by_cost = top_by_cost.into_iter().take(10).collect();
PluginAnalytics {
total_plugins: plugins.len() + dead_plugins.len(),
active_plugins: plugins.len(),
dead_plugins,
plugins,
top_by_usage,
top_by_cost,
computed_at: Utc::now(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_classify_plugin() {
let skills = vec!["rust-expert".to_string(), "boldguy-draft".to_string()];
let commands = vec!["commit".to_string()];
assert_eq!(
classify_plugin("rust-expert", &skills, &commands),
PluginType::Skill
);
assert_eq!(
classify_plugin("mcp__context7__search", &skills, &commands),
PluginType::McpServer
);
assert_eq!(
classify_plugin("Read", &skills, &commands),
PluginType::NativeTool
);
assert_eq!(
classify_plugin("Task", &skills, &commands),
PluginType::Agent
);
}
#[test]
fn test_aggregate_plugin_usage() {
use std::path::PathBuf;
let mut tool_usage1 = HashMap::new();
tool_usage1.insert("rust-expert".to_string(), 5);
tool_usage1.insert("mcp__context7__search".to_string(), 3);
let mut session1 = SessionMetadata::from_path(
PathBuf::from("/tmp/test-session.jsonl"),
"test-project".into(),
);
session1.tool_usage = tool_usage1;
session1.total_tokens = 10000;
session1.first_timestamp = Some(Utc::now());
session1.last_timestamp = Some(Utc::now());
let sessions = vec![Arc::new(session1)];
let skills = vec!["rust-expert".to_string()];
let commands = vec![];
let analytics = aggregate_plugin_usage(&sessions, &skills, &commands);
assert_eq!(analytics.active_plugins, 2);
assert_eq!(analytics.plugins[0].name, "rust-expert");
assert_eq!(analytics.plugins[0].total_invocations, 5);
assert_eq!(analytics.plugins[0].plugin_type, PluginType::Skill);
assert_eq!(analytics.plugins[1].name, "mcp__context7__search");
assert_eq!(analytics.plugins[1].plugin_type, PluginType::McpServer);
}
#[test]
fn test_dead_code_detection() {
let sessions = vec![];
let skills = vec!["rust-expert".to_string(), "unused-skill".to_string()];
let commands = vec!["commit".to_string(), "never-used".to_string()];
let analytics = aggregate_plugin_usage(&sessions, &skills, &commands);
assert_eq!(analytics.active_plugins, 0);
assert_eq!(analytics.dead_plugins.len(), 4); assert!(analytics.dead_plugins.contains(&"unused-skill".to_string()));
assert!(analytics.dead_plugins.contains(&"never-used".to_string()));
}
#[test]
fn test_empty_sessions() {
let sessions = vec![];
let skills = vec![];
let commands = vec![];
let analytics = aggregate_plugin_usage(&sessions, &skills, &commands);
assert_eq!(analytics.active_plugins, 0);
assert_eq!(analytics.total_plugins, 0);
assert!(analytics.plugins.is_empty());
}
}