1use std::collections::HashMap;
4use std::fmt::Write as _;
5use std::path::PathBuf;
6use std::process::ExitCode;
7
8use serde::Serialize;
9
10use crate::cli::SuggestArgs;
11use crate::config;
12use crate::error::RippyError;
13use crate::risk::{self, RiskLevel};
14use crate::rule_cmd;
15use crate::tracking;
16use crate::verdict::Decision;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "lowercase")]
21pub enum Confidence {
22 High,
23 Medium,
24 Low,
25}
26
27impl Confidence {
28 const fn as_str(self) -> &'static str {
29 match self {
30 Self::High => "high",
31 Self::Medium => "medium",
32 Self::Low => "low",
33 }
34 }
35}
36
37#[derive(Debug, Clone, Serialize)]
39pub struct Suggestion {
40 pub pattern: String,
41 pub action: String,
42 pub risk: RiskLevel,
43 pub confidence: Confidence,
44 pub evidence: Evidence,
45}
46
47#[derive(Debug, Clone, Serialize)]
49pub struct Evidence {
50 pub total: i64,
51 pub allow_count: i64,
52 pub ask_count: i64,
53 pub deny_count: i64,
54 pub example_commands: Vec<String>,
55}
56
57struct CommandGroup {
59 key: String,
60 evidence: Evidence,
61}
62
63pub fn run(args: &SuggestArgs) -> Result<ExitCode, RippyError> {
72 if let Some(command) = &args.from_command {
73 print_command_suggestions(command);
74 return Ok(ExitCode::SUCCESS);
75 }
76
77 let breakdowns = load_breakdowns(args)?;
78 let suggestions = analyze_breakdowns(&breakdowns, args.min_count);
79
80 if suggestions.is_empty() {
81 eprintln!("[rippy] No suggestions — not enough data yet.");
82 return Ok(ExitCode::SUCCESS);
83 }
84
85 if args.json {
86 let json = serde_json::to_string_pretty(&suggestions)
87 .map_err(|e| RippyError::Tracking(format!("JSON serialization failed: {e}")))?;
88 println!("{json}");
89 } else {
90 print_text(&suggestions);
91 }
92
93 if args.apply {
94 apply_suggestions(&suggestions, args.global)?;
95 }
96
97 Ok(ExitCode::SUCCESS)
98}
99
100fn load_breakdowns(args: &SuggestArgs) -> Result<Vec<tracking::CommandBreakdown>, RippyError> {
105 if let Some(file) = &args.session_file {
107 return load_from_sessions(args, || crate::sessions::parse_session_file(file));
108 }
109
110 if args.db.is_some() {
112 return load_from_db(args);
113 }
114
115 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
117 match crate::sessions::parse_project_sessions(&cwd) {
118 Ok(ref commands) if !commands.is_empty() => {
119 load_from_session_commands(args, commands, &cwd)
120 }
121 _ => load_from_db(args),
122 }
123}
124
125fn load_from_sessions(
126 args: &SuggestArgs,
127 parse: impl FnOnce() -> Result<Vec<crate::sessions::SessionCommand>, RippyError>,
128) -> Result<Vec<tracking::CommandBreakdown>, RippyError> {
129 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
130 let commands = parse()?;
131 load_from_session_commands(args, &commands, &cwd)
132}
133
134fn load_from_session_commands(
135 args: &SuggestArgs,
136 commands: &[crate::sessions::SessionCommand],
137 cwd: &std::path::Path,
138) -> Result<Vec<tracking::CommandBreakdown>, RippyError> {
139 if args.audit {
140 let audit = crate::sessions::audit_commands(commands, cwd)?;
141 crate::sessions::print_audit(&audit);
142 }
143
144 let filtered = crate::sessions::filter_auto_allowed(commands, cwd)?;
146 Ok(crate::sessions::to_breakdowns(&filtered))
147}
148
149fn load_from_db(args: &SuggestArgs) -> Result<Vec<tracking::CommandBreakdown>, RippyError> {
150 let db_path = resolve_db_path(args)?;
151 let conn = tracking::open_db(&db_path)?;
152 let since_modifier = parse_since(args.since.as_deref())?;
153 tracking::query_command_breakdown(&conn, since_modifier.as_deref())
154}
155
156fn print_command_suggestions(command: &str) {
157 let patterns = rule_cmd::suggest_patterns(command);
158 if patterns.is_empty() {
159 eprintln!("[rippy] No patterns to suggest for empty command");
160 return;
161 }
162 println!("Suggested patterns for: {command}\n");
163 for (i, pattern) in patterns.iter().enumerate() {
164 println!(" {}. {pattern}", i + 1);
165 }
166 let last = patterns.last().map_or("", String::as_str);
167 let first = patterns.first().map_or("", String::as_str);
168 println!("\nUsage: rippy allow \"{last}\"\n rippy deny \"{first}\"");
169}
170
171fn resolve_db_path(args: &SuggestArgs) -> Result<PathBuf, RippyError> {
172 tracking::resolve_db_path(args.db.as_deref())
173}
174
175fn parse_since(since: Option<&str>) -> Result<Option<String>, RippyError> {
176 since.map_or(Ok(None), |s| {
177 tracking::parse_duration(s)
178 .ok_or_else(|| {
179 RippyError::Tracking(format!(
180 "invalid duration: {s}. Use format like 7d, 1h, 30m"
181 ))
182 })
183 .map(Some)
184 })
185}
186
187#[must_use]
191pub fn analyze_breakdowns(
192 breakdowns: &[tracking::CommandBreakdown],
193 min_count: i64,
194) -> Vec<Suggestion> {
195 let groups = group_commands(breakdowns);
196
197 let mut suggestions: Vec<Suggestion> = groups
198 .into_iter()
199 .filter(|g| g.evidence.total >= min_count)
200 .map(|g| {
201 let risk = risk::classify(&g.key);
202 let confidence = compute_confidence(&g.evidence);
203 let action = suggest_action(&g.evidence, risk);
204 let pattern = generalize_pattern(&g.key, &g.evidence.example_commands);
205 Suggestion {
206 pattern,
207 action: action.as_str().to_string(),
208 risk,
209 confidence,
210 evidence: g.evidence,
211 }
212 })
213 .collect();
214
215 suggestions.sort_by(|a, b| {
217 b.risk
218 .cmp(&a.risk)
219 .then_with(|| b.evidence.total.cmp(&a.evidence.total))
220 });
221
222 suggestions
223}
224
225const SUBCOMMAND_TOOLS: &[&str] = &[
229 "git", "docker", "cargo", "npm", "yarn", "pnpm", "kubectl", "helm", "pip", "pip3",
230];
231
232fn group_key(command: &str) -> String {
233 let mut tokens = command.split_whitespace();
234 let Some(first) = tokens.next() else {
235 return command.to_string();
236 };
237 if SUBCOMMAND_TOOLS.contains(&first)
238 && let Some(second) = tokens.next()
239 {
240 return format!("{first} {second}");
241 }
242 first.to_string()
243}
244
245fn group_commands(breakdowns: &[tracking::CommandBreakdown]) -> Vec<CommandGroup> {
246 let mut map: HashMap<String, CommandGroup> = HashMap::new();
247
248 for bd in breakdowns {
249 let key = group_key(&bd.command);
250 let total = bd.allow_count + bd.ask_count + bd.deny_count;
251 let group = map.entry(key.clone()).or_insert_with(|| CommandGroup {
252 key,
253 evidence: Evidence {
254 total: 0,
255 allow_count: 0,
256 ask_count: 0,
257 deny_count: 0,
258 example_commands: Vec::new(),
259 },
260 });
261 group.evidence.total += total;
262 group.evidence.allow_count += bd.allow_count;
263 group.evidence.ask_count += bd.ask_count;
264 group.evidence.deny_count += bd.deny_count;
265 if group.evidence.example_commands.len() < 3 {
266 group.evidence.example_commands.push(bd.command.clone());
267 }
268 }
269
270 map.into_values().collect()
271}
272
273#[must_use]
277pub fn compute_confidence(evidence: &Evidence) -> Confidence {
278 if evidence.total == 0 {
279 return Confidence::Low;
280 }
281
282 #[allow(clippy::cast_precision_loss)]
283 let max_ratio = [
284 evidence.allow_count,
285 evidence.ask_count,
286 evidence.deny_count,
287 ]
288 .into_iter()
289 .max()
290 .unwrap_or(0) as f64
291 / evidence.total as f64;
292
293 if max_ratio >= 0.8 && evidence.total >= 10 {
294 Confidence::High
295 } else if max_ratio >= 0.6 && evidence.total >= 5 {
296 Confidence::Medium
297 } else {
298 Confidence::Low
299 }
300}
301
302#[must_use]
306pub fn suggest_action(evidence: &Evidence, risk: RiskLevel) -> Decision {
307 if evidence.total == 0 {
308 return Decision::Ask;
309 }
310
311 #[allow(clippy::cast_precision_loss)]
312 let allow_ratio = evidence.allow_count as f64 / evidence.total as f64;
313
314 #[allow(clippy::cast_precision_loss)]
316 let deny_ratio = evidence.deny_count as f64 / evidence.total as f64;
317
318 if deny_ratio >= 0.5 {
319 return Decision::Deny;
320 }
321
322 if allow_ratio >= 0.8 {
324 return match risk {
325 RiskLevel::Low | RiskLevel::Medium => Decision::Allow,
326 RiskLevel::High | RiskLevel::Critical => Decision::Ask,
327 };
328 }
329
330 Decision::Ask
332}
333
334fn generalize_pattern(group_key: &str, examples: &[String]) -> String {
338 if examples.len() == 1 {
340 return examples[0].clone();
341 }
342
343 let all_start_with_key = examples.iter().all(|e| {
345 e == group_key
346 || (e.starts_with(group_key) && e.as_bytes().get(group_key.len()) == Some(&b' '))
347 });
348
349 if all_start_with_key && examples.iter().any(|e| e != group_key) {
350 return format!("{group_key} *");
351 }
352
353 group_key.to_string()
355}
356
357fn print_text(suggestions: &[Suggestion]) {
360 let mut current_confidence: Option<Confidence> = None;
361
362 for s in suggestions {
363 if current_confidence != Some(s.confidence) {
364 current_confidence = Some(s.confidence);
365 println!("\n {} confidence:", s.confidence.as_str().to_uppercase());
366 }
367 let mut line = format!(
368 " {} {:<30} # {} {} times (risk: {})",
369 s.action,
370 s.pattern,
371 s.action,
372 s.evidence.total,
373 s.risk.as_str(),
374 );
375 if !s.evidence.example_commands.is_empty() && s.evidence.example_commands[0] != s.pattern {
376 let _ = write!(line, ", e.g. {}", s.evidence.example_commands[0]);
377 }
378 println!("{line}");
379 }
380 println!();
381}
382
383fn apply_suggestions(suggestions: &[Suggestion], global: bool) -> Result<(), RippyError> {
384 let path = if global {
385 config::home_dir()
386 .map(|h| h.join(".rippy/config.toml"))
387 .ok_or_else(|| RippyError::Setup("could not determine home directory".into()))?
388 } else {
389 PathBuf::from(".rippy.toml")
390 };
391
392 let guard = (!global).then(|| crate::trust::TrustGuard::before_write(&path));
393
394 for s in suggestions {
395 let decision = match s.action.as_str() {
396 "allow" => Decision::Allow,
397 "deny" => Decision::Deny,
398 _ => Decision::Ask,
399 };
400 rule_cmd::append_rule_to_toml(&path, decision, &s.pattern, None)?;
401 }
402
403 if let Some(g) = guard {
404 g.commit();
405 }
406
407 eprintln!(
408 "[rippy] Applied {} suggestion(s) to {}",
409 suggestions.len(),
410 path.display()
411 );
412 Ok(())
413}
414
415#[cfg(test)]
418#[allow(clippy::unwrap_used)]
419mod tests {
420 use super::*;
421 use crate::mode::Mode;
422
423 fn make_evidence(allow: i64, ask: i64, deny: i64) -> Evidence {
424 Evidence {
425 total: allow + ask + deny,
426 allow_count: allow,
427 ask_count: ask,
428 deny_count: deny,
429 example_commands: vec![],
430 }
431 }
432
433 #[test]
436 fn confidence_high() {
437 let e = make_evidence(20, 0, 0);
438 assert_eq!(compute_confidence(&e), Confidence::High);
439 }
440
441 #[test]
442 fn confidence_medium() {
443 let e = make_evidence(5, 2, 0);
444 assert_eq!(compute_confidence(&e), Confidence::Medium);
445 }
446
447 #[test]
448 fn confidence_low_small_sample() {
449 let e = make_evidence(3, 0, 0);
450 assert_eq!(compute_confidence(&e), Confidence::Low);
451 }
452
453 #[test]
454 fn confidence_low_mixed() {
455 let e = make_evidence(5, 4, 3);
456 assert_eq!(compute_confidence(&e), Confidence::Low);
457 }
458
459 #[test]
460 fn confidence_empty() {
461 let e = make_evidence(0, 0, 0);
462 assert_eq!(compute_confidence(&e), Confidence::Low);
463 }
464
465 #[test]
468 fn action_mostly_allowed_low_risk() {
469 let e = make_evidence(20, 1, 0);
470 assert_eq!(suggest_action(&e, RiskLevel::Low), Decision::Allow);
471 }
472
473 #[test]
474 fn action_mostly_allowed_high_risk() {
475 let e = make_evidence(20, 1, 0);
476 assert_eq!(suggest_action(&e, RiskLevel::High), Decision::Ask);
477 }
478
479 #[test]
480 fn action_mostly_denied() {
481 let e = make_evidence(2, 1, 10);
482 assert_eq!(suggest_action(&e, RiskLevel::Low), Decision::Deny);
483 }
484
485 #[test]
486 fn action_mixed_signals() {
487 let e = make_evidence(5, 5, 0);
488 assert_eq!(suggest_action(&e, RiskLevel::Medium), Decision::Ask);
489 }
490
491 #[test]
494 fn group_key_subcommand_tools() {
495 assert_eq!(group_key("git push origin main"), "git push");
496 assert_eq!(group_key("docker run -it ubuntu"), "docker run");
497 assert_eq!(group_key("cargo test --release"), "cargo test");
498 }
499
500 #[test]
501 fn group_key_simple_commands() {
502 assert_eq!(group_key("ls -la"), "ls");
503 assert_eq!(group_key("rm -rf /tmp"), "rm");
504 assert_eq!(group_key("make"), "make");
505 }
506
507 #[test]
508 fn group_commands_aggregates() {
509 let breakdowns = vec![
510 tracking::CommandBreakdown {
511 command: "git push origin main".into(),
512 allow_count: 5,
513 ask_count: 2,
514 deny_count: 0,
515 },
516 tracking::CommandBreakdown {
517 command: "git push origin dev".into(),
518 allow_count: 3,
519 ask_count: 1,
520 deny_count: 0,
521 },
522 ];
523 let groups = group_commands(&breakdowns);
524 assert_eq!(groups.len(), 1);
525 let g = &groups[0];
526 assert_eq!(g.key, "git push");
527 assert_eq!(g.evidence.allow_count, 8);
528 assert_eq!(g.evidence.ask_count, 3);
529 assert_eq!(g.evidence.total, 11);
530 assert_eq!(g.evidence.example_commands.len(), 2);
531 }
532
533 #[test]
536 fn generalize_single_example() {
537 let p = generalize_pattern("git push", &["git push origin main".into()]);
538 assert_eq!(p, "git push origin main");
539 }
540
541 #[test]
542 fn generalize_multiple_examples() {
543 let p = generalize_pattern(
544 "git push",
545 &["git push origin main".into(), "git push origin dev".into()],
546 );
547 assert_eq!(p, "git push *");
548 }
549
550 #[test]
551 fn generalize_exact_key_only() {
552 let p = generalize_pattern("ls", &["ls".into(), "ls".into()]);
553 assert_eq!(p, "ls");
554 }
555
556 fn populate_test_db(conn: &rusqlite::Connection) {
559 conn.execute_batch(
560 "CREATE TABLE IF NOT EXISTS decisions (
561 id INTEGER PRIMARY KEY,
562 timestamp TEXT NOT NULL DEFAULT (datetime('now')),
563 session_id TEXT, mode TEXT, tool_name TEXT NOT NULL,
564 command TEXT, decision TEXT NOT NULL, reason TEXT, payload_json TEXT
565 );",
566 )
567 .unwrap();
568
569 let entry = tracking::TrackingEntry {
570 session_id: None,
571 mode: Mode::Claude,
572 tool_name: "Bash",
573 command: Some("git status"),
574 decision: Decision::Allow,
575 reason: "safe",
576 payload_json: None,
577 };
578
579 for _ in 0..15 {
580 tracking::record_decision(conn, &entry).unwrap();
581 }
582 for _ in 0..10 {
583 tracking::record_decision(
584 conn,
585 &tracking::TrackingEntry {
586 decision: Decision::Ask,
587 command: Some("git push origin main"),
588 reason: "review",
589 ..entry
590 },
591 )
592 .unwrap();
593 }
594 for _ in 0..5 {
595 tracking::record_decision(
596 conn,
597 &tracking::TrackingEntry {
598 decision: Decision::Deny,
599 command: Some("rm -rf /"),
600 reason: "dangerous",
601 ..entry
602 },
603 )
604 .unwrap();
605 }
606 }
607
608 #[test]
609 fn analyze_produces_suggestions() {
610 let conn = rusqlite::Connection::open_in_memory().unwrap();
611 conn.execute_batch("PRAGMA journal_mode=WAL;").unwrap();
612 populate_test_db(&conn);
613
614 let breakdowns = tracking::query_command_breakdown(&conn, None).unwrap();
615 let suggestions = analyze_breakdowns(&breakdowns, 3);
616 assert!(!suggestions.is_empty());
617 assert!(suggestions.len() >= 3);
618 }
619
620 #[test]
621 fn analyze_risk_and_action_correct() {
622 let conn = rusqlite::Connection::open_in_memory().unwrap();
623 conn.execute_batch("PRAGMA journal_mode=WAL;").unwrap();
624 populate_test_db(&conn);
625
626 let breakdowns = tracking::query_command_breakdown(&conn, None).unwrap();
627 let suggestions = analyze_breakdowns(&breakdowns, 3);
628
629 let rm = suggestions
630 .iter()
631 .find(|s| s.pattern.contains("rm"))
632 .unwrap();
633 assert_eq!(rm.risk, RiskLevel::High);
634 assert_eq!(rm.action, "deny");
635
636 let status = suggestions
637 .iter()
638 .find(|s| s.pattern.contains("status"))
639 .unwrap();
640 assert_eq!(status.risk, RiskLevel::Low);
641 assert_eq!(status.action, "allow");
642
643 let push = suggestions
644 .iter()
645 .find(|s| s.pattern.contains("push"))
646 .unwrap();
647 assert_eq!(push.risk, RiskLevel::Medium);
648 }
649
650 #[test]
651 fn apply_suggestions_writes_rules() {
652 let dir = tempfile::TempDir::new().unwrap();
653 let config_path = dir.path().join(".rippy.toml");
654
655 let suggestions = vec![
656 Suggestion {
657 pattern: "git status".into(),
658 action: "allow".into(),
659 risk: RiskLevel::Low,
660 confidence: Confidence::High,
661 evidence: make_evidence(20, 0, 0),
662 },
663 Suggestion {
664 pattern: "rm -rf *".into(),
665 action: "deny".into(),
666 risk: RiskLevel::High,
667 confidence: Confidence::High,
668 evidence: make_evidence(0, 0, 10),
669 },
670 ];
671
672 let original_dir = std::env::current_dir().unwrap();
674 std::env::set_current_dir(dir.path()).unwrap();
675 apply_suggestions(&suggestions, false).unwrap();
676 std::env::set_current_dir(original_dir).unwrap();
677
678 let content = std::fs::read_to_string(&config_path).unwrap();
679 assert!(content.contains("action = \"allow\""));
680 assert!(content.contains("pattern = \"git status\""));
681 assert!(content.contains("action = \"deny\""));
682 assert!(content.contains("pattern = \"rm -rf *\""));
683 }
684}