feather_reader/lexicon.rs
1//! Serde types for the `community.lexicon.rss.*` atproto record schemas.
2//!
3//! FeatherReader's defining bet is that a user's feed subscriptions, folders,
4//! saved items, and batched read-state live as records in their own atproto PDS
5//! under an **open, vendor-neutral community lexicon** (`community.lexicon.rss.*`)
6//! rather than in the app's database — portable across any reader that adopts
7//! the standard, not merely across FeatherReader instances.
8//!
9//! These types mirror the `community.lexicon.rss.*` schemas, authored in
10//! the Lexicon Community idiom (`createdAt`/`updatedAt` as ISO-8601 datetimes,
11//! `url`/`siteUrl`/`feedUrl` as URIs, `folder` as an `at://` strong ref). Each
12//! record carries its `$type` NSID so it round-trips against the atproto record
13//! shape returned by `com.atproto.repo.getRecord` / `listRecords`.
14//!
15//! Storage rules (never write these authoritatively to local SQLite):
16//! - [`Subscription`] — one followed feed. `com.atproto.repo.createRecord` on
17//! subscribe; `deleteRecord` on unsubscribe. Source of truth for the follow list.
18//! - [`Folder`] — a lightweight named grouping (a feed lives in one folder).
19//! - [`Saved`] — a starred / save-for-later entry.
20//! - [`ReadState`] — the **batched** per-feed read cursor (one record per feed,
21//! at a feed-derived rkey — never one record per article). Written by the
22//! read-state flusher; see the caveats on that flush path in
23//! [`crate::atproto`].
24
25use serde::{Deserialize, Serialize};
26
27/// NSID `$type` constants for the `community.lexicon.rss.*` record collections.
28///
29/// These double as the atproto **collection** NSIDs for `listRecords` /
30/// `createRecord` / `putRecord` calls.
31pub mod nsid {
32 /// `community.lexicon.rss.subscription` — one followed feed.
33 pub const SUBSCRIPTION: &str = "community.lexicon.rss.subscription";
34 /// `community.lexicon.rss.folder` — a named grouping of subscriptions.
35 pub const FOLDER: &str = "community.lexicon.rss.folder";
36 /// `community.lexicon.rss.saved` — a starred / save-for-later entry.
37 pub const SAVED: &str = "community.lexicon.rss.saved";
38 /// `community.lexicon.rss.readState` — batched per-feed read cursor.
39 pub const READ_STATE: &str = "community.lexicon.rss.readState";
40
41 /// `site.standard.publication` — a standard.site publication. NOT one of
42 /// ours: it is another project's lexicon, named here because it is the only
43 /// foreign collection this reader will accept as a subscribable feed.
44 pub const STANDARD_PUBLICATION: &str = "site.standard.publication";
45
46 /// `site.standard.document` — one standard.site article. Also not ours.
47 pub const STANDARD_DOCUMENT: &str = "site.standard.document";
48}
49
50/// Optional polling-cadence hint on a [`Subscription`]. Readers MAY honor or
51/// ignore it. Mirrors the lexicon's `knownValues` for `fetchHint`.
52///
53/// `knownValues` in atproto is an *open* enum — an unrecognized value MUST NOT
54/// break deserialization — so [`FetchHint::Other`] captures forward-compatible
55/// values a future reader might write.
56#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
57#[serde(rename_all = "lowercase")]
58pub enum FetchHint {
59 /// Poll as close to realtime as the reader supports.
60 Realtime,
61 /// Poll roughly hourly.
62 Hourly,
63 /// Poll roughly daily.
64 Daily,
65 /// Poll roughly weekly.
66 Weekly,
67 /// An unrecognized (forward-compatible) hint value.
68 #[serde(untagged)]
69 Other(String),
70}
71
72/// Drop a `siteUrl` this reader would refuse to render, at the point a record
73/// crosses into the process.
74///
75/// Absent stays absent and a good URL is passed through trimmed, matching
76/// [`crate::net::safe_link`]'s handling of entry links — the same allow-list, so
77/// the two URL fields on a record cannot disagree about what a link is.
78fn de_scheme_checked<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
79where
80 D: serde::Deserializer<'de>,
81{
82 let raw = Option::<String>::deserialize(deserializer)?;
83 Ok(raw.as_deref().and_then(crate::net::safe_link))
84}
85
86/// `community.lexicon.rss.subscription` — a subscription to a syndication feed
87/// (RSS / Atom / JSON Feed). Record key: `tid`.
88///
89/// `url` + `createdAt` are required; everything else is optional.
90///
91/// ## Public feeds only (and the reserved `private` marker)
92///
93/// atproto PDS records are **public**: anyone can read them via unauthenticated
94/// `getRecord` / `listRecords` and off the firehose, and they are retained even
95/// after `deleteRecord`. A **private feed** (a Substack `…/feed/private/<token>`,
96/// a Patreon `?auth=…` feed, a Ghost members `?uuid=` feed, a private-podcast
97/// token feed, or any URL that carries a secret token / key / auth credential)
98/// has its *secret in the URL*, so writing that URL here would leak paid /
99/// members-only access to the whole network.
100///
101/// **Current decision: FeatherReader supports PUBLIC feeds only.** A private
102/// feed is *refused* at the add / import boundary (see
103/// [`crate::feed::classify_feed_privacy`]) — it is never fetched, never stored,
104/// and no record (redacted or otherwise) is ever written. The server therefore
105/// holds NO private secret, which keeps "your data lives in your public PDS"
106/// 100% honest. Consequently every [`Subscription`] record actually written
107/// carries a real, public feed `url`, and [`Subscription::private`] is **always omitted**.
108///
109/// The [`Subscription::private`] field is retained ONLY as a documented, forward-compatible
110/// **reserved marker** for the eventual migration once atproto ships
111/// **permissioned data / permission-sets** (early-proposal as of mid-2026,
112/// bluesky-social/proposals#94). At that point a private feed's secret can live
113/// in an owner-scoped, permission-gated collection and this record can reference
114/// it with `private: true`. Until then the field has **no runtime behavior** —
115/// nothing sets it and nothing branches on it.
116#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
117pub struct Subscription {
118 /// The `$type` NSID discriminator; always [`nsid::SUBSCRIPTION`].
119 #[serde(rename = "$type", default = "subscription_type")]
120 pub r#type: String,
121
122 /// Canonical feed URL (the RSS/Atom/JSON Feed document). Required.
123 ///
124 /// Always a real, PUBLIC feed URL: private/secret-bearing feeds are refused
125 /// at the add boundary (see the type-level docs), so no record with a
126 /// withheld or redacted `url` is ever written.
127 pub url: String,
128
129 /// Display title; a reader MAY override from feed metadata.
130 #[serde(skip_serializing_if = "Option::is_none", default)]
131 pub title: Option<String>,
132
133 /// Human-facing site the feed belongs to.
134 ///
135 /// **Scheme-checked on the way in.** Any atproto client can write this field
136 /// into the user's repo, and the lexicon invites readers to render it as a
137 /// link, so a record fetched from the PDS is attacker-controlled input. The
138 /// `deserialize_with` below is the read-side counterpart to the write-side
139 /// vet in [`crate::repo`]: together they mean a `Subscription` that entered
140 /// this process from outside cannot be carrying a `javascript:` URL, whatever
141 /// it is later rendered into — an `href`, or an OPML `htmlUrl` we hand back
142 /// to the user as a file.
143 ///
144 /// **The READ side only.** A record built in-process rather than
145 /// deserialised does not pass through here — OPML import parses `htmlUrl`
146 /// out of XML by hand, and the manage form assigns the field directly.
147 /// Those are the write boundary's to vet, which is why both guards exist
148 /// rather than either one being sufficient.
149 ///
150 /// A rejected value becomes `None`, so it is omitted rather than emitted
151 /// empty; a consumer renders no link instead of a broken one.
152 ///
153 /// **Round-trip fidelity is deliberately lost.** Read a record holding a
154 /// hostile `siteUrl`, re-put it, and we write it back cleaned rather than
155 /// preserving what another client stored. That heals the user's repo
156 /// instead of propagating someone else's script URL — but it does mean a
157 /// `putRecord` following a read is not byte-identical to what was there,
158 /// and that is a decision, not an accident.
159 #[serde(
160 rename = "siteUrl",
161 skip_serializing_if = "Option::is_none",
162 default,
163 deserialize_with = "de_scheme_checked"
164 )]
165 pub site_url: Option<String>,
166
167 /// Optional `at://` strong ref to a [`Folder`] record.
168 #[serde(skip_serializing_if = "Option::is_none", default)]
169 pub folder: Option<String>,
170
171 /// Optional polling-cadence hint; readers MAY honor or ignore it.
172 #[serde(rename = "fetchHint", skip_serializing_if = "Option::is_none", default)]
173 pub fetch_hint: Option<FetchHint>,
174
175 /// **Reserved** — no runtime behavior today.
176 ///
177 /// FeatherReader currently supports public feeds only (private/secret-bearing
178 /// feeds are refused at the add boundary), so nothing sets this and every
179 /// written record omits it (`None`). It is kept as a documented,
180 /// forward-compatible seam for the eventual migration once atproto ships
181 /// permissioned data: at that point a private feed's secret can live in an
182 /// owner-scoped, permission-gated collection and this record can reference it
183 /// with `private: true`. See the type-level docs.
184 #[serde(skip_serializing_if = "Option::is_none", default)]
185 pub private: Option<bool>,
186
187 /// Record creation time (ISO-8601 datetime). Required.
188 #[serde(rename = "createdAt")]
189 pub created_at: String,
190}
191
192fn subscription_type() -> String {
193 nsid::SUBSCRIPTION.to_string()
194}
195
196impl Subscription {
197 /// Construct a minimal subscription with only the required fields.
198 pub fn new(url: impl Into<String>, created_at: impl Into<String>) -> Self {
199 Self {
200 r#type: nsid::SUBSCRIPTION.to_string(),
201 url: url.into(),
202 title: None,
203 site_url: None,
204 folder: None,
205 fetch_hint: None,
206 private: None,
207 created_at: created_at.into(),
208 }
209 }
210}
211
212/// `community.lexicon.rss.folder` — a named folder/grouping for subscriptions.
213/// Record key: `tid`.
214///
215/// `name` + `createdAt` are required; `position` is an optional sort hint.
216#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
217pub struct Folder {
218 /// The `$type` NSID discriminator; always [`nsid::FOLDER`].
219 #[serde(rename = "$type", default = "folder_type")]
220 pub r#type: String,
221
222 /// Folder display name. Required.
223 pub name: String,
224
225 /// Optional sort hint among sibling folders (>= 0).
226 #[serde(skip_serializing_if = "Option::is_none", default)]
227 pub position: Option<u64>,
228
229 /// Record creation time (ISO-8601 datetime). Required.
230 #[serde(rename = "createdAt")]
231 pub created_at: String,
232
233 /// Every field of the record this build does not know, kept as it was
234 /// read so a put of the record writes them back (#268).
235 ///
236 /// The collection is shared with every other `community.lexicon.rss`
237 /// client, and a rename is a `putRecord` of the WHOLE record: without
238 /// this, a field another client added was erased by every rename here.
239 /// The known fields above are consumed by name before anything lands in
240 /// this map, so it never holds `$type`, `name`, `position` or `createdAt`
241 /// and a serialized record never carries a key twice. Empty for a folder
242 /// this build creates, so it adds nothing to that record.
243 #[serde(flatten)]
244 pub extra: serde_json::Map<String, serde_json::Value>,
245}
246
247fn folder_type() -> String {
248 nsid::FOLDER.to_string()
249}
250
251impl Folder {
252 /// Construct a minimal folder with only the required fields.
253 pub fn new(name: impl Into<String>, created_at: impl Into<String>) -> Self {
254 Self {
255 r#type: nsid::FOLDER.to_string(),
256 name: name.into(),
257 position: None,
258 created_at: created_at.into(),
259 extra: serde_json::Map::new(),
260 }
261 }
262}
263
264/// `community.lexicon.rss.saved` — an article kept for later (the reader's
265/// "star"). Record key: `tid`.
266///
267/// `url` + `createdAt` are required; the rest aid cross-reader dedup.
268#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
269pub struct Saved {
270 /// The `$type` NSID discriminator; always [`nsid::SAVED`].
271 #[serde(rename = "$type", default = "saved_type")]
272 pub r#type: String,
273
274 /// The article/entry permalink. Required.
275 pub url: String,
276
277 /// Display title of the saved entry.
278 #[serde(skip_serializing_if = "Option::is_none", default)]
279 pub title: Option<String>,
280
281 /// Feed the entry came from (soft ref; may outlive the subscription).
282 #[serde(rename = "feedUrl", skip_serializing_if = "Option::is_none", default)]
283 pub feed_url: Option<String>,
284
285 /// Feed-native guid/id when present, for cross-reader dedup.
286 #[serde(rename = "entryId", skip_serializing_if = "Option::is_none", default)]
287 pub entry_id: Option<String>,
288
289 /// Record creation time (ISO-8601 datetime). Required.
290 #[serde(rename = "createdAt")]
291 pub created_at: String,
292}
293
294fn saved_type() -> String {
295 nsid::SAVED.to_string()
296}
297
298impl Saved {
299 /// Construct a minimal saved entry with only the required fields.
300 pub fn new(url: impl Into<String>, created_at: impl Into<String>) -> Self {
301 Self {
302 r#type: nsid::SAVED.to_string(),
303 url: url.into(),
304 title: None,
305 feed_url: None,
306 entry_id: None,
307 created_at: created_at.into(),
308 }
309 }
310}
311
312/// `community.lexicon.rss.readState` — a batched read high-water-mark for a
313/// single feed. Record key: `any`; the rkey is derived deterministically from the
314/// feed (a hash of the feed URL), so there is one record per feed with a stable
315/// key, NOT one record per article.
316///
317/// `feedUrl` + `updatedAt` are required; `readThrough` is OPTIONAL — it is a
318/// water-mark ("every entry seen/published `<=` this is read"), so it is written
319/// only once a real high-water-mark exists. Omitting it (rather than synthesizing
320/// a flush-time value) means a brand-new cursor asserts nothing about the backlog:
321/// only the explicit `readIds` mark entries read. The two capped id-sets carry
322/// out-of-order reads and explicit mark-unread exceptions.
323#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
324pub struct ReadState {
325 /// The `$type` NSID discriminator; always [`nsid::READ_STATE`].
326 #[serde(rename = "$type", default = "read_state_type")]
327 pub r#type: String,
328
329 /// The feed this cursor covers. Required.
330 #[serde(rename = "feedUrl")]
331 pub feed_url: String,
332
333 /// High-water-mark: every entry with seen/published time <= this is READ.
334 /// **Optional** — omitted from the record when no local high-water-mark
335 /// exists yet, so a fresh cursor never implicitly marks the backlog read.
336 #[serde(
337 rename = "readThrough",
338 skip_serializing_if = "Option::is_none",
339 default
340 )]
341 pub read_through: Option<String>,
342
343 /// Entries newer than `readThrough` that are ALSO read (out-of-order reads).
344 /// Capped at 1000 by the lexicon; empty sets are omitted from the record.
345 #[serde(rename = "readIds", skip_serializing_if = "Vec::is_empty", default)]
346 pub read_ids: Vec<String>,
347
348 /// Entries older than `readThrough` explicitly kept UNREAD (mark-unread).
349 /// Capped at 1000 by the lexicon; empty sets are omitted from the record.
350 #[serde(rename = "unreadIds", skip_serializing_if = "Vec::is_empty", default)]
351 pub unread_ids: Vec<String>,
352
353 /// Last time this cursor was flushed (ISO-8601 datetime). Required. Intended
354 /// as the tie-breaker for cross-device merges (newest `updatedAt` wins);
355 /// a login-time reconcile that uses it is not implemented yet.
356 #[serde(rename = "updatedAt")]
357 pub updated_at: String,
358}
359
360fn read_state_type() -> String {
361 nsid::READ_STATE.to_string()
362}
363
364impl ReadState {
365 /// Maximum length of the `readIds` / `unreadIds` exception sets, per the
366 /// lexicon. The flusher enforces this cap before writing (see
367 /// `scheduler::cap`).
368 pub const MAX_IDS: usize = 1000;
369
370 /// Construct a minimal read cursor with only the required fields.
371 ///
372 /// `read_through` is optional: pass `None` for a cursor that has no local
373 /// high-water-mark yet, so the record omits `readThrough` entirely rather than
374 /// synthesizing a flush-time value that would mark the backlog read.
375 pub fn new(
376 feed_url: impl Into<String>,
377 read_through: Option<String>,
378 updated_at: impl Into<String>,
379 ) -> Self {
380 Self {
381 r#type: nsid::READ_STATE.to_string(),
382 feed_url: feed_url.into(),
383 read_through,
384 read_ids: Vec::new(),
385 unread_ids: Vec::new(),
386 updated_at: updated_at.into(),
387 }
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use serde_json::json;
395
396 /// A record written by some other client is attacker-controlled input.
397 ///
398 /// Asserted through `serde_json::from_str` rather than by calling the
399 /// deserialiser directly: the production path is a PDS fetch, and a test that
400 /// calls the helper would pass just as happily with `deserialize_with`
401 /// removed from the field.
402 #[test]
403 fn a_hostile_site_url_does_not_survive_deserialisation() {
404 for hostile in [
405 "javascript:alert(1)",
406 "data:text/html;base64,PHNjcmlwdD4=",
407 "vbscript:msgbox(1)",
408 " javascript:alert(1) ",
409 "not a url at all",
410 ] {
411 let json = serde_json::json!({
412 "$type": "community.lexicon.rss.subscription",
413 "url": "https://example.com/feed.xml",
414 "siteUrl": hostile,
415 "createdAt": "2026-01-01T00:00:00.000Z",
416 })
417 .to_string();
418 let sub: Subscription = serde_json::from_str(&json).expect("record should parse");
419 assert_eq!(
420 sub.site_url, None,
421 "{hostile:?} survived into a record this reader will re-publish and export"
422 );
423 assert_eq!(
424 sub.url, "https://example.com/feed.xml",
425 "the feed URL is not the field under test and must be untouched"
426 );
427 }
428 }
429
430 /// The check must not eat an ordinary record, and must normalise the way the
431 /// entry-link path already does.
432 #[test]
433 fn a_legitimate_site_url_survives_deserialisation() {
434 for (stored, expected) in [
435 ("https://example.com/blog", "https://example.com/blog"),
436 ("http://example.com/blog", "http://example.com/blog"),
437 (" https://example.com/blog ", "https://example.com/blog"),
438 ] {
439 let json = serde_json::json!({
440 "$type": "community.lexicon.rss.subscription",
441 "url": "https://example.com/feed.xml",
442 "siteUrl": stored,
443 "createdAt": "2026-01-01T00:00:00.000Z",
444 })
445 .to_string();
446 let sub: Subscription = serde_json::from_str(&json).expect("record should parse");
447 assert_eq!(sub.site_url.as_deref(), Some(expected));
448 }
449 }
450
451 /// An absent `siteUrl` stays absent — no empty string is invented, and the
452 /// `default` path must not trip over the custom deserialiser.
453 #[test]
454 fn an_absent_site_url_stays_absent() {
455 let json = serde_json::json!({
456 "$type": "community.lexicon.rss.subscription",
457 "url": "https://example.com/feed.xml",
458 "createdAt": "2026-01-01T00:00:00.000Z",
459 })
460 .to_string();
461 let sub: Subscription = serde_json::from_str(&json).expect("record should parse");
462 assert_eq!(sub.site_url, None);
463
464 // Explicit null is the same as absent, not an error.
465 let json = serde_json::json!({
466 "$type": "community.lexicon.rss.subscription",
467 "url": "https://example.com/feed.xml",
468 "siteUrl": serde_json::Value::Null,
469 "createdAt": "2026-01-01T00:00:00.000Z",
470 })
471 .to_string();
472 let sub: Subscription = serde_json::from_str(&json).expect("explicit null should parse");
473 assert_eq!(sub.site_url, None);
474 }
475
476 #[test]
477 fn subscription_round_trips_full_record() {
478 // Matches the atproto record shape returned by getRecord's `value`.
479 let value = json!({
480 "$type": "community.lexicon.rss.subscription",
481 "url": "https://example.com/feed.xml",
482 "title": "Example Blog",
483 "siteUrl": "https://example.com/",
484 "folder": "at://did:plc:abc123/community.lexicon.rss.folder/3kfolderrkey",
485 "fetchHint": "hourly",
486 "createdAt": "2026-07-12T00:00:00.000Z"
487 });
488
489 let sub: Subscription = serde_json::from_value(value.clone()).expect("deserialize");
490 assert_eq!(sub.r#type, nsid::SUBSCRIPTION);
491 assert_eq!(sub.url, "https://example.com/feed.xml");
492 assert_eq!(sub.title.as_deref(), Some("Example Blog"));
493 assert_eq!(sub.site_url.as_deref(), Some("https://example.com/"));
494 assert_eq!(sub.fetch_hint, Some(FetchHint::Hourly));
495
496 let back = serde_json::to_value(&sub).expect("serialize");
497 assert_eq!(back, value);
498 }
499
500 #[test]
501 fn subscription_minimal_omits_optional_fields() {
502 let sub = Subscription::new("https://example.com/feed.xml", "2026-07-12T00:00:00.000Z");
503 let back = serde_json::to_value(&sub).expect("serialize");
504 assert_eq!(
505 back,
506 json!({
507 "$type": "community.lexicon.rss.subscription",
508 "url": "https://example.com/feed.xml",
509 "createdAt": "2026-07-12T00:00:00.000Z"
510 })
511 );
512 }
513
514 #[test]
515 fn subscription_reserved_private_marker_omitted_by_default_but_round_trips() {
516 // Default construction never sets `private`; a public record omits it
517 // entirely (byte-for-byte unchanged from before the reserved field).
518 let public = Subscription::new("https://example.com/feed.xml", "2026-07-12T00:00:00.000Z");
519 assert_eq!(public.private, None);
520 let public_body = serde_json::to_value(&public).expect("serialize");
521 assert!(public_body.get("private").is_none());
522
523 // The reserved field is forward-compatible: if a future record ever
524 // carries `private: true`, it (de)serializes cleanly. Nothing in the
525 // current codebase sets it, but the seam must round-trip.
526 let mut future =
527 Subscription::new("https://example.com/feed.xml", "2026-07-12T00:00:00.000Z");
528 future.private = Some(true);
529 let back = serde_json::to_value(&future).expect("serialize");
530 assert_eq!(back["private"], serde_json::json!(true));
531 let parsed: Subscription = serde_json::from_value(back).expect("deserialize");
532 assert_eq!(parsed.private, Some(true));
533 }
534
535 #[test]
536 fn fetch_hint_open_enum_accepts_unknown() {
537 let sub: Subscription = serde_json::from_value(json!({
538 "url": "https://example.com/feed.xml",
539 "fetchHint": "every-15-min",
540 "createdAt": "2026-07-12T00:00:00.000Z"
541 }))
542 .expect("deserialize");
543 assert_eq!(
544 sub.fetch_hint,
545 Some(FetchHint::Other("every-15-min".to_string()))
546 );
547 // $type defaults in when the record value omits it.
548 assert_eq!(sub.r#type, nsid::SUBSCRIPTION);
549 }
550
551 #[test]
552 fn folder_round_trips() {
553 let value = json!({
554 "$type": "community.lexicon.rss.folder",
555 "name": "Tech",
556 "position": 2,
557 "createdAt": "2026-07-12T00:00:00.000Z"
558 });
559 let folder: Folder = serde_json::from_value(value.clone()).expect("deserialize");
560 assert_eq!(folder.name, "Tech");
561 assert_eq!(folder.position, Some(2));
562 assert_eq!(serde_json::to_value(&folder).expect("serialize"), value);
563 }
564
565 /// **A folder record keeps the fields this build does not know (#268).**
566 /// Other `community.lexicon.rss` clients write the same collection, and a
567 /// rename puts the whole record back: a field dropped on the way through
568 /// is erased from the reader's repo.
569 #[test]
570 fn folder_round_trips_fields_it_does_not_know() {
571 let value = json!({
572 "$type": "community.lexicon.rss.folder",
573 "name": "Tech",
574 "position": 3,
575 "createdAt": "2024-01-01T00:00:00.000Z",
576 "color": "#abc",
577 "nested": { "icon": "star", "tags": [1, "two", null] }
578 });
579 let folder: Folder = serde_json::from_value(value.clone()).expect("deserialize");
580 assert_eq!(folder.name, "Tech");
581 assert_eq!(folder.position, Some(3));
582 assert_eq!(serde_json::to_value(&folder).expect("serialize"), value);
583 // Serialized as text too: one key per field, never a duplicate.
584 let text = serde_json::to_string(&folder).expect("serialize");
585 for key in ["$type", "name", "position", "createdAt", "color", "nested"] {
586 assert_eq!(
587 text.matches(&format!("\"{key}\":")).count(),
588 1,
589 "{key} not emitted exactly once: {text}"
590 );
591 }
592 }
593
594 /// A folder this build creates carries the lexicon's fields and nothing
595 /// else — no empty catch-all key, no nulls.
596 #[test]
597 fn a_new_folder_serializes_only_its_own_fields() {
598 let folder = Folder::new("Tech", "2026-07-12T00:00:00.000Z");
599 assert_eq!(
600 serde_json::to_value(&folder).expect("serialize"),
601 json!({
602 "$type": "community.lexicon.rss.folder",
603 "name": "Tech",
604 "createdAt": "2026-07-12T00:00:00.000Z"
605 })
606 );
607 }
608
609 #[test]
610 fn saved_round_trips() {
611 let value = json!({
612 "$type": "community.lexicon.rss.saved",
613 "url": "https://example.com/post/1",
614 "title": "A kept post",
615 "feedUrl": "https://example.com/feed.xml",
616 "entryId": "tag:example.com,2026:1",
617 "createdAt": "2026-07-12T00:00:00.000Z"
618 });
619 let saved: Saved = serde_json::from_value(value.clone()).expect("deserialize");
620 assert_eq!(saved.url, "https://example.com/post/1");
621 assert_eq!(
622 saved.feed_url.as_deref(),
623 Some("https://example.com/feed.xml")
624 );
625 assert_eq!(saved.entry_id.as_deref(), Some("tag:example.com,2026:1"));
626 assert_eq!(serde_json::to_value(&saved).expect("serialize"), value);
627 }
628
629 #[test]
630 fn read_state_round_trips_with_id_sets() {
631 let value = json!({
632 "$type": "community.lexicon.rss.readState",
633 "feedUrl": "https://example.com/feed.xml",
634 "readThrough": "2026-07-12T00:00:00.000Z",
635 "readIds": ["entry-a", "entry-b"],
636 "unreadIds": ["entry-c"],
637 "updatedAt": "2026-07-12T01:00:00.000Z"
638 });
639 let rs: ReadState = serde_json::from_value(value.clone()).expect("deserialize");
640 assert_eq!(rs.feed_url, "https://example.com/feed.xml");
641 assert_eq!(rs.read_through.as_deref(), Some("2026-07-12T00:00:00.000Z"));
642 assert_eq!(rs.read_ids, vec!["entry-a", "entry-b"]);
643 assert_eq!(rs.unread_ids, vec!["entry-c"]);
644 assert_eq!(serde_json::to_value(&rs).expect("serialize"), value);
645 }
646
647 #[test]
648 fn read_state_minimal_omits_empty_id_sets() {
649 let rs = ReadState::new(
650 "https://example.com/feed.xml",
651 Some("2026-07-12T00:00:00.000Z".to_string()),
652 "2026-07-12T01:00:00.000Z",
653 );
654 let back = serde_json::to_value(&rs).expect("serialize");
655 assert_eq!(
656 back,
657 json!({
658 "$type": "community.lexicon.rss.readState",
659 "feedUrl": "https://example.com/feed.xml",
660 "readThrough": "2026-07-12T00:00:00.000Z",
661 "updatedAt": "2026-07-12T01:00:00.000Z"
662 })
663 );
664 }
665
666 #[test]
667 fn read_state_omits_read_through_when_none() {
668 // A brand-new cursor with no high-water-mark must NOT synthesize one:
669 // `readThrough` is absent entirely so the backlog is not implicitly read.
670 let rs = ReadState::new(
671 "https://example.com/feed.xml",
672 None,
673 "2026-07-12T01:00:00.000Z",
674 );
675 let back = serde_json::to_value(&rs).expect("serialize");
676 assert!(back.get("readThrough").is_none());
677 assert_eq!(
678 back,
679 json!({
680 "$type": "community.lexicon.rss.readState",
681 "feedUrl": "https://example.com/feed.xml",
682 "updatedAt": "2026-07-12T01:00:00.000Z"
683 })
684 );
685 // And a record without readThrough round-trips back to None.
686 let parsed: ReadState = serde_json::from_value(back).expect("deserialize");
687 assert_eq!(parsed.read_through, None);
688 }
689}
690
691/// Deterministic orderings for the reader's record lists.
692///
693/// These live here, beside the types, and are used by **both** the sidecar
694/// client and the Rust-native one. That is deliberate: the two clients coexist
695/// until cutover, and a divergence in ordering would not be a subtle bug — it
696/// would reorder the user's feed list the moment the implementation swapped, in
697/// a way no test comparing the clients' *data* would catch.
698pub mod sort {
699 use super::{Folder, Saved, Subscription};
700 use std::cmp::Ordering;
701
702 /// Subscriptions: display title (case-insensitive), then URL, then rkey.
703 ///
704 /// An untitled feed sorts by its URL, so it lands where a reader would look
705 /// for it rather than at one end of the list.
706 pub fn subscriptions(
707 (a_key, a): &(String, Subscription),
708 (b_key, b): &(String, Subscription),
709 ) -> Ordering {
710 let a_title = a.title.as_deref().unwrap_or(&a.url).to_lowercase();
711 let b_title = b.title.as_deref().unwrap_or(&b.url).to_lowercase();
712 a_title
713 .cmp(&b_title)
714 .then_with(|| a.url.cmp(&b.url))
715 .then_with(|| a_key.cmp(b_key))
716 }
717
718 /// Folders: `position` (the lexicon's sort hint; unset sorts LAST), then
719 /// name (case-insensitive), then rkey.
720 pub fn folders((a_key, a): &(String, Folder), (b_key, b): &(String, Folder)) -> Ordering {
721 a.position
722 .unwrap_or(u64::MAX)
723 .cmp(&b.position.unwrap_or(u64::MAX))
724 .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
725 .then_with(|| a_key.cmp(b_key))
726 }
727
728 /// Saved entries: newest first by `createdAt` (RFC 3339 sorts
729 /// lexicographically), then rkey ascending.
730 pub fn saved((a_key, a): &(String, Saved), (b_key, b): &(String, Saved)) -> Ordering {
731 b.created_at
732 .cmp(&a.created_at)
733 .then_with(|| a_key.cmp(b_key))
734 }
735}
736
737#[cfg(test)]
738mod sort_tests {
739 use super::sort;
740 use super::{Folder, Saved, Subscription};
741
742 fn sub(rkey: &str, url: &str, title: Option<&str>) -> (String, Subscription) {
743 let mut s = Subscription::new(url, "2026-01-01T00:00:00Z");
744 s.title = title.map(str::to_string);
745 (rkey.to_string(), s)
746 }
747
748 fn folder(rkey: &str, name: &str, position: Option<u64>) -> (String, Folder) {
749 let mut f = Folder::new(name, "2026-01-01T00:00:00Z");
750 f.position = position;
751 (rkey.to_string(), f)
752 }
753
754 fn saved(rkey: &str, url: &str, created_at: &str) -> (String, Saved) {
755 (rkey.to_string(), Saved::new(url, created_at))
756 }
757
758 fn order<T>(
759 mut items: Vec<(String, T)>,
760 cmp: fn(&(String, T), &(String, T)) -> std::cmp::Ordering,
761 ) -> Vec<String> {
762 items.sort_by(cmp);
763 items.into_iter().map(|(k, _)| k).collect()
764 }
765
766 /// Title first, and case must NOT split the alphabet.
767 #[test]
768 fn subscriptions_sort_by_title_case_insensitively() {
769 let items = vec![
770 sub("r1", "https://z.example/f", Some("banana")),
771 sub("r2", "https://a.example/f", Some("Apple")),
772 sub("r3", "https://m.example/f", Some("cherry")),
773 ];
774 assert_eq!(order(items, sort::subscriptions), ["r2", "r1", "r3"]);
775 }
776
777 /// An UNTITLED feed sorts by its URL, so it lands where a reader would look
778 /// rather than being bunched at one end.
779 #[test]
780 fn an_untitled_subscription_sorts_by_its_url() {
781 let items = vec![
782 sub("r1", "https://zebra.example/f", Some("aardvark")),
783 sub("r2", "https://bison.example/f", None),
784 ];
785 assert_eq!(order(items, sort::subscriptions), ["r1", "r2"]);
786 }
787
788 /// Equal titles fall to URL, then to rkey — so the order is TOTAL and a
789 /// re-read cannot shuffle the list.
790 #[test]
791 fn subscriptions_break_ties_by_url_then_rkey() {
792 let items = vec![
793 sub("r2", "https://b.example/f", Some("same")),
794 sub("r1", "https://b.example/f", Some("same")),
795 sub("r3", "https://a.example/f", Some("same")),
796 ];
797 assert_eq!(order(items, sort::subscriptions), ["r3", "r1", "r2"]);
798 }
799
800 /// `position` is the lexicon's sort hint; an UNSET one sorts last rather
801 /// than first, which `unwrap_or(0)` would have got backwards.
802 #[test]
803 fn folders_sort_by_position_with_unset_last() {
804 let items = vec![
805 folder("r1", "zulu", None),
806 folder("r2", "alpha", Some(10)),
807 folder("r3", "bravo", Some(2)),
808 ];
809 assert_eq!(order(items, sort::folders), ["r3", "r2", "r1"]);
810 }
811
812 #[test]
813 fn folders_break_ties_by_name_then_rkey() {
814 let items = vec![
815 folder("r2", "Beta", Some(1)),
816 folder("r1", "alpha", Some(1)),
817 folder("r3", "alpha", Some(1)),
818 ];
819 assert_eq!(order(items, sort::folders), ["r1", "r3", "r2"]);
820 }
821
822 /// Saved entries read NEWEST FIRST -- the one ordering here that is
823 /// descending, and the easiest to get backwards.
824 #[test]
825 fn saved_entries_are_newest_first() {
826 let items = vec![
827 saved("r1", "https://a.example/x", "2026-01-01T00:00:00Z"),
828 saved("r2", "https://b.example/x", "2026-06-01T00:00:00Z"),
829 saved("r3", "https://c.example/x", "2026-03-01T00:00:00Z"),
830 ];
831 assert_eq!(order(items, sort::saved), ["r2", "r3", "r1"]);
832 }
833
834 /// Same instant: rkey ASCENDING, even though the timestamp is descending.
835 #[test]
836 fn saved_entries_break_ties_by_ascending_rkey() {
837 let items = vec![
838 saved("r3", "https://c.example/x", "2026-01-01T00:00:00Z"),
839 saved("r1", "https://a.example/x", "2026-01-01T00:00:00Z"),
840 saved("r2", "https://b.example/x", "2026-01-01T00:00:00Z"),
841 ];
842 assert_eq!(order(items, sort::saved), ["r1", "r2", "r3"]);
843 }
844}