use sha2::{Digest, Sha256};
use crate::model::IdSource;
pub fn item_id(
feed_url: &str,
link: Option<&str>,
guid: Option<&str>,
title: Option<&str>,
published: Option<&str>,
) -> (String, IdSource) {
let (key, source) = match non_empty(link) {
Some(l) => (l.to_string(), IdSource::Link),
None => match non_empty(guid) {
Some(g) => (g.to_string(), IdSource::Guid),
None => {
let composite = format!("{}|{}", title.unwrap_or(""), published.unwrap_or(""));
let key = if composite == "|" {
String::new()
} else {
composite
};
(key, IdSource::Hash)
}
},
};
let mut hasher = Sha256::new();
hasher.update(feed_url.as_bytes());
hasher.update(b"\n");
hasher.update(key.as_bytes());
let digest = hasher.finalize();
let mut hex = String::with_capacity(16);
for byte in digest.iter().take(8) {
hex.push_str(&format!("{byte:02x}"));
}
(hex, source)
}
fn non_empty(value: Option<&str>) -> Option<&str> {
value.filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
const FEED: &str = "https://example.com/feed";
#[test]
fn known_answer_pins_byte_construction() {
let (id, source) = item_id(FEED, Some("https://example.com/a"), None, None, None);
assert_eq!(id, "1b9107de952289cb");
assert_eq!(source, IdSource::Link);
}
#[test]
fn deterministic_across_calls() {
let a = item_id(
FEED,
Some("https://example.com/a"),
Some("guid-1"),
None,
None,
);
let b = item_id(
FEED,
Some("https://example.com/a"),
Some("guid-1"),
None,
None,
);
assert_eq!(a, b);
}
#[test]
fn link_preferred_over_guid() {
let (with_link, src) = item_id(
FEED,
Some("https://example.com/a"),
Some("guid"),
None,
None,
);
let (link_only, _) = item_id(FEED, Some("https://example.com/a"), None, None, None);
assert_eq!(with_link, link_only);
assert_eq!(src, IdSource::Link);
let (guid_based, guid_src) = item_id(FEED, None, Some("guid"), None, None);
assert_ne!(with_link, guid_based);
assert_eq!(guid_src, IdSource::Guid);
}
#[test]
fn hash_fallback_when_no_link_or_guid() {
let (_, src) = item_id(
FEED,
None,
None,
Some("A Title"),
Some("2026-01-01T00:00:00Z"),
);
assert_eq!(src, IdSource::Hash);
let (empty_a, src_a) = item_id(FEED, Some(""), Some(""), None, None);
let (empty_b, _) = item_id(FEED, None, None, None, None);
assert_eq!(empty_a, empty_b);
assert_eq!(empty_a, "a86aced5664c7742");
assert_eq!(src_a, IdSource::Hash);
let (with_pub, _) = item_id(FEED, None, None, None, Some("2026-01-01T00:00:00Z"));
assert_ne!(with_pub, empty_a);
}
#[test]
fn different_feed_url_gives_different_id() {
let (a, _) = item_id(FEED, Some("https://example.com/a"), None, None, None);
let (b, _) = item_id(
"https://other.com/feed",
Some("https://example.com/a"),
None,
None,
None,
);
assert_ne!(a, b);
}
#[test]
fn id_is_16_lowercase_hex_chars() {
let (id, _) = item_id(FEED, Some("https://example.com/a"), None, None, None);
assert_eq!(id.len(), 16);
assert!(
id.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
);
}
}