this repo has no description
1use anyhow::{anyhow, Context, Result};
2
3use chrono::{DateTime, Utc};
4use serde_json_path::JsonPath;
5
6use rhai::{
7 serde::to_dynamic, Array, CustomType, Dynamic, Engine, ImmutableString, Scope, TypeBuilder, AST,
8};
9use std::{collections::HashMap, path::PathBuf, str::FromStr};
10
11use crate::config;
12
13#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq)]
14pub enum MatchOperation {
15 #[default]
16 Upsert,
17 Update,
18}
19
20#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, CustomType)]
21pub struct Match(pub MatchOperation, pub String);
22
23impl Match {
24 fn upsert(aturi: &str) -> Self {
25 Self(MatchOperation::Upsert, aturi.to_string())
26 }
27 fn update(aturi: &str) -> Self {
28 Self(MatchOperation::Update, aturi.to_string())
29 }
30}
31
32pub trait Matcher: Sync + Send {
33 fn matches(&self, value: &serde_json::Value) -> Result<Option<Match>>;
34}
35
36pub struct FeedMatcher {
37 pub(crate) feed: String,
38 matchers: Vec<Box<dyn Matcher>>,
39}
40
41pub(crate) struct FeedMatchers(pub(crate) Vec<FeedMatcher>);
42
43impl FeedMatchers {
44 pub(crate) fn from_config(config_feeds: &config::Feeds) -> Result<Self> {
45 let mut feed_matchers = vec![];
46
47 for config_feed in config_feeds.feeds.iter() {
48 let feed = config_feed.uri.clone();
49
50 let mut matchers = vec![];
51
52 for config_feed_matcher in config_feed.matchers.iter() {
53 match config_feed_matcher {
54 config::Matcher::Equal { path, value, aturi } => {
55 matchers
56 .push(Box::new(EqualsMatcher::new(value, path, aturi)?)
57 as Box<dyn Matcher>);
58 }
59 config::Matcher::Prefix { path, value, aturi } => {
60 matchers
61 .push(Box::new(PrefixMatcher::new(value, path, aturi)?)
62 as Box<dyn Matcher>);
63 }
64 config::Matcher::Sequence {
65 path,
66 values,
67 aturi,
68 } => {
69 matchers.push(Box::new(SequenceMatcher::new(values, path, aturi)?)
70 as Box<dyn Matcher>);
71 }
72
73 config::Matcher::Rhai { script } => {
74 matchers.push(Box::new(RhaiMatcher::new(script)?) as Box<dyn Matcher>);
75 }
76 }
77 }
78
79 feed_matchers.push(FeedMatcher { feed, matchers });
80 }
81
82 Ok(Self(feed_matchers))
83 }
84}
85
86impl FeedMatcher {
87 pub(crate) fn matches(&self, value: &serde_json::Value) -> Option<Match> {
88 for matcher in self.matchers.iter() {
89 let result = matcher.matches(value);
90 if let Err(err) = result {
91 tracing::error!(error = ?err, "matcher returned error");
92 continue;
93 }
94 let result = result.unwrap();
95 if result.is_some() {
96 return result;
97 }
98 }
99 None
100 }
101}
102
103pub struct EqualsMatcher {
104 expected: String,
105 path: JsonPath,
106 aturi_path: Option<JsonPath>,
107}
108
109impl EqualsMatcher {
110 pub fn new(expected: &str, path: &str, aturi: &Option<String>) -> Result<Self> {
111 let path = JsonPath::parse(path).context("cannot parse path")?;
112 let aturi_path = if let Some(aturi) = aturi {
113 let parsed_aturi_path =
114 JsonPath::parse(aturi).context("cannot parse aturi jsonpath")?;
115 Some(parsed_aturi_path)
116 } else {
117 None
118 };
119 Ok(Self {
120 expected: expected.to_string(),
121 path,
122 aturi_path,
123 })
124 }
125}
126
127impl Matcher for EqualsMatcher {
128 fn matches(&self, value: &serde_json::Value) -> Result<Option<Match>> {
129 let nodes = self.path.query(value).all();
130
131 let string_nodes = nodes
132 .iter()
133 .filter_map(|value| {
134 if let serde_json::Value::String(actual) = value {
135 Some(actual.to_lowercase().clone())
136 } else {
137 None
138 }
139 })
140 .collect::<Vec<String>>();
141
142 if string_nodes.iter().any(|value| value == &self.expected) {
143 extract_aturi(self.aturi_path.as_ref(), value)
144 .map(|value| Some(Match::upsert(&value)))
145 .ok_or(anyhow!(
146 "matcher matched but could not create at-uri: {:?}",
147 value
148 ))
149 } else {
150 Ok(None)
151 }
152 }
153}
154
155pub fn matcher_before_duration(duration: &str, date: &str) -> bool {
156 let parsed_date = DateTime::parse_from_rfc3339(date).map(|v| v.with_timezone(&Utc));
157 if let Err(err) = parsed_date {
158 tracing::debug!(error = ?err, "error parsing date");
159 return true;
160 }
161 let (direction, parsed_duration) = if let Some(duration) = duration.strip_prefix('-') {
162 (true, duration_str::parse_chrono(duration))
163 } else {
164 (false, duration_str::parse_chrono(duration))
165 };
166
167 if let Err(err) = parsed_duration {
168 tracing::debug!(error = ?err, "error parsing date");
169 return true;
170 }
171
172 let date = parsed_date.unwrap();
173 let duration = parsed_duration.unwrap();
174 let target = if direction {
175 Utc::now() - duration
176 } else {
177 Utc::now() + duration
178 };
179
180 date < target
181}
182
183pub struct PrefixMatcher {
184 prefix: String,
185 path: JsonPath,
186 aturi_path: Option<JsonPath>,
187}
188
189impl PrefixMatcher {
190 pub(crate) fn new(prefix: &str, path: &str, aturi: &Option<String>) -> Result<Self> {
191 let path = JsonPath::parse(path).context("cannot parse path")?;
192 let aturi_path = if let Some(aturi) = aturi {
193 let parsed_aturi_path =
194 JsonPath::parse(aturi).context("cannot parse aturi jsonpath")?;
195 Some(parsed_aturi_path)
196 } else {
197 None
198 };
199 Ok(Self {
200 prefix: prefix.to_string(),
201 path,
202 aturi_path,
203 })
204 }
205}
206
207impl Matcher for PrefixMatcher {
208 fn matches(&self, value: &serde_json::Value) -> Result<Option<Match>> {
209 let nodes = self.path.query(value).all();
210
211 let string_nodes = nodes
212 .iter()
213 .filter_map(|value| {
214 if let serde_json::Value::String(actual) = value {
215 Some(actual.to_lowercase().clone())
216 } else {
217 None
218 }
219 })
220 .collect::<Vec<String>>();
221
222 let found = string_nodes
223 .iter()
224 .any(|value| value.starts_with(&self.prefix));
225 if found {
226 extract_aturi(self.aturi_path.as_ref(), value)
227 .map(|value| Some(Match::upsert(&value)))
228 .ok_or(anyhow!(
229 "matcher matched but could not create at-uri: {:?}",
230 value
231 ))
232 } else {
233 Ok(None)
234 }
235 }
236}
237
238pub struct SequenceMatcher {
239 expected: Vec<String>,
240 path: JsonPath,
241 aturi_path: Option<JsonPath>,
242}
243
244impl SequenceMatcher {
245 pub(crate) fn new(expected: &[String], path: &str, aturi: &Option<String>) -> Result<Self> {
246 let path = JsonPath::parse(path).context("cannot parse path")?;
247 let aturi_path = if let Some(aturi) = aturi {
248 let parsed_aturi_path =
249 JsonPath::parse(aturi).context("cannot parse aturi jsonpath")?;
250 Some(parsed_aturi_path)
251 } else {
252 None
253 };
254 Ok(Self {
255 expected: expected.to_owned(),
256 path,
257 aturi_path,
258 })
259 }
260}
261
262impl Matcher for SequenceMatcher {
263 fn matches(&self, value: &serde_json::Value) -> Result<Option<Match>> {
264 let nodes = self.path.query(value).all();
265
266 let string_nodes = nodes
267 .iter()
268 .filter_map(|value| {
269 if let serde_json::Value::String(actual) = value {
270 Some(actual.to_lowercase().clone())
271 } else {
272 None
273 }
274 })
275 .collect::<Vec<String>>();
276
277 for string_node in string_nodes {
278 let mut last_found: i32 = -1;
279
280 let mut found_index = 0;
281 for (index, expected) in self.expected.iter().enumerate() {
282 if let Some(current_found) = string_node.find(expected) {
283 if (current_found as i32) > last_found {
284 last_found = current_found as i32;
285 found_index = index;
286 } else {
287 last_found = -1;
288 break;
289 }
290 } else {
291 last_found = -1;
292 break;
293 }
294 }
295
296 if last_found != -1 && found_index == self.expected.len() - 1 {
297 return extract_aturi(self.aturi_path.as_ref(), value)
298 .map(|value| Some(Match::upsert(&value)))
299 .ok_or(anyhow!(
300 "matcher matched but could not create at-uri: {:?}",
301 value
302 ));
303 }
304 }
305
306 Ok(None)
307 }
308}
309
310pub fn matcher_sequence_matches(sequence: Array, text: ImmutableString) -> bool {
311 let sequence = sequence
312 .iter()
313 .filter_map(|value| value.clone().try_cast::<String>())
314 .collect::<Vec<String>>();
315 sequence_matches(sequence.as_ref(), &text)
316}
317
318fn sequence_matches(sequence: &[String], text: &str) -> bool {
319 let mut last_found: i32 = -1;
320
321 let mut found_index = 0;
322 for (index, expected) in sequence.iter().enumerate() {
323 if let Some(current_found) = text.find(expected) {
324 if (current_found as i32) > last_found {
325 last_found = current_found as i32;
326 found_index = index;
327 } else {
328 last_found = -1;
329 break;
330 }
331 } else {
332 last_found = -1;
333 break;
334 }
335 }
336 last_found != -1 && found_index == sequence.len() - 1
337}
338
339fn extract_aturi(aturi: Option<&JsonPath>, event_value: &serde_json::Value) -> Option<String> {
340 if let Some(aturi_path) = aturi {
341 let nodes = aturi_path.query(event_value).all();
342 let string_nodes = nodes
343 .iter()
344 .filter_map(|value| {
345 if let serde_json::Value::String(actual) = value {
346 Some(actual.to_lowercase().clone())
347 } else {
348 None
349 }
350 })
351 .collect::<Vec<String>>();
352
353 for value in string_nodes {
354 if value.starts_with("at://") {
355 return Some(value);
356 }
357 }
358 }
359
360 let rtype = event_value
361 .get("commit")
362 .and_then(|commit| commit.get("record"))
363 .and_then(|commit| commit.get("$type"))
364 .and_then(|did| did.as_str());
365
366 if Some("app.bsky.feed.post") == rtype {
367 let did = event_value.get("did").and_then(|did| did.as_str())?;
368 let commit = event_value.get("commit")?;
369 let collection = commit.get("collection").and_then(|did| did.as_str())?;
370 let rkey = commit.get("rkey").and_then(|did| did.as_str())?;
371 let uri = format!("at://{}/{}/{}", did, collection, rkey);
372 return Some(uri);
373 }
374
375 if Some("app.bsky.feed.like") == rtype {
376 return event_value
377 .get("commit")
378 .and_then(|value| value.get("record"))
379 .and_then(|value| value.get("subject"))
380 .and_then(|value| value.get("uri"))
381 .and_then(|value| value.as_str())
382 .map(|value| value.to_string());
383 }
384
385 None
386}
387
388pub struct RhaiMatcher {
389 source: String,
390 engine: Engine,
391 ast: AST,
392}
393
394#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
395#[serde(untagged)]
396pub enum MaybeMatch {
397 Match(Match),
398
399 #[serde(untagged)]
400 Other {
401 #[serde(flatten)]
402 extra: HashMap<String, serde_json::Value>,
403 },
404}
405
406impl MaybeMatch {
407 pub fn into_match(self) -> Option<Match> {
408 match self {
409 MaybeMatch::Match(m) => Some(m),
410 _ => None,
411 }
412 }
413}
414
415impl RhaiMatcher {
416 pub fn new(source: &str) -> Result<Self> {
417 let mut engine = Engine::new();
418 engine
419 .build_type::<Match>()
420 .register_fn("build_aturi", build_aturi)
421 .register_fn("sequence_matches", matcher_sequence_matches)
422 .register_fn("matcher_before_duration", matcher_before_duration)
423 .register_fn("update_match", Match::update)
424 .register_fn("upsert_match", Match::upsert);
425 let ast = engine
426 .compile_file(PathBuf::from_str(source)?)
427 .context("cannot compile script")?;
428 Ok(Self {
429 source: source.to_string(),
430 engine,
431 ast,
432 })
433 }
434}
435
436fn dynamic_to_match(value: Dynamic) -> Result<Option<Match>> {
437 if value.is_bool() || value.is_int() {
438 return Ok(None);
439 }
440 if let Some(aturi) = value.clone().try_cast::<String>() {
441 return Ok(Some(Match::upsert(&aturi)));
442 }
443 if let Some(match_value) = value.try_cast::<Match>() {
444 return Ok(Some(match_value));
445 }
446 Err(anyhow!(
447 "unsupported return value type: must be int, string, or match"
448 ))
449}
450
451impl Matcher for RhaiMatcher {
452 fn matches(&self, value: &serde_json::Value) -> Result<Option<Match>> {
453 let mut scope = Scope::new();
454 let value_map = to_dynamic(value);
455 if let Err(err) = value_map {
456 tracing::error!(source = ?self.source, error = ?err, "error converting value to dynamic");
457 return Ok(None);
458 }
459 let value_map = value_map.unwrap();
460 scope.push("event", value_map);
461
462 self.engine
463 .eval_ast_with_scope::<Dynamic>(&mut scope, &self.ast)
464 .context("error evaluating script")
465 .and_then(dynamic_to_match)
466 }
467}
468
469fn build_aturi_maybe(event: Dynamic) -> Result<String> {
470 let event = event.as_map_ref().map_err(|err| anyhow!(err))?;
471
472 let commit = event
473 .get("commit")
474 .ok_or(anyhow!("no commit on event"))?
475 .as_map_ref()
476 .map_err(|err| anyhow!(err))?;
477 let record = commit
478 .get("record")
479 .ok_or(anyhow!("no record on event commit"))?
480 .as_map_ref()
481 .map_err(|err| anyhow!(err))?;
482
483 let rtype = record
484 .get("$type")
485 .ok_or(anyhow!("no $type on event commit record"))?
486 .as_immutable_string_ref()
487 .map_err(|err| anyhow!(err))?;
488
489 match rtype.as_str() {
490 "app.bsky.feed.post" => {
491 let did = event
492 .get("did")
493 .ok_or(anyhow!("no did on event"))?
494 .as_immutable_string_ref()
495 .map_err(|err| anyhow!(err))?;
496 let collection = commit
497 .get("collection")
498 .ok_or(anyhow!("no collection on event"))?
499 .as_immutable_string_ref()
500 .map_err(|err| anyhow!(err))?;
501 let rkey = commit
502 .get("rkey")
503 .ok_or(anyhow!("no rkey on event commit"))?
504 .as_immutable_string_ref()
505 .map_err(|err| anyhow!(err))?;
506
507 Ok(format!(
508 "at://{}/{}/{}",
509 did.as_str(),
510 collection.as_str(),
511 rkey.as_str()
512 ))
513 }
514 "app.bsky.feed.like" => {
515 let subject = record
516 .get("subject")
517 .ok_or(anyhow!("no subject on event commit record"))?
518 .as_map_ref()
519 .map_err(|err| anyhow!(err))?;
520 let uri = subject
521 .get("uri")
522 .ok_or(anyhow!("no uri on event commit record subject"))?
523 .as_immutable_string_ref()
524 .map_err(|err| anyhow!(err))?;
525
526 Ok(uri.to_string())
527 }
528
529 _ => Err(anyhow!("no aturi for event")),
530 }
531}
532
533fn build_aturi(event: Dynamic) -> String {
534 let aturi = build_aturi_maybe(event);
535 if let Err(err) = aturi {
536 tracing::warn!(error = ?err, "error creating at-uri");
537 return "".into();
538 }
539 aturi.unwrap()
540}
541
542#[cfg(test)]
543mod tests {
544
545 use super::*;
546 use anyhow::{anyhow, Result};
547 use std::path::PathBuf;
548
549 #[test]
550 fn equals_matcher() -> Result<()> {
551 let raw_json = r#"{
552 "did": "did:plc:tgudj2fjm77pzkuawquqhsxm",
553 "time_us": 1730491093829414,
554 "kind": "commit",
555 "commit": {
556 "rev": "3l7vxhiuibq2u",
557 "operation": "create",
558 "collection": "app.bsky.feed.post",
559 "rkey": "3l7vxhiu4kq2u",
560 "record": {
561 "$type": "app.bsky.feed.post",
562 "createdAt": "2024-11-01T19:58:12.980Z",
563 "langs": ["en", "es"],
564 "text": "hey dnd question, what does a 45 on a stealth check look like"
565 },
566 "cid": "bafyreide7jpu67vvkn4p2iznph6frbwv6vamt7yg5duppqjqggz4sdfik4"
567 }
568}"#;
569
570 let value: serde_json::Value = serde_json::from_str(raw_json).expect("json is valid");
571
572 let tests = vec![
573 ("$.did", "did:plc:tgudj2fjm77pzkuawquqhsxm", true),
574 ("$.commit.record['$type']", "app.bsky.feed.post", true),
575 ("$.commit.record.langs.*", "en", true),
576 (
577 "$.commit.record.text",
578 "hey dnd question, what does a 45 on a stealth check look like",
579 true,
580 ),
581 ("$.did", "did:plc:tgudj2fjm77pzkuawquqhsxn", false),
582 ("$.commit.record.notreal", "value", false),
583 ];
584
585 for (path, expected, result) in tests {
586 let matcher = EqualsMatcher::new(expected, path, &None).expect("matcher is valid");
587 let maybe_match = matcher.matches(&value)?;
588 assert_eq!(maybe_match.is_some(), result);
589 }
590
591 Ok(())
592 }
593
594 #[test]
595 fn prefix_matcher() -> Result<()> {
596 let raw_json = r#"{
597 "did": "did:plc:tgudj2fjm77pzkuawquqhsxm",
598 "time_us": 1730491093829414,
599 "kind": "commit",
600 "commit": {
601 "rev": "3l7vxhiuibq2u",
602 "operation": "create",
603 "collection": "app.bsky.feed.post",
604 "rkey": "3l7vxhiu4kq2u",
605 "record": {
606 "$type": "app.bsky.feed.post",
607 "createdAt": "2024-11-01T19:58:12.980Z",
608 "langs": ["en"],
609 "text": "hey dnd question, what does a 45 on a stealth check look like",
610 "facets": [
611 {
612 "features": [{"$type": "app.bsky.richtext.facet#tag", "tag": "dungeonsanddragons"}],
613 "index": { "byteEnd": 1, "byteStart": 0 }
614 },
615 {
616 "features": [{"$type": "app.bsky.richtext.facet#tag", "tag": "gaming"}],
617 "index": { "byteEnd": 1, "byteStart": 0 }
618 }
619 ]
620 },
621 "cid": "bafyreide7jpu67vvkn4p2iznph6frbwv6vamt7yg5duppqjqggz4sdfik4"
622 }
623}"#;
624
625 let value: serde_json::Value = serde_json::from_str(raw_json).expect("json is valid");
626
627 let tests = vec![
628 ("$.commit.record['$type']", "app.bsky.", true),
629 ("$.commit.record.langs.*", "e", true),
630 ("$.commit.record.text", "hey dnd question", true),
631 ("$.commit.record.facets[*].features[?(@['$type'] == 'app.bsky.richtext.facet#tag')].tag", "dungeons", true),
632 ("$.commit.record.notreal", "value", false),
633 ("$.commit.record['$type']", "com.bsky.", false),
634 ];
635
636 for (path, prefix, result) in tests {
637 let matcher = PrefixMatcher::new(prefix, path, &None).expect("matcher is valid");
638 let maybe_match = matcher.matches(&value)?;
639 assert_eq!(maybe_match.is_some(), result);
640 }
641
642 Ok(())
643 }
644
645 #[test]
646 fn sequence_matcher() -> Result<()> {
647 let raw_json = r#"{
648 "did": "did:plc:tgudj2fjm77pzkuawquqhsxm",
649 "time_us": 1730491093829414,
650 "kind": "commit",
651 "commit": {
652 "rev": "3l7vxhiuibq2u",
653 "operation": "create",
654 "collection": "app.bsky.feed.post",
655 "rkey": "3l7vxhiu4kq2u",
656 "record": {
657 "$type": "app.bsky.feed.post",
658 "createdAt": "2024-11-01T19:58:12.980Z",
659 "langs": ["en"],
660 "text": "hey dnd question, what does a 45 on a stealth check look like",
661 "facets": [
662 {
663 "features": [{"$type": "app.bsky.richtext.facet#tag", "tag": "dungeonsanddragons"}],
664 "index": { "byteEnd": 1, "byteStart": 0 }
665 },
666 {
667 "features": [{"$type": "app.bsky.richtext.facet#tag", "tag": "gaming"}],
668 "index": { "byteEnd": 1, "byteStart": 0 }
669 }
670 ]
671 },
672 "cid": "bafyreide7jpu67vvkn4p2iznph6frbwv6vamt7yg5duppqjqggz4sdfik4"
673 }
674}"#;
675
676 let value: serde_json::Value = serde_json::from_str(raw_json).expect("json is valid");
677
678 let tests = vec![
679 (
680 "$.commit.record.text",
681 vec!["hey".into(), "dnd".into(), "question".into()],
682 true,
683 ),
684 (
685 "$.commit.record.facets[*].features[?(@['$type'] == 'app.bsky.richtext.facet#tag')].tag",
686 vec!["dungeons".into(), "and".into(), "dragons".into()],
687 true,
688 ),
689 (
690 "$.commit.record.text",
691 vec!["hey".into(), "question".into(), "dnd".into()],
692 false,
693 ),
694 (
695 "$.commit.record.operation",
696 vec!["hey".into(), "dnd".into(), "question".into()],
697 false,
698 ),
699 (
700 "$.commit.record.text",
701 vec!["hey".into(), "nick".into()],
702 false,
703 ),
704 ];
705
706 for (path, values, result) in tests {
707 let matcher = SequenceMatcher::new(&values, path, &None).expect("matcher is valid");
708 let maybe_match = matcher.matches(&value)?;
709 assert_eq!(maybe_match.is_some(), result);
710 }
711
712 Ok(())
713 }
714
715 #[test]
716 fn sequence_matcher_edge_case_1() -> Result<()> {
717 let raw_json = r#"{"text": "Stellwerkstörung. Und Signalstörung. Und der Alternativzug ist auch ausgefallen. Und überhaupt."}"#;
718 let value: serde_json::Value = serde_json::from_str(raw_json).expect("json is valid");
719 let matcher = SequenceMatcher::new(
720 &vec!["smoke".to_string(), "signal".to_string()],
721 "$.text",
722 &None,
723 )?;
724 let maybe_match = matcher.matches(&value)?;
725 assert_eq!(maybe_match.is_some(), false);
726
727 Ok(())
728 }
729
730 #[test]
731 fn rhai_matcher() -> Result<()> {
732 let testdata = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata");
733
734 let tests: Vec<(&str, Vec<(&str, bool, &str)>)> = vec![
735 (
736 "post1.json",
737 vec![
738 ("rhai_match_nothing.rhai", false, ""),
739 (
740 "rhai_match_everything.rhai",
741 true,
742 "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/3laadb7behk25",
743 ),
744 (
745 "rhai_match_everything_simple.rhai",
746 true,
747 "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/3laadb7behk25",
748 ),
749 (
750 "rhai_match_type.rhai",
751 true,
752 "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/3laadb7behk25",
753 ),
754 (
755 "rhai_match_poster.rhai",
756 true,
757 "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/3laadb7behk25",
758 ),
759 ("rhai_match_reply_root.rhai", false, ""),
760 (
761 "rhai_match_sequence.rhai",
762 true,
763 "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/3laadb7behk25",
764 ),
765 (
766 "rhai_match_links.rhai",
767 true,
768 "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/3laadb7behk25",
769 ),
770 ],
771 ),
772 (
773 "post2.json",
774 vec![
775 (
776 "rhai_match_everything.rhai",
777 true,
778 "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/3laadftr72k25",
779 ),
780 (
781 "rhai_match_type.rhai",
782 true,
783 "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/3laadftr72k25",
784 ),
785 (
786 "rhai_match_poster.rhai",
787 true,
788 "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/3laadftr72k25",
789 ),
790 (
791 "rhai_match_reply_root.rhai",
792 true,
793 "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/3laadftr72k25",
794 ),
795 ],
796 ),
797 ];
798
799 for (input_json, matcher_tests) in tests {
800 let input_json_path = testdata.join(input_json);
801 let json_content = std::fs::read(input_json_path).map_err(|err| {
802 anyhow::Error::new(err).context(anyhow!("reading input_json failed"))
803 })?;
804 let value: serde_json::Value =
805 serde_json::from_slice(&json_content).context("parsing input_json failed")?;
806
807 for (matcher_file_name, matched, aturi) in matcher_tests {
808 let matcher_path = testdata.join(matcher_file_name);
809 let matcher = RhaiMatcher::new(&matcher_path.to_string_lossy())
810 .context("could not construct matcher")?;
811 let result = matcher.matches(&value)?;
812 assert_eq!(
813 result.is_some_and(|e| e.1 == aturi),
814 matched,
815 "matched {}: {}",
816 input_json,
817 matcher_file_name
818 );
819 }
820 }
821
822 Ok(())
823 }
824
825 #[test]
826 fn matcher_before_duration() {
827 assert!(super::matcher_before_duration(
828 "1mon",
829 "2024-11-15T11:05:01.000Z"
830 ));
831 assert!(!super::matcher_before_duration(
832 "-1mon",
833 "2024-11-15T11:05:01.000Z"
834 ));
835 }
836}