feather-reader 0.4.7

A minimalist, atproto-native RSS/Atom reader in Rust — your feed subscriptions live in your own PDS.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
//! Serde types for the `community.lexicon.rss.*` atproto record schemas.
//!
//! FeatherReader's defining bet is that a user's feed subscriptions, folders,
//! saved items, and batched read-state live as records in their own atproto PDS
//! under an **open, vendor-neutral community lexicon** (`community.lexicon.rss.*`)
//! rather than in the app's database — portable across any reader that adopts
//! the standard, not merely across FeatherReader instances.
//!
//! These types mirror the `community.lexicon.rss.*` schemas, authored in
//! the Lexicon Community idiom (`createdAt`/`updatedAt` as ISO-8601 datetimes,
//! `url`/`siteUrl`/`feedUrl` as URIs, `folder` as an `at://` strong ref). Each
//! record carries its `$type` NSID so it round-trips against the atproto record
//! shape returned by `com.atproto.repo.getRecord` / `listRecords`.
//!
//! Storage rules (never write these authoritatively to local SQLite):
//! - [`Subscription`] — one followed feed. `com.atproto.repo.createRecord` on
//!   subscribe; `deleteRecord` on unsubscribe. Source of truth for the follow list.
//! - [`Folder`] — a lightweight named grouping (a feed lives in one folder).
//! - [`Saved`] — a starred / save-for-later entry.
//! - [`ReadState`] — the **batched** per-feed read cursor (one record per feed,
//!   at a feed-derived rkey — never one record per article). Written by the
//!   read-state flusher; see the caveats on that flush path in
//!   [`crate::atproto`].

use serde::{Deserialize, Serialize};

/// NSID `$type` constants for the `community.lexicon.rss.*` record collections.
///
/// These double as the atproto **collection** NSIDs for `listRecords` /
/// `createRecord` / `putRecord` calls.
pub mod nsid {
    /// `community.lexicon.rss.subscription` — one followed feed.
    pub const SUBSCRIPTION: &str = "community.lexicon.rss.subscription";
    /// `community.lexicon.rss.folder` — a named grouping of subscriptions.
    pub const FOLDER: &str = "community.lexicon.rss.folder";
    /// `community.lexicon.rss.saved` — a starred / save-for-later entry.
    pub const SAVED: &str = "community.lexicon.rss.saved";
    /// `community.lexicon.rss.readState` — batched per-feed read cursor.
    pub const READ_STATE: &str = "community.lexicon.rss.readState";

    /// `site.standard.publication` — a standard.site publication. NOT one of
    /// ours: it is another project's lexicon, named here because it is the only
    /// foreign collection this reader will accept as a subscribable feed.
    pub const STANDARD_PUBLICATION: &str = "site.standard.publication";

    /// `site.standard.document` — one standard.site article. Also not ours.
    pub const STANDARD_DOCUMENT: &str = "site.standard.document";
}

/// Optional polling-cadence hint on a [`Subscription`]. Readers MAY honor or
/// ignore it. Mirrors the lexicon's `knownValues` for `fetchHint`.
///
/// `knownValues` in atproto is an *open* enum — an unrecognized value MUST NOT
/// break deserialization — so [`FetchHint::Other`] captures forward-compatible
/// values a future reader might write.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum FetchHint {
    /// Poll as close to realtime as the reader supports.
    Realtime,
    /// Poll roughly hourly.
    Hourly,
    /// Poll roughly daily.
    Daily,
    /// Poll roughly weekly.
    Weekly,
    /// An unrecognized (forward-compatible) hint value.
    #[serde(untagged)]
    Other(String),
}

/// Drop a `siteUrl` this reader would refuse to render, at the point a record
/// crosses into the process.
///
/// Absent stays absent and a good URL is passed through trimmed, matching
/// [`crate::net::safe_link`]'s handling of entry links — the same allow-list, so
/// the two URL fields on a record cannot disagree about what a link is.
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))
}

/// `community.lexicon.rss.subscription` — a subscription to a syndication feed
/// (RSS / Atom / JSON Feed). Record key: `tid`.
///
/// `url` + `createdAt` are required; everything else is optional.
///
/// ## Public feeds only (and the reserved `private` marker)
///
/// atproto PDS records are **public**: anyone can read them via unauthenticated
/// `getRecord` / `listRecords` and off the firehose, and they are retained even
/// after `deleteRecord`. A **private feed** (a Substack `…/feed/private/<token>`,
/// a Patreon `?auth=…` feed, a Ghost members `?uuid=` feed, a private-podcast
/// token feed, or any URL that carries a secret token / key / auth credential)
/// has its *secret in the URL*, so writing that URL here would leak paid /
/// members-only access to the whole network.
///
/// **Current decision: FeatherReader supports PUBLIC feeds only.** A private
/// feed is *refused* at the add / import boundary (see
/// [`crate::feed::classify_feed_privacy`]) — it is never fetched, never stored,
/// and no record (redacted or otherwise) is ever written. The server therefore
/// holds NO private secret, which keeps "your data lives in your public PDS"
/// 100% honest. Consequently every [`Subscription`] record actually written
/// carries a real, public feed `url`, and [`Subscription::private`] is **always omitted**.
///
/// The [`Subscription::private`] field is retained ONLY as a documented, forward-compatible
/// **reserved marker** for the eventual migration once atproto ships
/// **permissioned data / permission-sets** (early-proposal as of mid-2026,
/// bluesky-social/proposals#94). At that point a private feed's secret can live
/// in an owner-scoped, permission-gated collection and this record can reference
/// it with `private: true`. Until then the field has **no runtime behavior** —
/// nothing sets it and nothing branches on it.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct Subscription {
    /// The `$type` NSID discriminator; always [`nsid::SUBSCRIPTION`].
    #[serde(rename = "$type", default = "subscription_type")]
    pub r#type: String,

    /// Canonical feed URL (the RSS/Atom/JSON Feed document). Required.
    ///
    /// Always a real, PUBLIC feed URL: private/secret-bearing feeds are refused
    /// at the add boundary (see the type-level docs), so no record with a
    /// withheld or redacted `url` is ever written.
    pub url: String,

    /// Display title; a reader MAY override from feed metadata.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub title: Option<String>,

    /// Human-facing site the feed belongs to.
    ///
    /// **Scheme-checked on the way in.** Any atproto client can write this field
    /// into the user's repo, and the lexicon invites readers to render it as a
    /// link, so a record fetched from the PDS is attacker-controlled input. The
    /// `deserialize_with` below is the read-side counterpart to the write-side
    /// vet in [`crate::repo`]: together they mean a `Subscription` that entered
    /// this process from outside cannot be carrying a `javascript:` URL, whatever
    /// it is later rendered into — an `href`, or an OPML `htmlUrl` we hand back
    /// to the user as a file.
    ///
    /// **The READ side only.** A record built in-process rather than
    /// deserialised does not pass through here — OPML import parses `htmlUrl`
    /// out of XML by hand, and the manage form assigns the field directly.
    /// Those are the write boundary's to vet, which is why both guards exist
    /// rather than either one being sufficient.
    ///
    /// A rejected value becomes `None`, so it is omitted rather than emitted
    /// empty; a consumer renders no link instead of a broken one.
    ///
    /// **Round-trip fidelity is deliberately lost.** Read a record holding a
    /// hostile `siteUrl`, re-put it, and we write it back cleaned rather than
    /// preserving what another client stored. That heals the user's repo
    /// instead of propagating someone else's script URL — but it does mean a
    /// `putRecord` following a read is not byte-identical to what was there,
    /// and that is a decision, not an accident.
    #[serde(
        rename = "siteUrl",
        skip_serializing_if = "Option::is_none",
        default,
        deserialize_with = "de_scheme_checked"
    )]
    pub site_url: Option<String>,

    /// Optional `at://` strong ref to a [`Folder`] record.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub folder: Option<String>,

    /// Optional polling-cadence hint; readers MAY honor or ignore it.
    #[serde(rename = "fetchHint", skip_serializing_if = "Option::is_none", default)]
    pub fetch_hint: Option<FetchHint>,

    /// **Reserved** — no runtime behavior today.
    ///
    /// FeatherReader currently supports public feeds only (private/secret-bearing
    /// feeds are refused at the add boundary), so nothing sets this and every
    /// written record omits it (`None`). It is kept as a documented,
    /// forward-compatible seam for the eventual migration once atproto ships
    /// permissioned data: at that point a private feed's secret can live in an
    /// owner-scoped, permission-gated collection and this record can reference it
    /// with `private: true`. See the type-level docs.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub private: Option<bool>,

    /// Record creation time (ISO-8601 datetime). Required.
    #[serde(rename = "createdAt")]
    pub created_at: String,
}

fn subscription_type() -> String {
    nsid::SUBSCRIPTION.to_string()
}

impl Subscription {
    /// Construct a minimal subscription with only the required fields.
    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(),
        }
    }
}

/// `community.lexicon.rss.folder` — a named folder/grouping for subscriptions.
/// Record key: `tid`.
///
/// `name` + `createdAt` are required; `position` is an optional sort hint.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct Folder {
    /// The `$type` NSID discriminator; always [`nsid::FOLDER`].
    #[serde(rename = "$type", default = "folder_type")]
    pub r#type: String,

    /// Folder display name. Required.
    pub name: String,

    /// Optional sort hint among sibling folders (>= 0).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub position: Option<u64>,

    /// Record creation time (ISO-8601 datetime). Required.
    #[serde(rename = "createdAt")]
    pub created_at: String,

    /// Every field of the record this build does not know, kept as it was
    /// read so a put of the record writes them back (#268).
    ///
    /// The collection is shared with every other `community.lexicon.rss`
    /// client, and a rename is a `putRecord` of the WHOLE record: without
    /// this, a field another client added was erased by every rename here.
    /// The known fields above are consumed by name before anything lands in
    /// this map, so it never holds `$type`, `name`, `position` or `createdAt`
    /// and a serialized record never carries a key twice. Empty for a folder
    /// this build creates, so it adds nothing to that record.
    #[serde(flatten)]
    pub extra: serde_json::Map<String, serde_json::Value>,
}

fn folder_type() -> String {
    nsid::FOLDER.to_string()
}

impl Folder {
    /// Construct a minimal folder with only the required fields.
    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(),
        }
    }
}

/// `community.lexicon.rss.saved` — an article kept for later (the reader's
/// "star"). Record key: `tid`.
///
/// `url` + `createdAt` are required; the rest aid cross-reader dedup.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct Saved {
    /// The `$type` NSID discriminator; always [`nsid::SAVED`].
    #[serde(rename = "$type", default = "saved_type")]
    pub r#type: String,

    /// The article/entry permalink. Required.
    pub url: String,

    /// Display title of the saved entry.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub title: Option<String>,

    /// Feed the entry came from (soft ref; may outlive the subscription).
    #[serde(rename = "feedUrl", skip_serializing_if = "Option::is_none", default)]
    pub feed_url: Option<String>,

    /// Feed-native guid/id when present, for cross-reader dedup.
    #[serde(rename = "entryId", skip_serializing_if = "Option::is_none", default)]
    pub entry_id: Option<String>,

    /// Record creation time (ISO-8601 datetime). Required.
    #[serde(rename = "createdAt")]
    pub created_at: String,
}

fn saved_type() -> String {
    nsid::SAVED.to_string()
}

impl Saved {
    /// Construct a minimal saved entry with only the required fields.
    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(),
        }
    }
}

/// `community.lexicon.rss.readState` — a batched read high-water-mark for a
/// single feed. Record key: `any`; the rkey is derived deterministically from the
/// feed (a hash of the feed URL), so there is one record per feed with a stable
/// key, NOT one record per article.
///
/// `feedUrl` + `updatedAt` are required; `readThrough` is OPTIONAL — it is a
/// water-mark ("every entry seen/published `<=` this is read"), so it is written
/// only once a real high-water-mark exists. Omitting it (rather than synthesizing
/// a flush-time value) means a brand-new cursor asserts nothing about the backlog:
/// only the explicit `readIds` mark entries read. The two capped id-sets carry
/// out-of-order reads and explicit mark-unread exceptions.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct ReadState {
    /// The `$type` NSID discriminator; always [`nsid::READ_STATE`].
    #[serde(rename = "$type", default = "read_state_type")]
    pub r#type: String,

    /// The feed this cursor covers. Required.
    #[serde(rename = "feedUrl")]
    pub feed_url: String,

    /// High-water-mark: every entry with seen/published time <= this is READ.
    /// **Optional** — omitted from the record when no local high-water-mark
    /// exists yet, so a fresh cursor never implicitly marks the backlog read.
    #[serde(
        rename = "readThrough",
        skip_serializing_if = "Option::is_none",
        default
    )]
    pub read_through: Option<String>,

    /// Entries newer than `readThrough` that are ALSO read (out-of-order reads).
    /// Capped at 1000 by the lexicon; empty sets are omitted from the record.
    #[serde(rename = "readIds", skip_serializing_if = "Vec::is_empty", default)]
    pub read_ids: Vec<String>,

    /// Entries older than `readThrough` explicitly kept UNREAD (mark-unread).
    /// Capped at 1000 by the lexicon; empty sets are omitted from the record.
    #[serde(rename = "unreadIds", skip_serializing_if = "Vec::is_empty", default)]
    pub unread_ids: Vec<String>,

    /// Last time this cursor was flushed (ISO-8601 datetime). Required. Intended
    /// as the tie-breaker for cross-device merges (newest `updatedAt` wins);
    /// a login-time reconcile that uses it is not implemented yet.
    #[serde(rename = "updatedAt")]
    pub updated_at: String,
}

fn read_state_type() -> String {
    nsid::READ_STATE.to_string()
}

impl ReadState {
    /// Maximum length of the `readIds` / `unreadIds` exception sets, per the
    /// lexicon. The flusher enforces this cap before writing (see
    /// `scheduler::cap`).
    pub const MAX_IDS: usize = 1000;

    /// Construct a minimal read cursor with only the required fields.
    ///
    /// `read_through` is optional: pass `None` for a cursor that has no local
    /// high-water-mark yet, so the record omits `readThrough` entirely rather than
    /// synthesizing a flush-time value that would mark the backlog read.
    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;

    /// A record written by some other client is attacker-controlled input.
    ///
    /// Asserted through `serde_json::from_str` rather than by calling the
    /// deserialiser directly: the production path is a PDS fetch, and a test that
    /// calls the helper would pass just as happily with `deserialize_with`
    /// removed from the field.
    #[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"
            );
        }
    }

    /// The check must not eat an ordinary record, and must normalise the way the
    /// entry-link path already does.
    #[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));
        }
    }

    /// An absent `siteUrl` stays absent — no empty string is invented, and the
    /// `default` path must not trip over the custom deserialiser.
    #[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);

        // Explicit null is the same as absent, not an error.
        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() {
        // Matches the atproto record shape returned by getRecord's `value`.
        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() {
        // Default construction never sets `private`; a public record omits it
        // entirely (byte-for-byte unchanged from before the reserved field).
        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());

        // The reserved field is forward-compatible: if a future record ever
        // carries `private: true`, it (de)serializes cleanly. Nothing in the
        // current codebase sets it, but the seam must round-trip.
        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()))
        );
        // $type defaults in when the record value omits it.
        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);
    }

    /// **A folder record keeps the fields this build does not know (#268).**
    /// Other `community.lexicon.rss` clients write the same collection, and a
    /// rename puts the whole record back: a field dropped on the way through
    /// is erased from the reader's repo.
    #[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);
        // Serialized as text too: one key per field, never a duplicate.
        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}"
            );
        }
    }

    /// A folder this build creates carries the lexicon's fields and nothing
    /// else — no empty catch-all key, no nulls.
    #[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() {
        // A brand-new cursor with no high-water-mark must NOT synthesize one:
        // `readThrough` is absent entirely so the backlog is not implicitly read.
        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"
            })
        );
        // And a record without readThrough round-trips back to None.
        let parsed: ReadState = serde_json::from_value(back).expect("deserialize");
        assert_eq!(parsed.read_through, None);
    }
}

/// Deterministic orderings for the reader's record lists.
///
/// These live here, beside the types, and are used by **both** the sidecar
/// client and the Rust-native one. That is deliberate: the two clients coexist
/// until cutover, and a divergence in ordering would not be a subtle bug — it
/// would reorder the user's feed list the moment the implementation swapped, in
/// a way no test comparing the clients' *data* would catch.
pub mod sort {
    use super::{Folder, Saved, Subscription};
    use std::cmp::Ordering;

    /// Subscriptions: display title (case-insensitive), then URL, then rkey.
    ///
    /// An untitled feed sorts by its URL, so it lands where a reader would look
    /// for it rather than at one end of the list.
    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))
    }

    /// Folders: `position` (the lexicon's sort hint; unset sorts LAST), then
    /// name (case-insensitive), then rkey.
    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))
    }

    /// Saved entries: newest first by `createdAt` (RFC 3339 sorts
    /// lexicographically), then rkey ascending.
    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()
    }

    /// Title first, and case must NOT split the alphabet.
    #[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"]);
    }

    /// An UNTITLED feed sorts by its URL, so it lands where a reader would look
    /// rather than being bunched at one end.
    #[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"]);
    }

    /// Equal titles fall to URL, then to rkey — so the order is TOTAL and a
    /// re-read cannot shuffle the list.
    #[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"]);
    }

    /// `position` is the lexicon's sort hint; an UNSET one sorts last rather
    /// than first, which `unwrap_or(0)` would have got backwards.
    #[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"]);
    }

    /// Saved entries read NEWEST FIRST -- the one ordering here that is
    /// descending, and the easiest to get backwards.
    #[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"]);
    }

    /// Same instant: rkey ASCENDING, even though the timestamp is descending.
    #[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"]);
    }
}