Skip to main content

postvak_core/rules/
edit.rs

1//! The only code that writes rules.toml. Each edit is validated as a whole file before it replaces the old one.
2use 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
13/// Appends `rule` disabled and attributed to `by`, so it only acts once a human approves it.
14pub 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
31/// Turns a rule on or off, keeping the file's comments and layout.
32pub 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
55/// Approves `name` and restarts its clock in each account's store, so it acts only on mail that arrives from now on,
56/// even where an older clock of the rule survived.
57pub 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        // Comments above a table belong to its decor; keep the human's, minus the blank line `propose` added.
90        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
141/// An exclusive lock on `.rules.lock` beside rules.toml, held until dropped, so concurrent edits cannot lose each other's.
142fn 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}