use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use anyhow::Context as _;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::atproto::{ChainState, SettledRepo, short_hash};
use crate::cas::{atomic_write, link_or_reflink};
use crate::config::ValidatedConfig;
use crate::nginx::conf::Block;
use super::{ATPROTO_DIR, Refused, empty, map, map_with_default, reroute, spellings};
mod identity;
mod repo;
mod ticket;
pub(crate) const MAPS_FILE: &str = "63_social.conf";
pub(crate) const AVATARS_DIR: &str = "avatars";
pub(crate) const AVATARS_WWW: &str = "www/_avatars";
const AVATAR_MIMES: [&str; 3] = ["image/png", "image/jpeg", "image/webp"];
/// the square webps an avatar is also kept at, smallest first
pub(crate) const AVATAR_SIZES: [u32; 3] = [42, 96, 192];
/// the original is served only up to the lexicon's avatar size, else the biggest webp stands in
pub(crate) const AVATAR_ORIGINAL_MAX: u64 = 1_000_000;
const DIR: &str = "social";
const INVALID_HANDLE: &str = "handle.invalid";
/// the accounts the social answers name, which `sync_handles` verifies handles for
pub(crate) const ACCOUNTS_FILE: &str = "accounts.json";
const MANIFEST: &str = "org.tangled.repo.manifest";
const GIT_REFS: &str = "org.tangled.git.refs";
const TICKET: &str = "org.tangled.track.ticket";
const COMMENT: &str = "org.tangled.feed.comment";
const REACTION: &str = "org.tangled.feed.reaction";
const PATCH: &str = "org.tangled.review.patch";
const LABEL: &str = "org.tangled.label.definition";
const ARTIFACT: &str = "org.tangled.repo.artifact";
const COLLABORATOR: &str = knot_record::grant::COLLABORATOR_COLLECTION;
const COLLECTIONS: [&str; 9] = [
MANIFEST,
GIT_REFS,
TICKET,
COMMENT,
REACTION,
PATCH,
LABEL,
ARTIFACT,
COLLABORATOR,
];
#[derive(Default)]
pub struct Social {
pub hostname: String,
pub owner: Option<String>,
pub handles: BTreeMap<String, String>,
pub avatars: BTreeMap<String, Avatar>,
pub avatars_dir: Option<PathBuf>,
/// org.tangled.actor.profile records, without their avatar
pub profiles: BTreeMap<String, serde_json::Map<String, Value>>,
pub handles_digest: String,
}
/// the avatar served without a size: the original, or the biggest webp when the original is too big
pub struct Avatar {
pub cid: String,
pub ext: String,
pub size: u64,
/// the account's avatar cid, which names the cached files
pub source: String,
pub sized: bool,
}
pub(crate) fn sized_avatar(source: &str, size: u32) -> String {
format!("{source}-{size}.webp")
}
impl Avatar {
pub(crate) fn file(&self) -> String {
format!("{}.{}", self.cid, self.ext)
}
/// what the knot keeps of it
fn cached(&self) -> String {
match self.cid == self.source {
true => self.file(),
false => sized_avatar(&self.source, AVATAR_SIZES[AVATAR_SIZES.len() - 1]),
}
}
pub(crate) fn of(avatars: &Path, source: &str, ext: &str) -> Option<Self> {
let sized = AVATAR_SIZES
.iter()
.all(|&size| avatars.join(sized_avatar(source, size)).is_file());
let original = avatars.join(format!("{source}.{ext}"));
if let Ok(meta) = std::fs::metadata(&original) {
return Some(Self {
cid: source.to_string(),
ext: ext.to_string(),
size: meta.len(),
source: source.to_string(),
sized,
});
}
let biggest = std::fs::read(avatars.join(sized_avatar(source, AVATAR_SIZES[2])))
.ok()
.filter(|_| sized)?;
let cid = knot_types::BlobCid::from_digest(knot_types::Sha256Digest::hash(&biggest));
Some(Self {
cid: cid.to_string(),
ext: "webp".into(),
size: biggest.len() as u64,
source: source.to_string(),
sized,
})
}
fn blob(&self) -> Value {
let mime = avatar_types().find(|(_, ext)| *ext == self.ext);
json!({
"$type": "blob",
"ref": { "$link": self.cid },
"mimeType": mime.map_or("", |(mime, _)| mime),
"size": self.size,
})
}
}
pub(crate) fn avatar_types() -> impl Iterator<Item = (&'static str, &'static str)> + Clone {
crate::blobs::SERVED
.into_iter()
.filter(|(mime, _)| AVATAR_MIMES.contains(mime))
}
impl Social {
pub fn of(config: &ValidatedConfig) -> Self {
let stored = crate::index_page::cached_handles(&config.output_root);
let avatars_dir = config.output_root.join(AVATARS_DIR);
let handles_digest = crate::index_page::served_digest(&stored, &avatars_dir);
let entries = stored.entries;
let avatars = entries
.iter()
.filter_map(|(did, entry)| {
let avatar = Avatar::of(
&avatars_dir,
entry.avatar_cid.as_deref()?,
entry.avatar_ext.as_deref()?,
)?;
Some((did.clone(), avatar))
})
.collect();
let profiles = entries
.iter()
.filter_map(|(did, entry)| Some((did.clone(), entry.profile.clone()?)))
.collect();
Self {
hostname: config.hostname.as_str().to_string(),
owner: Some(config.owner.as_str().to_string()),
handles: entries
.into_iter()
.map(|(did, entry)| (did, entry.handle))
.collect(),
avatars,
avatars_dir: Some(avatars_dir),
profiles,
handles_digest,
}
}
fn handle(&self, did: &str) -> &str {
self.handles.get(did).map_or(INVALID_HANDLE, String::as_str)
}
fn profile(&self, did: &str) -> Value {
json!({ "did": did, "handle": self.handle(did) })
}
fn doc(&self, did: &str) -> Value {
json!({
"did": did,
"handle": self.handle(did),
"pds": format!("https://{}", self.hostname),
"signing_key": "",
})
}
}
#[derive(Clone, Serialize, Deserialize)]
pub(crate) struct SocialRecord {
pub(crate) collection: String,
pub(crate) rkey: String,
pub(crate) uri: String,
pub(crate) cid: String,
pub(crate) value: Value,
/// who made the first version, `editor` is the last one
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) author: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) editor: Option<String>,
}
impl SocialRecord {
fn view(&self) -> Value {
json!({ "uri": self.uri, "cid": self.cid, "value": self.value })
}
fn created_at(&self) -> &str {
self.text("createdAt")
}
fn text(&self, field: &str) -> &str {
self.value[field].as_str().unwrap_or_default()
}
fn subject_uri(&self) -> &str {
self.value["subject"]["uri"].as_str().unwrap_or_default()
}
}
pub(crate) fn digest(state: &ChainState) -> Vec<SocialRecord> {
state
.records
.iter()
.filter(|record| COLLECTIONS.contains(&record.collection.as_str()))
.map(|record| {
let versions = state
.versions
.get(&format!("{}/{}", record.collection, record.rkey));
let editor = |version: Option<&crate::atproto::Version>| {
version.map(|version| version.editor().to_string())
};
let mut value = record.answer["value"].clone();
if record.collection == GIT_REFS {
let refs = value["refs"]["$bytes"].as_str().unwrap_or_default();
value = json!({
"defaultBranch": value["defaultBranch"],
"isEmpty": refs.is_empty(),
});
}
SocialRecord {
collection: record.collection.clone(),
rkey: record.rkey.clone(),
uri: record.answer["uri"]
.as_str()
.unwrap_or_default()
.to_string(),
cid: record.answer["cid"]
.as_str()
.unwrap_or_default()
.to_string(),
value,
author: editor(versions.and_then(|versions| versions.first())),
editor: editor(versions.and_then(|versions| versions.last())),
}
})
.collect()
}
struct Hosted<'a> {
did: &'a str,
manifest: Option<&'a SocialRecord>,
refs: Option<&'a SocialRecord>,
tickets: Vec<&'a SocialRecord>,
comments: BTreeMap<&'a str, Vec<&'a SocialRecord>>,
reactions: BTreeMap<&'a str, Vec<&'a SocialRecord>>,
patches: BTreeMap<&'a str, Vec<&'a SocialRecord>>,
labels: Vec<&'a SocialRecord>,
artifacts: Vec<&'a SocialRecord>,
collaborators: Vec<&'a SocialRecord>,
}
impl<'a> Hosted<'a> {
fn of(repo: &'a SettledRepo) -> Self {
let mut hosted = Hosted {
did: repo.did.as_str(),
manifest: None,
refs: None,
tickets: Vec::new(),
comments: BTreeMap::new(),
reactions: BTreeMap::new(),
patches: BTreeMap::new(),
labels: Vec::new(),
artifacts: Vec::new(),
collaborators: Vec::new(),
};
for record in &repo.rows.social {
match record.collection.as_str() {
MANIFEST => hosted.manifest = Some(record),
GIT_REFS => hosted.refs = Some(record),
TICKET => hosted.tickets.push(record),
COMMENT => hosted
.comments
.entry(record.subject_uri())
.or_default()
.push(record),
REACTION => hosted
.reactions
.entry(record.subject_uri())
.or_default()
.push(record),
PATCH => hosted
.patches
.entry(record.text("ticket"))
.or_default()
.push(record),
LABEL => hosted.labels.push(record),
ARTIFACT => hosted.artifacts.push(record),
_ => hosted.collaborators.push(record),
}
}
let newest_first = |a: &&SocialRecord, b: &&SocialRecord| {
(b.created_at(), &b.rkey).cmp(&(a.created_at(), &a.rkey))
};
hosted.tickets.sort_by(newest_first);
hosted.labels.sort_by(newest_first);
hosted.artifacts.sort_by(newest_first);
hosted.collaborators.sort_by(newest_first);
for list in hosted.comments.values_mut() {
list.sort_by(newest_first);
}
for list in hosted.patches.values_mut() {
list.sort_by(newest_first);
}
hosted
}
fn owner(&self) -> Option<&'a str> {
self.manifest?.value["declaration"]["owner"].as_str()
}
fn name(&self) -> Option<&'a str> {
self.manifest?.value["declaration"]["name"].as_str()
}
fn subjects(&self) -> Vec<String> {
let mut subjects = spellings(self.did);
subjects.extend(spellings(&format!("at://{}/{MANIFEST}/self", self.did)));
subjects
}
fn comment_count(&self, uri: &str) -> usize {
self.comments.get(uri).map_or(0, Vec::len)
}
fn accounts(&self) -> impl Iterator<Item = &'a str> + '_ {
let records = self
.tickets
.iter()
.chain(self.comments.values().flatten())
.chain(self.reactions.values().flatten())
.chain(self.patches.values().flatten())
.chain(&self.labels)
.chain(&self.artifacts);
let editors = records
.flat_map(|record| [record.author.as_deref(), record.editor.as_deref()])
.flatten();
let assignees = self.tickets.iter().flat_map(|ticket| {
ticket.value["assignees"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
});
let collaborators = self.collaborators.iter().map(|grant| grant.rkey.as_str());
editors
.chain(assignees)
.chain(collaborators)
.chain(self.owner())
.chain([self.did])
}
}
struct Out {
root: PathBuf,
written: BTreeSet<String>,
rows: BTreeMap<&'static str, Vec<(String, String)>>,
extra: Vec<Block>,
}
impl Out {
fn put(&mut self, body: &Value) -> anyhow::Result<String> {
let bytes = serde_json::to_vec(body)?;
let file = format!("{DIR}/{}.json", short_hash(&bytes));
if self.written.insert(file.clone()) {
let path = self.root.join(&file);
atomic_write(&path, &bytes).with_context(|| format!("write {}", path.display()))?;
}
Ok(file)
}
fn answer(
&mut self,
route: &Route,
keys: impl IntoIterator<Item = String>,
body: &Value,
) -> anyhow::Result<()> {
let file = self.put(body)?;
let rows = self.rows.entry(route.var).or_default();
rows.extend(keys.into_iter().map(|key| (key, file.clone())));
Ok(())
}
}
fn keys(parts: &[&[String]]) -> Vec<String> {
let Some((first, rest)) = parts.split_first() else {
return Vec::new();
};
rest.iter().fold(first.to_vec(), |joined, part| {
joined
.iter()
.flat_map(|prefix| {
part.iter()
.map(move |spelling| format!("{prefix}|{spelling}"))
})
.collect()
})
}
fn literal(values: &[&str]) -> Vec<String> {
values.iter().map(|value| value.to_string()).collect()
}
const NO_CURSOR: Refused = Refused::Unless("cursor", "");
const NO_OFFSET: Refused = Refused::Unless("offset", "0*");
const NEWEST_FIRST: Refused = Refused::Unless("order", "desc|");
/// a whole list is one page, paging and reordering it get refused
const WHOLE_LIST: &[Refused] = &[NO_CURSOR, NO_OFFSET, NEWEST_FIRST];
struct Route {
nsid: &'static str,
/// nginx variables joined, a name the client didn't send is empty
source: &'static str,
var: &'static str,
miss: &'static str,
empty: Option<fn() -> Value>,
refused: &'static [Refused],
/// params the lexicon takes that don't key the map and aren't refused unless empty
ignored: &'static [&'static str],
}
impl Route {
fn location(&self) -> Block {
reroute(
Block::exact(format!("/xrpc/{}", self.nsid))
.blocks(self.refused.iter().map(|refused| refused.block()))
.block(empty(self.var, self.miss)),
format!("/_ks_atproto/{}", self.var),
)
}
fn map(&self, out: &mut Out) -> anyhow::Result<Block> {
let default = match self.empty {
Some(body) => out.put(&body())?,
None => String::new(),
};
let rows = out.rows.remove(self.var).unwrap_or_default();
Ok(map_with_default(&default, self.source, self.var, rows))
}
}
fn no_items() -> Value {
json!({ "items": [] })
}
fn no_count() -> Value {
json!({ "count": 0, "distinctAuthors": 0 })
}
fn count(records: &[&SocialRecord]) -> Value {
let authors: BTreeSet<_> = records.iter().filter_map(|r| r.author.as_deref()).collect();
json!({ "count": records.len(), "distinctAuthors": authors.len() })
}
fn routes() -> impl Iterator<Item = &'static Route> {
identity::ROUTES
.iter()
.chain(repo::ROUTES)
.chain(ticket::ROUTES)
}
pub(crate) fn ignored_params(nsid: &str) -> &'static [&'static str] {
routes()
.find(|route| route.nsid == nsid)
.map_or(&[], |route| route.ignored)
}
pub(crate) fn route_vars() -> impl Iterator<Item = (&'static str, &'static str)> {
routes()
.map(|route| (route.nsid, route.var))
.chain([(ticket::SEARCH_NSID, ticket::SEARCH)])
}
pub(crate) fn repo_spellings(repos: &[&SettledRepo], social: &Social) -> Vec<(String, String)> {
let mut out = Vec::new();
for repo in repos {
let hosted = Hosted::of(repo);
let (Some(owner), Some(name)) = (hosted.owner(), hosted.name()) else {
continue;
};
let dir = repo.dir_name();
let handle = social.handle(owner);
for owner_id in [owner, handle] {
if owner_id != INVALID_HANDLE {
for s in spellings(&format!("{owner_id}/{name}")) {
out.push((s, dir.clone()));
}
}
}
}
out
}
pub(crate) fn emit(
generation_dir: &Path,
repos: &[&SettledRepo],
social: &Social,
) -> anyhow::Result<()> {
let root = generation_dir.join(ATPROTO_DIR);
std::fs::create_dir_all(root.join(DIR))?;
let mut out = Out {
root,
written: BTreeSet::new(),
rows: BTreeMap::new(),
extra: Vec::new(),
};
let hosted: Vec<Hosted> = repos.iter().map(|repo| Hosted::of(repo)).collect();
write_accounts(generation_dir, &hosted)?;
identity::emit(&mut out, &hosted, social)?;
repo::emit(&mut out, &hosted, social)?;
ticket::emit(&mut out, &hosted, social)?;
let avatars = link_avatars(generation_dir, social);
out.extra
.push(map("$arg_size", "$ks_avatar_size", avatar_sizes()));
out.extra
.push(map("$uri|$ks_avatar_size", "$ks_avatar_file", avatars));
let mut maps = Block::file()
.blocks(ticket::maps())
.blocks(std::mem::take(&mut out.extra));
for route in routes() {
maps = maps.block(route.map(&mut out)?);
}
let path = generation_dir.join("nginx/maps").join(MAPS_FILE);
atomic_write(&path, maps.to_string().as_bytes())
.with_context(|| format!("write {}", path.display()))
}
/// what the og cards show of each hosted repo and its tickets
pub(crate) fn card_repos(
repos: &[SettledRepo],
social: &Social,
) -> Vec<crate::og_card::SocialRepo> {
let hosted: Vec<Hosted> = repos.iter().map(Hosted::of).collect();
hosted
.iter()
.filter(|repo| repo.manifest.is_some())
.map(|repo| {
let basic = repo::basic(repo, social, 0);
// a plain string, or a markup object's text
let text = |value: &Value| {
value
.as_str()
.or_else(|| value["text"].as_str())
.unwrap_or_default()
.to_string()
};
let owner_did = repo.owner().unwrap_or_default();
let tickets = repo
.tickets
.iter()
.map(|ticket| {
let author = ticket.author.as_deref().unwrap_or(repo.did);
let pull = ticket::is_pull(ticket).then(|| {
let newest = repo
.patches
.get(ticket.rkey.as_str())
.and_then(|patches| patches.first());
let oid = |side: &str| {
newest
.and_then(|patch| patch.value[side]["oid"].as_str())
.and_then(|hex| knot_types::Oid::from_hex(hex).ok())
};
let source = newest.map(|patch| &patch.value["source"]);
let branch = source.map(|source| text(&source["branch"]));
let fork = source
.and_then(|source| source["repo"].as_str())
.is_some_and(|from| from != repo.did);
crate::og_card::SocialPull {
from: match fork {
true => format!("fork:{}", branch.unwrap_or_default()),
false => branch.unwrap_or_default(),
},
into: ticket.text("targetBranch").to_string(),
range: oid("base").zip(oid("head")),
}
});
let state = match (ticket::state(ticket), pull.is_some()) {
("completed", true) => "merged",
(state, _) => state,
};
crate::og_card::SocialTicket {
rkey: ticket.rkey.clone(),
title: text(&ticket.value["title"]),
state: state.to_string(),
author_did: author.to_string(),
author_handle: social.handle(author).to_string(),
created_at: ticket.created_at().to_string(),
body: text(&ticket.value["body"]),
pull,
}
})
.collect();
crate::og_card::SocialRepo {
did: repo.did.to_string(),
owner_did: owner_did.to_string(),
owner_handle: social.handle(owner_did).to_string(),
name: repo.name().unwrap_or_default().to_string(),
description: text(&basic["description"]),
topics: repo
.manifest
.and_then(|manifest| manifest.value["topics"].as_array())
.into_iter()
.flatten()
.filter_map(|topic| topic.as_str().map(str::to_string))
.collect(),
open_tickets: basic["openIssueCount"].as_u64().unwrap_or_default() as usize,
open_pulls: basic["openPullCount"].as_u64().unwrap_or_default() as usize,
tickets,
}
})
.collect()
}
/// what the og cards show of each owner: their profile, their avatar and the repos they host here
pub(crate) fn card_users(
repos: &[crate::og_card::SocialRepo],
social: &Social,
) -> Vec<crate::og_card::SocialUser> {
let mut owned: BTreeMap<&str, Vec<String>> = BTreeMap::new();
for repo in repos {
owned
.entry(repo.owner_did.as_str())
.or_default()
.push(repo.name.clone());
}
owned
.into_iter()
.filter(|(did, _)| !did.is_empty())
.map(|(did, mut names)| {
names.sort();
let avatar = social
.avatars
.get(did)
.filter(|avatar| avatar.sized)
.and_then(|avatar| {
let dir = social.avatars_dir.as_ref()?;
let file = dir.join(sized_avatar(
&avatar.source,
AVATAR_SIZES[AVATAR_SIZES.len() - 1],
));
Some((file, avatar.source.clone()))
});
crate::og_card::SocialUser {
did: did.to_string(),
handle: social.handle(did).to_string(),
about: social
.profiles
.get(did)
.and_then(|profile| profile.get("description"))
.and_then(Value::as_str)
.map(crate::og_card::prose)
.unwrap_or_default(),
repos: names,
avatar,
}
})
.collect()
}
pub(crate) fn accounts(repos: &[SettledRepo]) -> BTreeSet<String> {
let hosted: Vec<Hosted> = repos.iter().map(Hosted::of).collect();
named_accounts(&hosted)
.into_iter()
.map(str::to_string)
.collect()
}
fn named_accounts<'a>(hosted: &[Hosted<'a>]) -> BTreeSet<&'a str> {
let repos: BTreeSet<&str> = hosted.iter().map(|repo| repo.did).collect();
hosted
.iter()
.flat_map(Hosted::accounts)
.filter(|did| !repos.contains(did) && knot_types::AccountDid::new(*did).is_ok())
.collect()
}
fn write_accounts(generation_dir: &Path, hosted: &[Hosted]) -> anyhow::Result<()> {
let path = generation_dir.join(ACCOUNTS_FILE);
atomic_write(&path, &serde_json::to_vec(&named_accounts(hosted))?)
.with_context(|| format!("write {}", path.display()))
}
fn link_avatars(generation_dir: &Path, social: &Social) -> Vec<(String, String)> {
let Some(dir) = &social.avatars_dir else {
return Vec::new();
};
let served = generation_dir.join(AVATARS_WWW);
let mut rows = Vec::new();
for (did, avatar) in &social.avatars {
let file = avatar.file();
let mut linked = vec![(String::new(), avatar.cached(), file.clone())];
for size in AVATAR_SIZES {
let sized = sized_avatar(&avatar.source, size);
linked.push(match avatar.sized {
true => (size.to_string(), sized.clone(), sized),
false => (size.to_string(), avatar.cached(), file.clone()),
});
}
let files: BTreeSet<(&String, &String)> = linked
.iter()
.map(|(_, cached, served_as)| (cached, served_as))
.collect();
let link = |(cached, served_as): (&String, &String)| {
link_or_reflink(&dir.join(cached), &served.join(served_as))
};
if let Err(error) = files.into_iter().try_for_each(link) {
tracing::warn!(%did, %error, "link a cached avatar");
continue;
}
for did in spellings(did) {
rows.extend(
linked
.iter()
.map(|(size, _, file)| (format!("/avatar/{did}|{size}"), file.clone())),
);
}
}
rows
}
/// `?size=` picks the smallest webp at least that big; none, past the biggest, or not a number
/// is the full avatar
fn avatar_sizes() -> Vec<(String, String)> {
(1..=AVATAR_SIZES[AVATAR_SIZES.len() - 1])
.map(|asked| {
let size = AVATAR_SIZES.iter().find(|&&size| size >= asked);
(
asked.to_string(),
size.map_or(String::new(), u32::to_string),
)
})
.collect()
}
pub(crate) fn locations() -> Vec<Block> {
let avatar = reroute(
Block::new("location", ["^~", "/avatar/"])
.block(Block::when("$ks_avatar_file", "=", "").directive("return", ["404"])),
"/_ks_avatars/$ks_avatar_file",
);
[ticket::search_location(), avatar]
.into_iter()
.chain(routes().map(Route::location))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// what the lexicon takes, less what keys the map and what is refused unless empty
#[test]
fn ignored_params_are_what_the_lexicon_takes_and_the_map_doesnt_key_on() {
fn read(dir: &Path, by_id: &mut BTreeMap<String, Value>) {
for entry in std::fs::read_dir(dir).unwrap().flatten() {
let path = entry.path();
if path.is_dir() {
read(&path, by_id);
} else if path.extension().is_some_and(|ext| ext == "json") {
let lexicon: Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
if let Some(id) = lexicon["id"].as_str() {
by_id.insert(id.to_string(), lexicon);
}
}
}
}
let mut by_id = BTreeMap::new();
read(
&Path::new(env!("CARGO_MANIFEST_DIR")).join("vendor/core/lexicons"),
&mut by_id,
);
for route in routes() {
let Some(lexicon) = by_id.get(route.nsid) else {
assert!(route.ignored.is_empty(), "{}", route.nsid);
continue;
};
let keyed: BTreeSet<&str> = route
.source
.split("$arg_")
.skip(1)
.map(|name| name.trim_end_matches('|'))
.collect();
let refused: BTreeSet<&str> = route
.refused
.iter()
.filter_map(|refused| match refused {
Refused::Unless(name, "") => Some(*name),
_ => None,
})
.collect();
let expected: Vec<&str> = lexicon["defs"]["main"]["parameters"]["properties"]
.as_object()
.into_iter()
.flat_map(|properties| properties.keys())
.map(String::as_str)
.filter(|name| !keyed.contains(name) && !refused.contains(name))
.collect();
assert_eq!(route.ignored, expected, "{}", route.nsid);
}
}
}