use std::collections::HashMap;
use std::fmt::Write as _;
use std::path::PathBuf;
use std::process::ExitCode;
use serde::Serialize;
use crate::cli::SuggestArgs;
use crate::config;
use crate::error::RippyError;
use crate::risk::{self, RiskLevel};
use crate::rule_cmd;
use crate::tracking;
use crate::verdict::Decision;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Confidence {
High,
Medium,
Low,
}
impl Confidence {
const fn as_str(self) -> &'static str {
match self {
Self::High => "high",
Self::Medium => "medium",
Self::Low => "low",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Suggestion {
pub pattern: String,
pub action: String,
pub risk: RiskLevel,
pub confidence: Confidence,
pub evidence: Evidence,
}
#[derive(Debug, Clone, Serialize)]
pub struct Evidence {
pub total: i64,
pub allow_count: i64,
pub ask_count: i64,
pub deny_count: i64,
pub example_commands: Vec<String>,
}
struct CommandGroup {
key: String,
evidence: Evidence,
}
pub fn run(args: &SuggestArgs) -> Result<ExitCode, RippyError> {
if let Some(command) = &args.from_command {
print_command_suggestions(command);
return Ok(ExitCode::SUCCESS);
}
let breakdowns = load_breakdowns(args)?;
let suggestions = analyze_breakdowns(&breakdowns, args.min_count);
if suggestions.is_empty() {
eprintln!("[rippy] No suggestions — not enough data yet.");
return Ok(ExitCode::SUCCESS);
}
if args.json {
let json = serde_json::to_string_pretty(&suggestions)
.map_err(|e| RippyError::Tracking(format!("JSON serialization failed: {e}")))?;
println!("{json}");
} else {
print_text(&suggestions);
}
if args.apply {
apply_suggestions(&suggestions, args.global)?;
}
Ok(ExitCode::SUCCESS)
}
fn load_breakdowns(args: &SuggestArgs) -> Result<Vec<tracking::CommandBreakdown>, RippyError> {
if let Some(file) = &args.session_file {
return load_from_sessions(args, || crate::sessions::parse_session_file(file));
}
if args.db.is_some() {
return load_from_db(args);
}
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
match crate::sessions::parse_project_sessions(&cwd) {
Ok(ref commands) if !commands.is_empty() => {
load_from_session_commands(args, commands, &cwd)
}
_ => load_from_db(args),
}
}
fn load_from_sessions(
args: &SuggestArgs,
parse: impl FnOnce() -> Result<Vec<crate::sessions::SessionCommand>, RippyError>,
) -> Result<Vec<tracking::CommandBreakdown>, RippyError> {
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let commands = parse()?;
load_from_session_commands(args, &commands, &cwd)
}
fn load_from_session_commands(
args: &SuggestArgs,
commands: &[crate::sessions::SessionCommand],
cwd: &std::path::Path,
) -> Result<Vec<tracking::CommandBreakdown>, RippyError> {
if args.audit {
let audit = crate::sessions::audit_commands(commands, cwd)?;
crate::sessions::print_audit(&audit);
}
let filtered = crate::sessions::filter_auto_allowed(commands, cwd)?;
Ok(crate::sessions::to_breakdowns(&filtered))
}
fn load_from_db(args: &SuggestArgs) -> Result<Vec<tracking::CommandBreakdown>, RippyError> {
let db_path = resolve_db_path(args)?;
let conn = tracking::open_db(&db_path)?;
let since_modifier = parse_since(args.since.as_deref())?;
tracking::query_command_breakdown(&conn, since_modifier.as_deref())
}
fn print_command_suggestions(command: &str) {
let patterns = rule_cmd::suggest_patterns(command);
if patterns.is_empty() {
eprintln!("[rippy] No patterns to suggest for empty command");
return;
}
println!("Suggested patterns for: {command}\n");
for (i, pattern) in patterns.iter().enumerate() {
println!(" {}. {pattern}", i + 1);
}
let last = patterns.last().map_or("", String::as_str);
let first = patterns.first().map_or("", String::as_str);
println!("\nUsage: rippy allow \"{last}\"\n rippy deny \"{first}\"");
}
fn resolve_db_path(args: &SuggestArgs) -> Result<PathBuf, RippyError> {
tracking::resolve_db_path(args.db.as_deref())
}
fn parse_since(since: Option<&str>) -> Result<Option<String>, RippyError> {
since.map_or(Ok(None), |s| {
tracking::parse_duration(s)
.ok_or_else(|| {
RippyError::Tracking(format!(
"invalid duration: {s}. Use format like 7d, 1h, 30m"
))
})
.map(Some)
})
}
#[must_use]
pub fn analyze_breakdowns(
breakdowns: &[tracking::CommandBreakdown],
min_count: i64,
) -> Vec<Suggestion> {
let groups = group_commands(breakdowns);
let mut suggestions: Vec<Suggestion> = groups
.into_iter()
.filter(|g| g.evidence.total >= min_count)
.map(|g| {
let risk = risk::classify(&g.key);
let confidence = compute_confidence(&g.evidence);
let action = suggest_action(&g.evidence, risk);
let pattern = generalize_pattern(&g.key, &g.evidence.example_commands);
Suggestion {
pattern,
action: action.as_str().to_string(),
risk,
confidence,
evidence: g.evidence,
}
})
.collect();
suggestions.sort_by(|a, b| {
b.risk
.cmp(&a.risk)
.then_with(|| b.evidence.total.cmp(&a.evidence.total))
});
suggestions
}
const SUBCOMMAND_TOOLS: &[&str] = &[
"git", "docker", "cargo", "npm", "yarn", "pnpm", "kubectl", "helm", "pip", "pip3",
];
fn group_key(command: &str) -> String {
let mut tokens = command.split_whitespace();
let Some(first) = tokens.next() else {
return command.to_string();
};
if SUBCOMMAND_TOOLS.contains(&first)
&& let Some(second) = tokens.next()
{
return format!("{first} {second}");
}
first.to_string()
}
fn group_commands(breakdowns: &[tracking::CommandBreakdown]) -> Vec<CommandGroup> {
let mut map: HashMap<String, CommandGroup> = HashMap::new();
for bd in breakdowns {
let key = group_key(&bd.command);
let total = bd.allow_count + bd.ask_count + bd.deny_count;
let group = map.entry(key.clone()).or_insert_with(|| CommandGroup {
key,
evidence: Evidence {
total: 0,
allow_count: 0,
ask_count: 0,
deny_count: 0,
example_commands: Vec::new(),
},
});
group.evidence.total += total;
group.evidence.allow_count += bd.allow_count;
group.evidence.ask_count += bd.ask_count;
group.evidence.deny_count += bd.deny_count;
if group.evidence.example_commands.len() < 3 {
group.evidence.example_commands.push(bd.command.clone());
}
}
map.into_values().collect()
}
#[must_use]
pub fn compute_confidence(evidence: &Evidence) -> Confidence {
if evidence.total == 0 {
return Confidence::Low;
}
#[allow(clippy::cast_precision_loss)]
let max_ratio = [
evidence.allow_count,
evidence.ask_count,
evidence.deny_count,
]
.into_iter()
.max()
.unwrap_or(0) as f64
/ evidence.total as f64;
if max_ratio >= 0.8 && evidence.total >= 10 {
Confidence::High
} else if max_ratio >= 0.6 && evidence.total >= 5 {
Confidence::Medium
} else {
Confidence::Low
}
}
#[must_use]
pub fn suggest_action(evidence: &Evidence, risk: RiskLevel) -> Decision {
if evidence.total == 0 {
return Decision::Ask;
}
#[allow(clippy::cast_precision_loss)]
let allow_ratio = evidence.allow_count as f64 / evidence.total as f64;
#[allow(clippy::cast_precision_loss)]
let deny_ratio = evidence.deny_count as f64 / evidence.total as f64;
if deny_ratio >= 0.5 {
return Decision::Deny;
}
if allow_ratio >= 0.8 {
return match risk {
RiskLevel::Low | RiskLevel::Medium => Decision::Allow,
RiskLevel::High | RiskLevel::Critical => Decision::Ask,
};
}
Decision::Ask
}
fn generalize_pattern(group_key: &str, examples: &[String]) -> String {
if examples.len() == 1 {
return examples[0].clone();
}
let all_start_with_key = examples.iter().all(|e| {
e == group_key
|| (e.starts_with(group_key) && e.as_bytes().get(group_key.len()) == Some(&b' '))
});
if all_start_with_key && examples.iter().any(|e| e != group_key) {
return format!("{group_key} *");
}
group_key.to_string()
}
fn print_text(suggestions: &[Suggestion]) {
let mut current_confidence: Option<Confidence> = None;
for s in suggestions {
if current_confidence != Some(s.confidence) {
current_confidence = Some(s.confidence);
println!("\n {} confidence:", s.confidence.as_str().to_uppercase());
}
let mut line = format!(
" {} {:<30} # {} {} times (risk: {})",
s.action,
s.pattern,
s.action,
s.evidence.total,
s.risk.as_str(),
);
if !s.evidence.example_commands.is_empty() && s.evidence.example_commands[0] != s.pattern {
let _ = write!(line, ", e.g. {}", s.evidence.example_commands[0]);
}
println!("{line}");
}
println!();
}
fn apply_suggestions(suggestions: &[Suggestion], global: bool) -> Result<(), RippyError> {
let path = if global {
config::home_dir()
.map(|h| h.join(".rippy/config.toml"))
.ok_or_else(|| RippyError::Setup("could not determine home directory".into()))?
} else {
PathBuf::from(".rippy.toml")
};
let guard = (!global).then(|| crate::trust::TrustGuard::before_write(&path));
for s in suggestions {
let decision = match s.action.as_str() {
"allow" => Decision::Allow,
"deny" => Decision::Deny,
_ => Decision::Ask,
};
rule_cmd::append_rule_to_toml(&path, decision, &s.pattern, None)?;
}
if let Some(g) = guard {
g.commit();
}
eprintln!(
"[rippy] Applied {} suggestion(s) to {}",
suggestions.len(),
path.display()
);
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::mode::Mode;
fn make_evidence(allow: i64, ask: i64, deny: i64) -> Evidence {
Evidence {
total: allow + ask + deny,
allow_count: allow,
ask_count: ask,
deny_count: deny,
example_commands: vec![],
}
}
#[test]
fn confidence_high() {
let e = make_evidence(20, 0, 0);
assert_eq!(compute_confidence(&e), Confidence::High);
}
#[test]
fn confidence_medium() {
let e = make_evidence(5, 2, 0);
assert_eq!(compute_confidence(&e), Confidence::Medium);
}
#[test]
fn confidence_low_small_sample() {
let e = make_evidence(3, 0, 0);
assert_eq!(compute_confidence(&e), Confidence::Low);
}
#[test]
fn confidence_low_mixed() {
let e = make_evidence(5, 4, 3);
assert_eq!(compute_confidence(&e), Confidence::Low);
}
#[test]
fn confidence_empty() {
let e = make_evidence(0, 0, 0);
assert_eq!(compute_confidence(&e), Confidence::Low);
}
#[test]
fn action_mostly_allowed_low_risk() {
let e = make_evidence(20, 1, 0);
assert_eq!(suggest_action(&e, RiskLevel::Low), Decision::Allow);
}
#[test]
fn action_mostly_allowed_high_risk() {
let e = make_evidence(20, 1, 0);
assert_eq!(suggest_action(&e, RiskLevel::High), Decision::Ask);
}
#[test]
fn action_mostly_denied() {
let e = make_evidence(2, 1, 10);
assert_eq!(suggest_action(&e, RiskLevel::Low), Decision::Deny);
}
#[test]
fn action_mixed_signals() {
let e = make_evidence(5, 5, 0);
assert_eq!(suggest_action(&e, RiskLevel::Medium), Decision::Ask);
}
#[test]
fn group_key_subcommand_tools() {
assert_eq!(group_key("git push origin main"), "git push");
assert_eq!(group_key("docker run -it ubuntu"), "docker run");
assert_eq!(group_key("cargo test --release"), "cargo test");
}
#[test]
fn group_key_simple_commands() {
assert_eq!(group_key("ls -la"), "ls");
assert_eq!(group_key("rm -rf /tmp"), "rm");
assert_eq!(group_key("make"), "make");
}
#[test]
fn group_commands_aggregates() {
let breakdowns = vec![
tracking::CommandBreakdown {
command: "git push origin main".into(),
allow_count: 5,
ask_count: 2,
deny_count: 0,
},
tracking::CommandBreakdown {
command: "git push origin dev".into(),
allow_count: 3,
ask_count: 1,
deny_count: 0,
},
];
let groups = group_commands(&breakdowns);
assert_eq!(groups.len(), 1);
let g = &groups[0];
assert_eq!(g.key, "git push");
assert_eq!(g.evidence.allow_count, 8);
assert_eq!(g.evidence.ask_count, 3);
assert_eq!(g.evidence.total, 11);
assert_eq!(g.evidence.example_commands.len(), 2);
}
#[test]
fn generalize_single_example() {
let p = generalize_pattern("git push", &["git push origin main".into()]);
assert_eq!(p, "git push origin main");
}
#[test]
fn generalize_multiple_examples() {
let p = generalize_pattern(
"git push",
&["git push origin main".into(), "git push origin dev".into()],
);
assert_eq!(p, "git push *");
}
#[test]
fn generalize_exact_key_only() {
let p = generalize_pattern("ls", &["ls".into(), "ls".into()]);
assert_eq!(p, "ls");
}
fn populate_test_db(conn: &rusqlite::Connection) {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS decisions (
id INTEGER PRIMARY KEY,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
session_id TEXT, mode TEXT, tool_name TEXT NOT NULL,
command TEXT, decision TEXT NOT NULL, reason TEXT, payload_json TEXT
);",
)
.unwrap();
let entry = tracking::TrackingEntry {
session_id: None,
mode: Mode::Claude,
tool_name: "Bash",
command: Some("git status"),
decision: Decision::Allow,
reason: "safe",
payload_json: None,
};
for _ in 0..15 {
tracking::record_decision(conn, &entry).unwrap();
}
for _ in 0..10 {
tracking::record_decision(
conn,
&tracking::TrackingEntry {
decision: Decision::Ask,
command: Some("git push origin main"),
reason: "review",
..entry
},
)
.unwrap();
}
for _ in 0..5 {
tracking::record_decision(
conn,
&tracking::TrackingEntry {
decision: Decision::Deny,
command: Some("rm -rf /"),
reason: "dangerous",
..entry
},
)
.unwrap();
}
}
#[test]
fn analyze_produces_suggestions() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA journal_mode=WAL;").unwrap();
populate_test_db(&conn);
let breakdowns = tracking::query_command_breakdown(&conn, None).unwrap();
let suggestions = analyze_breakdowns(&breakdowns, 3);
assert!(!suggestions.is_empty());
assert!(suggestions.len() >= 3);
}
#[test]
fn analyze_risk_and_action_correct() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA journal_mode=WAL;").unwrap();
populate_test_db(&conn);
let breakdowns = tracking::query_command_breakdown(&conn, None).unwrap();
let suggestions = analyze_breakdowns(&breakdowns, 3);
let rm = suggestions
.iter()
.find(|s| s.pattern.contains("rm"))
.unwrap();
assert_eq!(rm.risk, RiskLevel::High);
assert_eq!(rm.action, "deny");
let status = suggestions
.iter()
.find(|s| s.pattern.contains("status"))
.unwrap();
assert_eq!(status.risk, RiskLevel::Low);
assert_eq!(status.action, "allow");
let push = suggestions
.iter()
.find(|s| s.pattern.contains("push"))
.unwrap();
assert_eq!(push.risk, RiskLevel::Medium);
}
#[test]
fn apply_suggestions_writes_rules() {
let dir = tempfile::TempDir::new().unwrap();
let config_path = dir.path().join(".rippy.toml");
let suggestions = vec![
Suggestion {
pattern: "git status".into(),
action: "allow".into(),
risk: RiskLevel::Low,
confidence: Confidence::High,
evidence: make_evidence(20, 0, 0),
},
Suggestion {
pattern: "rm -rf *".into(),
action: "deny".into(),
risk: RiskLevel::High,
confidence: Confidence::High,
evidence: make_evidence(0, 0, 10),
},
];
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
apply_suggestions(&suggestions, false).unwrap();
std::env::set_current_dir(original_dir).unwrap();
let content = std::fs::read_to_string(&config_path).unwrap();
assert!(content.contains("action = \"allow\""));
assert!(content.contains("pattern = \"git status\""));
assert!(content.contains("action = \"deny\""));
assert!(content.contains("pattern = \"rm -rf *\""));
}
}