Skip to main content

rippy_cli/
rule_cmd.rs

1//! CLI commands for adding rules: rippy allow/deny/ask
2
3use 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
12/// Run the allow/deny/ask subcommand.
13///
14/// # Errors
15///
16/// Returns `RippyError::Setup` if the config file cannot be read or written.
17pub 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
47/// Append a rule to a TOML config file, creating it if necessary.
48///
49/// # Errors
50///
51/// Returns `RippyError::Setup` if the file cannot be read, created, or written.
52pub fn append_rule_to_toml(
53    path: &Path,
54    decision: Decision,
55    pattern: &str,
56    message: Option<&str>,
57) -> Result<(), RippyError> {
58    // Create parent directories if needed
59    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    // Read existing content
68    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    // Build the rule block
80    let mut block = String::new();
81    // Add separator if file is non-empty and doesn't end with newline
82    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    // Append
93    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/// Generate pattern suggestions from most specific to most general.
105#[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    // 1. Exact command (normalized whitespace)
118    suggestions.push(tokens.join(" "));
119
120    // 2. Wildcard last arg (if >2 tokens)
121    if tokens.len() > 2 {
122        let prefix: Vec<&str> = tokens[..tokens.len() - 1].to_vec();
123        suggestions.push(format!("{} *", prefix.join(" ")));
124    }
125
126    // 3. Wildcard after first two tokens (command + subcommand)
127    if tokens.len() > 2 {
128        suggestions.push(format!("{} {} *", tokens[0], tokens[1]));
129    } else {
130        // 2 tokens: command + arg, wildcard the arg
131        suggestions.push(format!("{} *", tokens[0]));
132    }
133
134    // 4. Wildcard entire command (only if >2 tokens, otherwise redundant)
135    if tokens.len() > 2 {
136        suggestions.push(format!("{} *", tokens[0]));
137    }
138
139    // Deduplicate while preserving order
140    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        // Both rules should parse as valid TOML
237        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}