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";
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum FetchHint {
Realtime,
Hourly,
Daily,
Weekly,
#[serde(untagged)]
Other(String),
}
#[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)]
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,
}
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(),
}
}
}
#[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 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 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);
}
}