1use 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#[derive(Debug, Clone)]
16pub struct SessionCommand {
17 pub command: String,
18 pub allowed: bool,
19}
20
21pub 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
35fn 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
117pub 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
149fn 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 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
170pub 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#[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#[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
248pub 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 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
296pub 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#[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); }
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 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); 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}