use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use crate::cli::RuleArgs;
use crate::config;
use crate::error::RippyError;
use crate::verdict::Decision;
pub fn run(decision: Decision, args: &RuleArgs) -> Result<ExitCode, RippyError> {
let path = resolve_config_path(args.global)?;
let guard = (!args.global).then(|| crate::trust::TrustGuard::before_write(&path));
append_rule_to_toml(&path, decision, &args.pattern, args.message.as_deref())?;
if let Some(g) = guard {
g.commit();
}
eprintln!(
"[rippy] Added to {}:\n {} {}{}",
path.display(),
decision.as_str(),
args.pattern,
args.message
.as_ref()
.map_or(String::new(), |m| format!(" \"{m}\""))
);
Ok(ExitCode::SUCCESS)
}
fn resolve_config_path(global: bool) -> Result<PathBuf, RippyError> {
if global {
config::home_dir()
.map(|h| h.join(".rippy/config.toml"))
.ok_or_else(|| RippyError::Setup("could not determine home directory".into()))
} else {
Ok(PathBuf::from(".rippy.toml"))
}
}
pub fn append_rule_to_toml(
path: &Path,
decision: Decision,
pattern: &str,
message: Option<&str>,
) -> Result<(), RippyError> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|e| {
RippyError::Setup(format!("could not create {}: {e}", parent.display()))
})?;
}
let existing = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => {
return Err(RippyError::Setup(format!(
"could not read {}: {e}",
path.display()
)));
}
};
let mut block = String::new();
if !existing.is_empty() && !existing.ends_with('\n') {
block.push('\n');
}
let _ = writeln!(block, "\n[[rules]]");
let _ = writeln!(block, "action = {:?}", decision.as_str());
let _ = writeln!(block, "pattern = {pattern:?}");
if let Some(msg) = message {
let _ = writeln!(block, "message = {msg:?}");
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| RippyError::Setup(format!("could not open {}: {e}", path.display())))?;
std::io::Write::write_all(&mut file, block.as_bytes())
.map_err(|e| RippyError::Setup(format!("could not write {}: {e}", path.display())))?;
Ok(())
}
#[must_use]
pub fn suggest_patterns(command: &str) -> Vec<String> {
let tokens: Vec<&str> = command.split_whitespace().collect();
if tokens.is_empty() {
return vec![];
}
if tokens.len() == 1 {
return vec![tokens[0].to_string()];
}
let mut suggestions = Vec::new();
suggestions.push(tokens.join(" "));
if tokens.len() > 2 {
let prefix: Vec<&str> = tokens[..tokens.len() - 1].to_vec();
suggestions.push(format!("{} *", prefix.join(" ")));
}
if tokens.len() > 2 {
suggestions.push(format!("{} {} *", tokens[0], tokens[1]));
} else {
suggestions.push(format!("{} *", tokens[0]));
}
if tokens.len() > 2 {
suggestions.push(format!("{} *", tokens[0]));
}
let mut seen = std::collections::HashSet::new();
suggestions.retain(|s| seen.insert(s.clone()));
suggestions
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn suggest_single_command() {
let s = suggest_patterns("ls");
assert_eq!(s, vec!["ls"]);
}
#[test]
fn suggest_two_tokens() {
let s = suggest_patterns("git status");
assert_eq!(s, vec!["git status", "git *"]);
}
#[test]
fn suggest_three_tokens() {
let s = suggest_patterns("git push origin");
assert_eq!(s, vec!["git push origin", "git push *", "git *"]);
}
#[test]
fn suggest_four_tokens() {
let s = suggest_patterns("git push origin main");
assert_eq!(
s,
vec![
"git push origin main",
"git push origin *",
"git push *",
"git *",
]
);
}
#[test]
fn suggest_empty() {
assert!(suggest_patterns("").is_empty());
}
#[test]
fn suggest_normalizes_whitespace() {
let s = suggest_patterns("git push origin");
assert_eq!(s, vec!["git push origin", "git push *", "git *"]);
}
#[test]
fn append_creates_file() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join(".rippy.toml");
append_rule_to_toml(&path, Decision::Allow, "git status", None).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("action = \"allow\""));
assert!(content.contains("pattern = \"git status\""));
}
#[test]
fn append_with_message() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join(".rippy.toml");
append_rule_to_toml(&path, Decision::Deny, "rm -rf *", Some("use trash")).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("action = \"deny\""));
assert!(content.contains("message = \"use trash\""));
}
#[test]
fn append_preserves_existing() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join(".rippy.toml");
std::fs::write(&path, "[settings]\ndefault = \"ask\"\n").unwrap();
append_rule_to_toml(&path, Decision::Allow, "git status", None).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.starts_with("[settings]"));
assert!(content.contains("action = \"allow\""));
}
#[test]
fn append_twice_no_duplicates_in_format() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join(".rippy.toml");
append_rule_to_toml(&path, Decision::Allow, "git status", None).unwrap();
append_rule_to_toml(&path, Decision::Deny, "rm -rf *", None).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let parsed: toml::Value = toml::from_str(&content).unwrap();
let rules = parsed["rules"].as_array().unwrap();
assert_eq!(rules.len(), 2);
}
#[test]
fn appended_rule_is_loadable() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join(".rippy.toml");
append_rule_to_toml(&path, Decision::Deny, "rm -rf *", Some("no")).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let directives = crate::toml_config::parse_toml_config(&content, &path).unwrap();
let config = crate::config::Config::from_directives(directives);
let v = config.match_command("rm -rf /tmp", None).unwrap();
assert_eq!(v.decision, Decision::Deny);
}
}