Skip to main content

rss_cli/
identity.rs

1//! Deterministic, cache-independent stable item IDs — **the keystone.** Owner: `parser`.
2//!
3//! GUIDs are unreliable in the wild (~41% of feeds regenerate them every fetch), so the id
4//! is a deterministic content hash that is identical across runs and machines *by
5//! construction*, never relying on the cache or on guid stability:
6//!
7//! ```text
8//! key = first present of: link -> guid -> (title + "|" + published)
9//! id  = lowercase_hex(sha256(feed_url + "\n" + key))[..16]
10//! ```
11//!
12//! `id_source` records which field supplied `key`. If nothing is present, fall back to a
13//! hash of `feed_url` alone with `IdSource::Hash` (degenerate but stable for that feed).
14
15use sha2::{Digest, Sha256};
16
17use crate::model::IdSource;
18
19/// Compute the stable id and its source for an item. **Owner: `parser` agent.**
20pub fn item_id(
21    feed_url: &str,
22    link: Option<&str>,
23    guid: Option<&str>,
24    title: Option<&str>,
25    published: Option<&str>,
26) -> (String, IdSource) {
27    // Pick the first present & non-empty field, recording where the key came from.
28    let (key, source) = match non_empty(link) {
29        Some(l) => (l.to_string(), IdSource::Link),
30        None => match non_empty(guid) {
31            Some(g) => (g.to_string(), IdSource::Guid),
32            // Neither link nor guid: derive the key from the (title + "|" + published)
33            // pair. When both are absent the composite collapses to the empty key, per the
34            // spec's "if none present, key = \"\"" — a stable hash of the feed namespace
35            // alone. A present title or published still disambiguates such items.
36            None => {
37                let composite = format!("{}|{}", title.unwrap_or(""), published.unwrap_or(""));
38                let key = if composite == "|" {
39                    String::new()
40                } else {
41                    composite
42                };
43                (key, IdSource::Hash)
44            }
45        },
46    };
47
48    // id = lowercase hex of sha256(feed_url + "\n" + key), truncated to 16 hex chars.
49    let mut hasher = Sha256::new();
50    hasher.update(feed_url.as_bytes());
51    hasher.update(b"\n");
52    hasher.update(key.as_bytes());
53    let digest = hasher.finalize();
54
55    let mut hex = String::with_capacity(16);
56    for byte in digest.iter().take(8) {
57        hex.push_str(&format!("{byte:02x}"));
58    }
59
60    (hex, source)
61}
62
63/// `Some(s)` only when `s` is present and not empty.
64fn non_empty(value: Option<&str>) -> Option<&str> {
65    value.filter(|s| !s.is_empty())
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    const FEED: &str = "https://example.com/feed";
73
74    #[test]
75    fn known_answer_pins_byte_construction() {
76        // Anchor test: expected hex computed independently via
77        //   printf 'https://example.com/feed\nhttps://example.com/a' | shasum -a 256
78        // This pins the exact construction: single '\n' separator, no trailing newline,
79        // hex-string (not byte) truncation to 16 chars, lowercase.
80        let (id, source) = item_id(FEED, Some("https://example.com/a"), None, None, None);
81        assert_eq!(id, "1b9107de952289cb");
82        assert_eq!(source, IdSource::Link);
83    }
84
85    #[test]
86    fn deterministic_across_calls() {
87        let a = item_id(
88            FEED,
89            Some("https://example.com/a"),
90            Some("guid-1"),
91            None,
92            None,
93        );
94        let b = item_id(
95            FEED,
96            Some("https://example.com/a"),
97            Some("guid-1"),
98            None,
99            None,
100        );
101        assert_eq!(a, b);
102    }
103
104    #[test]
105    fn link_preferred_over_guid() {
106        let (with_link, src) = item_id(
107            FEED,
108            Some("https://example.com/a"),
109            Some("guid"),
110            None,
111            None,
112        );
113        let (link_only, _) = item_id(FEED, Some("https://example.com/a"), None, None, None);
114        // The id ignores the guid entirely when a link is present.
115        assert_eq!(with_link, link_only);
116        assert_eq!(src, IdSource::Link);
117
118        // Falling back to guid yields a different id and records the source.
119        let (guid_based, guid_src) = item_id(FEED, None, Some("guid"), None, None);
120        assert_ne!(with_link, guid_based);
121        assert_eq!(guid_src, IdSource::Guid);
122    }
123
124    #[test]
125    fn hash_fallback_when_no_link_or_guid() {
126        let (_, src) = item_id(
127            FEED,
128            None,
129            None,
130            Some("A Title"),
131            Some("2026-01-01T00:00:00Z"),
132        );
133        assert_eq!(src, IdSource::Hash);
134
135        // Truly-all-absent collapses to the empty key, per spec ("if none present, key = ''").
136        // Known answer: printf 'https://example.com/feed\n' | shasum -a 256 -> a86aced5664c7742
137        let (empty_a, src_a) = item_id(FEED, Some(""), Some(""), None, None);
138        let (empty_b, _) = item_id(FEED, None, None, None, None);
139        assert_eq!(empty_a, empty_b);
140        assert_eq!(empty_a, "a86aced5664c7742");
141        assert_eq!(src_a, IdSource::Hash);
142
143        // A present published timestamp still disambiguates an otherwise title-less item.
144        let (with_pub, _) = item_id(FEED, None, None, None, Some("2026-01-01T00:00:00Z"));
145        assert_ne!(with_pub, empty_a);
146    }
147
148    #[test]
149    fn different_feed_url_gives_different_id() {
150        let (a, _) = item_id(FEED, Some("https://example.com/a"), None, None, None);
151        let (b, _) = item_id(
152            "https://other.com/feed",
153            Some("https://example.com/a"),
154            None,
155            None,
156            None,
157        );
158        assert_ne!(a, b);
159    }
160
161    #[test]
162    fn id_is_16_lowercase_hex_chars() {
163        let (id, _) = item_id(FEED, Some("https://example.com/a"), None, None, None);
164        assert_eq!(id.len(), 16);
165        assert!(
166            id.chars()
167                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
168        );
169    }
170}