Skip to main content

rippy_cli/
suggest.rs

1//! The `rippy suggest` command — analyze tracking data and suggest config rules.
2
3use 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/// Confidence that a suggestion is correct.
19#[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/// A single rule suggestion with supporting evidence.
38#[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/// Supporting evidence from the tracking DB.
48#[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
57/// Internal: a group of commands sharing a common prefix.
58struct CommandGroup {
59    key: String,
60    evidence: Evidence,
61}
62
63// ── Entry point ────────────────────────────────────────────────────────
64
65/// Run the `rippy suggest` command.
66///
67/// # Errors
68///
69/// Returns `RippyError` if the database cannot be opened or queried,
70/// or if applying suggestions fails.
71pub 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
100/// Load command breakdowns from the appropriate source.
101///
102/// Priority: explicit `--session-file` > explicit `--db` > auto-detect sessions > tracking DB.
103/// Sessions are the default for Claude Code users (always available, no setup needed).
104fn load_breakdowns(args: &SuggestArgs) -> Result<Vec<tracking::CommandBreakdown>, RippyError> {
105    // Explicit session file always wins.
106    if let Some(file) = &args.session_file {
107        return load_from_sessions(args, || crate::sessions::parse_session_file(file));
108    }
109
110    // Explicit --db flag uses tracking DB.
111    if args.db.is_some() {
112        return load_from_db(args);
113    }
114
115    // Default: try sessions first, fall back to tracking DB.
116    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    // Filter out commands already handled by CC permissions or rippy config.
145    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// ── Analysis engine ────────────────────────────────────────────────────
188
189/// Analyze command breakdowns and produce rule suggestions.
190#[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    // Sort: critical first, then by total descending.
216    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
225// ── Grouping ───────────────────────────────────────────────────────────
226
227/// Tools whose subcommand (second token) should be part of the group key.
228const 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// ── Confidence ─────────────────────────────────────────────────────────
274
275/// Compute confidence from the evidence ratios.
276#[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// ── Action suggestion ──────────────────────────────────────────────────
303
304/// Suggest an action based on evidence and risk.
305#[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    // Mostly denied → deny.
315    #[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    // Mostly allowed: action depends on risk level.
323    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    // Mixed signals → ask.
331    Decision::Ask
332}
333
334// ── Pattern generalization ─────────────────────────────────────────────
335
336/// Produce a glob pattern from a group key and example commands.
337fn generalize_pattern(group_key: &str, examples: &[String]) -> String {
338    // If all examples are the same command, use exact match.
339    if examples.len() == 1 {
340        return examples[0].clone();
341    }
342
343    // If examples all share the group key as prefix, use "group_key *".
344    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    // Fallback: use the group key as a prefix pattern.
354    group_key.to_string()
355}
356
357// ── Output ─────────────────────────────────────────────────────────────
358
359fn 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// ── Tests ──────────────────────────────────────────────────────────────
416
417#[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    // ── Confidence ─────────────────────────────────────────────────
434
435    #[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    // ── Action suggestion ──────────────────────────────────────────
466
467    #[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    // ── Grouping ───────────────────────────────────────────────────
492
493    #[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    // ── Pattern generalization ─────────────────────────────────────
534
535    #[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    // ── End-to-end with in-memory DB ───────────────────────────────
557
558    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        // We need to run in the tmpdir so .rippy.toml lands there.
673        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}