1use std::collections::{BTreeMap, HashMap, HashSet};
8use std::path::Path;
9
10use serde::Deserialize;
11
12use crate::error::{Error, Result};
13use crate::site::{Engine, Site};
14
15const EMBEDDED_REGISTRY: &str = include_str!("../data/sites.json");
16
17const EMBEDDED_WMN_REGISTRY: &str = include_str!("../data/sites_wmn.json");
24
25#[derive(Debug, Clone, Deserialize)]
34pub struct Registry {
35 #[serde(default)]
36 engines: BTreeMap<String, Engine>,
37 sites: Vec<Site>,
38}
39
40#[derive(Debug, Clone, Default)]
48pub struct SiteFilter {
49 pub include: Vec<String>,
52 pub exclude: Vec<String>,
54 pub tags: Vec<String>,
57 pub exclude_tags: Vec<String>,
59 pub include_nsfw: bool,
61 pub top: Option<u32>,
65}
66
67impl SiteFilter {
68 pub fn apply(&self, sites: &[Site]) -> Vec<Site> {
71 self.apply_inner(sites, DisabledMode::Exclude)
72 }
73
74 pub fn apply_including_disabled(&self, sites: &[Site]) -> Vec<Site> {
78 self.apply_inner(sites, DisabledMode::Include)
79 }
80
81 fn apply_inner(&self, sites: &[Site], disabled_mode: DisabledMode) -> Vec<Site> {
82 let include: Vec<String> = self.include.iter().map(|s| s.to_lowercase()).collect();
83 let exclude: Vec<String> = self.exclude.iter().map(|s| s.to_lowercase()).collect();
84 let want_tags: Vec<String> = self.tags.iter().map(|s| s.to_lowercase()).collect();
85 let mut drop_tags: Vec<String> =
86 self.exclude_tags.iter().map(|s| s.to_lowercase()).collect();
87
88 let nsfw_tag = "nsfw".to_owned();
91 let asking_for_nsfw = want_tags.contains(&nsfw_tag);
92 if !self.include_nsfw && !asking_for_nsfw && !drop_tags.contains(&nsfw_tag) {
93 drop_tags.push(nsfw_tag);
94 }
95
96 let mut filtered: Vec<Site> = sites
97 .iter()
98 .filter(|site| {
99 match disabled_mode {
100 DisabledMode::Exclude if site.disabled => {
101 return false;
106 }
107 DisabledMode::Only if !site.disabled => return false,
108 DisabledMode::Exclude | DisabledMode::Only | DisabledMode::Include => {}
109 }
110 let name = site.name.to_lowercase();
111 let included = include.is_empty() || include.iter().any(|i| name.contains(i));
112 let excluded = exclude.iter().any(|x| name.contains(x));
113 let lower_tags: Vec<String> = site.tags.iter().map(|t| t.to_lowercase()).collect();
114 let tagged =
115 want_tags.is_empty() || lower_tags.iter().any(|t| want_tags.contains(t));
116 let tag_excluded = lower_tags.iter().any(|t| drop_tags.contains(t));
117 included && !excluded && tagged && !tag_excluded
118 })
119 .cloned()
120 .collect();
121
122 if let Some(n) = self.top {
123 filtered.retain(|s| s.popularity.is_some_and(|p| p <= n));
124 filtered.sort_by_key(|s| s.popularity.unwrap_or(u32::MAX));
125 }
126 filtered
127 }
128}
129
130#[derive(Debug, Clone, Copy)]
131enum DisabledMode {
132 Exclude,
133 Only,
134 Include,
135}
136
137impl Registry {
138 pub fn default_embedded() -> Result<Self> {
140 Self::from_json_str(EMBEDDED_REGISTRY)
141 }
142
143 pub fn default_embedded_with_wmn() -> Result<Self> {
158 let mut base = Self::default_embedded()?;
159 let wmn: Self = serde_json::from_str(EMBEDDED_WMN_REGISTRY)?;
160 let existing_names: HashSet<String> =
161 base.sites.iter().map(|s| s.name.to_lowercase()).collect();
162 let claimed_urls: HashSet<String> = base
167 .sites
168 .iter()
169 .filter(|s| !s.disabled)
170 .map(|s| s.url.as_str().to_owned())
171 .collect();
172 for (name, engine) in wmn.engines {
173 base.engines.entry(name).or_insert(engine);
174 }
175 for site in wmn.sites {
176 if existing_names.contains(&site.name.to_lowercase()) {
177 continue;
178 }
179 if !site.disabled && claimed_urls.contains(site.url.as_str()) {
180 continue;
186 }
187 base.sites.push(site);
188 }
189 base.resolve_engines()?;
190 base.validate()?;
191 Ok(base)
192 }
193
194 pub fn from_json_str(json: &str) -> Result<Self> {
199 let mut registry: Self = serde_json::from_str(json)?;
200 registry.resolve_engines()?;
201 registry.apply_tag_derived_policy();
202 registry.validate()?;
203 Ok(registry)
204 }
205
206 pub fn engines(&self) -> &BTreeMap<String, Engine> {
211 &self.engines
212 }
213
214 fn apply_tag_derived_policy(&mut self) {
233 for site in &mut self.sites {
234 if !site.access.geo.is_empty() {
235 continue;
236 }
237 for tag in &site.tags {
238 let Some(rest) = tag.strip_prefix("region:") else {
239 continue;
240 };
241 let Some(cc) = crate::access::CountryCode::new(rest) else {
242 continue;
243 };
244 if !site.access.prefer_geo.contains(&cc) {
245 site.access.prefer_geo.push(cc);
246 }
247 }
248 }
249 }
250
251 fn resolve_engines(&mut self) -> Result<()> {
260 for (name, engine) in &self.engines {
261 engine.validate(name)?;
262 }
263 for site in &mut self.sites {
264 let Some(name) = &site.engine else {
265 continue;
266 };
267 let Some(engine) = self.engines.get(name) else {
268 return Err(Error::InvalidSite {
269 reason: format!(
270 "site {:?}: references engine {name:?} which is not defined",
271 site.name
272 ),
273 });
274 };
275 engine.merge_into(site);
276 }
277 Ok(())
278 }
279
280 pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
282 let bytes = std::fs::read(path)?;
283 let json = std::str::from_utf8(&bytes).map_err(|e| Error::InvalidSite {
284 reason: format!("registry file is not valid UTF-8: {e}"),
285 })?;
286 Self::from_json_str(json)
287 }
288
289 pub fn sites(&self) -> &[Site] {
291 &self.sites
292 }
293
294 pub fn len(&self) -> usize {
296 self.sites.len()
297 }
298
299 pub fn is_empty(&self) -> bool {
302 self.sites.is_empty()
303 }
304
305 pub fn filter(
324 &self,
325 include: &[String],
326 exclude: &[String],
327 tags: &[String],
328 exclude_tags: &[String],
329 include_nsfw: bool,
330 ) -> Vec<Site> {
331 self.filter_with(&SiteFilter {
332 include: include.to_vec(),
333 exclude: exclude.to_vec(),
334 tags: tags.to_vec(),
335 exclude_tags: exclude_tags.to_vec(),
336 include_nsfw,
337 top: None,
338 })
339 }
340
341 pub fn filter_with(&self, filter: &SiteFilter) -> Vec<Site> {
343 filter.apply(&self.sites)
344 }
345
346 pub fn matches_with(&self, filter: &SiteFilter) -> Vec<Site> {
351 filter.apply_including_disabled(&self.sites)
352 }
353
354 pub fn disabled_matches_with(&self, filter: &SiteFilter) -> Vec<Site> {
359 filter.apply_inner(&self.sites, DisabledMode::Only)
360 }
361
362 pub fn tag_counts(&self) -> Vec<(String, usize)> {
365 let mut counts: std::collections::BTreeMap<String, usize> =
366 std::collections::BTreeMap::new();
367 for site in &self.sites {
368 for tag in &site.tags {
369 *counts.entry(tag.clone()).or_insert(0) += 1;
370 }
371 }
372 counts.into_iter().collect()
373 }
374
375 fn validate(&self) -> Result<()> {
376 if self.sites.is_empty() {
377 return Err(Error::InvalidSite {
378 reason: "registry has no sites".into(),
379 });
380 }
381 for site in &self.sites {
382 site.validate()?;
383 }
384 let mut seen: HashSet<String> = HashSet::new();
385 for site in &self.sites {
386 let key = site.name.to_lowercase();
387 if !seen.insert(key) {
388 return Err(Error::InvalidSite {
389 reason: format!("duplicate site name: {:?}", site.name),
390 });
391 }
392 }
393 let mut seen_url_sig: HashMap<(String, String), &str> = HashMap::new();
409 for site in &self.sites {
410 if site.disabled {
411 continue;
412 }
413 let sigs_key = serde_json::to_string(&site.signals)
420 .expect("Signal derives Serialize and contains no Map<_, _> with non-string keys");
421 let key = (site.url.as_str().to_owned(), sigs_key);
422 if let Some(prev) = seen_url_sig.insert(key, site.name.as_str()) {
423 return Err(Error::InvalidSite {
424 reason: format!(
425 "duplicate (URL, signals) among enabled sites: {:?} and {:?} both back \
426 {:?} with identical signals. Mark one `disabled: true` with \
427 `disabled_reason: \"duplicate of {prev}\"` (or, if the two entries are \
428 supposed to disambiguate via different markers, give each a distinct \
429 signal set).",
430 prev,
431 site.name,
432 site.url.as_str(),
433 ),
434 });
435 }
436 }
437 Ok(())
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 #[test]
446 fn embedded_registry_loads_and_validates() {
447 let registry = Registry::default_embedded().expect("embedded registry must load");
448 assert!(
451 registry.len() >= 100,
452 "imported registry should have ≥100 sites, got {}",
453 registry.len()
454 );
455 let names: Vec<&str> = registry.sites().iter().map(|s| s.name.as_str()).collect();
461 assert!(names.contains(&"GitHub"));
462 assert!(names.contains(&"Reddit"));
463 assert!(names.contains(&"Telegram"));
464 }
465
466 #[test]
467 fn wmn_embedded_registry_loads_and_supersets_default() {
468 let base = Registry::default_embedded().unwrap();
469 let merged = Registry::default_embedded_with_wmn().expect("WMN-merged registry must load");
470 assert!(
471 merged.len() > base.len(),
472 "WMN merge must add sites: base={} merged={}",
473 base.len(),
474 merged.len()
475 );
476 let merged_names: HashSet<String> = merged
479 .sites()
480 .iter()
481 .map(|s| s.name.to_lowercase())
482 .collect();
483 for s in base.sites() {
484 assert!(
485 merged_names.contains(&s.name.to_lowercase()),
486 "merge dropped base-tranche site {:?}",
487 s.name
488 );
489 }
490 let has_wmn_tag = merged
492 .sites()
493 .iter()
494 .any(|s| s.tags.iter().any(|t| t == "source:wmn"));
495 assert!(has_wmn_tag, "no site carries the source:wmn tag");
496 }
497
498 #[test]
499 fn rejects_empty_registry() {
500 let err = Registry::from_json_str(r#"{ "sites": [] }"#).unwrap_err();
501 assert!(matches!(err, Error::InvalidSite { .. }));
502 }
503
504 #[test]
505 fn rejects_duplicate_site_names() {
506 let json = r#"{
507 "sites": [
508 { "name": "GitHub", "url": "https://github.com/{username}",
509 "signals": [{ "kind": "status_found", "codes": [200] }] },
510 { "name": "github", "url": "https://github.com/{username}",
511 "signals": [{ "kind": "status_found", "codes": [200] }] }
512 ]
513 }"#;
514 let err = Registry::from_json_str(json).unwrap_err();
515 assert!(matches!(err, Error::InvalidSite { .. }));
516 assert!(err.to_string().contains("duplicate"));
517 }
518
519 #[test]
520 fn rejects_duplicate_enabled_urls() {
521 let json = r#"{
525 "sites": [
526 { "name": "Hub Code", "url": "https://example.com/{username}",
527 "signals": [{ "kind": "status_found", "codes": [200] }] },
528 { "name": "HubCode", "url": "https://example.com/{username}",
529 "signals": [{ "kind": "status_found", "codes": [200] }] }
530 ]
531 }"#;
532 let err = Registry::from_json_str(json).unwrap_err();
533 assert!(matches!(err, Error::InvalidSite { .. }));
534 let msg = err.to_string();
535 assert!(msg.contains("duplicate (URL, signals)"), "msg: {msg}");
536 assert!(msg.contains("Hub Code"), "msg: {msg}");
537 assert!(msg.contains("HubCode"), "msg: {msg}");
538 }
539
540 #[test]
541 fn allows_duplicate_urls_with_distinct_signals() {
542 let json = r#"{
547 "sites": [
548 { "name": "Site Public", "url": "https://example.com/{username}",
549 "signals": [{ "kind": "status_found", "codes": [200] }] },
550 { "name": "Site Private", "url": "https://example.com/{username}",
551 "signals": [{ "kind": "status_found", "codes": [403] }] }
552 ]
553 }"#;
554 let registry = Registry::from_json_str(json).expect("distinct-signal alias must validate");
555 assert_eq!(registry.len(), 2);
556 }
557
558 #[test]
559 fn allows_duplicate_urls_when_one_side_is_disabled() {
560 let json = r#"{
565 "sites": [
566 { "name": "Hub Code", "url": "https://example.com/{username}",
567 "signals": [{ "kind": "status_found", "codes": [200] }] },
568 { "name": "HubCode", "url": "https://example.com/{username}",
569 "signals": [{ "kind": "status_found", "codes": [200] }],
570 "disabled": true,
571 "disabled_reason": "duplicate of Hub Code" }
572 ]
573 }"#;
574 let registry = Registry::from_json_str(json).expect("dedup pattern must validate");
575 assert_eq!(registry.len(), 2);
576 }
577
578 #[test]
579 fn rejects_invalid_site_definition() {
580 let json = r#"{
582 "sites": [
583 { "name": "Bad", "url": "https://example.com/",
584 "signals": [{ "kind": "status_found", "codes": [200] }] }
585 ]
586 }"#;
587 assert!(Registry::from_json_str(json).is_err());
588 }
589
590 #[test]
591 fn rejects_malformed_json() {
592 let err = Registry::from_json_str("{").unwrap_err();
593 assert!(matches!(err, Error::Json(_)));
594 }
595
596 #[test]
597 fn filter_include_is_case_insensitive_substring() {
598 let registry = Registry::default_embedded().unwrap();
599 let only_github = registry.filter(&["github".into()], &[], &[], &[], false);
600 assert_eq!(only_github.len(), 1);
601 assert_eq!(only_github[0].name, "GitHub");
602
603 let many = registry.filter(&["e".into()], &[], &[], &[], false); assert!(many.len() > 1);
605 }
606
607 #[test]
608 fn filter_exclude_drops_matches() {
609 let registry = Registry::default_embedded().unwrap();
610 let baseline = registry.filter(&[], &[], &[], &[], true);
613 let without_github = registry.filter(&[], &["github".into()], &[], &[], true);
614 assert!(without_github.iter().all(|s| s.name != "GitHub"));
615 assert_eq!(without_github.len(), baseline.len() - 1);
620 }
621
622 #[test]
623 fn filter_include_and_exclude_compose() {
624 let registry = Registry::default_embedded().unwrap();
625 let filtered = registry.filter(&["git".into()], &["lab".into()], &[], &[], false);
627 let names: Vec<&str> = filtered.iter().map(|s| s.name.as_str()).collect();
628 assert!(names.contains(&"GitHub"));
629 assert!(!names.contains(&"GitLab"));
630 }
632
633 #[test]
634 fn filter_with_no_matches_returns_empty() {
635 let registry = Registry::default_embedded().unwrap();
636 let filtered = registry.filter(&["does-not-exist-xyz".into()], &[], &[], &[], false);
637 assert!(filtered.is_empty());
638 }
639
640 #[test]
641 fn disabled_sites_are_skipped_by_filter() {
642 let json = r#"{
643 "sites": [
644 { "name": "Alive", "url": "https://alive.example/{username}",
645 "signals": [{ "kind": "status_found", "codes": [200] }] },
646 { "name": "Parked", "url": "https://parked.example/{username}",
647 "signals": [{ "kind": "status_found", "codes": [200] }],
648 "disabled": true }
649 ]
650 }"#;
651 let registry = Registry::from_json_str(json).unwrap();
652 assert_eq!(registry.sites().len(), 2);
656 let scanned = registry.filter(&[], &[], &[], &[], false);
657 let names: Vec<&str> = scanned.iter().map(|s| s.name.as_str()).collect();
658 assert_eq!(names, vec!["Alive"]);
659 }
660
661 #[test]
662 fn disabled_matches_with_explains_parked_filter_hits() {
663 let json = r#"{
664 "sites": [
665 { "name": "Alive", "url": "https://alive.example/{username}",
666 "signals": [{ "kind": "status_found", "codes": [200] }] },
667 { "name": "Parked Social", "url": "https://parked.example/@{username}",
668 "signals": [{ "kind": "status_found", "codes": [200] }],
669 "disabled": true,
670 "disabled_reason": "Honest Limits: parked",
671 "tags": ["social"] }
672 ]
673 }"#;
674 let registry = Registry::from_json_str(json).unwrap();
675 let filter = SiteFilter {
676 include: vec!["parked".into()],
677 tags: vec!["social".into()],
678 ..SiteFilter::default()
679 };
680
681 assert!(registry.filter_with(&filter).is_empty());
682 let disabled = registry.disabled_matches_with(&filter);
683 assert_eq!(disabled.len(), 1);
684 assert_eq!(disabled[0].name, "Parked Social");
685 assert_eq!(
686 disabled[0].disabled_reason.as_deref(),
687 Some("Honest Limits: parked")
688 );
689 }
690
691 #[test]
692 fn threads_stays_parked_behind_login_wall() {
693 let registry = Registry::default_embedded().unwrap();
694 let threads = registry
695 .sites()
696 .iter()
697 .find(|s| s.name == "Threads")
698 .expect("Threads entry should document the login-wall limitation");
699
700 assert!(threads.disabled, "Threads must not be probed by default");
701 let reason = threads
702 .disabled_reason
703 .as_deref()
704 .expect("disabled Threads entry should explain why it is parked");
705 assert!(
706 reason.contains("Honest Limits") && reason.contains("indistinguishable"),
707 "unexpected Threads disabled_reason: {reason}"
708 );
709
710 let scanned = registry.filter(&["threads".into()], &[], &[], &[], true);
711 assert!(
712 scanned.is_empty(),
713 "disabled Threads entry must not leak into scan filters"
714 );
715 }
716
717 #[test]
718 fn reddit_uses_oauth_endpoint_and_requires_session() {
719 let registry = Registry::default_embedded_with_wmn().unwrap();
720 let reddit_entries: Vec<&Site> = registry
721 .sites()
722 .iter()
723 .filter(|s| s.name == "Reddit")
724 .collect();
725
726 assert_eq!(
727 reddit_entries.len(),
728 1,
729 "WMN merge must not reintroduce a second Reddit probe"
730 );
731 let reddit = reddit_entries[0];
732 assert!(!reddit.disabled, "Reddit OAuth probe should remain enabled");
733 assert_eq!(
734 reddit.url.as_str(),
735 "https://oauth.reddit.com/user/{username}/about"
736 );
737 assert_eq!(reddit.access.session.as_deref(), Some("reddit"));
738 assert!(
739 reddit
740 .protection
741 .iter()
742 .any(|p| matches!(p, super::super::site::ProtectionKind::UserAuth)),
743 "Reddit should be classified as requiring user auth"
744 );
745 assert!(
746 reddit.signals.iter().any(|signal| matches!(
747 signal,
748 super::super::site::Signal::JsonUsername { pointer }
749 if pointer == "/data/name"
750 )),
751 "Reddit should expose exact username evidence from OAuth JSON"
752 );
753 assert!(
754 reddit.tags.iter().any(|t| t == "reddit-oauth"),
755 "Reddit should be discoverable as an OAuth-gated site"
756 );
757 assert!(
758 reddit
759 .tags
760 .iter()
761 .all(|t| !t.eq_ignore_ascii_case("bot-protected")),
762 "Reddit OAuth should use HTTP session headers, not browser routing"
763 );
764
765 let scanned = registry.filter(&["reddit".into()], &[], &[], &[], true);
766 assert_eq!(
767 scanned.iter().filter(|s| s.name == "Reddit").count(),
768 1,
769 "enabled Reddit OAuth entry should be scan-filterable"
770 );
771 }
772
773 #[test]
774 fn tiktok_uses_public_oembed_endpoint() {
775 let registry = Registry::default_embedded_with_wmn().unwrap();
776 let tiktok_entries: Vec<&Site> = registry
777 .sites()
778 .iter()
779 .filter(|s| s.name == "TikTok")
780 .collect();
781
782 assert_eq!(
783 tiktok_entries.len(),
784 1,
785 "WMN merge must keep one TikTok probe"
786 );
787 let tiktok = tiktok_entries[0];
788 assert!(
789 !tiktok.disabled,
790 "TikTok's public oEmbed endpoint is probeable by default"
791 );
792 assert!(
793 tiktok
794 .url
795 .as_str()
796 .contains("/oembed?url=https://www.tiktok.com/@{username}"),
797 "TikTok should use the public oEmbed endpoint, not the JS-only profile shell"
798 );
799 assert!(
800 tiktok.signals.iter().any(|signal| matches!(
801 signal,
802 super::super::site::Signal::BodyUsername { text }
803 if text == "\"embed_product_id\":\"{username}\""
804 )),
805 "TikTok should expose exact username evidence from oEmbed"
806 );
807 assert!(
808 tiktok.protection.is_empty(),
809 "oEmbed path should not require browser/captcha routing"
810 );
811
812 let scanned = registry.filter(&["tiktok".into()], &[], &[], &[], true);
813 assert!(
814 scanned.iter().any(|s| s.name == "TikTok"),
815 "enabled TikTok oEmbed entry should be scan-filterable"
816 );
817 }
818
819 #[test]
820 fn pinterest_uses_oembed_instead_of_js_shell() {
821 let registry = Registry::default_embedded_with_wmn().unwrap();
822 let pinterest_entries: Vec<&Site> = registry
823 .sites()
824 .iter()
825 .filter(|s| s.name == "Pinterest")
826 .collect();
827
828 assert_eq!(
829 pinterest_entries.len(),
830 1,
831 "WMN merge must not reintroduce Pinterest's canonical JS shell"
832 );
833 let pinterest = pinterest_entries[0];
834 assert!(
835 !pinterest.disabled,
836 "Pinterest oEmbed probe should remain enabled"
837 );
838 assert!(
839 pinterest.url.as_str().contains("/oembed.json"),
840 "Pinterest should use the oEmbed endpoint, got {}",
841 pinterest.url.as_str()
842 );
843 assert!(
844 pinterest.url.as_str() != "https://www.pinterest.com/{username}/",
845 "Pinterest must not fall back to the canonical JS shell"
846 );
847 assert!(
848 pinterest.signals.iter().any(|signal| matches!(
849 signal,
850 super::super::site::Signal::BodyUsername { text }
851 if text == "\"author_url\":\"https://www.pinterest.com/{username}/\""
852 )),
853 "Pinterest should expose exact username evidence from oEmbed"
854 );
855
856 let scanned = registry.filter(&["pinterest".into()], &[], &[], &[], true);
857 assert_eq!(
858 scanned.iter().filter(|s| s.name == "Pinterest").count(),
859 1,
860 "enabled Pinterest oEmbed entry should be scan-filterable"
861 );
862 }
863
864 #[test]
865 fn source_field_round_trips() {
866 let json = r#"{
867 "sites": [
868 { "name": "Nitter", "url": "https://nitter.example/{username}",
869 "signals": [{ "kind": "status_found", "codes": [200] }],
870 "source": "Twitter" }
871 ]
872 }"#;
873 let registry = Registry::from_json_str(json).unwrap();
874 assert_eq!(registry.sites()[0].source.as_deref(), Some("Twitter"));
875 }
876
877 fn tagged_registry() -> Registry {
878 let json = r#"{
879 "sites": [
880 { "name": "Soc", "url": "https://soc.example/{username}",
881 "signals": [{ "kind": "status_found", "codes": [200] }],
882 "tags": ["social", "region:ru"] },
883 { "name": "Dev", "url": "https://dev.example/{username}",
884 "signals": [{ "kind": "status_found", "codes": [200] }],
885 "tags": ["dev"] },
886 { "name": "Plain", "url": "https://plain.example/{username}",
887 "signals": [{ "kind": "status_found", "codes": [200] }] }
888 ]
889 }"#;
890 Registry::from_json_str(json).unwrap()
891 }
892
893 #[test]
894 fn tag_filter_keeps_only_matching_tags_and_drops_untagged() {
895 let r = tagged_registry();
896 let social = r.filter(&[], &[], &["social".into()], &[], false);
897 let names: Vec<&str> = social.iter().map(|s| s.name.as_str()).collect();
898 assert_eq!(names, ["Soc"], "tag filter should keep only tagged matches");
899 }
900
901 #[test]
902 fn tag_filter_is_or_within_requested_tags_and_case_insensitive() {
903 let r = tagged_registry();
904 let either = r.filter(&[], &[], &["DEV".into(), "social".into()], &[], false);
905 let names: Vec<&str> = either.iter().map(|s| s.name.as_str()).collect();
906 assert_eq!(names, ["Soc", "Dev"]);
907 }
908
909 #[test]
910 fn no_tag_filter_includes_untagged_sites() {
911 let r = tagged_registry();
912 assert_eq!(r.filter(&[], &[], &[], &[], false).len(), 3);
913 }
914
915 #[test]
916 fn exclude_tag_drops_matching_sites() {
917 let r = tagged_registry();
918 let kept = r.filter(&[], &[], &[], &["social".into()], false);
919 let names: Vec<&str> = kept.iter().map(|s| s.name.as_str()).collect();
920 assert_eq!(names, ["Dev", "Plain"], "{names:?}");
922 }
923
924 fn nsfw_registry() -> Registry {
925 let json = r#"{
926 "sites": [
927 { "name": "Family", "url": "https://family.example/{username}",
928 "signals": [{ "kind": "status_found", "codes": [200] }],
929 "tags": ["social"] },
930 { "name": "Adult", "url": "https://adult.example/{username}",
931 "signals": [{ "kind": "status_found", "codes": [200] }],
932 "tags": ["nsfw"] }
933 ]
934 }"#;
935 Registry::from_json_str(json).unwrap()
936 }
937
938 #[test]
939 fn nsfw_sites_excluded_by_default() {
940 let r = nsfw_registry();
941 let kept = r.filter(&[], &[], &[], &[], false);
942 let names: Vec<&str> = kept.iter().map(|s| s.name.as_str()).collect();
943 assert_eq!(names, ["Family"], "nsfw site must be excluded by default");
944 }
945
946 #[test]
947 fn nsfw_sites_included_when_flag_set() {
948 let r = nsfw_registry();
949 let kept = r.filter(&[], &[], &[], &[], true);
950 assert_eq!(kept.len(), 2, "both sites present with include_nsfw=true");
951 }
952
953 #[test]
954 fn nsfw_sites_included_when_tag_asked_for_explicitly() {
955 let r = nsfw_registry();
958 let kept = r.filter(&[], &[], &["nsfw".into()], &[], false);
959 let names: Vec<&str> = kept.iter().map(|s| s.name.as_str()).collect();
960 assert_eq!(names, ["Adult"]);
961 }
962
963 #[test]
964 fn tag_counts_are_sorted_with_per_tag_totals() {
965 let r = tagged_registry();
966 assert_eq!(
967 r.tag_counts(),
968 vec![
969 ("dev".to_owned(), 1),
970 ("region:ru".to_owned(), 1),
971 ("social".to_owned(), 1),
972 ]
973 );
974 }
975
976 #[test]
977 fn engine_inheritance_fills_empty_site_signals() {
978 let json = r#"{
980 "engines": {
981 "Discourse": {
982 "signals": [
983 { "kind": "status_found", "codes": [200] },
984 { "kind": "body_absent", "text": "Oops! That page doesn't exist" }
985 ]
986 }
987 },
988 "sites": [
989 { "name": "Mozilla Forum", "url": "https://discourse.mozilla.org/u/{username}",
990 "engine": "Discourse" }
991 ]
992 }"#;
993 let r = Registry::from_json_str(json).unwrap();
994 let site = &r.sites()[0];
995 assert_eq!(site.signals.len(), 2);
996 assert_eq!(site.engine.as_deref(), Some("Discourse"));
997 assert!(r.engines().contains_key("Discourse"));
999 }
1000
1001 #[test]
1002 fn site_overrides_engine_signals_on_conflict() {
1003 let json = r#"{
1005 "engines": {
1006 "Discourse": {
1007 "signals": [{ "kind": "status_found", "codes": [200] }]
1008 }
1009 },
1010 "sites": [
1011 { "name": "Custom", "url": "https://example.com/{username}",
1012 "engine": "Discourse",
1013 "signals": [
1014 { "kind": "status_found", "codes": [200] },
1015 { "kind": "status_not_found", "codes": [404] }
1016 ] }
1017 ]
1018 }"#;
1019 let r = Registry::from_json_str(json).unwrap();
1020 assert_eq!(r.sites()[0].signals.len(), 2);
1022 }
1023
1024 #[test]
1025 fn engine_headers_merge_with_site_headers_per_key() {
1026 let json = r#"{
1029 "engines": {
1030 "Foo": {
1031 "signals": [{ "kind": "status_found", "codes": [200] }],
1032 "request_headers": {
1033 "X-Engine": "engine-value",
1034 "User-Agent": "engine-ua"
1035 }
1036 }
1037 },
1038 "sites": [
1039 { "name": "S", "url": "https://example.com/{username}",
1040 "engine": "Foo",
1041 "request_headers": { "User-Agent": "site-ua" } }
1042 ]
1043 }"#;
1044 let r = Registry::from_json_str(json).unwrap();
1045 let h = &r.sites()[0].request_headers;
1046 assert_eq!(h.get("X-Engine").map(String::as_str), Some("engine-value"));
1047 assert_eq!(h.get("User-Agent").map(String::as_str), Some("site-ua"));
1048 }
1049
1050 #[test]
1051 fn missing_engine_reference_fails_load() {
1052 let json = r#"{
1053 "engines": {},
1054 "sites": [
1055 { "name": "Mock", "url": "https://example.com/{username}",
1056 "engine": "DoesNotExist" }
1057 ]
1058 }"#;
1059 let err = Registry::from_json_str(json).unwrap_err();
1060 assert!(
1061 err.to_string()
1062 .contains("references engine \"DoesNotExist\""),
1063 "expected missing-engine error, got: {err}"
1064 );
1065 }
1066
1067 #[test]
1068 fn engine_regex_check_inherited_when_site_has_none() {
1069 let json = r#"{
1070 "engines": {
1071 "Bounded": {
1072 "signals": [{ "kind": "status_found", "codes": [200] }],
1073 "regex_check": "^[a-z]{3,16}$"
1074 }
1075 },
1076 "sites": [
1077 { "name": "S", "url": "https://example.com/{username}",
1078 "engine": "Bounded" }
1079 ]
1080 }"#;
1081 let r = Registry::from_json_str(json).unwrap();
1082 assert_eq!(r.sites()[0].regex_check.as_deref(), Some("^[a-z]{3,16}$"));
1083 }
1084
1085 #[test]
1086 fn region_tag_auto_populates_prefer_geo() {
1087 let json = r#"{
1088 "sites": [
1089 { "name": "vk.com", "url": "https://vk.com/{username}",
1090 "signals": [{ "kind": "status_found", "codes": [200] }],
1091 "tags": ["region:ru", "social"] }
1092 ]
1093 }"#;
1094 let r = Registry::from_json_str(json).unwrap();
1095 let prefer = &r.sites()[0].access.prefer_geo;
1096 assert_eq!(prefer.len(), 1);
1097 assert_eq!(prefer[0].as_str(), "ru");
1098 assert!(r.sites()[0].access.geo.is_empty());
1100 }
1101
1102 #[test]
1103 fn multiple_region_tags_stack() {
1104 let json = r#"{
1105 "sites": [
1106 { "name": "Pan-Slavic", "url": "https://example.test/{username}",
1107 "signals": [{ "kind": "status_found", "codes": [200] }],
1108 "tags": ["region:ru", "region:by", "region:ua"] }
1109 ]
1110 }"#;
1111 let r = Registry::from_json_str(json).unwrap();
1112 let codes: Vec<&str> = r.sites()[0]
1113 .access
1114 .prefer_geo
1115 .iter()
1116 .map(super::super::access::CountryCode::as_str)
1117 .collect();
1118 assert_eq!(codes, vec!["ru", "by", "ua"]);
1119 }
1120
1121 #[test]
1122 fn explicit_hard_geo_suppresses_tag_derived_soft() {
1123 let json = r#"{
1128 "sites": [
1129 { "name": "PL-only", "url": "https://example.test/{username}",
1130 "signals": [{ "kind": "status_found", "codes": [200] }],
1131 "tags": ["region:ru"],
1132 "access": { "geo": ["pl"] } }
1133 ]
1134 }"#;
1135 let r = Registry::from_json_str(json).unwrap();
1136 assert_eq!(r.sites()[0].access.geo[0].as_str(), "pl");
1137 assert!(r.sites()[0].access.prefer_geo.is_empty());
1138 }
1139
1140 #[test]
1141 fn malformed_region_tag_is_ignored() {
1142 let json = r#"{
1146 "sites": [
1147 { "name": "Weird", "url": "https://example.test/{username}",
1148 "signals": [{ "kind": "status_found", "codes": [200] }],
1149 "tags": ["region:eurasia", "region:r", "region:RU"] }
1150 ]
1151 }"#;
1152 let r = Registry::from_json_str(json).unwrap();
1153 let codes: Vec<&str> = r.sites()[0]
1155 .access
1156 .prefer_geo
1157 .iter()
1158 .map(super::super::access::CountryCode::as_str)
1159 .collect();
1160 assert_eq!(codes, vec!["ru"]);
1161 }
1162
1163 #[test]
1164 fn load_from_path_round_trips_via_tempfile() {
1165 let mut path = std::env::temp_dir();
1166 path.push(format!("adler-test-registry-{}.json", std::process::id()));
1167 std::fs::write(
1168 &path,
1169 r#"{
1170 "sites": [
1171 { "name": "Mock", "url": "https://example.com/{username}",
1172 "signals": [{ "kind": "status_found", "codes": [200] }] }
1173 ]
1174 }"#,
1175 )
1176 .unwrap();
1177 let result = Registry::load_from_path(&path);
1178 let _ = std::fs::remove_file(&path);
1179 let registry = result.unwrap();
1180 assert_eq!(registry.len(), 1);
1181 assert_eq!(registry.sites()[0].name, "Mock");
1182 }
1183}