1use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use std::collections::{HashMap, HashSet};
15use std::sync::Arc;
16
17use crate::models::session::SessionMetadata;
18use crate::pricing::calculate_cost;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22pub enum PluginType {
23 Skill,
25 Command,
27 Agent,
29 McpServer,
31 NativeTool,
33}
34
35impl PluginType {
36 pub fn icon(&self) -> &'static str {
38 match self {
39 Self::Skill => "🎓",
40 Self::Command => "⚡",
41 Self::Agent => "🤖",
42 Self::McpServer => "🔌",
43 Self::NativeTool => "🛠️",
44 }
45 }
46
47 pub fn label(&self) -> &'static str {
49 match self {
50 Self::Skill => "Skill",
51 Self::Command => "Command",
52 Self::Agent => "Agent",
53 Self::McpServer => "MCP Server",
54 Self::NativeTool => "Native Tool",
55 }
56 }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct PluginUsage {
62 pub name: String,
64 pub plugin_type: PluginType,
66 pub icon: String,
68 pub total_invocations: usize,
70 pub sessions_used: Vec<String>,
72 pub total_cost: f64,
74 pub avg_tokens_per_invocation: u64,
76 pub first_seen: DateTime<Utc>,
78 pub last_seen: DateTime<Utc>,
80}
81
82impl PluginUsage {
83 fn new(
85 name: String,
86 plugin_type: PluginType,
87 invocations: usize,
88 session_id: String,
89 cost: f64,
90 avg_tokens: u64,
91 timestamp: DateTime<Utc>,
92 ) -> Self {
93 Self {
94 name,
95 icon: plugin_type.icon().to_string(),
96 plugin_type,
97 total_invocations: invocations,
98 sessions_used: vec![session_id],
99 total_cost: cost,
100 avg_tokens_per_invocation: avg_tokens,
101 first_seen: timestamp,
102 last_seen: timestamp,
103 }
104 }
105
106 #[allow(dead_code)]
108 fn merge(&mut self, other: &Self) {
109 self.total_invocations += other.total_invocations;
110 if !self.sessions_used.contains(&other.sessions_used[0]) {
111 self.sessions_used.push(other.sessions_used[0].clone());
112 }
113 self.total_cost += other.total_cost;
114 if other.first_seen < self.first_seen {
115 self.first_seen = other.first_seen;
116 }
117 if other.last_seen > self.last_seen {
118 self.last_seen = other.last_seen;
119 }
120 }
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct PluginAnalytics {
126 pub total_plugins: usize,
128 pub active_plugins: usize,
130 pub dead_plugins: Vec<String>,
132 pub plugins: Vec<PluginUsage>,
134 pub top_by_usage: Vec<PluginUsage>,
136 pub top_by_cost: Vec<PluginUsage>,
138 pub computed_at: DateTime<Utc>,
140}
141
142impl PluginAnalytics {
143 pub fn empty() -> Self {
145 Self {
146 total_plugins: 0,
147 active_plugins: 0,
148 dead_plugins: Vec::new(),
149 plugins: Vec::new(),
150 top_by_usage: Vec::new(),
151 top_by_cost: Vec::new(),
152 computed_at: Utc::now(),
153 }
154 }
155}
156
157fn classify_plugin(name: &str, skills: &[String], commands: &[String]) -> PluginType {
159 if name.starts_with("mcp__") {
161 return PluginType::McpServer;
162 }
163
164 if name == "Task" {
166 return PluginType::Agent;
167 }
168
169 let lower_name = name.to_lowercase();
171 if skills
172 .iter()
173 .any(|s| lower_name.contains(&s.to_lowercase()))
174 {
175 return PluginType::Skill;
176 }
177
178 if commands
180 .iter()
181 .any(|c| lower_name.contains(&c.to_lowercase()))
182 {
183 return PluginType::Command;
184 }
185
186 const NATIVE_TOOLS: &[&str] = &[
188 "Read",
189 "Write",
190 "Edit",
191 "MultiEdit",
192 "Bash",
193 "Grep",
194 "Glob",
195 "WebSearch",
196 "WebFetch",
197 "NotebookEdit",
198 "AskUserQuestion",
199 "EnterPlanMode",
200 "ExitPlanMode",
201 "TaskCreate",
202 "TaskUpdate",
203 "TaskGet",
204 "TaskList",
205 "TeamCreate",
206 "TeamDelete",
207 "SendMessage",
208 "Skill",
209 ];
210
211 if NATIVE_TOOLS.contains(&name) {
212 return PluginType::NativeTool;
213 }
214
215 PluginType::NativeTool
217}
218
219pub fn aggregate_plugin_usage(
229 sessions: &[Arc<SessionMetadata>],
230 available_skills: &[String],
231 available_commands: &[String],
232) -> PluginAnalytics {
233 let mut usage_map: HashMap<String, PluginUsage> = HashMap::new();
234
235 for session in sessions {
237 let model = session
240 .models_used
241 .first()
242 .map(|s| s.as_str())
243 .unwrap_or("sonnet-4.5");
244 let session_cost = calculate_cost(
245 model,
246 session.input_tokens,
247 session.output_tokens,
248 session.cache_creation_tokens,
249 session.cache_read_tokens,
250 );
251 let session_tokens = session.total_tokens;
252
253 if session.tool_usage.is_empty() {
255 continue;
256 }
257
258 let total_calls: usize = session.tool_usage.values().sum();
260 if total_calls == 0 {
261 continue;
262 }
263
264 for (tool_name, call_count) in &session.tool_usage {
265 if tool_name.is_empty() {
267 continue;
268 }
269
270 let plugin_type = classify_plugin(tool_name, available_skills, available_commands);
271
272 let tool_cost = session_cost * (*call_count as f64 / total_calls as f64);
274
275 let avg_tokens = if *call_count > 0 {
277 session_tokens / *call_count as u64
278 } else {
279 0
280 };
281
282 let timestamp = session
284 .first_timestamp
285 .or(session.last_timestamp)
286 .unwrap_or_else(Utc::now);
287
288 usage_map
289 .entry(tool_name.clone())
290 .and_modify(|usage| {
291 usage.total_invocations += call_count;
292 if !usage.sessions_used.contains(&session.id.to_string()) {
293 usage.sessions_used.push(session.id.to_string());
294 }
295 usage.total_cost += tool_cost;
296
297 if let Some(first_ts) = session.first_timestamp {
299 if first_ts < usage.first_seen {
300 usage.first_seen = first_ts;
301 }
302 }
303 if let Some(last_ts) = session.last_timestamp {
304 if last_ts > usage.last_seen {
305 usage.last_seen = last_ts;
306 }
307 }
308
309 usage.avg_tokens_per_invocation = (usage.avg_tokens_per_invocation
311 * (usage.total_invocations - call_count) as u64
312 + avg_tokens * *call_count as u64)
313 / usage.total_invocations as u64;
314 })
315 .or_insert_with(|| {
316 PluginUsage::new(
317 tool_name.clone(),
318 plugin_type,
319 *call_count,
320 session.id.to_string(),
321 tool_cost,
322 avg_tokens,
323 timestamp,
324 )
325 });
326 }
327 }
328
329 let used_names: HashSet<_> = usage_map.keys().map(|s| s.to_lowercase()).collect();
331 let dead_plugins: Vec<String> = available_skills
332 .iter()
333 .chain(available_commands.iter())
334 .filter(|name| !used_names.contains(&name.to_lowercase()))
335 .cloned()
336 .collect();
337
338 let mut plugins: Vec<_> = usage_map.into_values().collect();
340 plugins.sort_by(|a, b| b.total_invocations.cmp(&a.total_invocations));
341
342 let top_by_usage = plugins.iter().take(10).cloned().collect();
344
345 let mut top_by_cost = plugins.clone();
347 top_by_cost.sort_by(|a, b| {
348 b.total_cost
349 .partial_cmp(&a.total_cost)
350 .unwrap_or(std::cmp::Ordering::Equal)
351 });
352 let top_by_cost = top_by_cost.into_iter().take(10).collect();
353
354 PluginAnalytics {
355 total_plugins: plugins.len() + dead_plugins.len(),
356 active_plugins: plugins.len(),
357 dead_plugins,
358 plugins,
359 top_by_usage,
360 top_by_cost,
361 computed_at: Utc::now(),
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 #[test]
370 fn test_classify_plugin() {
371 let skills = vec!["rust-expert".to_string(), "boldguy-draft".to_string()];
372 let commands = vec!["commit".to_string()];
373
374 assert_eq!(
375 classify_plugin("rust-expert", &skills, &commands),
376 PluginType::Skill
377 );
378 assert_eq!(
379 classify_plugin("mcp__context7__search", &skills, &commands),
380 PluginType::McpServer
381 );
382 assert_eq!(
383 classify_plugin("Read", &skills, &commands),
384 PluginType::NativeTool
385 );
386 assert_eq!(
387 classify_plugin("Task", &skills, &commands),
388 PluginType::Agent
389 );
390 }
391
392 #[test]
393 fn test_aggregate_plugin_usage() {
394 use std::path::PathBuf;
395
396 let mut tool_usage1 = HashMap::new();
398 tool_usage1.insert("rust-expert".to_string(), 5);
399 tool_usage1.insert("mcp__context7__search".to_string(), 3);
400
401 let mut session1 = SessionMetadata::from_path(
402 PathBuf::from("/tmp/test-session.jsonl"),
403 "test-project".into(),
404 );
405 session1.tool_usage = tool_usage1;
406 session1.total_tokens = 10000;
407 session1.first_timestamp = Some(Utc::now());
408 session1.last_timestamp = Some(Utc::now());
409
410 let sessions = vec![Arc::new(session1)];
411 let skills = vec!["rust-expert".to_string()];
412 let commands = vec![];
413
414 let analytics = aggregate_plugin_usage(&sessions, &skills, &commands);
415
416 assert_eq!(analytics.active_plugins, 2);
417 assert_eq!(analytics.plugins[0].name, "rust-expert");
418 assert_eq!(analytics.plugins[0].total_invocations, 5);
419 assert_eq!(analytics.plugins[0].plugin_type, PluginType::Skill);
420 assert_eq!(analytics.plugins[1].name, "mcp__context7__search");
421 assert_eq!(analytics.plugins[1].plugin_type, PluginType::McpServer);
422 }
423
424 #[test]
425 fn test_dead_code_detection() {
426 let sessions = vec![];
427 let skills = vec!["rust-expert".to_string(), "unused-skill".to_string()];
428 let commands = vec!["commit".to_string(), "never-used".to_string()];
429
430 let analytics = aggregate_plugin_usage(&sessions, &skills, &commands);
431
432 assert_eq!(analytics.active_plugins, 0);
433 assert_eq!(analytics.dead_plugins.len(), 4); assert!(analytics.dead_plugins.contains(&"unused-skill".to_string()));
435 assert!(analytics.dead_plugins.contains(&"never-used".to_string()));
436 }
437
438 #[test]
439 fn test_empty_sessions() {
440 let sessions = vec![];
441 let skills = vec![];
442 let commands = vec![];
443
444 let analytics = aggregate_plugin_usage(&sessions, &skills, &commands);
445
446 assert_eq!(analytics.active_plugins, 0);
447 assert_eq!(analytics.total_plugins, 0);
448 assert!(analytics.plugins.is_empty());
449 }
450}