Skip to main content

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}