Skip to main content

rippy_cli/
sessions.rs

1//! Parse Claude Code session files (JSONL) for Bash command history.
2//!
3//! Extracts tool calls and user decisions (allow/deny) from session transcripts,
4//! producing `CommandBreakdown` data compatible with the suggestion engine.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use crate::config;
10use crate::error::RippyError;
11use crate::tracking::CommandBreakdown;
12use crate::verdict::Decision;
13
14/// A single Bash command extracted from a session with the user's decision.
15#[derive(Debug, Clone)]
16pub struct SessionCommand {
17    pub command: String,
18    pub allowed: bool,
19}
20
21/// Parse a single JSONL session file for Bash tool commands.
22///
23/// Extracts `tool_use` entries with `name == "Bash"` and correlates them with
24/// `tool_result` entries to determine if the user allowed or denied each command.
25///
26/// # Errors
27///
28/// Returns `RippyError::Parse` if the file cannot be read.
29pub fn parse_session_file(path: &Path) -> Result<Vec<SessionCommand>, RippyError> {
30    let content = std::fs::read_to_string(path)
31        .map_err(|e| RippyError::Parse(format!("could not read {}: {e}", path.display())))?;
32    Ok(parse_session_content(&content))
33}
34
35/// Parse JSONL content for Bash tool commands.
36fn parse_session_content(content: &str) -> Vec<SessionCommand> {
37    let mut pending: HashMap<String, String> = HashMap::new();
38    let mut commands = Vec::new();
39
40    for line in content.lines() {
41        let line = line.trim();
42        if line.is_empty() {
43            continue;
44        }
45        let Ok(entry) = serde_json::from_str::<serde_json::Value>(line) else {
46            continue;
47        };
48
49        match entry.get("type").and_then(serde_json::Value::as_str) {
50            Some("assistant") => extract_tool_uses(&entry, &mut pending),
51            Some("user") => extract_tool_results(&entry, &mut pending, &mut commands),
52            _ => {}
53        }
54    }
55
56    commands
57}
58
59fn extract_tool_uses(entry: &serde_json::Value, pending: &mut HashMap<String, String>) {
60    let Some(content) = entry
61        .get("message")
62        .and_then(|m| m.get("content"))
63        .and_then(serde_json::Value::as_array)
64    else {
65        return;
66    };
67
68    for item in content {
69        if item.get("type").and_then(serde_json::Value::as_str) != Some("tool_use") {
70            continue;
71        }
72        if item.get("name").and_then(serde_json::Value::as_str) != Some("Bash") {
73            continue;
74        }
75        if let (Some(id), Some(command)) = (
76            item.get("id").and_then(serde_json::Value::as_str),
77            item.get("input")
78                .and_then(|i| i.get("command"))
79                .and_then(serde_json::Value::as_str),
80        ) && !command.is_empty()
81        {
82            pending.insert(id.to_string(), command.to_string());
83        }
84    }
85}
86
87fn extract_tool_results(
88    entry: &serde_json::Value,
89    pending: &mut HashMap<String, String>,
90    commands: &mut Vec<SessionCommand>,
91) {
92    let Some(content) = entry
93        .get("message")
94        .and_then(|m| m.get("content"))
95        .and_then(serde_json::Value::as_array)
96    else {
97        return;
98    };
99
100    for item in content {
101        if item.get("type").and_then(serde_json::Value::as_str) != Some("tool_result") {
102            continue;
103        }
104        let Some(tool_use_id) = item.get("tool_use_id").and_then(serde_json::Value::as_str) else {
105            continue;
106        };
107        if let Some(command) = pending.remove(tool_use_id) {
108            let is_error = item.get("is_error").and_then(serde_json::Value::as_bool);
109            commands.push(SessionCommand {
110                command,
111                allowed: is_error != Some(true),
112            });
113        }
114    }
115}
116
117// ── Project directory discovery ────────────────────────────────────────
118
119/// Find and parse all session files for the current project.
120///
121/// # Errors
122///
123/// Returns `RippyError::Parse` if session files cannot be read.
124pub fn parse_project_sessions(cwd: &Path) -> Result<Vec<SessionCommand>, RippyError> {
125    let Some(project_dir) = find_project_dir(cwd) else {
126        return Err(RippyError::Parse(
127            "no Claude Code session directory found for this project".to_string(),
128        ));
129    };
130
131    let mut all_commands = Vec::new();
132    let entries = std::fs::read_dir(&project_dir)
133        .map_err(|e| RippyError::Parse(format!("could not read {}: {e}", project_dir.display())))?;
134
135    for entry in entries {
136        let Ok(entry) = entry else { continue };
137        let path = entry.path();
138        if path.extension().is_some_and(|ext| ext == "jsonl") {
139            match parse_session_file(&path) {
140                Ok(cmds) => all_commands.extend(cmds),
141                Err(e) => eprintln!("[rippy] warning: {}: {e}", path.display()),
142            }
143        }
144    }
145
146    Ok(all_commands)
147}
148
149/// Find the Claude Code project directory for a given working directory.
150fn find_project_dir(cwd: &Path) -> Option<PathBuf> {
151    let home = config::home_dir()?;
152    let projects_dir = home.join(".claude/projects");
153    if !projects_dir.is_dir() {
154        return None;
155    }
156
157    // Claude Code uses cwd path with '/' replaced by '-'.
158    let cwd_str = cwd.to_str()?;
159    let normalized = cwd_str.trim_start_matches('/').replace(['/', '.'], "-");
160    let project_name = format!("-{normalized}");
161    let candidate = projects_dir.join(&project_name);
162
163    if candidate.is_dir() {
164        Some(candidate)
165    } else {
166        None
167    }
168}
169
170// ── Filtering ──────────────────────────────────────────────────────────
171
172/// Filter out commands that CC permissions or rippy config already auto-allow.
173///
174/// Only returns commands that would actually need new rules (user-allowed or user-denied).
175///
176/// # Errors
177///
178/// Returns `RippyError` if the config cannot be loaded.
179pub fn filter_auto_allowed(
180    commands: &[SessionCommand],
181    cwd: &Path,
182) -> Result<Vec<SessionCommand>, RippyError> {
183    let config = crate::config::Config::load(cwd, None)?;
184    let cc_rules = crate::cc_permissions::load_cc_rules(cwd);
185
186    let filtered = commands
187        .iter()
188        .filter(|cmd| {
189            let cc = cc_rules.check(&cmd.command);
190            let rippy = config.match_command(&cmd.command, None);
191            let auto_allowed = cc == Some(Decision::Allow)
192                || rippy
193                    .as_ref()
194                    .is_some_and(|v| v.decision == Decision::Allow);
195            !auto_allowed
196        })
197        .cloned()
198        .collect();
199
200    Ok(filtered)
201}
202
203// ── Conversion to CommandBreakdown ─────────────────────────────────────
204
205/// Convert session commands to `CommandBreakdown` format for the suggest engine.
206#[must_use]
207pub fn to_breakdowns(commands: &[SessionCommand]) -> Vec<CommandBreakdown> {
208    let mut map: HashMap<String, CommandBreakdown> = HashMap::new();
209
210    for cmd in commands {
211        let entry = map
212            .entry(cmd.command.clone())
213            .or_insert_with(|| CommandBreakdown {
214                command: cmd.command.clone(),
215                allow_count: 0,
216                ask_count: 0,
217                deny_count: 0,
218            });
219        if cmd.allowed {
220            entry.allow_count += 1;
221        } else {
222            entry.deny_count += 1;
223        }
224    }
225
226    let mut result: Vec<CommandBreakdown> = map.into_values().collect();
227    result.sort_by(|a, b| {
228        let total_b = b.allow_count + b.ask_count + b.deny_count;
229        let total_a = a.allow_count + a.ask_count + a.deny_count;
230        total_b
231            .cmp(&total_a)
232            .then_with(|| a.command.cmp(&b.command))
233    });
234    result
235}
236
237// ── Audit classification ───────────────────────────────────────────────
238
239/// Audit results: classify commands against current rippy config.
240#[derive(Debug)]
241pub struct AuditResult {
242    pub auto_allowed: Vec<(String, i64)>,
243    pub user_allowed: Vec<(String, i64)>,
244    pub user_denied: Vec<(String, i64)>,
245    pub total: i64,
246}
247
248/// Classify session commands against the current rippy config.
249///
250/// # Errors
251///
252/// Returns `RippyError` if the config cannot be loaded.
253pub fn audit_commands(commands: &[SessionCommand], cwd: &Path) -> Result<AuditResult, RippyError> {
254    let config = crate::config::Config::load(cwd, None)?;
255    let cc_rules = crate::cc_permissions::load_cc_rules(cwd);
256
257    let mut auto_allowed: HashMap<String, i64> = HashMap::new();
258    let mut user_allowed: HashMap<String, i64> = HashMap::new();
259    let mut user_denied: HashMap<String, i64> = HashMap::new();
260
261    for cmd in commands {
262        // Check CC permissions first (same priority as analyzer).
263        let cc_decision = cc_rules.check(&cmd.command);
264        let rippy_verdict = config.match_command(&cmd.command, None);
265        let would_allow = cc_decision == Some(Decision::Allow)
266            || rippy_verdict
267                .as_ref()
268                .is_some_and(|v| v.decision == Decision::Allow);
269
270        if would_allow {
271            *auto_allowed.entry(cmd.command.clone()).or_default() += 1;
272        } else if cmd.allowed {
273            *user_allowed.entry(cmd.command.clone()).or_default() += 1;
274        } else {
275            *user_denied.entry(cmd.command.clone()).or_default() += 1;
276        }
277    }
278
279    #[allow(clippy::cast_possible_wrap)]
280    let total = commands.len() as i64;
281
282    Ok(AuditResult {
283        auto_allowed: sorted_counts(auto_allowed),
284        user_allowed: sorted_counts(user_allowed),
285        user_denied: sorted_counts(user_denied),
286        total,
287    })
288}
289
290fn sorted_counts(map: HashMap<String, i64>) -> Vec<(String, i64)> {
291    let mut v: Vec<_> = map.into_iter().collect();
292    v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
293    v
294}
295
296/// Print audit results to stdout.
297pub fn print_audit(result: &AuditResult) {
298    let auto_count: i64 = result.auto_allowed.iter().map(|(_, c)| c).sum();
299    let user_count: i64 = result.user_allowed.iter().map(|(_, c)| c).sum();
300    let deny_count: i64 = result.user_denied.iter().map(|(_, c)| c).sum();
301
302    println!("Analyzed {} commands\n", result.total);
303
304    #[allow(clippy::cast_precision_loss)]
305    let pct = |n: i64| {
306        if result.total > 0 {
307            (n as f64 / result.total as f64) * 100.0
308        } else {
309            0.0
310        }
311    };
312
313    println!(
314        "  Auto-allowed (no action needed):     {:>4} ({:.1}%)",
315        auto_count,
316        pct(auto_count)
317    );
318    println!(
319        "  User-allowed (consider allow rules): {:>4} ({:.1}%)",
320        user_count,
321        pct(user_count)
322    );
323    println!(
324        "  User-denied  (consider deny rules):  {:>4} ({:.1}%)",
325        deny_count,
326        pct(deny_count)
327    );
328
329    if !result.user_allowed.is_empty() {
330        println!("\n  Top user-allowed commands:");
331        for (cmd, count) in result.user_allowed.iter().take(10) {
332            println!("    {cmd:<50} {count}x");
333        }
334    }
335
336    if !result.user_denied.is_empty() {
337        println!("\n  User-denied commands:");
338        for (cmd, count) in &result.user_denied {
339            println!("    {cmd:<50} {count}x");
340        }
341    }
342    println!();
343}
344
345// ── Tests ──────────────────────────────────────────────────────────────
346
347#[cfg(test)]
348#[allow(clippy::unwrap_used)]
349mod tests {
350    use super::*;
351
352    const SAMPLE_JSONL: &str = r#"
353{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"git status"}}]}}
354{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}
355{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t2","name":"Bash","input":{"command":"rm -rf /"}}]}}
356{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t2","is_error":true,"content":"denied"}]}}
357{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t3","name":"Bash","input":{"command":"git status"}}]}}
358{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t3","content":"ok"}]}}
359{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t4","name":"Read","input":{"path":"foo.rs"}}]}}
360{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t4","content":"file contents"}]}}
361"#;
362
363    #[test]
364    fn parse_extracts_bash_commands() {
365        let commands = parse_session_content(SAMPLE_JSONL);
366        assert_eq!(commands.len(), 3); // 2x git status + 1x rm -rf /
367    }
368
369    #[test]
370    fn parse_detects_allowed_and_denied() {
371        let commands = parse_session_content(SAMPLE_JSONL);
372        let allowed_count = commands.iter().filter(|c| c.allowed).count();
373        let denied: Vec<_> = commands.iter().filter(|c| !c.allowed).collect();
374        assert_eq!(allowed_count, 2);
375        assert_eq!(denied.len(), 1);
376        assert_eq!(denied[0].command, "rm -rf /");
377    }
378
379    #[test]
380    fn parse_ignores_non_bash_tools() {
381        let commands = parse_session_content(SAMPLE_JSONL);
382        // Read tool (t4) should not appear
383        assert!(!commands.iter().any(|c| c.command.contains("foo.rs")));
384    }
385
386    #[test]
387    fn parse_handles_empty_input() {
388        let commands = parse_session_content("");
389        assert!(commands.is_empty());
390    }
391
392    #[test]
393    fn parse_handles_malformed_lines() {
394        let input = "not json\n{\"type\":\"unknown\"}\n";
395        let commands = parse_session_content(input);
396        assert!(commands.is_empty());
397    }
398
399    #[test]
400    fn to_breakdowns_aggregates() {
401        let commands = parse_session_content(SAMPLE_JSONL);
402        let breakdowns = to_breakdowns(&commands);
403
404        assert_eq!(breakdowns.len(), 2); // git status, rm -rf /
405
406        let git = breakdowns
407            .iter()
408            .find(|b| b.command == "git status")
409            .unwrap();
410        assert_eq!(git.allow_count, 2);
411        assert_eq!(git.deny_count, 0);
412
413        let rm = breakdowns.iter().find(|b| b.command == "rm -rf /").unwrap();
414        assert_eq!(rm.allow_count, 0);
415        assert_eq!(rm.deny_count, 1);
416    }
417
418    #[test]
419    fn to_breakdowns_empty() {
420        let breakdowns = to_breakdowns(&[]);
421        assert!(breakdowns.is_empty());
422    }
423
424    #[test]
425    fn project_dir_mapping() {
426        let cwd = Path::new("/Users/mdp/src/github.com/mpecan/rippy");
427        let cwd_str = cwd.to_str().unwrap();
428        let normalized = cwd_str.trim_start_matches('/').replace(['/', '.'], "-");
429        let name = format!("-{normalized}");
430        assert_eq!(name, "-Users-mdp-src-github-com-mpecan-rippy");
431    }
432
433    #[test]
434    fn parse_session_file_from_disk() {
435        let dir = tempfile::TempDir::new().unwrap();
436        let path = dir.path().join("test.jsonl");
437        std::fs::write(&path, SAMPLE_JSONL).unwrap();
438
439        let commands = parse_session_file(&path).unwrap();
440        assert_eq!(commands.len(), 3);
441    }
442}