Skip to main content

didbot_policy_count/
statement.rs

1//! What a statement says, and how it is read.
2
3use serde_json::Value;
4
5/// The field naming the collections a statement counts.
6const COLLECTIONS: &str = "collections";
7/// The field narrowing a statement to records that name somebody else.
8const NAMING: &str = "naming";
9/// The field saying whose count is judged.
10const SCOPE: &str = "scope";
11/// The field naming the window.
12const WINDOW: &str = "window";
13/// The field holding the limit.
14const LIMIT: &str = "limit";
15/// The field holding what a refusal says.
16const REASON: &str = "reason";
17
18/// Every field a statement may carry.
19const FIELDS: [&str; 6] = [COLLECTIONS, NAMING, SCOPE, WINDOW, LIMIT, REASON];
20
21/// The one value [`NAMING`] takes.
22const ANOTHER_ACCOUNT: &str = "anotherAccount";
23
24/// Whose writes one statement counts together.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Scope {
27    /// Each account on its own.
28    Account,
29    /// Every account together.
30    Server,
31}
32
33impl Scope {
34    fn named(name: &str) -> Option<Self> {
35        match name {
36            "account" => Some(Self::Account),
37            "server" => Some(Self::Server),
38            _ => None,
39        }
40    }
41}
42
43/// How far back one statement counts.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Window {
46    /// The hour the write falls in.
47    Hour,
48    /// The day the write falls in, as the last twenty-four hours.
49    Day,
50}
51
52impl Window {
53    fn named(name: &str) -> Option<Self> {
54        match name {
55            "hour" => Some(Self::Hour),
56            "day" => Some(Self::Day),
57            _ => None,
58        }
59    }
60}
61
62/// One statement: what it counts, over what, and how much it allows.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Statement {
65    /// The collections it counts writes to. Empty counts every
66    /// collection.
67    pub collections: Vec<String>,
68    /// Whether it counts only a record that names an account other than
69    /// the one writing it.
70    pub names_another: bool,
71    /// Whose writes are counted together.
72    pub scope: Scope,
73    /// How far back.
74    pub window: Window,
75    /// How many writes the window holds before the next is refused.
76    pub limit: u32,
77    /// What the refusal says.
78    pub reason: String,
79    /// The statement as written, sorted, which is the name its count is
80    /// held under -- see [`crate::Counts`]. It survives a rebuild of the
81    /// tree, because it is the statement rather than anything a build
82    /// assigned.
83    pub counter: String,
84}
85
86impl Statement {
87    /// Reads one statement.
88    ///
89    /// # Errors
90    ///
91    /// What is wrong with it, as an operator reads it.
92    pub fn parse(value: &Value) -> Result<Self, String> {
93        let object = value
94            .as_object()
95            .ok_or_else(|| "a statement is an object".to_owned())?;
96        for name in object.keys() {
97            if !FIELDS.contains(&name.as_str()) {
98                return Err(format!("`{name}` is not a field of a count statement"));
99            }
100        }
101        let text = |field: &str| -> Result<&str, String> {
102            object
103                .get(field)
104                .and_then(Value::as_str)
105                .ok_or_else(|| format!("`{field}` is a string"))
106        };
107        let collections = match object.get(COLLECTIONS) {
108            None => Vec::new(),
109            Some(value) => value
110                .as_array()
111                .ok_or_else(|| format!("`{COLLECTIONS}` is an array of collections"))?
112                .iter()
113                .map(|collection| {
114                    collection
115                        .as_str()
116                        .map(str::to_owned)
117                        .ok_or_else(|| format!("`{COLLECTIONS}` holds collection names"))
118                })
119                .collect::<Result<Vec<String>, String>>()?,
120        };
121        let names_another = match object.get(NAMING) {
122            None => false,
123            Some(naming) => {
124                let naming = naming
125                    .as_str()
126                    .ok_or_else(|| format!("`{NAMING}` is `{ANOTHER_ACCOUNT}`"))?;
127                if naming != ANOTHER_ACCOUNT {
128                    return Err(format!("`{NAMING}` is `{ANOTHER_ACCOUNT}`, not `{naming}`"));
129                }
130                true
131            }
132        };
133        let scope = Scope::named(text(SCOPE)?)
134            .ok_or_else(|| format!("`{SCOPE}` is `account` or `server`"))?;
135        let window =
136            Window::named(text(WINDOW)?).ok_or_else(|| format!("`{WINDOW}` is `hour` or `day`"))?;
137        let limit = object
138            .get(LIMIT)
139            .and_then(Value::as_u64)
140            .and_then(|limit| u32::try_from(limit).ok())
141            .ok_or_else(|| format!("`{LIMIT}` is a whole number of writes"))?;
142        let reason = text(REASON)?.to_owned();
143        if reason.trim().is_empty() {
144            return Err(format!("`{REASON}` says why the write is refused"));
145        }
146        let counter = didbot_policy::sorted_json(value);
147        Ok(Self {
148            collections,
149            names_another,
150            scope,
151            window,
152            limit,
153            reason,
154            counter,
155        })
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use serde_json::json;
163
164    fn statement() -> Value {
165        json!({
166            "collections": ["app.bsky.feed.post"],
167            "naming": "anotherAccount",
168            "scope": "account",
169            "window": "hour",
170            "limit": 100,
171            "reason": "this account has reached its hourly limit.",
172        })
173    }
174
175    #[test]
176    fn a_statement_is_read_field_by_field() {
177        let parsed = Statement::parse(&statement()).expect("reads");
178        assert_eq!(parsed.collections, ["app.bsky.feed.post"]);
179        assert!(parsed.names_another);
180        assert_eq!(parsed.scope, Scope::Account);
181        assert_eq!(parsed.window, Window::Hour);
182        assert_eq!(parsed.limit, 100);
183    }
184
185    /// Every collection, every record, when the two narrowing fields are
186    /// left out.
187    #[test]
188    fn a_statement_naming_no_collection_counts_every_write() {
189        let mut value = statement();
190        let object = value.as_object_mut().expect("an object");
191        object.remove("collections");
192        object.remove("naming");
193        let parsed = Statement::parse(&value).expect("reads");
194        assert!(parsed.collections.is_empty());
195        assert!(!parsed.names_another);
196    }
197
198    #[test]
199    fn what_a_statement_may_not_say() {
200        let refused = |edit: fn(&mut serde_json::Map<String, Value>)| {
201            let mut value = statement();
202            edit(value.as_object_mut().expect("an object"));
203            Statement::parse(&value).expect_err("refused")
204        };
205        assert!(refused(|s| {
206            s.insert("every".into(), json!(true));
207        })
208        .contains("not a field"));
209        assert!(refused(|s| {
210            s.insert("scope".into(), json!("everyone"));
211        })
212        .contains("`account` or `server`"));
213        assert!(refused(|s| {
214            s.insert("window".into(), json!("week"));
215        })
216        .contains("`hour` or `day`"));
217        assert!(refused(|s| {
218            s.insert("limit".into(), json!(-1));
219        })
220        .contains("whole number"));
221        assert!(refused(|s| {
222            s.insert("naming".into(), json!("me"));
223        })
224        .contains("anotherAccount"));
225        assert!(refused(|s| {
226            s.insert("reason".into(), json!("  "));
227        })
228        .contains("why the write is refused"));
229    }
230
231    /// A statement's count is held under the statement itself, so the
232    /// same statement compiled again finds the count it left, and a
233    /// different one counts for itself.
234    #[test]
235    fn the_counter_a_statement_keeps_is_its_own() {
236        let one = Statement::parse(&statement()).expect("reads");
237        let again = Statement::parse(&statement()).expect("reads");
238        assert_eq!(one.counter, again.counter);
239        let mut narrower = statement();
240        narrower["limit"] = json!(10);
241        let two = Statement::parse(&narrower).expect("reads");
242        assert_ne!(one.counter, two.counter);
243    }
244}