didbot_policy_count/
statement.rs1use serde_json::Value;
4
5const COLLECTIONS: &str = "collections";
7const NAMING: &str = "naming";
9const SCOPE: &str = "scope";
11const WINDOW: &str = "window";
13const LIMIT: &str = "limit";
15const REASON: &str = "reason";
17
18const FIELDS: [&str; 6] = [COLLECTIONS, NAMING, SCOPE, WINDOW, LIMIT, REASON];
20
21const ANOTHER_ACCOUNT: &str = "anotherAccount";
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Scope {
27 Account,
29 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Window {
46 Hour,
48 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#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Statement {
65 pub collections: Vec<String>,
68 pub names_another: bool,
71 pub scope: Scope,
73 pub window: Window,
75 pub limit: u32,
77 pub reason: String,
79 pub counter: String,
84}
85
86impl Statement {
87 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 #[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 #[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}