kintsugi_model/
heuristic.rs1use kintsugi_core::{Class, ProposedCommand};
10
11use crate::{ModelOutput, Scorer};
12
13#[derive(Debug, Default, Clone)]
15pub struct HeuristicScorer;
16
17impl HeuristicScorer {
18 pub fn new() -> Self {
19 Self
20 }
21}
22
23impl Scorer for HeuristicScorer {
24 fn name(&self) -> &str {
25 "heuristic"
26 }
27
28 fn score(&self, cmd: &ProposedCommand, class: Class, rule: &str) -> ModelOutput {
29 ModelOutput {
30 summary: summarize(&cmd.raw, class, rule),
31 risk: risk_for(&cmd.raw, class),
32 }
33 }
34}
35
36fn summarize(raw: &str, class: Class, rule: &str) -> String {
38 let prog = raw.split_whitespace().next().unwrap_or("the command");
39 let detail = friendly(rule);
40 match class {
41 Class::Safe => format!("Runs `{prog}` — a read-only or build/test command."),
42 Class::Ambiguous => {
43 if detail.is_empty() {
44 format!("Runs `{prog}`; effects are unclear, so it needs your call.")
45 } else {
46 format!("Runs `{prog}` — {detail}")
47 }
48 }
49 Class::Catastrophic => {
50 if detail.is_empty() {
51 format!("`{prog}` is destructive and may be irreversible.")
52 } else {
53 format!("{detail} This is hard or impossible to undo.")
54 }
55 }
56 }
57}
58
59fn risk_for(raw: &str, class: Class) -> u8 {
61 let base: i32 = match class {
62 Class::Safe => 5,
63 Class::Ambiguous => 45,
64 Class::Catastrophic => 95,
65 };
66 let lower = raw.to_lowercase();
67 let mut score = base;
68 for (needle, bump) in [
70 ("--force", 15),
71 ("-f", 8),
72 ("--hard", 15),
73 ("-rf", 20),
74 ("-r ", 8),
75 ("prod", 20),
76 ("production", 20),
77 ("--all", 12),
78 ("-a ", 6),
79 ("/", 4),
80 ("sudo", 10),
81 ("--no-preserve-root", 25),
82 ("drop ", 20),
83 ("delete", 12),
84 ("destroy", 20),
85 ] {
86 if lower.contains(needle) {
87 score += bump;
88 }
89 }
90 score.clamp(0, 100) as u8
91}
92
93fn friendly(rule: &str) -> &'static str {
95 match rule.split_whitespace().next().unwrap_or(rule) {
96 "rm:recursive" => "recursively deletes files and directories.",
97 "rm:force-root" => "force-deletes a top-level path.",
98 "git:force-push" => "force-pushes, overwriting remote history.",
99 "git:reset-hard" => "discards local commits and changes.",
100 "git:clean" => "deletes untracked files.",
101 "git:history-rewrite" => "rewrites git history.",
102 "git:branch-delete" => "force-deletes a branch.",
103 "terraform:destroy" => "tears down infrastructure.",
104 "kubectl:delete" => "deletes Kubernetes resources.",
105 "helm:uninstall" => "uninstalls a release.",
106 "sql:destructive" | "sql:truncate" => "runs destructive SQL.",
107 "dd:write" | "disk:destructive" | "disk:mkfs" | "disk:block-device-write" => {
108 "writes directly to a disk or device."
109 }
110 "secret:read" => "reads a secret or credential file.",
111 "net:pipe-to-shell" => "pipes a download straight into a shell.",
112 "docker:system-prune" | "docker:volume-destroy" => "destroys Docker data.",
113 "forkbomb" => "is a fork bomb that will exhaust the system.",
114 _ => "",
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 fn cmd(raw: &str) -> ProposedCommand {
123 ProposedCommand::new("t", "/tmp", vec![raw.into()], raw)
124 }
125
126 #[test]
127 fn safe_is_low_risk() {
128 let out = HeuristicScorer::new().score(&cmd("ls -la"), Class::Safe, "safe:ls");
129 assert!(out.risk < 20);
130 assert!(out.summary.contains("read-only") || out.summary.contains("build"));
131 }
132
133 #[test]
134 fn catastrophic_is_high_risk_and_explained() {
135 let out =
136 HeuristicScorer::new().score(&cmd("rm -rf /"), Class::Catastrophic, "rm:recursive");
137 assert!(out.risk >= 95);
138 assert!(out.summary.to_lowercase().contains("undo") || out.summary.contains("delete"));
139 }
140
141 #[test]
142 fn ambiguous_signal_words_raise_score() {
143 let plain =
144 HeuristicScorer::new().score(&cmd("make build"), Class::Ambiguous, "ambiguous:make");
145 let prod = HeuristicScorer::new().score(
146 &cmd("./deploy.sh --force production"),
147 Class::Ambiguous,
148 "ambiguous:deploy.sh",
149 );
150 assert!(prod.risk > plain.risk, "prod/force should score higher");
151 assert!(prod.risk <= 100);
152 }
153
154 #[test]
155 fn risk_is_always_in_range() {
156 let out = HeuristicScorer::new().score(
157 &cmd("sudo rm -rf / --no-preserve-root --force production drop destroy"),
158 Class::Catastrophic,
159 "rm:recursive",
160 );
161 assert!(out.risk <= 100);
162 }
163}