1use std::fs::{File, OpenOptions};
3use std::io;
4use std::os::unix::fs::PermissionsExt;
5use std::path::Path;
6
7use toml_edit::{ArrayOfTables, DocumentMut, Item, Value};
8
9use crate::paths::{Paths, create_private_dir, read_or_empty, write_atomic};
10use crate::rules::{Rule, RuleFile, RulesError, compile, invalid, parse};
11use crate::store::Store;
12
13pub fn propose(path: &Path, mut rule: Rule, by: &str) -> Result<(), RulesError> {
15 rule.enabled = false;
16 rule.proposed_by = Some(by.to_string());
17 let snippet = toml::to_string(&RuleFile { rules: vec![rule] })
18 .map_err(|e| RulesError::Parse(e.to_string()))?;
19 let _lock = lock(path)?;
20 let mut text = read_or_empty(path)?;
21 if !text.is_empty() {
22 if !text.ends_with('\n') {
23 text.push('\n');
24 }
25 text.push('\n');
26 }
27 text.push_str(&snippet);
28 save(path, &text)
29}
30
31pub fn set_enabled(path: &Path, name: &str, enabled: bool) -> Result<(), RulesError> {
33 edit(path, name, |rules, index| {
34 let table = rules
35 .get_mut(index)
36 .ok_or_else(|| invalid(name, "no such rule"))?;
37 write_enabled(table, enabled);
38 Ok(None)
39 })
40}
41
42fn approve(path: &Path, name: &str) -> Result<(), RulesError> {
43 edit(path, name, |rules, index| {
44 let table = rules
45 .get_mut(index)
46 .ok_or_else(|| invalid(name, "no such rule"))?;
47 if !is_disabled(table) {
48 return Err(invalid(name, "is already enabled"));
49 }
50 write_enabled(table, true);
51 Ok(None)
52 })
53}
54
55pub fn approve_from_now(
58 paths: &Paths,
59 accounts: &[String],
60 name: &str,
61 now: i64,
62) -> Result<(), RulesError> {
63 approve(&paths.rules_file(), name)?;
64 for account in accounts {
65 Store::open_account(paths, account)?.restart_rule_clock(name, now)?;
66 }
67 Ok(())
68}
69
70fn write_enabled(table: &mut toml_edit::Table, enabled: bool) {
71 let mut value = Value::from(enabled);
72 if let Some(old) = table.get("enabled").and_then(|item| item.as_value()) {
73 *value.decor_mut() = old.decor().clone();
74 }
75 table["enabled"] = Item::Value(value);
76}
77
78pub fn reject(path: &Path, name: &str) -> Result<(), RulesError> {
79 edit(path, name, |rules, index| {
80 let table = rules
81 .get(index)
82 .ok_or_else(|| invalid(name, "no such rule"))?;
83 if !is_disabled(table) || !table.contains_key("proposed_by") {
84 return Err(invalid(
85 name,
86 "is not a pending proposal; edit rules.toml to remove it",
87 ));
88 }
89 let prefix = table
91 .decor()
92 .prefix()
93 .and_then(|p| p.as_str())
94 .unwrap_or("");
95 let mut orphaned = prefix
96 .strip_suffix('\n')
97 .filter(|p| p.ends_with('\n'))
98 .unwrap_or(prefix)
99 .to_string();
100 rules.remove(index);
101 match rules.get_mut(index) {
102 Some(next) => {
103 let next_prefix = next.decor().prefix().and_then(|p| p.as_str()).unwrap_or("");
104 orphaned.push_str(next_prefix);
105 next.decor_mut().set_prefix(orphaned);
106 Ok(None)
107 }
108 None => Ok(Some(orphaned)),
109 }
110 })
111}
112
113fn is_disabled(table: &toml_edit::Table) -> bool {
114 table.get("enabled").and_then(|v| v.as_bool()) == Some(false)
115}
116
117fn edit(
118 path: &Path,
119 name: &str,
120 change: impl FnOnce(&mut ArrayOfTables, usize) -> Result<Option<String>, RulesError>,
121) -> Result<(), RulesError> {
122 let _lock = lock(path)?;
123 let mut doc: DocumentMut = read_or_empty(path)?
124 .parse()
125 .map_err(|e: toml_edit::TomlError| RulesError::Parse(e.to_string()))?;
126 let rules = doc
127 .get_mut("rules")
128 .and_then(|item| item.as_array_of_tables_mut())
129 .ok_or_else(|| invalid(name, "no such rule"))?;
130 let index = rules
131 .iter()
132 .position(|t| t.get("name").and_then(|v| v.as_str()) == Some(name))
133 .ok_or_else(|| invalid(name, "no such rule"))?;
134 if let Some(orphaned) = change(rules, index)? {
135 let trailing = format!("{orphaned}{}", doc.trailing().as_str().unwrap_or(""));
136 doc.set_trailing(trailing);
137 }
138 save(path, &doc.to_string())
139}
140
141fn lock(path: &Path) -> Result<File, RulesError> {
143 let dir = path
144 .parent()
145 .ok_or_else(|| io::Error::other("path has no parent"))?;
146 create_private_dir(dir)?;
147 let file = OpenOptions::new()
148 .create(true)
149 .truncate(false)
150 .write(true)
151 .open(dir.join(".rules.lock"))?;
152 file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
153 file.lock()?;
154 Ok(file)
155}
156
157fn save(path: &Path, text: &str) -> Result<(), RulesError> {
158 compile(&parse(text)?)?;
159 write_atomic(path, text.as_bytes())?;
160 Ok(())
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use crate::rules::{Action, TextMatch, load};
167
168 const HUMAN: &str = "# my rules\n[[rules]]\nname = \"keep\" # do not touch\nmatch.seen = true\nactions = [\"flag\"]\n";
169
170 fn proposal(name: &str) -> Rule {
171 serde_json::from_str(&format!(
172 r#"{{"name": "{name}", "match": {{"subject": {{"contains": "code"}}}}, "actions": [{{"move": "Codes"}}, "mark_read"]}}"#
173 ))
174 .unwrap()
175 }
176
177 fn rules_file(text: &str) -> (tempfile::TempDir, std::path::PathBuf) {
178 let dir = tempfile::tempdir().unwrap();
179 let path = dir.path().join("rules.toml");
180 std::fs::write(&path, text).unwrap();
181 (dir, path)
182 }
183
184 #[test]
185 fn propose_appends_disabled_and_keeps_the_rest_of_the_file() {
186 let (_dir, path) = rules_file(HUMAN);
187 propose(&path, proposal("codes"), "cli:test").unwrap();
188 let text = std::fs::read_to_string(&path).unwrap();
189 assert!(text.starts_with(HUMAN), "{text}");
190 let added = &parse(&text).unwrap().rules[1];
191 assert_eq!(
192 (added.enabled, added.proposed_by.as_deref()),
193 (false, Some("cli:test"))
194 );
195 assert_eq!(
196 added.actions,
197 [Action::Move("Codes".into()), Action::MarkRead]
198 );
199 }
200
201 #[test]
202 fn propose_into_a_missing_file_creates_it() {
203 let dir = tempfile::tempdir().unwrap();
204 let path = dir.path().join("rules.toml");
205 propose(&path, proposal("codes"), "cli").unwrap();
206 assert_eq!(load(&path).unwrap().rules.len(), 1);
207 }
208
209 #[test]
210 fn invalid_proposal_leaves_the_file_untouched() {
211 let (_dir, path) = rules_file(HUMAN);
212 assert!(
213 propose(&path, proposal("keep"), "cli").is_err(),
214 "duplicate name"
215 );
216 let mut bad = proposal("bad");
217 bad.matches.subject = Some(TextMatch {
218 regex: Some("(".into()),
219 ..Default::default()
220 });
221 assert!(propose(&path, bad, "cli").is_err(), "invalid regex");
222 assert_eq!(std::fs::read_to_string(&path).unwrap(), HUMAN);
223 }
224
225 #[test]
226 fn approve_enables_in_place_and_keeps_comments() {
227 let (_dir, path) = rules_file(HUMAN);
228 propose(&path, proposal("codes"), "cli").unwrap();
229 approve(&path, "codes").unwrap();
230 let text = std::fs::read_to_string(&path).unwrap();
231 assert!(
232 text.contains("# my rules") && text.contains("# do not touch"),
233 "{text}"
234 );
235 assert!(parse(&text).unwrap().rules.iter().all(|r| r.enabled));
236 assert!(approve(&path, "codes").is_err(), "already enabled");
237 assert!(approve(&path, "missing").is_err());
238 }
239
240 #[test]
241 fn reject_removes_only_pending_proposals() {
242 let (_dir, path) = rules_file(HUMAN);
243 propose(&path, proposal("codes"), "cli").unwrap();
244 assert!(
245 reject(&path, "keep").is_err(),
246 "a human rule is not a proposal"
247 );
248 reject(&path, "codes").unwrap();
249 let text = std::fs::read_to_string(&path).unwrap();
250 assert_eq!(parse(&text).unwrap().rules.len(), 1);
251 assert!(text.contains("# do not touch"), "{text}");
252 }
253
254 #[test]
255 fn reject_keeps_comments_above_the_proposal() {
256 let human = format!("{HUMAN}\n# TODO newsletters rule\n# [[rules]]\n# name = \"draft\"\n");
257 let (_dir, path) = rules_file(&human);
258 propose(&path, proposal("codes"), "cli").unwrap();
259 reject(&path, "codes").unwrap();
260 assert_eq!(std::fs::read_to_string(&path).unwrap(), human);
261
262 propose(&path, proposal("codes"), "cli").unwrap();
263 propose(&path, proposal("other"), "cli").unwrap();
264 reject(&path, "codes").unwrap();
265 let text = std::fs::read_to_string(&path).unwrap();
266 assert!(
267 text.contains("# TODO newsletters rule\n# [[rules]]\n# name = \"draft\""),
268 "{text}"
269 );
270 assert_eq!(parse(&text).unwrap().rules.len(), 2);
271 }
272
273 #[test]
274 fn reject_of_the_last_table_keeps_comment_order() {
275 let (_dir, path) = rules_file(
276 "# head\n[[rules]]\nname = \"keep\"\nmatch.seen = true\nactions = [\"flag\"]\n\n# above\n",
277 );
278 propose(&path, proposal("codes"), "cli").unwrap();
279 let mut text = std::fs::read_to_string(&path).unwrap();
280 text.push_str("# trailing\n");
281 std::fs::write(&path, text).unwrap();
282 reject(&path, "codes").unwrap();
283 let text = std::fs::read_to_string(&path).unwrap();
284 let at = |needle: &str| text.find(needle).unwrap();
285 assert!(
286 at("# head") < at("# above") && at("# above") < at("# trailing"),
287 "{text}"
288 );
289 assert_eq!(parse(&text).unwrap().rules.len(), 1);
290 }
291
292 #[test]
293 fn concurrent_proposals_are_all_kept() {
294 let (_dir, path) = rules_file(HUMAN);
295 std::thread::scope(|scope| {
296 for i in 0..8 {
297 let path = &path;
298 scope.spawn(move || propose(path, proposal(&format!("p{i}")), "cli").unwrap());
299 }
300 });
301 let names: std::collections::HashSet<String> = load(&path)
302 .unwrap()
303 .rules
304 .into_iter()
305 .map(|r| r.name)
306 .collect();
307 assert_eq!(names.len(), 9, "{names:?}");
308 }
309
310 #[test]
311 fn approve_keeps_a_comment_on_the_enabled_line() {
312 let (_dir, path) = rules_file(
313 "[[rules]]\nname = \"x\"\nenabled = false # waiting for review\nproposed_by = \"cli\"\nmatch.seen = true\nactions = [\"flag\"]\n",
314 );
315 approve(&path, "x").unwrap();
316 let text = std::fs::read_to_string(&path).unwrap();
317 assert!(
318 text.contains("enabled = true # waiting for review\n"),
319 "{text}"
320 );
321 }
322
323 #[test]
324 fn lock_file_is_private() {
325 let (_dir, path) = rules_file("");
326 let lock_path = path.parent().unwrap().join(".rules.lock");
327 std::fs::write(&lock_path, "").unwrap();
328 std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o644)).unwrap();
329 drop(lock(&path).unwrap());
330 let mode = std::fs::metadata(&lock_path).unwrap().permissions().mode() & 0o777;
331 assert_eq!(mode, 0o600);
332 }
333
334 #[test]
335 fn set_enabled_toggles_one_rule_and_keeps_the_rest() {
336 const TWO_RULES: &str = "# keep me\n[[rules]]\nname = \"a\" # inline\nmatch.seen = true\nactions = [\"flag\"]\n\n[[rules]]\nname = \"b\"\nenabled = false # off for now\nmatch.seen = true\nactions = [\"flag\"]\n";
337
338 let dir = tempfile::tempdir().unwrap();
339 let path = dir.path().join("rules.toml");
340 std::fs::write(&path, TWO_RULES).unwrap();
341 let b_section =
342 |text: &str| text[text.find("[[rules]]\nname = \"b\"").unwrap()..].to_string();
343
344 set_enabled(&path, "a", false).unwrap();
345 let text = std::fs::read_to_string(&path).unwrap();
346 assert!(
347 text.starts_with("# keep me\n[[rules]]\nname = \"a\" # inline\n"),
348 "{text}"
349 );
350 assert!(text.contains("enabled = false"));
351 assert_eq!(b_section(&text), b_section(TWO_RULES));
352
353 set_enabled(&path, "b", true).unwrap();
354 let text = std::fs::read_to_string(&path).unwrap();
355 assert!(text.contains("enabled = true # off for now"), "{text}");
356 assert!(set_enabled(&path, "missing", true).is_err());
357 }
358}