Skip to main content

adler_core/
registry.rs

1//! Site registry — loading, validation, filtering.
2//!
3//! The default registry is embedded into the binary at compile time via
4//! [`include_str!`]. Callers can override it with a file at runtime through
5//! [`Registry::load_from_path`].
6
7use 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
17/// Supplementary registry derived from the `WhatsMyName` project
18/// (`WebBreacher/WhatsMyName`, CC BY-SA 4.0). Kept as a separate
19/// constant because its data license is incompatible with the
20/// MIT-only [`EMBEDDED_REGISTRY`] above; callers opt in explicitly
21/// via [`Registry::default_embedded_with_wmn`] to keep the default
22/// MIT-clean for downstream redistribution.
23const EMBEDDED_WMN_REGISTRY: &str = include_str!("../data/sites_wmn.json");
24
25/// A loaded, validated collection of site definitions.
26///
27/// Engines (shared signature templates referenced by [`Site::engine`])
28/// are resolved into sites at load time — by the time you call
29/// [`Registry::sites`] every entry already has its inherited
30/// `signals` / `request_headers` / `regex_check` materialised. The original
31/// [`Engine`] objects are kept on the registry for re-export and
32/// inspection via [`Registry::engines`].
33#[derive(Debug, Clone, Deserialize)]
34pub struct Registry {
35    #[serde(default)]
36    engines: BTreeMap<String, Engine>,
37    sites: Vec<Site>,
38}
39
40/// Reusable site-filter specification shared by CLI, server, and MCP surfaces.
41///
42/// Filtering semantics match [`Registry::filter`]: name include/exclude terms
43/// are case-insensitive substrings, tag filters are case-insensitive exact
44/// matches, disabled sites are always skipped, and `nsfw` sites are hidden
45/// unless [`include_nsfw`](Self::include_nsfw) is true or the `nsfw` tag is
46/// requested explicitly.
47#[derive(Debug, Clone, Default)]
48pub struct SiteFilter {
49    /// Only keep sites whose name contains at least one term. Empty = no
50    /// include filter.
51    pub include: Vec<String>,
52    /// Drop sites whose name contains any term.
53    pub exclude: Vec<String>,
54    /// Only keep sites carrying at least one requested tag. Empty = no tag
55    /// include filter.
56    pub tags: Vec<String>,
57    /// Drop sites carrying any of these tags.
58    pub exclude_tags: Vec<String>,
59    /// Include sites tagged `nsfw`.
60    pub include_nsfw: bool,
61    /// Optional popularity-rank ceiling (`popularity <= top`). Sites
62    /// without a popularity rank are dropped when this is set; returned
63    /// sites are sorted by rank.
64    pub top: Option<u32>,
65}
66
67impl SiteFilter {
68    /// Apply this filter to a site slice, returning cloned sites in scan order
69    /// (or popularity order when [`top`](Self::top) is set).
70    pub fn apply(&self, sites: &[Site]) -> Vec<Site> {
71        self.apply_inner(sites, DisabledMode::Exclude)
72    }
73
74    /// Apply this filter to a site slice without dropping disabled entries.
75    /// Useful for catalogue/diagnostic surfaces that need to explain parked
76    /// sites while scan paths continue to call [`apply`](Self::apply).
77    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        // NSFW gate: auto-exclude unless the caller explicitly opted in,
89        // either via `include_nsfw` or by asking for the `nsfw` tag.
90        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                        // Disabled sites are skipped unconditionally in
102                        // the scan view — the bool is meant for parking
103                        // known-broken entries with a reason comment
104                        // instead of deleting them.
105                        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    /// Load the default site list embedded into the crate at build time.
139    pub fn default_embedded() -> Result<Self> {
140        Self::from_json_str(EMBEDDED_REGISTRY)
141    }
142
143    /// Load the default site list *plus* the `WhatsMyName`-derived
144    /// supplementary set. `WhatsMyName` data is licensed CC BY-SA 4.0
145    /// (see `LICENSE-CC-BY-SA-4.0` at the repo root); enabling this
146    /// path means downstream redistribution of the merged scan data
147    /// must respect the `ShareAlike` obligation. Sites contributed by
148    /// the `WhatsMyName` tranche carry the `source:wmn` tag for
149    /// provenance.
150    ///
151    /// Engines from the WMN tranche merge with the MIT tranche;
152    /// case-insensitive site-name collisions resolve in favour of the
153    /// MIT-tranche entry (the hand-curated Sherlock/Maigret-derived
154    /// signature wins; the WMN duplicate is dropped). Returns an
155    /// error only if either tranche fails its own validation —
156    /// engine references are checked across the merged set.
157    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        // URL-claim only counts enabled base entries — the dedup pattern
163        // keeps disabled siblings at the canonical's URL, and a WMN
164        // entry colliding with one of *those* is no worse than colliding
165        // with the canonical.
166        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                // Base already has an enabled site at this URL; WMN's
181                // version would just produce a doubled probe, and
182                // validate() would refuse the merged registry. Drop the
183                // WMN entry; base canonical wins (same precedence rule
184                // we apply for name collisions).
185                continue;
186            }
187            base.sites.push(site);
188        }
189        base.resolve_engines()?;
190        base.validate()?;
191        Ok(base)
192    }
193
194    /// Parse and validate a registry from a JSON string. Engine
195    /// references on each site are resolved before validation;
196    /// a site that names an engine which doesn't exist in the
197    /// `engines` block fails loading with [`Error::InvalidSite`].
198    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    /// Inheritable engine templates, keyed by name. Useful for
207    /// introspection and for serialising the registry back out;
208    /// detection paths read the resolved fields off the sites
209    /// directly and don't need to consult this map.
210    pub fn engines(&self) -> &BTreeMap<String, Engine> {
211        &self.engines
212    }
213
214    /// Walk every site's tags for `region:XX` markers and fill
215    /// [`AccessPolicy::prefer_geo`](crate::AccessPolicy::prefer_geo)
216    /// with the matching country codes. **Soft** routing only — a
217    /// site declaring `region:ru` *prefers* a Russian egress when one
218    /// is configured but still works from anywhere else; the router
219    /// falls back to the default egress on no match rather than
220    /// reporting `Uncertain(GeoUnavailable)`.
221    ///
222    /// Skips sites that already declare a hard
223    /// [`AccessPolicy::geo`](crate::AccessPolicy::geo) — explicit
224    /// policy wins on conflict, same convention as engine inheritance.
225    /// Existing `prefer_geo` entries are also preserved: tag-derived
226    /// codes are *added*, not replaced, so a hand-tuned policy can
227    /// stack on top of the tag. Invalid country codes (`region:xx` is
228    /// not exactly two ASCII letters) are silently skipped — they
229    /// shouldn't exist in the registry, and a parse error here would
230    /// break the load for a tag the scanner already ignores in
231    /// every other context.
232    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    /// Merge each engine's inheritable fields into the sites that
252    /// reference it. After this call every site's `signals`,
253    /// `request_headers` and `regex_check` reflect the effective
254    /// values used by the scanner.
255    ///
256    /// Per-site fields are authoritative: anything declared
257    /// explicitly on a site wins on conflict; only empty / unset
258    /// fields are filled from the engine.
259    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    /// Read a registry from a JSON file.
281    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    /// Borrow all sites in load order.
290    pub fn sites(&self) -> &[Site] {
291        &self.sites
292    }
293
294    /// Number of sites.
295    pub fn len(&self) -> usize {
296        self.sites.len()
297    }
298
299    /// True if the registry has no sites (always false for a valid load,
300    /// since we'd already have rejected it).
301    pub fn is_empty(&self) -> bool {
302        self.sites.is_empty()
303    }
304
305    /// Apply include/exclude name filters and a tag filter.
306    ///
307    /// - If `include` is non-empty, only sites whose name contains at least
308    ///   one include term (case-insensitive substring) are kept.
309    /// - Sites whose name contains any exclude term are dropped.
310    /// - If `tags` is non-empty, only sites carrying at least one of the
311    ///   requested tags are kept (case-insensitive). A site with no tags is
312    ///   therefore dropped by a tag filter — asking for `--tag social` means
313    ///   "only social-tagged sites".
314    /// - Sites carrying any tag in `exclude_tags` are dropped (e.g.
315    ///   `--exclude-tag bot-protected` for a fast clean run).
316    /// - **NSFW sites are auto-excluded** (the `nsfw` tag) unless
317    ///   `include_nsfw` is `true` or `tags` explicitly asks for `nsfw`.
318    ///   This matches Sherlock's `--nsfw` opt-in pattern and prevents
319    ///   the default `adler <username>` from surfacing adult-site URLs
320    ///   the user didn't ask for.
321    /// - Sites are returned by value (cloned) so the result is independent
322    ///   of the registry's lifetime — convenient for handing to the executor.
323    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    /// Apply a reusable [`SiteFilter`] to this registry.
342    pub fn filter_with(&self, filter: &SiteFilter) -> Vec<Site> {
343        filter.apply(&self.sites)
344    }
345
346    /// Apply a reusable [`SiteFilter`] without dropping disabled entries.
347    /// This is the catalogue view: scans still call
348    /// [`filter_with`](Self::filter_with), while UI/agent surfaces can keep
349    /// parked entries visible with their reasons.
350    pub fn matches_with(&self, filter: &SiteFilter) -> Vec<Site> {
351        filter.apply_including_disabled(&self.sites)
352    }
353
354    /// Apply a reusable [`SiteFilter`] but return only disabled/parked
355    /// entries. This is for diagnostics: scan surfaces still call
356    /// [`filter_with`](Self::filter_with), while CLIs and UIs can use this
357    /// to explain why an otherwise matching site is unavailable.
358    pub fn disabled_matches_with(&self, filter: &SiteFilter) -> Vec<Site> {
359        filter.apply_inner(&self.sites, DisabledMode::Only)
360    }
361
362    /// Distinct tags across all sites, sorted, with the count of sites
363    /// carrying each. Powers `--list-tags`.
364    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        // (URL, signals) uniqueness among ENABLED sites: each
394        // (URL template, signal-set) pair should back exactly one live
395        // entry. Disabled entries can legitimately share URLs with
396        // their canonicals — that's how the `duplicate of <canonical>`
397        // dedup pattern works. A second enabled hit at the same URL
398        // *and* the same signal array is almost always an importer
399        // re-introducing a known duplicate
400        // (Sherlock/Maigret/WhatsMyName each name the same site
401        // slightly differently); the doctor would otherwise
402        // double-probe the URL for an identical verdict.
403        //
404        // Same URL with *distinct* signals is the legitimate-alias
405        // shape — WordPress.com (Public/Private/Deleted) hit the same
406        // API endpoint and disambiguate via their `body_present`
407        // marker, which the doctor reads as three independent verdicts.
408        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            // `serde_json` for the signal key gives a canonical
414            // serialisation that doesn't depend on field-order or
415            // `Debug` formatting, both of which could shift between
416            // Rust releases or after a `#[derive(Debug)]` rearrange.
417            // serde_json is already a workspace dep; the cost is
418            // ~one allocation per enabled site at load time.
419            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        // The registry is imported from Sherlock (~450 sites); a floor well
449        // above the old hand-written 15 guards against accidental truncation.
450        assert!(
451            registry.len() >= 100,
452            "imported registry should have ≥100 sites, got {}",
453            registry.len()
454        );
455        // Spot-check a couple of well-known entries. (HackerNews used
456        // to be here but was pruned 2026-05-26 — its Sherlock-side
457        // known_present went stale and the imported signature
458        // doctor-failed; can be restored via OVERRIDES in
459        // import_sherlock.py with a working account.)
460        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        // Every base-tranche name survives the merge; case-insensitive
477        // collisions resolve in favour of the MIT-tranche entry.
478        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        // At least one WMN-only site carries the provenance tag.
491        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        // Two enabled sites at the same URL is almost always an importer
522        // re-introducing a known duplicate. Reject at load time with a
523        // message naming both entries.
524        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        // Same URL, distinct signal sets — this is the legitimate-alias
543        // shape (e.g. WordPress.com (Public/Private/Deleted) hit one
544        // endpoint and disambiguate via the body marker). Must NOT
545        // trigger the URL-uniqueness rule.
546        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        // The dedup pattern that the v0.14 hygiene pass established:
561        // canonical stays enabled, the surplus entry gets
562        // `disabled: true` + `disabled_reason: "duplicate of <canonical>"`.
563        // This shape must continue loading cleanly.
564        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        // Missing {username} placeholder.
581        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); // matches anything with "e"
604        assert!(many.len() > 1);
605    }
606
607    #[test]
608    fn filter_exclude_drops_matches() {
609        let registry = Registry::default_embedded().unwrap();
610        // Include NSFW to keep the test focused on the name-exclude
611        // path; the NSFW auto-exclusion is exercised separately.
612        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        // Asserting against the baseline (filtered) count rather than
616        // `registry.len()` so this test is robust to changes in the
617        // disabled-site count — `len()` includes disabled entries,
618        // `filter()` does not.
619        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        // Include "git", then exclude "lab" → keep GitHub, drop GitLab.
626        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        // Exclude wins over include for sites containing both terms (none here).
631    }
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        // sites() returns everything including disabled — it's the
653        // serialisation view. filter() is the scan view and drops
654        // disabled entries.
655        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        // Soc carries "social" → dropped; Dev and untagged Plain remain.
921        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        // `--tag nsfw` is an explicit opt-in; should bypass the default
956        // auto-exclusion even with include_nsfw=false.
957        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        // Site has no `signals` block — should inherit the engine's.
979        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        // engines map preserved
998        assert!(r.engines().contains_key("Discourse"));
999    }
1000
1001    #[test]
1002    fn site_overrides_engine_signals_on_conflict() {
1003        // Site declares its own `signals` — engine's must NOT replace them.
1004        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        // The site-declared 2 signals win over the engine's 1 signal.
1021        assert_eq!(r.sites()[0].signals.len(), 2);
1022    }
1023
1024    #[test]
1025    fn engine_headers_merge_with_site_headers_per_key() {
1026        // Engine declares one header; site declares another. Resolved
1027        // site should carry both. On per-key conflict the site wins.
1028        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        // Hard geo stays empty — the tag is soft.
1099        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        // A site with hard `access.geo = ["pl"]` AND a `region:ru` tag:
1124        // the explicit hard policy wins, prefer_geo stays empty.
1125        // Otherwise tag-derived soft would silently re-route a probe
1126        // that the maintainer deliberately pinned to PL.
1127        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        // `region:` followed by something that isn't a 2-letter code:
1143        // skip it silently rather than reject the whole load. The tag
1144        // already had no routing semantics in older versions.
1145        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        // Only the valid 2-letter "RU" survives (lowercased to "ru").
1154        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}