use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::config;
use crate::error::RippyError;
use crate::tracking::CommandBreakdown;
use crate::verdict::Decision;
#[derive(Debug, Clone)]
pub struct SessionCommand {
pub command: String,
pub allowed: bool,
}
pub fn parse_session_file(path: &Path) -> Result<Vec<SessionCommand>, RippyError> {
let content = std::fs::read_to_string(path)
.map_err(|e| RippyError::Parse(format!("could not read {}: {e}", path.display())))?;
Ok(parse_session_content(&content))
}
fn parse_session_content(content: &str) -> Vec<SessionCommand> {
let mut pending: HashMap<String, String> = HashMap::new();
let mut commands = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(entry) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
match entry.get("type").and_then(serde_json::Value::as_str) {
Some("assistant") => extract_tool_uses(&entry, &mut pending),
Some("user") => extract_tool_results(&entry, &mut pending, &mut commands),
_ => {}
}
}
commands
}
fn extract_tool_uses(entry: &serde_json::Value, pending: &mut HashMap<String, String>) {
let Some(content) = entry
.get("message")
.and_then(|m| m.get("content"))
.and_then(serde_json::Value::as_array)
else {
return;
};
for item in content {
if item.get("type").and_then(serde_json::Value::as_str) != Some("tool_use") {
continue;
}
if item.get("name").and_then(serde_json::Value::as_str) != Some("Bash") {
continue;
}
if let (Some(id), Some(command)) = (
item.get("id").and_then(serde_json::Value::as_str),
item.get("input")
.and_then(|i| i.get("command"))
.and_then(serde_json::Value::as_str),
) && !command.is_empty()
{
pending.insert(id.to_string(), command.to_string());
}
}
}
fn extract_tool_results(
entry: &serde_json::Value,
pending: &mut HashMap<String, String>,
commands: &mut Vec<SessionCommand>,
) {
let Some(content) = entry
.get("message")
.and_then(|m| m.get("content"))
.and_then(serde_json::Value::as_array)
else {
return;
};
for item in content {
if item.get("type").and_then(serde_json::Value::as_str) != Some("tool_result") {
continue;
}
let Some(tool_use_id) = item.get("tool_use_id").and_then(serde_json::Value::as_str) else {
continue;
};
if let Some(command) = pending.remove(tool_use_id) {
let is_error = item.get("is_error").and_then(serde_json::Value::as_bool);
commands.push(SessionCommand {
command,
allowed: is_error != Some(true),
});
}
}
}
pub fn parse_project_sessions(cwd: &Path) -> Result<Vec<SessionCommand>, RippyError> {
let Some(project_dir) = find_project_dir(cwd) else {
return Err(RippyError::Parse(
"no Claude Code session directory found for this project".to_string(),
));
};
let mut all_commands = Vec::new();
let entries = std::fs::read_dir(&project_dir)
.map_err(|e| RippyError::Parse(format!("could not read {}: {e}", project_dir.display())))?;
for entry in entries {
let Ok(entry) = entry else { continue };
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "jsonl") {
match parse_session_file(&path) {
Ok(cmds) => all_commands.extend(cmds),
Err(e) => eprintln!("[rippy] warning: {}: {e}", path.display()),
}
}
}
Ok(all_commands)
}
fn find_project_dir(cwd: &Path) -> Option<PathBuf> {
let home = config::home_dir()?;
let projects_dir = home.join(".claude/projects");
if !projects_dir.is_dir() {
return None;
}
let cwd_str = cwd.to_str()?;
let normalized = cwd_str.trim_start_matches('/').replace(['/', '.'], "-");
let project_name = format!("-{normalized}");
let candidate = projects_dir.join(&project_name);
if candidate.is_dir() {
Some(candidate)
} else {
None
}
}
pub fn filter_auto_allowed(
commands: &[SessionCommand],
cwd: &Path,
) -> Result<Vec<SessionCommand>, RippyError> {
let config = crate::config::Config::load(cwd, None)?;
let cc_rules = crate::cc_permissions::load_cc_rules(cwd);
let filtered = commands
.iter()
.filter(|cmd| {
let cc = cc_rules.check(&cmd.command);
let rippy = config.match_command(&cmd.command, None);
let auto_allowed = cc == Some(Decision::Allow)
|| rippy
.as_ref()
.is_some_and(|v| v.decision == Decision::Allow);
!auto_allowed
})
.cloned()
.collect();
Ok(filtered)
}
#[must_use]
pub fn to_breakdowns(commands: &[SessionCommand]) -> Vec<CommandBreakdown> {
let mut map: HashMap<String, CommandBreakdown> = HashMap::new();
for cmd in commands {
let entry = map
.entry(cmd.command.clone())
.or_insert_with(|| CommandBreakdown {
command: cmd.command.clone(),
allow_count: 0,
ask_count: 0,
deny_count: 0,
});
if cmd.allowed {
entry.allow_count += 1;
} else {
entry.deny_count += 1;
}
}
let mut result: Vec<CommandBreakdown> = map.into_values().collect();
result.sort_by(|a, b| {
let total_b = b.allow_count + b.ask_count + b.deny_count;
let total_a = a.allow_count + a.ask_count + a.deny_count;
total_b
.cmp(&total_a)
.then_with(|| a.command.cmp(&b.command))
});
result
}
#[derive(Debug)]
pub struct AuditResult {
pub auto_allowed: Vec<(String, i64)>,
pub user_allowed: Vec<(String, i64)>,
pub user_denied: Vec<(String, i64)>,
pub total: i64,
}
pub fn audit_commands(commands: &[SessionCommand], cwd: &Path) -> Result<AuditResult, RippyError> {
let config = crate::config::Config::load(cwd, None)?;
let cc_rules = crate::cc_permissions::load_cc_rules(cwd);
let mut auto_allowed: HashMap<String, i64> = HashMap::new();
let mut user_allowed: HashMap<String, i64> = HashMap::new();
let mut user_denied: HashMap<String, i64> = HashMap::new();
for cmd in commands {
let cc_decision = cc_rules.check(&cmd.command);
let rippy_verdict = config.match_command(&cmd.command, None);
let would_allow = cc_decision == Some(Decision::Allow)
|| rippy_verdict
.as_ref()
.is_some_and(|v| v.decision == Decision::Allow);
if would_allow {
*auto_allowed.entry(cmd.command.clone()).or_default() += 1;
} else if cmd.allowed {
*user_allowed.entry(cmd.command.clone()).or_default() += 1;
} else {
*user_denied.entry(cmd.command.clone()).or_default() += 1;
}
}
#[allow(clippy::cast_possible_wrap)]
let total = commands.len() as i64;
Ok(AuditResult {
auto_allowed: sorted_counts(auto_allowed),
user_allowed: sorted_counts(user_allowed),
user_denied: sorted_counts(user_denied),
total,
})
}
fn sorted_counts(map: HashMap<String, i64>) -> Vec<(String, i64)> {
let mut v: Vec<_> = map.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
v
}
pub fn print_audit(result: &AuditResult) {
let auto_count: i64 = result.auto_allowed.iter().map(|(_, c)| c).sum();
let user_count: i64 = result.user_allowed.iter().map(|(_, c)| c).sum();
let deny_count: i64 = result.user_denied.iter().map(|(_, c)| c).sum();
println!("Analyzed {} commands\n", result.total);
#[allow(clippy::cast_precision_loss)]
let pct = |n: i64| {
if result.total > 0 {
(n as f64 / result.total as f64) * 100.0
} else {
0.0
}
};
println!(
" Auto-allowed (no action needed): {:>4} ({:.1}%)",
auto_count,
pct(auto_count)
);
println!(
" User-allowed (consider allow rules): {:>4} ({:.1}%)",
user_count,
pct(user_count)
);
println!(
" User-denied (consider deny rules): {:>4} ({:.1}%)",
deny_count,
pct(deny_count)
);
if !result.user_allowed.is_empty() {
println!("\n Top user-allowed commands:");
for (cmd, count) in result.user_allowed.iter().take(10) {
println!(" {cmd:<50} {count}x");
}
}
if !result.user_denied.is_empty() {
println!("\n User-denied commands:");
for (cmd, count) in &result.user_denied {
println!(" {cmd:<50} {count}x");
}
}
println!();
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
const SAMPLE_JSONL: &str = r#"
{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"git status"}}]}}
{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}
{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t2","name":"Bash","input":{"command":"rm -rf /"}}]}}
{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t2","is_error":true,"content":"denied"}]}}
{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t3","name":"Bash","input":{"command":"git status"}}]}}
{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t3","content":"ok"}]}}
{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t4","name":"Read","input":{"path":"foo.rs"}}]}}
{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t4","content":"file contents"}]}}
"#;
#[test]
fn parse_extracts_bash_commands() {
let commands = parse_session_content(SAMPLE_JSONL);
assert_eq!(commands.len(), 3); }
#[test]
fn parse_detects_allowed_and_denied() {
let commands = parse_session_content(SAMPLE_JSONL);
let allowed_count = commands.iter().filter(|c| c.allowed).count();
let denied: Vec<_> = commands.iter().filter(|c| !c.allowed).collect();
assert_eq!(allowed_count, 2);
assert_eq!(denied.len(), 1);
assert_eq!(denied[0].command, "rm -rf /");
}
#[test]
fn parse_ignores_non_bash_tools() {
let commands = parse_session_content(SAMPLE_JSONL);
assert!(!commands.iter().any(|c| c.command.contains("foo.rs")));
}
#[test]
fn parse_handles_empty_input() {
let commands = parse_session_content("");
assert!(commands.is_empty());
}
#[test]
fn parse_handles_malformed_lines() {
let input = "not json\n{\"type\":\"unknown\"}\n";
let commands = parse_session_content(input);
assert!(commands.is_empty());
}
#[test]
fn to_breakdowns_aggregates() {
let commands = parse_session_content(SAMPLE_JSONL);
let breakdowns = to_breakdowns(&commands);
assert_eq!(breakdowns.len(), 2);
let git = breakdowns
.iter()
.find(|b| b.command == "git status")
.unwrap();
assert_eq!(git.allow_count, 2);
assert_eq!(git.deny_count, 0);
let rm = breakdowns.iter().find(|b| b.command == "rm -rf /").unwrap();
assert_eq!(rm.allow_count, 0);
assert_eq!(rm.deny_count, 1);
}
#[test]
fn to_breakdowns_empty() {
let breakdowns = to_breakdowns(&[]);
assert!(breakdowns.is_empty());
}
#[test]
fn project_dir_mapping() {
let cwd = Path::new("/Users/mdp/src/github.com/mpecan/rippy");
let cwd_str = cwd.to_str().unwrap();
let normalized = cwd_str.trim_start_matches('/').replace(['/', '.'], "-");
let name = format!("-{normalized}");
assert_eq!(name, "-Users-mdp-src-github-com-mpecan-rippy");
}
#[test]
fn parse_session_file_from_disk() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("test.jsonl");
std::fs::write(&path, SAMPLE_JSONL).unwrap();
let commands = parse_session_file(&path).unwrap();
assert_eq!(commands.len(), 3);
}
}