1use std::fmt::Write as _;
4use std::path::{Path, PathBuf};
5use std::process::ExitCode;
6
7use crate::cli::RuleArgs;
8use crate::config;
9use crate::error::RippyError;
10use crate::verdict::Decision;
11
12pub fn run(decision: Decision, args: &RuleArgs) -> Result<ExitCode, RippyError> {
18 let path = resolve_config_path(args.global)?;
19 let guard = (!args.global).then(|| crate::trust::TrustGuard::before_write(&path));
20 append_rule_to_toml(&path, decision, &args.pattern, args.message.as_deref())?;
21 if let Some(g) = guard {
22 g.commit();
23 }
24
25 eprintln!(
26 "[rippy] Added to {}:\n {} {}{}",
27 path.display(),
28 decision.as_str(),
29 args.pattern,
30 args.message
31 .as_ref()
32 .map_or(String::new(), |m| format!(" \"{m}\""))
33 );
34 Ok(ExitCode::SUCCESS)
35}
36
37fn resolve_config_path(global: bool) -> Result<PathBuf, RippyError> {
38 if global {
39 config::home_dir()
40 .map(|h| h.join(".rippy/config.toml"))
41 .ok_or_else(|| RippyError::Setup("could not determine home directory".into()))
42 } else {
43 Ok(PathBuf::from(".rippy.toml"))
44 }
45}
46
47pub fn append_rule_to_toml(
53 path: &Path,
54 decision: Decision,
55 pattern: &str,
56 message: Option<&str>,
57) -> Result<(), RippyError> {
58 if let Some(parent) = path.parent()
60 && !parent.as_os_str().is_empty()
61 {
62 std::fs::create_dir_all(parent).map_err(|e| {
63 RippyError::Setup(format!("could not create {}: {e}", parent.display()))
64 })?;
65 }
66
67 let existing = match std::fs::read_to_string(path) {
69 Ok(s) => s,
70 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
71 Err(e) => {
72 return Err(RippyError::Setup(format!(
73 "could not read {}: {e}",
74 path.display()
75 )));
76 }
77 };
78
79 let mut block = String::new();
81 if !existing.is_empty() && !existing.ends_with('\n') {
83 block.push('\n');
84 }
85 let _ = writeln!(block, "\n[[rules]]");
86 let _ = writeln!(block, "action = {:?}", decision.as_str());
87 let _ = writeln!(block, "pattern = {pattern:?}");
88 if let Some(msg) = message {
89 let _ = writeln!(block, "message = {msg:?}");
90 }
91
92 let mut file = std::fs::OpenOptions::new()
94 .create(true)
95 .append(true)
96 .open(path)
97 .map_err(|e| RippyError::Setup(format!("could not open {}: {e}", path.display())))?;
98 std::io::Write::write_all(&mut file, block.as_bytes())
99 .map_err(|e| RippyError::Setup(format!("could not write {}: {e}", path.display())))?;
100
101 Ok(())
102}
103
104#[must_use]
106pub fn suggest_patterns(command: &str) -> Vec<String> {
107 let tokens: Vec<&str> = command.split_whitespace().collect();
108 if tokens.is_empty() {
109 return vec![];
110 }
111 if tokens.len() == 1 {
112 return vec![tokens[0].to_string()];
113 }
114
115 let mut suggestions = Vec::new();
116
117 suggestions.push(tokens.join(" "));
119
120 if tokens.len() > 2 {
122 let prefix: Vec<&str> = tokens[..tokens.len() - 1].to_vec();
123 suggestions.push(format!("{} *", prefix.join(" ")));
124 }
125
126 if tokens.len() > 2 {
128 suggestions.push(format!("{} {} *", tokens[0], tokens[1]));
129 } else {
130 suggestions.push(format!("{} *", tokens[0]));
132 }
133
134 if tokens.len() > 2 {
136 suggestions.push(format!("{} *", tokens[0]));
137 }
138
139 let mut seen = std::collections::HashSet::new();
141 suggestions.retain(|s| seen.insert(s.clone()));
142 suggestions
143}
144
145#[cfg(test)]
146#[allow(clippy::unwrap_used)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn suggest_single_command() {
152 let s = suggest_patterns("ls");
153 assert_eq!(s, vec!["ls"]);
154 }
155
156 #[test]
157 fn suggest_two_tokens() {
158 let s = suggest_patterns("git status");
159 assert_eq!(s, vec!["git status", "git *"]);
160 }
161
162 #[test]
163 fn suggest_three_tokens() {
164 let s = suggest_patterns("git push origin");
165 assert_eq!(s, vec!["git push origin", "git push *", "git *"]);
166 }
167
168 #[test]
169 fn suggest_four_tokens() {
170 let s = suggest_patterns("git push origin main");
171 assert_eq!(
172 s,
173 vec![
174 "git push origin main",
175 "git push origin *",
176 "git push *",
177 "git *",
178 ]
179 );
180 }
181
182 #[test]
183 fn suggest_empty() {
184 assert!(suggest_patterns("").is_empty());
185 }
186
187 #[test]
188 fn suggest_normalizes_whitespace() {
189 let s = suggest_patterns("git push origin");
190 assert_eq!(s, vec!["git push origin", "git push *", "git *"]);
191 }
192
193 #[test]
194 fn append_creates_file() {
195 let dir = tempfile::TempDir::new().unwrap();
196 let path = dir.path().join(".rippy.toml");
197 append_rule_to_toml(&path, Decision::Allow, "git status", None).unwrap();
198
199 let content = std::fs::read_to_string(&path).unwrap();
200 assert!(content.contains("action = \"allow\""));
201 assert!(content.contains("pattern = \"git status\""));
202 }
203
204 #[test]
205 fn append_with_message() {
206 let dir = tempfile::TempDir::new().unwrap();
207 let path = dir.path().join(".rippy.toml");
208 append_rule_to_toml(&path, Decision::Deny, "rm -rf *", Some("use trash")).unwrap();
209
210 let content = std::fs::read_to_string(&path).unwrap();
211 assert!(content.contains("action = \"deny\""));
212 assert!(content.contains("message = \"use trash\""));
213 }
214
215 #[test]
216 fn append_preserves_existing() {
217 let dir = tempfile::TempDir::new().unwrap();
218 let path = dir.path().join(".rippy.toml");
219 std::fs::write(&path, "[settings]\ndefault = \"ask\"\n").unwrap();
220
221 append_rule_to_toml(&path, Decision::Allow, "git status", None).unwrap();
222
223 let content = std::fs::read_to_string(&path).unwrap();
224 assert!(content.starts_with("[settings]"));
225 assert!(content.contains("action = \"allow\""));
226 }
227
228 #[test]
229 fn append_twice_no_duplicates_in_format() {
230 let dir = tempfile::TempDir::new().unwrap();
231 let path = dir.path().join(".rippy.toml");
232 append_rule_to_toml(&path, Decision::Allow, "git status", None).unwrap();
233 append_rule_to_toml(&path, Decision::Deny, "rm -rf *", None).unwrap();
234
235 let content = std::fs::read_to_string(&path).unwrap();
236 let parsed: toml::Value = toml::from_str(&content).unwrap();
238 let rules = parsed["rules"].as_array().unwrap();
239 assert_eq!(rules.len(), 2);
240 }
241
242 #[test]
243 fn appended_rule_is_loadable() {
244 let dir = tempfile::TempDir::new().unwrap();
245 let path = dir.path().join(".rippy.toml");
246 append_rule_to_toml(&path, Decision::Deny, "rm -rf *", Some("no")).unwrap();
247
248 let content = std::fs::read_to_string(&path).unwrap();
249 let directives = crate::toml_config::parse_toml_config(&content, &path).unwrap();
250 let config = crate::config::Config::from_directives(directives);
251 let v = config.match_command("rm -rf /tmp", None).unwrap();
252 assert_eq!(v.decision, Decision::Deny);
253 }
254}