this repo has no description
at main 27 kB view raw
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}