1use sha2::{Digest, Sha256};
16
17use crate::model::IdSource;
18
19pub 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 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 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 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
63fn 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 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 assert_eq!(with_link, link_only);
116 assert_eq!(src, IdSource::Link);
117
118 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 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 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}