use serde::{Deserialize, Serialize};
pub mod nsid {
pub const SUBSCRIPTION: &str = "community.lexicon.rss.subscription";
pub const FOLDER: &str = "community.lexicon.rss.folder";
pub const SAVED: &str = "community.lexicon.rss.saved";
pub const READ_STATE: &str = "community.lexicon.rss.readState";
pub const STANDARD_PUBLICATION: &str = "site.standard.publication";
pub const STANDARD_DOCUMENT: &str = "site.standard.document";
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum FetchHint {
Realtime,
Hourly,
Daily,
Weekly,
#[serde(untagged)]
Other(String),
}
fn de_scheme_checked<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = Option::<String>::deserialize(deserializer)?;
Ok(raw.as_deref().and_then(crate::net::safe_link))
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct Subscription {
#[serde(rename = "$type", default = "subscription_type")]
pub r#type: String,
pub url: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub title: Option<String>,
#[serde(
rename = "siteUrl",
skip_serializing_if = "Option::is_none",
default,
deserialize_with = "de_scheme_checked"
)]
pub site_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub folder: Option<String>,
#[serde(rename = "fetchHint", skip_serializing_if = "Option::is_none", default)]
pub fetch_hint: Option<FetchHint>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub private: Option<bool>,
#[serde(rename = "createdAt")]
pub created_at: String,
}
fn subscription_type() -> String {
nsid::SUBSCRIPTION.to_string()
}
impl Subscription {
pub fn new(url: impl Into<String>, created_at: impl Into<String>) -> Self {
Self {
r#type: nsid::SUBSCRIPTION.to_string(),
url: url.into(),
title: None,
site_url: None,
folder: None,
fetch_hint: None,
private: None,
created_at: created_at.into(),
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct Folder {
#[serde(rename = "$type", default = "folder_type")]
pub r#type: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub position: Option<u64>,
#[serde(rename = "createdAt")]
pub created_at: String,
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
fn folder_type() -> String {
nsid::FOLDER.to_string()
}
impl Folder {
pub fn new(name: impl Into<String>, created_at: impl Into<String>) -> Self {
Self {
r#type: nsid::FOLDER.to_string(),
name: name.into(),
position: None,
created_at: created_at.into(),
extra: serde_json::Map::new(),
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct Saved {
#[serde(rename = "$type", default = "saved_type")]
pub r#type: String,
pub url: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub title: Option<String>,
#[serde(rename = "feedUrl", skip_serializing_if = "Option::is_none", default)]
pub feed_url: Option<String>,
#[serde(rename = "entryId", skip_serializing_if = "Option::is_none", default)]
pub entry_id: Option<String>,
#[serde(rename = "createdAt")]
pub created_at: String,
}
fn saved_type() -> String {
nsid::SAVED.to_string()
}
impl Saved {
pub fn new(url: impl Into<String>, created_at: impl Into<String>) -> Self {
Self {
r#type: nsid::SAVED.to_string(),
url: url.into(),
title: None,
feed_url: None,
entry_id: None,
created_at: created_at.into(),
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct ReadState {
#[serde(rename = "$type", default = "read_state_type")]
pub r#type: String,
#[serde(rename = "feedUrl")]
pub feed_url: String,
#[serde(
rename = "readThrough",
skip_serializing_if = "Option::is_none",
default
)]
pub read_through: Option<String>,
#[serde(rename = "readIds", skip_serializing_if = "Vec::is_empty", default)]
pub read_ids: Vec<String>,
#[serde(rename = "unreadIds", skip_serializing_if = "Vec::is_empty", default)]
pub unread_ids: Vec<String>,
#[serde(rename = "updatedAt")]
pub updated_at: String,
}
fn read_state_type() -> String {
nsid::READ_STATE.to_string()
}
impl ReadState {
pub const MAX_IDS: usize = 1000;
pub fn new(
feed_url: impl Into<String>,
read_through: Option<String>,
updated_at: impl Into<String>,
) -> Self {
Self {
r#type: nsid::READ_STATE.to_string(),
feed_url: feed_url.into(),
read_through,
read_ids: Vec::new(),
unread_ids: Vec::new(),
updated_at: updated_at.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_hostile_site_url_does_not_survive_deserialisation() {
for hostile in [
"javascript:alert(1)",
"data:text/html;base64,PHNjcmlwdD4=",
"vbscript:msgbox(1)",
" javascript:alert(1) ",
"not a url at all",
] {
let json = serde_json::json!({
"$type": "community.lexicon.rss.subscription",
"url": "https://example.com/feed.xml",
"siteUrl": hostile,
"createdAt": "2026-01-01T00:00:00.000Z",
})
.to_string();
let sub: Subscription = serde_json::from_str(&json).expect("record should parse");
assert_eq!(
sub.site_url, None,
"{hostile:?} survived into a record this reader will re-publish and export"
);
assert_eq!(
sub.url, "https://example.com/feed.xml",
"the feed URL is not the field under test and must be untouched"
);
}
}
#[test]
fn a_legitimate_site_url_survives_deserialisation() {
for (stored, expected) in [
("https://example.com/blog", "https://example.com/blog"),
("http://example.com/blog", "http://example.com/blog"),
(" https://example.com/blog ", "https://example.com/blog"),
] {
let json = serde_json::json!({
"$type": "community.lexicon.rss.subscription",
"url": "https://example.com/feed.xml",
"siteUrl": stored,
"createdAt": "2026-01-01T00:00:00.000Z",
})
.to_string();
let sub: Subscription = serde_json::from_str(&json).expect("record should parse");
assert_eq!(sub.site_url.as_deref(), Some(expected));
}
}
#[test]
fn an_absent_site_url_stays_absent() {
let json = serde_json::json!({
"$type": "community.lexicon.rss.subscription",
"url": "https://example.com/feed.xml",
"createdAt": "2026-01-01T00:00:00.000Z",
})
.to_string();
let sub: Subscription = serde_json::from_str(&json).expect("record should parse");
assert_eq!(sub.site_url, None);
let json = serde_json::json!({
"$type": "community.lexicon.rss.subscription",
"url": "https://example.com/feed.xml",
"siteUrl": serde_json::Value::Null,
"createdAt": "2026-01-01T00:00:00.000Z",
})
.to_string();
let sub: Subscription = serde_json::from_str(&json).expect("explicit null should parse");
assert_eq!(sub.site_url, None);
}
#[test]
fn subscription_round_trips_full_record() {
let value = json!({
"$type": "community.lexicon.rss.subscription",
"url": "https://example.com/feed.xml",
"title": "Example Blog",
"siteUrl": "https://example.com/",
"folder": "at://did:plc:abc123/community.lexicon.rss.folder/3kfolderrkey",
"fetchHint": "hourly",
"createdAt": "2026-07-12T00:00:00.000Z"
});
let sub: Subscription = serde_json::from_value(value.clone()).expect("deserialize");
assert_eq!(sub.r#type, nsid::SUBSCRIPTION);
assert_eq!(sub.url, "https://example.com/feed.xml");
assert_eq!(sub.title.as_deref(), Some("Example Blog"));
assert_eq!(sub.site_url.as_deref(), Some("https://example.com/"));
assert_eq!(sub.fetch_hint, Some(FetchHint::Hourly));
let back = serde_json::to_value(&sub).expect("serialize");
assert_eq!(back, value);
}
#[test]
fn subscription_minimal_omits_optional_fields() {
let sub = Subscription::new("https://example.com/feed.xml", "2026-07-12T00:00:00.000Z");
let back = serde_json::to_value(&sub).expect("serialize");
assert_eq!(
back,
json!({
"$type": "community.lexicon.rss.subscription",
"url": "https://example.com/feed.xml",
"createdAt": "2026-07-12T00:00:00.000Z"
})
);
}
#[test]
fn subscription_reserved_private_marker_omitted_by_default_but_round_trips() {
let public = Subscription::new("https://example.com/feed.xml", "2026-07-12T00:00:00.000Z");
assert_eq!(public.private, None);
let public_body = serde_json::to_value(&public).expect("serialize");
assert!(public_body.get("private").is_none());
let mut future =
Subscription::new("https://example.com/feed.xml", "2026-07-12T00:00:00.000Z");
future.private = Some(true);
let back = serde_json::to_value(&future).expect("serialize");
assert_eq!(back["private"], serde_json::json!(true));
let parsed: Subscription = serde_json::from_value(back).expect("deserialize");
assert_eq!(parsed.private, Some(true));
}
#[test]
fn fetch_hint_open_enum_accepts_unknown() {
let sub: Subscription = serde_json::from_value(json!({
"url": "https://example.com/feed.xml",
"fetchHint": "every-15-min",
"createdAt": "2026-07-12T00:00:00.000Z"
}))
.expect("deserialize");
assert_eq!(
sub.fetch_hint,
Some(FetchHint::Other("every-15-min".to_string()))
);
assert_eq!(sub.r#type, nsid::SUBSCRIPTION);
}
#[test]
fn folder_round_trips() {
let value = json!({
"$type": "community.lexicon.rss.folder",
"name": "Tech",
"position": 2,
"createdAt": "2026-07-12T00:00:00.000Z"
});
let folder: Folder = serde_json::from_value(value.clone()).expect("deserialize");
assert_eq!(folder.name, "Tech");
assert_eq!(folder.position, Some(2));
assert_eq!(serde_json::to_value(&folder).expect("serialize"), value);
}
#[test]
fn folder_round_trips_fields_it_does_not_know() {
let value = json!({
"$type": "community.lexicon.rss.folder",
"name": "Tech",
"position": 3,
"createdAt": "2024-01-01T00:00:00.000Z",
"color": "#abc",
"nested": { "icon": "star", "tags": [1, "two", null] }
});
let folder: Folder = serde_json::from_value(value.clone()).expect("deserialize");
assert_eq!(folder.name, "Tech");
assert_eq!(folder.position, Some(3));
assert_eq!(serde_json::to_value(&folder).expect("serialize"), value);
let text = serde_json::to_string(&folder).expect("serialize");
for key in ["$type", "name", "position", "createdAt", "color", "nested"] {
assert_eq!(
text.matches(&format!("\"{key}\":")).count(),
1,
"{key} not emitted exactly once: {text}"
);
}
}
#[test]
fn a_new_folder_serializes_only_its_own_fields() {
let folder = Folder::new("Tech", "2026-07-12T00:00:00.000Z");
assert_eq!(
serde_json::to_value(&folder).expect("serialize"),
json!({
"$type": "community.lexicon.rss.folder",
"name": "Tech",
"createdAt": "2026-07-12T00:00:00.000Z"
})
);
}
#[test]
fn saved_round_trips() {
let value = json!({
"$type": "community.lexicon.rss.saved",
"url": "https://example.com/post/1",
"title": "A kept post",
"feedUrl": "https://example.com/feed.xml",
"entryId": "tag:example.com,2026:1",
"createdAt": "2026-07-12T00:00:00.000Z"
});
let saved: Saved = serde_json::from_value(value.clone()).expect("deserialize");
assert_eq!(saved.url, "https://example.com/post/1");
assert_eq!(
saved.feed_url.as_deref(),
Some("https://example.com/feed.xml")
);
assert_eq!(saved.entry_id.as_deref(), Some("tag:example.com,2026:1"));
assert_eq!(serde_json::to_value(&saved).expect("serialize"), value);
}
#[test]
fn read_state_round_trips_with_id_sets() {
let value = json!({
"$type": "community.lexicon.rss.readState",
"feedUrl": "https://example.com/feed.xml",
"readThrough": "2026-07-12T00:00:00.000Z",
"readIds": ["entry-a", "entry-b"],
"unreadIds": ["entry-c"],
"updatedAt": "2026-07-12T01:00:00.000Z"
});
let rs: ReadState = serde_json::from_value(value.clone()).expect("deserialize");
assert_eq!(rs.feed_url, "https://example.com/feed.xml");
assert_eq!(rs.read_through.as_deref(), Some("2026-07-12T00:00:00.000Z"));
assert_eq!(rs.read_ids, vec!["entry-a", "entry-b"]);
assert_eq!(rs.unread_ids, vec!["entry-c"]);
assert_eq!(serde_json::to_value(&rs).expect("serialize"), value);
}
#[test]
fn read_state_minimal_omits_empty_id_sets() {
let rs = ReadState::new(
"https://example.com/feed.xml",
Some("2026-07-12T00:00:00.000Z".to_string()),
"2026-07-12T01:00:00.000Z",
);
let back = serde_json::to_value(&rs).expect("serialize");
assert_eq!(
back,
json!({
"$type": "community.lexicon.rss.readState",
"feedUrl": "https://example.com/feed.xml",
"readThrough": "2026-07-12T00:00:00.000Z",
"updatedAt": "2026-07-12T01:00:00.000Z"
})
);
}
#[test]
fn read_state_omits_read_through_when_none() {
let rs = ReadState::new(
"https://example.com/feed.xml",
None,
"2026-07-12T01:00:00.000Z",
);
let back = serde_json::to_value(&rs).expect("serialize");
assert!(back.get("readThrough").is_none());
assert_eq!(
back,
json!({
"$type": "community.lexicon.rss.readState",
"feedUrl": "https://example.com/feed.xml",
"updatedAt": "2026-07-12T01:00:00.000Z"
})
);
let parsed: ReadState = serde_json::from_value(back).expect("deserialize");
assert_eq!(parsed.read_through, None);
}
}
pub mod sort {
use super::{Folder, Saved, Subscription};
use std::cmp::Ordering;
pub fn subscriptions(
(a_key, a): &(String, Subscription),
(b_key, b): &(String, Subscription),
) -> Ordering {
let a_title = a.title.as_deref().unwrap_or(&a.url).to_lowercase();
let b_title = b.title.as_deref().unwrap_or(&b.url).to_lowercase();
a_title
.cmp(&b_title)
.then_with(|| a.url.cmp(&b.url))
.then_with(|| a_key.cmp(b_key))
}
pub fn folders((a_key, a): &(String, Folder), (b_key, b): &(String, Folder)) -> Ordering {
a.position
.unwrap_or(u64::MAX)
.cmp(&b.position.unwrap_or(u64::MAX))
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
.then_with(|| a_key.cmp(b_key))
}
pub fn saved((a_key, a): &(String, Saved), (b_key, b): &(String, Saved)) -> Ordering {
b.created_at
.cmp(&a.created_at)
.then_with(|| a_key.cmp(b_key))
}
}
#[cfg(test)]
mod sort_tests {
use super::sort;
use super::{Folder, Saved, Subscription};
fn sub(rkey: &str, url: &str, title: Option<&str>) -> (String, Subscription) {
let mut s = Subscription::new(url, "2026-01-01T00:00:00Z");
s.title = title.map(str::to_string);
(rkey.to_string(), s)
}
fn folder(rkey: &str, name: &str, position: Option<u64>) -> (String, Folder) {
let mut f = Folder::new(name, "2026-01-01T00:00:00Z");
f.position = position;
(rkey.to_string(), f)
}
fn saved(rkey: &str, url: &str, created_at: &str) -> (String, Saved) {
(rkey.to_string(), Saved::new(url, created_at))
}
fn order<T>(
mut items: Vec<(String, T)>,
cmp: fn(&(String, T), &(String, T)) -> std::cmp::Ordering,
) -> Vec<String> {
items.sort_by(cmp);
items.into_iter().map(|(k, _)| k).collect()
}
#[test]
fn subscriptions_sort_by_title_case_insensitively() {
let items = vec![
sub("r1", "https://z.example/f", Some("banana")),
sub("r2", "https://a.example/f", Some("Apple")),
sub("r3", "https://m.example/f", Some("cherry")),
];
assert_eq!(order(items, sort::subscriptions), ["r2", "r1", "r3"]);
}
#[test]
fn an_untitled_subscription_sorts_by_its_url() {
let items = vec![
sub("r1", "https://zebra.example/f", Some("aardvark")),
sub("r2", "https://bison.example/f", None),
];
assert_eq!(order(items, sort::subscriptions), ["r1", "r2"]);
}
#[test]
fn subscriptions_break_ties_by_url_then_rkey() {
let items = vec![
sub("r2", "https://b.example/f", Some("same")),
sub("r1", "https://b.example/f", Some("same")),
sub("r3", "https://a.example/f", Some("same")),
];
assert_eq!(order(items, sort::subscriptions), ["r3", "r1", "r2"]);
}
#[test]
fn folders_sort_by_position_with_unset_last() {
let items = vec![
folder("r1", "zulu", None),
folder("r2", "alpha", Some(10)),
folder("r3", "bravo", Some(2)),
];
assert_eq!(order(items, sort::folders), ["r3", "r2", "r1"]);
}
#[test]
fn folders_break_ties_by_name_then_rkey() {
let items = vec![
folder("r2", "Beta", Some(1)),
folder("r1", "alpha", Some(1)),
folder("r3", "alpha", Some(1)),
];
assert_eq!(order(items, sort::folders), ["r1", "r3", "r2"]);
}
#[test]
fn saved_entries_are_newest_first() {
let items = vec![
saved("r1", "https://a.example/x", "2026-01-01T00:00:00Z"),
saved("r2", "https://b.example/x", "2026-06-01T00:00:00Z"),
saved("r3", "https://c.example/x", "2026-03-01T00:00:00Z"),
];
assert_eq!(order(items, sort::saved), ["r2", "r3", "r1"]);
}
#[test]
fn saved_entries_break_ties_by_ascending_rkey() {
let items = vec![
saved("r3", "https://c.example/x", "2026-01-01T00:00:00Z"),
saved("r1", "https://a.example/x", "2026-01-01T00:00:00Z"),
saved("r2", "https://b.example/x", "2026-01-01T00:00:00Z"),
];
assert_eq!(order(items, sort::saved), ["r1", "r2", "r3"]);
}
}