diff --git a/src/discover/cline_provider.rs b/src/discover/cline_provider.rs new file mode 100644 index 00000000..ea4c147a --- /dev/null +++ b/src/discover/cline_provider.rs @@ -0,0 +1,264 @@ +use anyhow::{Context, Result}; +use std::collections::HashMap; +use std::fs; +use std::io::BufReader; +use std::path::{Path, PathBuf}; + +use super::provider::{ + cutoff_from_days, is_recent, join_tool_uses_with_results, ExtractedCommand, SessionProvider, + ToolSource, OUTPUT_PREVIEW_CHARS, +}; + +pub struct ClineProvider; + +impl ClineProvider { + fn task_dirs() -> Vec { + let Some(home) = dirs::home_dir() else { + return vec![]; + }; + + let mut dirs = vec![home.join(".cline").join("tasks")]; + + #[cfg(target_os = "macos")] + { + dirs.push(home.join( + "Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/tasks", + )); + } + #[cfg(target_os = "linux")] + { + dirs.push(home.join(".config/Code/User/globalStorage/saoudrizwan.claude-dev/tasks")); + } + + dirs + } +} + +impl SessionProvider for ClineProvider { + fn tool_source(&self) -> ToolSource { + ToolSource::Cline + } + + fn discover_sessions( + &self, + _project_filter: Option<&str>, + since_days: Option, + ) -> Result> { + let cutoff = cutoff_from_days(since_days); + let mut sessions = Vec::new(); + + for task_dir in Self::task_dirs() { + if !task_dir.exists() { + continue; + } + + let entries = match fs::read_dir(&task_dir) { + Ok(e) => e, + Err(_) => continue, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let history_file = path.join("api_conversation_history.json"); + if !history_file.exists() { + continue; + } + + if !is_recent(&history_file, cutoff) { + continue; + } + + sessions.push(history_file); + } + } + + Ok(sessions) + } + + fn extract_commands(&self, path: &Path) -> Result> { + let file = + fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?; + let reader = BufReader::new(file); + + let messages: Vec = serde_json::from_reader(reader) + .with_context(|| format!("failed to parse {}", path.display()))?; + + let session_id = path + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + let mut tool_uses: Vec<(String, String, usize)> = Vec::new(); + let mut tool_results: HashMap = HashMap::new(); + let mut sequence_counter = 0; + + for msg in &messages { + let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or(""); + let content = match msg.get("content").and_then(|c| c.as_array()) { + Some(c) => c, + None => continue, + }; + + match role { + "assistant" => { + for block in content { + if block.get("type").and_then(|t| t.as_str()) != Some("tool_use") { + continue; + } + let name = block.get("name").and_then(|n| n.as_str()).unwrap_or(""); + if name != "execute_command" { + continue; + } + let id = match block.get("id").and_then(|i| i.as_str()) { + Some(id) => id, + None => continue, + }; + let command = block + .pointer("/input/command") + .and_then(|c| c.as_str()) + .unwrap_or(""); + if command.is_empty() { + continue; + } + tool_uses.push((id.to_string(), command.to_string(), sequence_counter)); + sequence_counter += 1; + } + } + "user" => { + for block in content { + if block.get("type").and_then(|t| t.as_str()) != Some("tool_result") { + continue; + } + let id = match block.get("tool_use_id").and_then(|i| i.as_str()) { + Some(id) => id, + None => continue, + }; + let output = block.get("content").and_then(|c| c.as_str()).unwrap_or(""); + let is_error = block + .get("is_error") + .and_then(|e| e.as_bool()) + .unwrap_or(false); + let preview: String = output.chars().take(OUTPUT_PREVIEW_CHARS).collect(); + tool_results.insert(id.to_string(), (output.len(), preview, is_error)); + } + } + _ => {} + } + } + + Ok(join_tool_uses_with_results( + tool_uses, + &tool_results, + &session_id, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn make_json(content: &str) -> tempfile::NamedTempFile { + let mut f = tempfile::NamedTempFile::new().unwrap(); + write!(f, "{}", content).unwrap(); + f.flush().unwrap(); + f + } + + #[test] + fn test_extract_execute_command() { + let json = make_json( + r#"[ + {"role":"assistant","content":[{"type":"tool_use","id":"tu_1","name":"execute_command","input":{"command":"npm test"}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"tu_1","content":"All tests passed","is_error":false}]} + ]"#, + ); + + let provider = ClineProvider; + let cmds = provider.extract_commands(json.path()).unwrap(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].command, "npm test"); + assert!(!cmds[0].is_error); + assert_eq!(cmds[0].output_len.unwrap(), "All tests passed".len()); + } + + #[test] + fn test_non_execute_tools_ignored() { + let json = make_json( + r#"[ + {"role":"assistant","content":[{"type":"tool_use","id":"tu_1","name":"read_file","input":{"path":"/tmp/foo"}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"tu_1","content":"file contents"}]} + ]"#, + ); + + let provider = ClineProvider; + let cmds = provider.extract_commands(json.path()).unwrap(); + assert_eq!(cmds.len(), 0); + } + + #[test] + fn test_error_command() { + let json = make_json( + r#"[ + {"role":"assistant","content":[{"type":"tool_use","id":"tu_1","name":"execute_command","input":{"command":"invalid_cmd"}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"tu_1","content":"command not found","is_error":true}]} + ]"#, + ); + + let provider = ClineProvider; + let cmds = provider.extract_commands(json.path()).unwrap(); + assert_eq!(cmds.len(), 1); + assert!(cmds[0].is_error); + assert!(cmds[0] + .output_content + .as_ref() + .unwrap() + .contains("command not found")); + } + + #[test] + fn test_multiple_commands() { + let json = make_json( + r#"[ + {"role":"assistant","content":[ + {"type":"tool_use","id":"tu_1","name":"execute_command","input":{"command":"git status"}}, + {"type":"tool_use","id":"tu_2","name":"execute_command","input":{"command":"git diff"}} + ]}, + {"role":"user","content":[ + {"type":"tool_result","tool_use_id":"tu_1","content":"clean"}, + {"type":"tool_result","tool_use_id":"tu_2","content":"no changes"} + ]} + ]"#, + ); + + let provider = ClineProvider; + let cmds = provider.extract_commands(json.path()).unwrap(); + assert_eq!(cmds.len(), 2); + assert_eq!(cmds[0].command, "git status"); + assert_eq!(cmds[1].command, "git diff"); + assert_eq!(cmds[0].sequence_index, 0); + assert_eq!(cmds[1].sequence_index, 1); + } + + #[test] + fn test_empty_conversation() { + let json = make_json("[]"); + + let provider = ClineProvider; + let cmds = provider.extract_commands(json.path()).unwrap(); + assert_eq!(cmds.len(), 0); + } + + #[test] + fn test_tool_source() { + let provider = ClineProvider; + assert_eq!(provider.tool_source(), ToolSource::Cline); + } +} diff --git a/src/discover/codex_provider.rs b/src/discover/codex_provider.rs new file mode 100644 index 00000000..8d5aeae1 --- /dev/null +++ b/src/discover/codex_provider.rs @@ -0,0 +1,332 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +use super::provider::{ + cutoff_from_days, is_recent, ExtractedCommand, SessionProvider, ToolSource, + OUTPUT_PREVIEW_CHARS, +}; + +pub struct CodexProvider; + +impl CodexProvider { + fn base_dir() -> Option { + // $CODEX_HOME overrides default location + if let Ok(home) = std::env::var("CODEX_HOME") { + let p = PathBuf::from(home); + if p.exists() { + return Some(p); + } + } + let home = dirs::home_dir()?; + let dir = home.join(".codex"); + if dir.exists() { + Some(dir) + } else { + None + } + } +} + +impl SessionProvider for CodexProvider { + fn tool_source(&self) -> ToolSource { + ToolSource::CodexCli + } + + fn discover_sessions( + &self, + project_filter: Option<&str>, + since_days: Option, + ) -> Result> { + let base = match Self::base_dir() { + Some(d) => d, + None => return Ok(vec![]), + }; + + let sessions_dir = base.join("sessions"); + if !sessions_dir.exists() { + return Ok(vec![]); + } + + let cutoff = cutoff_from_days(since_days); + let mut sessions = Vec::new(); + + for entry in WalkDir::new(&sessions_dir) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { + continue; + } + + if !is_recent(path, cutoff) { + continue; + } + + // Project filter: peek at first few lines for SessionMeta with cwd + if let Some(filter) = project_filter { + if !session_matches_project(path, filter) { + continue; + } + } + + sessions.push(path.to_path_buf()); + } + + Ok(sessions) + } + + fn extract_commands(&self, path: &Path) -> Result> { + let file = + fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?; + let reader = BufReader::new(file); + + let session_id = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + let mut commands = Vec::new(); + let mut sequence_counter = 0; + + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(_) => continue, + }; + + // Pre-filter: look for exec-related events + if !line.contains("exec") && !line.contains("Exec") { + continue; + } + + let entry: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + + // Codex events: { type: "ExecCommandEnd", payload: { command, output, exitCode } } + // Also handle: { event: "exec_command_end", ... } variant + let event_type = entry + .get("type") + .or_else(|| entry.get("event")) + .and_then(|t| t.as_str()) + .unwrap_or(""); + + let is_exec_end = event_type == "ExecCommandEnd" + || event_type == "exec_command_end" + || event_type == "ExecCommand"; + + if !is_exec_end { + continue; + } + + let payload = entry.get("payload").unwrap_or(&entry); + + let command = payload + .get("command") + .or_else(|| payload.get("cmd")) + .and_then(|c| c.as_str()); + + let Some(command) = command else { + continue; + }; + + let output = payload + .get("output") + .or_else(|| payload.get("stdout")) + .and_then(|o| o.as_str()) + .unwrap_or(""); + + let exit_code = payload + .get("exitCode") + .or_else(|| payload.get("exit_code")) + .and_then(|e| e.as_i64()) + .unwrap_or(0); + + let output_preview: String = output.chars().take(OUTPUT_PREVIEW_CHARS).collect(); + + commands.push(ExtractedCommand { + command: command.to_string(), + output_len: Some(output.len()), + session_id: session_id.clone(), + output_content: if output_preview.is_empty() { + None + } else { + Some(output_preview) + }, + is_error: exit_code != 0, + sequence_index: sequence_counter, + }); + sequence_counter += 1; + } + + Ok(commands) + } +} + +/// Check if a Codex session file's SessionMeta event mentions the project path. +fn session_matches_project(path: &Path, filter: &str) -> bool { + let file = match fs::File::open(path) { + Ok(f) => f, + Err(_) => return false, + }; + let reader = BufReader::new(file); + + // Only check first 20 lines for SessionMeta + for line in reader.lines().take(20) { + let line = match line { + Ok(l) => l, + Err(_) => continue, + }; + + if !line.contains("cwd") && !line.contains("SessionMeta") { + continue; + } + + let entry: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + + // Look for cwd field in payload or top-level + let cwd = entry + .pointer("/payload/cwd") + .or_else(|| entry.get("cwd")) + .and_then(|c| c.as_str()); + + if let Some(cwd) = cwd { + return cwd.contains(filter); + } + } + + // No project info found: include by default + true +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn make_jsonl(lines: &[&str]) -> tempfile::NamedTempFile { + let mut f = tempfile::NamedTempFile::new().unwrap(); + for line in lines { + writeln!(f, "{}", line).unwrap(); + } + f.flush().unwrap(); + f + } + + #[test] + fn test_extract_exec_command_end() { + let jsonl = make_jsonl(&[ + r#"{"type":"ExecCommandEnd","payload":{"command":"git status","output":"On branch main\nnothing to commit","exitCode":0}}"#, + ]); + + let provider = CodexProvider; + let cmds = provider.extract_commands(jsonl.path()).unwrap(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].command, "git status"); + assert!(!cmds[0].is_error); + assert_eq!( + cmds[0].output_len.unwrap(), + "On branch main\nnothing to commit".len() + ); + } + + #[test] + fn test_extract_exec_command_error() { + let jsonl = make_jsonl(&[ + r#"{"type":"ExecCommandEnd","payload":{"command":"npm test","output":"FAIL tests/app.test.js","exitCode":1}}"#, + ]); + + let provider = CodexProvider; + let cmds = provider.extract_commands(jsonl.path()).unwrap(); + assert_eq!(cmds.len(), 1); + assert!(cmds[0].is_error); + assert!(cmds[0].output_content.is_some()); + } + + #[test] + fn test_extract_snake_case_variant() { + let jsonl = make_jsonl(&[ + r#"{"event":"exec_command_end","payload":{"cmd":"ls -la","stdout":"total 42","exit_code":0}}"#, + ]); + + let provider = CodexProvider; + let cmds = provider.extract_commands(jsonl.path()).unwrap(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].command, "ls -la"); + } + + #[test] + fn test_non_exec_events_ignored() { + let jsonl = make_jsonl(&[ + r#"{"type":"SessionMeta","payload":{"cwd":"/home/user/project"}}"#, + r#"{"type":"ChatMessage","payload":{"text":"hello"}}"#, + ]); + + let provider = CodexProvider; + let cmds = provider.extract_commands(jsonl.path()).unwrap(); + assert_eq!(cmds.len(), 0); + } + + #[test] + fn test_malformed_lines_skipped() { + let jsonl = make_jsonl(&[ + "not valid json with exec", + r#"{"type":"ExecCommandEnd","payload":{"command":"echo hello","output":"hello","exitCode":0}}"#, + ]); + + let provider = CodexProvider; + let cmds = provider.extract_commands(jsonl.path()).unwrap(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].command, "echo hello"); + } + + #[test] + fn test_session_matches_project_filter() { + let jsonl = make_jsonl(&[ + r#"{"type":"SessionMeta","payload":{"cwd":"/Users/dev/myproject"}}"#, + r#"{"type":"ExecCommandEnd","payload":{"command":"git status","output":"ok","exitCode":0}}"#, + ]); + + assert!(session_matches_project(jsonl.path(), "myproject")); + assert!(!session_matches_project(jsonl.path(), "other")); + } + + #[test] + fn test_session_no_meta_includes_by_default() { + let jsonl = make_jsonl(&[ + r#"{"type":"ExecCommandEnd","payload":{"command":"ls","output":"ok","exitCode":0}}"#, + ]); + + assert!(session_matches_project(jsonl.path(), "anything")); + } + + #[test] + fn test_tool_source() { + let provider = CodexProvider; + assert_eq!(provider.tool_source(), ToolSource::CodexCli); + } + + #[test] + fn test_sequence_ordering() { + let jsonl = make_jsonl(&[ + r#"{"type":"ExecCommandEnd","payload":{"command":"first","output":"a","exitCode":0}}"#, + r#"{"type":"ExecCommandEnd","payload":{"command":"second","output":"b","exitCode":0}}"#, + ]); + + let provider = CodexProvider; + let cmds = provider.extract_commands(jsonl.path()).unwrap(); + assert_eq!(cmds.len(), 2); + assert_eq!(cmds[0].sequence_index, 0); + assert_eq!(cmds[1].sequence_index, 1); + } +} diff --git a/src/discover/cursor_provider.rs b/src/discover/cursor_provider.rs new file mode 100644 index 00000000..1836abc9 --- /dev/null +++ b/src/discover/cursor_provider.rs @@ -0,0 +1,594 @@ +use anyhow::{Context, Result}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::provider::{ + cutoff_from_days, is_recent, join_tool_uses_with_results, ExtractedCommand, SessionProvider, + ToolSource, OUTPUT_PREVIEW_CHARS, +}; + +/// Terminal tool names Cursor may use (undocumented, best-effort). +const TERMINAL_TOOL_NAMES: &[&str] = &[ + "run_terminal_command", + "terminal", + "execute_command", + "run_command", +]; + +pub struct CursorProvider; + +impl CursorProvider { + /// Find all Cursor database files (state.vscdb for desktop, store.db for agent CLI). + fn find_db_paths() -> Vec { + let Some(home) = dirs::home_dir() else { + return vec![]; + }; + + let mut paths = Vec::new(); + + // Desktop: state.vscdb + let desktop_candidates = [ + // macOS + home.join("Library/Application Support/Cursor/User/globalStorage/state.vscdb"), + // Linux + home.join(".config/Cursor/User/globalStorage/state.vscdb"), + ]; + + for candidate in &desktop_candidates { + if candidate.exists() { + paths.push(candidate.clone()); + } + } + + // Agent CLI: store.db files in chat directories + let agent_dirs = [ + home.join(".config/cursor/chats"), + home.join(".cursor/chats"), + ]; + + for agent_dir in &agent_dirs { + if !agent_dir.exists() { + continue; + } + if let Ok(entries) = fs::read_dir(agent_dir) { + for entry in entries.flatten() { + let store_db = entry.path().join("store.db"); + if store_db.exists() { + paths.push(store_db); + } + } + } + } + + paths + } +} + +impl SessionProvider for CursorProvider { + fn tool_source(&self) -> ToolSource { + ToolSource::Cursor + } + + fn discover_sessions( + &self, + _project_filter: Option<&str>, + since_days: Option, + ) -> Result> { + let cutoff = cutoff_from_days(since_days); + let paths: Vec = Self::find_db_paths() + .into_iter() + .filter(|p| is_recent(p, cutoff)) + .collect(); + Ok(paths) + } + + fn extract_commands(&self, path: &Path) -> Result> { + let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + if filename == "state.vscdb" { + extract_from_desktop_db(path) + } else { + extract_from_agent_db(path) + } + } +} + +/// Extract commands from Cursor Desktop's state.vscdb (cursorDiskKV table). +fn extract_from_desktop_db(path: &Path) -> Result> { + let conn = rusqlite::Connection::open_with_flags( + path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .with_context(|| format!("failed to open Cursor DB: {}", path.display()))?; + + // Check if cursorDiskKV table exists + let table_exists: bool = conn + .query_row( + "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='cursorDiskKV'", + [], + |row| row.get(0), + ) + .unwrap_or(false); + + if !table_exists { + return Ok(vec![]); + } + + let mut stmt = conn + .prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'") + .context("failed to query cursorDiskKV")?; + + let session_id = path + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or("cursor-desktop") + .to_string(); + + let mut commands = Vec::new(); + let mut sequence_counter = 0; + + let rows = stmt + .query_map([], |row| { + let value: String = row.get(1)?; + Ok(value) + }) + .context("failed to read cursorDiskKV rows")?; + + for row in rows { + let value = match row { + Ok(v) => v, + Err(_) => continue, + }; + + let parsed: serde_json::Value = match serde_json::from_str(&value) { + Ok(v) => v, + Err(_) => continue, + }; + + // Look for assistant bubbles (type == 2) with tool calls + let bubble_type = parsed.get("type").and_then(|t| t.as_i64()).unwrap_or(0); + if bubble_type != 2 { + continue; + } + + // Extract from richText or text fields + let text = parsed + .get("richText") + .or_else(|| parsed.get("text")) + .and_then(|t| t.as_str()) + .unwrap_or(""); + + // Look for tool call patterns in the text + if let Some(cmd) = extract_command_from_bubble_text(text) { + commands.push(ExtractedCommand { + command: cmd, + output_len: None, + session_id: session_id.clone(), + output_content: None, + is_error: false, + sequence_index: sequence_counter, + }); + sequence_counter += 1; + } + } + + Ok(commands) +} + +/// Extract commands from Cursor Agent CLI's store.db (blobs table). +fn extract_from_agent_db(path: &Path) -> Result> { + let conn = rusqlite::Connection::open_with_flags( + path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .with_context(|| format!("failed to open Cursor agent DB: {}", path.display()))?; + + // Check if blobs table exists + let table_exists: bool = conn + .query_row( + "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='blobs'", + [], + |row| row.get(0), + ) + .unwrap_or(false); + + if !table_exists { + return Ok(vec![]); + } + + // Try both possible column layouts + let query = if has_column(&conn, "blobs", "data") { + "SELECT data FROM blobs" + } else if has_column(&conn, "blobs", "value") { + "SELECT value FROM blobs" + } else { + return Ok(vec![]); + }; + + let mut stmt = conn.prepare(query).context("failed to query blobs")?; + + let session_id = path + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or("cursor-agent") + .to_string(); + + let mut tool_calls: Vec<(String, String, usize)> = Vec::new(); + let mut tool_responses: HashMap = HashMap::new(); + let mut sequence_counter = 0; + + let rows = stmt + .query_map([], |row| { + let value: String = row.get(0)?; + Ok(value) + }) + .context("failed to read blobs")?; + + for row in rows { + let value = match row { + Ok(v) => v, + Err(_) => continue, + }; + + let parsed: serde_json::Value = match serde_json::from_str(&value) { + Ok(v) => v, + Err(_) => continue, + }; + + // Handle both single objects and arrays of content items + let items = if parsed.is_array() { + parsed.as_array().cloned().unwrap_or_default() + } else if let Some(content) = parsed.get("content").and_then(|c| c.as_array()) { + content.clone() + } else { + vec![parsed.clone()] + }; + + for item in &items { + let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + match item_type { + "tool_call" => { + let name = item.get("name").and_then(|n| n.as_str()).unwrap_or(""); + if !TERMINAL_TOOL_NAMES.contains(&name) { + continue; + } + let tool_call_id = item + .get("tool_call_id") + .or_else(|| item.get("id")) + .and_then(|i| i.as_str()) + .unwrap_or(""); + if tool_call_id.is_empty() { + continue; + } + + // Arguments may be a JSON string or an object + let command = extract_command_from_args(item); + if let Some(cmd) = command { + tool_calls.push((tool_call_id.to_string(), cmd, sequence_counter)); + sequence_counter += 1; + } + } + "tool" | "tool_result" => { + let tool_call_id = item + .get("tool_call_id") + .and_then(|i| i.as_str()) + .unwrap_or(""); + if tool_call_id.is_empty() { + continue; + } + let output = item.get("content").and_then(|c| c.as_str()).unwrap_or(""); + let is_error = item + .get("is_error") + .and_then(|e| e.as_bool()) + .unwrap_or(false); + let preview: String = output.chars().take(OUTPUT_PREVIEW_CHARS).collect(); + tool_responses + .insert(tool_call_id.to_string(), (output.len(), preview, is_error)); + } + _ => {} + } + } + } + + Ok(join_tool_uses_with_results( + tool_calls, + &tool_responses, + &session_id, + )) +} + +fn has_column(conn: &rusqlite::Connection, table: &str, column: &str) -> bool { + if !table.chars().all(|c| c.is_alphanumeric() || c == '_') { + return false; + } + let query = format!("PRAGMA table_info(\"{}\")", table); + let mut stmt = match conn.prepare(&query) { + Ok(s) => s, + Err(_) => return false, + }; + let cols: Vec = stmt + .query_map([], |row| row.get::<_, String>(1)) + .ok() + .map(|rows| rows.filter_map(|r| r.ok()).collect()) + .unwrap_or_default(); + cols.iter().any(|c| c == column) +} + +/// Try to extract a shell command from a Cursor desktop bubble's text. +fn extract_command_from_bubble_text(text: &str) -> Option { + // Look for patterns like: ```bash\n\n``` or tool_call markers + if text.contains("```bash") || text.contains("```sh") || text.contains("```shell") { + let start_markers = ["```bash\n", "```sh\n", "```shell\n"]; + for marker in &start_markers { + if let Some(start) = text.find(marker) { + let cmd_start = start + marker.len(); + if let Some(end) = text[cmd_start..].find("```") { + let cmd = text[cmd_start..cmd_start + end].trim(); + if !cmd.is_empty() { + return Some(cmd.to_string()); + } + } + } + } + } + None +} + +/// Extract command string from tool_call arguments. +fn extract_command_from_args(item: &serde_json::Value) -> Option { + let args = item.get("arguments")?; + + // Arguments might be a JSON string that needs parsing + if let Some(args_str) = args.as_str() { + if let Ok(parsed) = serde_json::from_str::(args_str) { + return parsed + .get("command") + .and_then(|c| c.as_str()) + .map(|s| s.to_string()); + } + // If not JSON, treat the whole string as the command + if !args_str.is_empty() { + return Some(args_str.to_string()); + } + } + + // Arguments might already be an object + if let Some(obj) = args.as_object() { + return obj + .get("command") + .and_then(|c| c.as_str()) + .map(|s| s.to_string()); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + use tempfile::tempdir; + + #[test] + fn test_extract_from_agent_db_tool_calls() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("store.db"); + + let conn = Connection::open(&db_path).unwrap(); + conn.execute("CREATE TABLE blobs (id INTEGER PRIMARY KEY, data TEXT)", []) + .unwrap(); + + // Insert a tool_call and tool_result + let tool_call = serde_json::json!([ + {"type":"tool_call","name":"run_terminal_command","tool_call_id":"tc_1","arguments":"{\"command\":\"git status\"}"}, + {"type":"tool","tool_call_id":"tc_1","content":"On branch main","is_error":false} + ]); + conn.execute( + "INSERT INTO blobs (data) VALUES (?1)", + [tool_call.to_string()], + ) + .unwrap(); + drop(conn); + + let cmds = extract_from_agent_db(&db_path).unwrap(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].command, "git status"); + assert!(!cmds[0].is_error); + assert_eq!(cmds[0].output_len.unwrap(), "On branch main".len()); + } + + #[test] + fn test_extract_from_agent_db_non_terminal_ignored() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("store.db"); + + let conn = Connection::open(&db_path).unwrap(); + conn.execute("CREATE TABLE blobs (id INTEGER PRIMARY KEY, data TEXT)", []) + .unwrap(); + + let tool_call = serde_json::json!([ + {"type":"tool_call","name":"read_file","tool_call_id":"tc_1","arguments":"{\"path\":\"/tmp/foo\"}"} + ]); + conn.execute( + "INSERT INTO blobs (data) VALUES (?1)", + [tool_call.to_string()], + ) + .unwrap(); + drop(conn); + + let cmds = extract_from_agent_db(&db_path).unwrap(); + assert_eq!(cmds.len(), 0); + } + + #[test] + fn test_extract_from_agent_db_missing_table() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("store.db"); + + let conn = Connection::open(&db_path).unwrap(); + conn.execute( + "CREATE TABLE other_table (id INTEGER PRIMARY KEY, value TEXT)", + [], + ) + .unwrap(); + drop(conn); + + let cmds = extract_from_agent_db(&db_path).unwrap(); + assert_eq!(cmds.len(), 0); + } + + #[test] + fn test_extract_from_desktop_db_bubbles() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("state.vscdb"); + + let conn = Connection::open(&db_path).unwrap(); + conn.execute( + "CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)", + [], + ) + .unwrap(); + + let bubble = serde_json::json!({ + "type": 2, + "richText": "Let me run this:\n```bash\nnpm test\n```\nDone." + }); + conn.execute( + "INSERT INTO cursorDiskKV (key, value) VALUES ('bubbleId:abc', ?1)", + [bubble.to_string()], + ) + .unwrap(); + drop(conn); + + let cmds = extract_from_desktop_db(&db_path).unwrap(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].command, "npm test"); + } + + #[test] + fn test_extract_from_desktop_db_user_bubble_ignored() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("state.vscdb"); + + let conn = Connection::open(&db_path).unwrap(); + conn.execute( + "CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)", + [], + ) + .unwrap(); + + // User bubble (type == 1) should be ignored + let bubble = serde_json::json!({ + "type": 1, + "text": "```bash\ngit status\n```" + }); + conn.execute( + "INSERT INTO cursorDiskKV (key, value) VALUES ('bubbleId:xyz', ?1)", + [bubble.to_string()], + ) + .unwrap(); + drop(conn); + + let cmds = extract_from_desktop_db(&db_path).unwrap(); + assert_eq!(cmds.len(), 0); + } + + #[test] + fn test_extract_from_desktop_db_missing_table() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("state.vscdb"); + + let conn = Connection::open(&db_path).unwrap(); + conn.execute("CREATE TABLE other (key TEXT PRIMARY KEY)", []) + .unwrap(); + drop(conn); + + let cmds = extract_from_desktop_db(&db_path).unwrap(); + assert_eq!(cmds.len(), 0); + } + + #[test] + fn test_extract_command_from_bubble_text() { + assert_eq!( + extract_command_from_bubble_text("Run:\n```bash\ngit status\n```\nDone."), + Some("git status".to_string()) + ); + assert_eq!( + extract_command_from_bubble_text("```sh\nls -la\n```"), + Some("ls -la".to_string()) + ); + assert_eq!(extract_command_from_bubble_text("No code here."), None); + } + + #[test] + fn test_extract_command_from_args_json_string() { + let item = serde_json::json!({ + "type": "tool_call", + "name": "run_terminal_command", + "tool_call_id": "tc_1", + "arguments": "{\"command\":\"cargo test\"}" + }); + assert_eq!( + extract_command_from_args(&item), + Some("cargo test".to_string()) + ); + } + + #[test] + fn test_extract_command_from_args_object() { + let item = serde_json::json!({ + "type": "tool_call", + "name": "run_terminal_command", + "tool_call_id": "tc_1", + "arguments": {"command": "npm install"} + }); + assert_eq!( + extract_command_from_args(&item), + Some("npm install".to_string()) + ); + } + + #[test] + fn test_tool_source() { + let provider = CursorProvider; + assert_eq!(provider.tool_source(), ToolSource::Cursor); + } + + #[test] + fn test_multiple_terminal_tool_names() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("store.db"); + + let conn = Connection::open(&db_path).unwrap(); + conn.execute("CREATE TABLE blobs (id INTEGER PRIMARY KEY, data TEXT)", []) + .unwrap(); + + // Test different tool names + for (i, name) in [ + "run_terminal_command", + "terminal", + "execute_command", + "run_command", + ] + .iter() + .enumerate() + { + let tool_call = serde_json::json!([ + {"type":"tool_call","name":name,"tool_call_id":format!("tc_{}", i),"arguments":format!("{{\"command\":\"cmd_{}\"}}", i)} + ]); + conn.execute( + "INSERT INTO blobs (data) VALUES (?1)", + [tool_call.to_string()], + ) + .unwrap(); + } + drop(conn); + + let cmds = extract_from_agent_db(&db_path).unwrap(); + assert_eq!(cmds.len(), 4); + } +} diff --git a/src/discover/mod.rs b/src/discover/mod.rs index a8cee127..f7da1f72 100644 --- a/src/discover/mod.rs +++ b/src/discover/mod.rs @@ -1,3 +1,6 @@ +pub mod cline_provider; +pub mod codex_provider; +pub mod cursor_provider; pub mod provider; pub mod registry; mod report; @@ -5,7 +8,7 @@ mod report; use anyhow::Result; use std::collections::HashMap; -use provider::{ClaudeProvider, SessionProvider}; +use provider::{build_providers, VALID_TOOL_NAMES}; use registry::{category_avg_tokens, classify_command, split_command_chain, Classification}; use report::{DiscoverReport, SupportedEntry, UnsupportedEntry}; @@ -33,111 +36,132 @@ pub fn run( limit: usize, format: &str, verbose: u8, + tool: Option<&str>, ) -> Result<()> { - let provider = ClaudeProvider; + if let Some(t) = tool { + if !VALID_TOOL_NAMES.contains(&t) { + anyhow::bail!( + "Unknown tool '{}'. Valid options: {}", + t, + VALID_TOOL_NAMES.join(", ") + ); + } + } + + let providers = build_providers(tool); - // Determine project filter let project_filter = if all { None } else if let Some(p) = project { Some(p.to_string()) } else { - // Default: current working directory let cwd = std::env::current_dir()?; - let cwd_str = cwd.to_string_lossy().to_string(); - let encoded = ClaudeProvider::encode_project_path(&cwd_str); - Some(encoded) + Some(cwd.to_string_lossy().to_string()) }; - let sessions = provider.discover_sessions(project_filter.as_deref(), Some(since_days))?; - - if verbose > 0 { - eprintln!("Scanning {} session files...", sessions.len()); - for s in &sessions { - eprintln!(" {}", s.display()); - } - } - + let mut total_sessions: usize = 0; let mut total_commands: usize = 0; let mut already_rtk: usize = 0; let mut parse_errors: usize = 0; let mut supported_map: HashMap<&'static str, SupportedBucket> = HashMap::new(); let mut unsupported_map: HashMap = HashMap::new(); + let mut tools_scanned: Vec = Vec::new(); - for session_path in &sessions { - let extracted = match provider.extract_commands(session_path) { - Ok(cmds) => cmds, - Err(e) => { - if verbose > 0 { - eprintln!("Warning: skipping {}: {}", session_path.display(), e); - } - parse_errors += 1; - continue; - } + for provider in &providers { + tools_scanned.push(provider.tool_source().display_name().to_string()); + + let sessions = match provider.discover_sessions(project_filter.as_deref(), Some(since_days)) + { + Ok(s) => s, + Err(_) => continue, // tool not installed, skip }; - for ext_cmd in &extracted { - let parts = split_command_chain(&ext_cmd.command); - for part in parts { - total_commands += 1; - - match classify_command(part) { - Classification::Supported { - rtk_equivalent, - category, - estimated_savings_pct, - status, - } => { - let bucket = supported_map.entry(rtk_equivalent).or_insert_with(|| { - SupportedBucket { - rtk_equivalent, - category, - count: 0, - total_output_tokens: 0, - savings_pct: estimated_savings_pct, - command_counts: HashMap::new(), - } - }); - - bucket.count += 1; - - // Estimate tokens for this command - let output_tokens = if let Some(len) = ext_cmd.output_len { - // Real: from tool_result content length - len / 4 - } else { - // Fallback: category average - let subcmd = extract_subcmd(part); - category_avg_tokens(category, subcmd) - }; + if verbose > 0 { + eprintln!( + "[{}] Scanning {} session files...", + provider.tool_source().display_name(), + sessions.len() + ); + for s in &sessions { + eprintln!(" {}", s.display()); + } + } + + total_sessions += sessions.len(); - let savings = - (output_tokens as f64 * estimated_savings_pct / 100.0) as usize; - bucket.total_output_tokens += savings; - - // Track the display name with status - let display_name = truncate_command(part); - let entry = bucket - .command_counts - .entry(format!("{}:{:?}", display_name, status)) - .or_insert(0); - *entry += 1; + for session_path in &sessions { + let extracted = match provider.extract_commands(session_path) { + Ok(cmds) => cmds, + Err(e) => { + if verbose > 0 { + eprintln!("Warning: skipping {}: {}", session_path.display(), e); } - Classification::Unsupported { base_command } => { - let bucket = unsupported_map.entry(base_command).or_insert_with(|| { - UnsupportedBucket { - count: 0, - example: part.to_string(), + parse_errors += 1; + continue; + } + }; + + for ext_cmd in &extracted { + let parts = split_command_chain(&ext_cmd.command); + for part in parts { + total_commands += 1; + + match classify_command(part) { + Classification::Supported { + rtk_equivalent, + category, + estimated_savings_pct, + status, + } => { + let bucket = supported_map.entry(rtk_equivalent).or_insert_with(|| { + SupportedBucket { + rtk_equivalent, + category, + count: 0, + total_output_tokens: 0, + savings_pct: estimated_savings_pct, + command_counts: HashMap::new(), + } + }); + + bucket.count += 1; + + // Estimate tokens for this command + let output_tokens = if let Some(len) = ext_cmd.output_len { + // Real: from tool_result content length + len / 4 + } else { + // Fallback: category average + let subcmd = extract_subcmd(part); + category_avg_tokens(category, subcmd) + }; + + let savings = + (output_tokens as f64 * estimated_savings_pct / 100.0) as usize; + bucket.total_output_tokens += savings; + + // Track the display name with status + let display_name = truncate_command(part); + let entry = bucket + .command_counts + .entry(format!("{}:{:?}", display_name, status)) + .or_insert(0); + *entry += 1; + } + Classification::Unsupported { base_command } => { + let bucket = unsupported_map.entry(base_command).or_insert_with(|| { + UnsupportedBucket { + count: 0, + example: part.to_string(), + } + }); + bucket.count += 1; + } + Classification::Ignored => { + if part.trim().starts_with("rtk ") { + already_rtk += 1; } - }); - bucket.count += 1; - } - Classification::Ignored => { - // Check if it starts with "rtk " - if part.trim().starts_with("rtk ") { - already_rtk += 1; } - // Otherwise just skip } } } @@ -198,13 +222,14 @@ pub fn run( unsupported.sort_by(|a, b| b.count.cmp(&a.count)); let report = DiscoverReport { - sessions_scanned: sessions.len(), + sessions_scanned: total_sessions, total_commands, already_rtk, since_days, supported, unsupported, parse_errors, + tools_scanned, }; match format { diff --git a/src/discover/provider.rs b/src/discover/provider.rs index e9218b2d..5eaaea4e 100644 --- a/src/discover/provider.rs +++ b/src/discover/provider.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use serde::Serialize; use std::collections::HashMap; use std::fs; use std::io::{BufRead, BufReader}; @@ -6,6 +7,39 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; use walkdir::WalkDir; +const SECONDS_PER_DAY: u64 = 86_400; +/// Max chars to keep from tool output for error detection. +pub const OUTPUT_PREVIEW_CHARS: usize = 1000; + +/// Which AI coding tool a session came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum ToolSource { + ClaudeCode, + CodexCli, + Cline, + Cursor, +} + +impl ToolSource { + pub fn short_name(&self) -> &'static str { + match self { + ToolSource::ClaudeCode => "claude", + ToolSource::CodexCli => "codex", + ToolSource::Cline => "cline", + ToolSource::Cursor => "cursor", + } + } + + pub fn display_name(&self) -> &'static str { + match self { + ToolSource::ClaudeCode => "Claude Code", + ToolSource::CodexCli => "Codex CLI", + ToolSource::Cline => "Cline", + ToolSource::Cursor => "Cursor", + } + } +} + /// A command extracted from a session file. #[derive(Debug)] pub struct ExtractedCommand { @@ -21,8 +55,9 @@ pub struct ExtractedCommand { pub sequence_index: usize, } -/// Trait for session providers (Claude Code, future: Cursor, Windsurf). +/// Trait for session providers (Claude Code, Codex CLI, Cline, Cursor). pub trait SessionProvider { + fn tool_source(&self) -> ToolSource; fn discover_sessions( &self, project_filter: Option<&str>, @@ -31,6 +66,76 @@ pub trait SessionProvider { fn extract_commands(&self, path: &Path) -> Result>; } +/// Compute a mtime cutoff from a number of days ago. +pub fn cutoff_from_days(since_days: Option) -> Option { + since_days.map(|days| { + SystemTime::now() + .checked_sub(Duration::from_secs(days * SECONDS_PER_DAY)) + .unwrap_or(SystemTime::UNIX_EPOCH) + }) +} + +/// Check if a file's mtime is after the cutoff. +pub fn is_recent(path: &Path, cutoff: Option) -> bool { + let Some(cutoff_time) = cutoff else { + return true; + }; + match fs::metadata(path).and_then(|m| m.modified()) { + Ok(mtime) => mtime >= cutoff_time, + Err(_) => false, + } +} + +/// Join collected tool_use entries with their tool_result responses by ID. +pub fn join_tool_uses_with_results( + tool_uses: Vec<(String, String, usize)>, + tool_results: &HashMap, + session_id: &str, +) -> Vec { + tool_uses + .into_iter() + .map(|(tool_id, command, sequence_index)| { + let (output_len, output_content, is_error) = tool_results + .get(&tool_id) + .map(|(len, content, err)| (Some(*len), Some(content.clone()), *err)) + .unwrap_or((None, None, false)); + + ExtractedCommand { + command, + output_len, + session_id: session_id.to_string(), + output_content, + is_error, + sequence_index, + } + }) + .collect() +} + +pub const VALID_TOOL_NAMES: &[&str] = &["claude", "codex", "cline", "cursor"]; + +/// Build the list of session providers, optionally filtered by tool short name. +pub fn build_providers(tool_filter: Option<&str>) -> Vec> { + use super::cline_provider::ClineProvider; + use super::codex_provider::CodexProvider; + use super::cursor_provider::CursorProvider; + + let all: Vec> = vec![ + Box::new(ClaudeProvider), + Box::new(CodexProvider), + Box::new(ClineProvider), + Box::new(CursorProvider), + ]; + + match tool_filter { + Some(filter) => all + .into_iter() + .filter(|p| p.tool_source().short_name() == filter) + .collect(), + None => all, + } +} + pub struct ClaudeProvider; impl ClaudeProvider { @@ -55,21 +160,23 @@ impl ClaudeProvider { } impl SessionProvider for ClaudeProvider { + fn tool_source(&self) -> ToolSource { + ToolSource::ClaudeCode + } + fn discover_sessions( &self, project_filter: Option<&str>, since_days: Option, ) -> Result> { let projects_dir = Self::projects_dir()?; - let cutoff = since_days.map(|days| { - SystemTime::now() - .checked_sub(Duration::from_secs(days * 86400)) - .unwrap_or(SystemTime::UNIX_EPOCH) - }); + let cutoff = cutoff_from_days(since_days); + + // For Claude, encode the project filter to match directory name format + let encoded_filter = project_filter.map(|f| Self::encode_project_path(f)); let mut sessions = Vec::new(); - // List project directories let entries = fs::read_dir(&projects_dir) .with_context(|| format!("failed to read {}", projects_dir.display()))?; @@ -79,10 +186,9 @@ impl SessionProvider for ClaudeProvider { continue; } - // Apply project filter: substring match on directory name - if let Some(filter) = project_filter { + if let Some(ref filter) = encoded_filter { let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - if !dir_name.contains(filter) { + if !dir_name.contains(filter.as_str()) { continue; } } @@ -98,15 +204,8 @@ impl SessionProvider for ClaudeProvider { continue; } - // Apply mtime filter - if let Some(cutoff_time) = cutoff { - if let Ok(meta) = fs::metadata(file_path) { - if let Ok(mtime) = meta.modified() { - if mtime < cutoff_time { - continue; - } - } - } + if !is_recent(file_path, cutoff) { + continue; } sessions.push(file_path.to_path_buf()); @@ -127,11 +226,8 @@ impl SessionProvider for ClaudeProvider { .unwrap_or("unknown") .to_string(); - // First pass: collect all tool_use Bash commands with their IDs and sequence - // Second pass (same loop): collect tool_result output lengths, content, and error status - let mut pending_tool_uses: Vec<(String, String, usize)> = Vec::new(); // (tool_use_id, command, sequence) - let mut tool_results: HashMap = HashMap::new(); // (len, content, is_error) - let mut commands = Vec::new(); + let mut pending_tool_uses: Vec<(String, String, usize)> = Vec::new(); + let mut tool_results: HashMap = HashMap::new(); let mut sequence_counter = 0; for line in reader.lines() { @@ -186,19 +282,15 @@ impl SessionProvider for ClaudeProvider { if block.get("type").and_then(|t| t.as_str()) == Some("tool_result") { if let Some(id) = block.get("tool_use_id").and_then(|i| i.as_str()) { - // Get content, length, and error status let content = block.get("content").and_then(|c| c.as_str()).unwrap_or(""); - let output_len = content.len(); let is_error = block .get("is_error") .and_then(|e| e.as_bool()) .unwrap_or(false); - - // Store first ~1000 chars of content for error detection let content_preview: String = - content.chars().take(1000).collect(); + content.chars().take(OUTPUT_PREVIEW_CHARS).collect(); tool_results.insert( id.to_string(), @@ -213,24 +305,11 @@ impl SessionProvider for ClaudeProvider { } } - // Match tool_uses with their results - for (tool_id, command, sequence_index) in pending_tool_uses { - let (output_len, output_content, is_error) = tool_results - .get(&tool_id) - .map(|(len, content, err)| (Some(*len), Some(content.clone()), *err)) - .unwrap_or((None, None, false)); - - commands.push(ExtractedCommand { - command, - output_len, - session_id: session_id.clone(), - output_content, - is_error, - sequence_index, - }); - } - - Ok(commands) + Ok(join_tool_uses_with_results( + pending_tool_uses, + &tool_results, + &session_id, + )) } } @@ -347,7 +426,7 @@ mod tests { let cmds = provider.extract_commands(jsonl.path()).unwrap(); assert_eq!(cmds.len(), 1); assert_eq!(cmds[0].command, "git commit --ammend"); - assert_eq!(cmds[0].is_error, true); + assert!(cmds[0].is_error); assert!(cmds[0].output_content.is_some()); assert_eq!( cmds[0].output_content.as_ref().unwrap(), @@ -365,7 +444,7 @@ mod tests { let provider = ClaudeProvider; let cmds = provider.extract_commands(jsonl.path()).unwrap(); assert_eq!(cmds.len(), 2); - assert_eq!(cmds[0].is_error, false); + assert!(!cmds[0].is_error); assert_eq!(cmds[1].is_error, true); } @@ -385,4 +464,45 @@ mod tests { assert_eq!(cmds[1].command, "second"); assert_eq!(cmds[2].command, "third"); } + + #[test] + fn test_tool_source_short_names() { + assert_eq!(ToolSource::ClaudeCode.short_name(), "claude"); + assert_eq!(ToolSource::CodexCli.short_name(), "codex"); + assert_eq!(ToolSource::Cline.short_name(), "cline"); + assert_eq!(ToolSource::Cursor.short_name(), "cursor"); + } + + #[test] + fn test_tool_source_display_names() { + assert_eq!(ToolSource::ClaudeCode.display_name(), "Claude Code"); + assert_eq!(ToolSource::CodexCli.display_name(), "Codex CLI"); + assert_eq!(ToolSource::Cline.display_name(), "Cline"); + assert_eq!(ToolSource::Cursor.display_name(), "Cursor"); + } + + #[test] + fn test_build_providers_all() { + let providers = build_providers(None); + assert_eq!(providers.len(), 4); + } + + #[test] + fn test_build_providers_filtered() { + let providers = build_providers(Some("claude")); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].tool_source(), ToolSource::ClaudeCode); + } + + #[test] + fn test_build_providers_unknown_filter() { + let providers = build_providers(Some("unknown")); + assert_eq!(providers.len(), 0); + } + + #[test] + fn test_claude_provider_tool_source() { + let provider = ClaudeProvider; + assert_eq!(provider.tool_source(), ToolSource::ClaudeCode); + } } diff --git a/src/discover/report.rs b/src/discover/report.rs index fdc16427..7169110d 100644 --- a/src/discover/report.rs +++ b/src/discover/report.rs @@ -51,6 +51,7 @@ pub struct DiscoverReport { pub supported: Vec, pub unsupported: Vec, pub parse_errors: usize, + pub tools_scanned: Vec, } impl DiscoverReport { @@ -73,8 +74,11 @@ pub fn format_text(report: &DiscoverReport, limit: usize, verbose: bool) -> Stri out.push_str("RTK Discover -- Savings Opportunities\n"); out.push_str(&"=".repeat(52)); out.push('\n'); + if !report.tools_scanned.is_empty() { + out.push_str(&format!("Tools: {}\n", report.tools_scanned.join(", "))); + } out.push_str(&format!( - "Scanned: {} sessions (last {} days), {} Bash commands\n", + "Scanned: {} sessions (last {} days), {} shell commands\n", report.sessions_scanned, report.since_days, report.total_commands )); out.push_str(&format!( diff --git a/src/learn/mod.rs b/src/learn/mod.rs index 2e1e78b3..a3712cbf 100644 --- a/src/learn/mod.rs +++ b/src/learn/mod.rs @@ -1,7 +1,7 @@ pub mod detector; pub mod report; -use crate::discover::provider::{ClaudeProvider, SessionProvider}; +use crate::discover::provider::{build_providers, VALID_TOOL_NAMES}; use anyhow::Result; use detector::{deduplicate_corrections, find_corrections, CommandExecution}; use report::{format_console_report, write_rules_file}; @@ -14,51 +14,63 @@ pub fn run( write_rules: bool, min_confidence: f64, min_occurrences: usize, + tool: Option, ) -> Result<()> { - let provider = ClaudeProvider; + if let Some(ref t) = tool { + if !VALID_TOOL_NAMES.contains(&t.as_str()) { + anyhow::bail!( + "Unknown tool '{}'. Valid options: {}", + t, + VALID_TOOL_NAMES.join(", ") + ); + } + } + + let providers = build_providers(tool.as_deref()); - // Determine project filter (same logic as discover) let project_filter = if all { None } else if let Some(p) = project { Some(p) } else { - // Default: current working directory let cwd = std::env::current_dir()?; - let cwd_str = cwd.to_string_lossy().to_string(); - let encoded = ClaudeProvider::encode_project_path(&cwd_str); - Some(encoded) + Some(cwd.to_string_lossy().to_string()) }; - // Discover sessions - let sessions = provider.discover_sessions(project_filter.as_deref(), Some(since))?; - - if sessions.is_empty() { - println!("No Claude Code sessions found in the last {} days.", since); - return Ok(()); - } - - // Extract commands from all sessions + let mut total_sessions: usize = 0; let mut all_commands: Vec = Vec::new(); - for session_path in &sessions { - let extracted = match provider.extract_commands(session_path) { - Ok(cmds) => cmds, - Err(_) => continue, // Skip malformed sessions + for provider in &providers { + let sessions = match provider.discover_sessions(project_filter.as_deref(), Some(since)) { + Ok(s) => s, + Err(_) => continue, }; - for ext_cmd in extracted { - // Only process commands with output content - if let Some(output) = ext_cmd.output_content { - all_commands.push(CommandExecution { - command: ext_cmd.command, - is_error: ext_cmd.is_error, - output, - }); + total_sessions += sessions.len(); + + for session_path in &sessions { + let extracted = match provider.extract_commands(session_path) { + Ok(cmds) => cmds, + Err(_) => continue, + }; + + for ext_cmd in extracted { + if let Some(output) = ext_cmd.output_content { + all_commands.push(CommandExecution { + command: ext_cmd.command, + is_error: ext_cmd.is_error, + output, + }); + } } } } + if total_sessions == 0 { + println!("No AI coding sessions found in the last {} days.", since); + return Ok(()); + } + // Sort by sequence index to maintain chronological order // (already sorted by extraction order within each session) @@ -68,7 +80,7 @@ pub fn run( if corrections.is_empty() { println!( "No CLI corrections detected in {} sessions.", - sessions.len() + total_sessions ); return Ok(()); } @@ -90,7 +102,7 @@ pub fn run( "json" => { // JSON output let json = serde_json::json!({ - "sessions_scanned": sessions.len(), + "sessions_scanned": total_sessions, "total_corrections": filtered.len(), "rules": rules.iter().map(|r| serde_json::json!({ "wrong": r.wrong_pattern, @@ -104,7 +116,7 @@ pub fn run( } _ => { // Text output - let report = format_console_report(&rules, filtered.len(), sessions.len(), since); + let report = format_console_report(&rules, filtered.len(), total_sessions, since); print!("{}", report); if write_rules && !rules.is_empty() { diff --git a/src/main.rs b/src/main.rs index fcb39303..fba5c48c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -444,7 +444,7 @@ enum Commands { args: Vec, }, - /// Discover missed RTK savings from Claude Code history + /// Discover missed RTK savings from AI coding tool history Discover { /// Filter by project path (substring match) #[arg(short, long)] @@ -461,9 +461,12 @@ enum Commands { /// Output format: text, json #[arg(short, long, default_value = "text")] format: String, + /// Filter by AI tool: claude, codex, cline, cursor (default: all) + #[arg(short = 't', long)] + tool: Option, }, - /// Learn CLI corrections from Claude Code error history + /// Learn CLI corrections from AI coding tool error history Learn { /// Filter by project path (substring match) #[arg(short, long)] @@ -486,6 +489,9 @@ enum Commands { /// Minimum occurrences to include in report #[arg(long, default_value = "1")] min_occurrences: usize, + /// Filter by AI tool: claude, codex, cline, cursor (default: all) + #[arg(short = 't', long)] + tool: Option, }, /// Execute command without filtering but track usage @@ -1294,8 +1300,17 @@ fn main() -> Result<()> { all, since, format, + tool, } => { - discover::run(project.as_deref(), all, since, limit, &format, cli.verbose)?; + discover::run( + project.as_deref(), + all, + since, + limit, + &format, + cli.verbose, + tool.as_deref(), + )?; } Commands::Learn { @@ -1306,6 +1321,7 @@ fn main() -> Result<()> { write_rules, min_confidence, min_occurrences, + tool, } => { learn::run( project, @@ -1315,6 +1331,7 @@ fn main() -> Result<()> { write_rules, min_confidence, min_occurrences, + tool, )?; } diff --git a/src/runner.rs b/src/runner.rs index 6ae6599e..afd77b80 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -58,7 +58,8 @@ pub fn run_err(command: &str, verbose: u8) -> Result<()> { } else { println!("{}", rtk); } - timer.track(command, "rtk run-err", &raw, &rtk); + let label = format!("rtk err {}", command); + timer.track(command, &label, &raw, &rtk); Ok(()) } @@ -99,7 +100,8 @@ pub fn run_test(command: &str, verbose: u8) -> Result<()> { } else { println!("{}", summary); } - timer.track(command, "rtk run-test", &raw, &summary); + let label = format!("rtk test {}", command); + timer.track(command, &label, &raw, &summary); Ok(()) }