use std::ops::Range;
use uuid::Uuid;
pub const SCHEME: &str = "chat://";
pub const SHORT_ID_CHARS: usize = 8;
pub const MIN_ID_CHARS: usize = SHORT_ID_CHARS / 2;
pub const MAX_ID_CHARS: usize = 32;
pub fn short_id(id: Uuid) -> String {
let hex = id.simple().to_string();
hex[..SHORT_ID_CHARS.min(hex.len())].to_string()
}
pub fn uri(id: Uuid) -> String {
format!("{SCHEME}{}", short_id(id))
}
pub fn hex_needle(reference: &str) -> Option<String> {
let trimmed = reference.trim();
let body = match trimmed.as_bytes() {
b if b.len() >= SCHEME.len()
&& b[..SCHEME.len()].eq_ignore_ascii_case(SCHEME.as_bytes()) =>
{
&trimmed[SCHEME.len()..]
}
_ => trimmed,
};
let hex: String = body
.chars()
.filter(|c| *c != '-')
.map(|c| c.to_ascii_lowercase())
.collect();
(hex.len() >= MIN_ID_CHARS
&& hex.len() <= MAX_ID_CHARS
&& hex.chars().all(|c| c.is_ascii_hexdigit()))
.then_some(hex)
}
pub fn resolve_prefix(known: &[Uuid], hex: &str) -> Option<Uuid> {
let mut hits = known
.iter()
.filter(|id| id.simple().to_string().starts_with(hex));
let first = hits.next()?;
hits.next().is_none().then_some(*first)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChatLink {
pub range: Range<usize>,
pub chat: Uuid,
}
pub fn find_refs(text: &str, known: &[Uuid]) -> Vec<ChatLink> {
if known.is_empty() || !text.contains("://") {
return Vec::new();
}
let bytes = text.as_bytes();
let mut out = Vec::new();
let mut at = 0usize;
while at + SCHEME.len() <= bytes.len() {
if !bytes[at..at + SCHEME.len()].eq_ignore_ascii_case(SCHEME.as_bytes()) {
at += 1;
continue;
}
let body = at + SCHEME.len();
let mut end = body;
while end < bytes.len() && (bytes[end].is_ascii_hexdigit() || bytes[end] == b'-') {
end += 1;
}
while end > body && bytes[end - 1] == b'-' {
end -= 1;
}
match hex_needle(&text[body..end]).and_then(|hex| resolve_prefix(known, &hex)) {
Some(chat) => {
out.push(ChatLink {
range: at..end,
chat,
});
at = end;
}
None => at = body,
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn ids() -> Vec<Uuid> {
vec![
Uuid::parse_str("1a2b3c4d-0000-4000-8000-000000000001").unwrap(),
Uuid::parse_str("9f8e7d6c-0000-4000-8000-000000000002").unwrap(),
]
}
#[test]
fn uri_is_scheme_plus_short_id() {
let id = ids()[0];
assert_eq!(uri(id), "chat://1a2b3c4d");
assert_eq!(short_id(id).len(), SHORT_ID_CHARS);
}
#[test]
fn hex_needle_accepts_both_forms_and_case() {
assert_eq!(hex_needle("chat://1A2B3C4D").as_deref(), Some("1a2b3c4d"));
assert_eq!(hex_needle("CHAT://1a2b3c4d").as_deref(), Some("1a2b3c4d"));
assert_eq!(hex_needle("1a2b3c4d").as_deref(), Some("1a2b3c4d"));
assert_eq!(
hex_needle("1a2b3c4d-0000-4000-8000-000000000001").as_deref(),
Some("1a2b3c4d000040008000000000000001")
);
}
#[test]
fn hex_needle_rejects_non_ids() {
assert!(hex_needle("abc").is_none());
assert!(hex_needle("a project").is_none());
assert!(hex_needle("").is_none());
assert!(hex_needle(&"a".repeat(MAX_ID_CHARS + 1)).is_none());
assert!(hex_needle("cafe").is_some());
}
#[test]
fn resolve_prefix_needs_exactly_one() {
let ids = ids();
assert_eq!(resolve_prefix(&ids, "1a2b"), Some(ids[0]));
assert_eq!(resolve_prefix(&ids, "dead"), None);
let same = vec![
Uuid::parse_str("aaaaaaaa-0000-4000-8000-000000000001").unwrap(),
Uuid::parse_str("aaaaaaaa-0000-4000-8000-000000000002").unwrap(),
];
assert_eq!(resolve_prefix(&same, "aaaa"), None);
}
#[test]
fn finds_a_bare_reference() {
let ids = ids();
let text = format!("see {} for the numbers", uri(ids[0]));
let found = find_refs(&text, &ids);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].chat, ids[0]);
assert_eq!(&text[found[0].range.clone()], "chat://1a2b3c4d");
}
#[test]
fn closing_paren_and_punctuation_are_not_part_of_the_address() {
let ids = ids();
for text in [
"Budget (chat://1a2b3c4d)",
"Budget chat://1a2b3c4d.",
"Budget chat://1a2b3c4d, and more",
"Budget chat://1a2b3c4d-",
] {
let found = find_refs(text, &ids);
assert_eq!(found.len(), 1, "{text}");
assert_eq!(&text[found[0].range.clone()], "chat://1a2b3c4d", "{text}");
}
}
#[test]
fn finds_several_and_keeps_them_ordered() {
let ids = ids();
let text = format!("{} then {}", uri(ids[0]), uri(ids[1]));
let found = find_refs(&text, &ids);
assert_eq!(found.len(), 2);
assert_eq!(found[0].chat, ids[0]);
assert_eq!(found[1].chat, ids[1]);
assert!(found[0].range.end <= found[1].range.start);
}
#[test]
fn unresolvable_references_are_not_returned() {
let ids = ids();
assert!(find_refs("chat://deadbeef", &ids).is_empty());
assert!(find_refs("chat://abc", &ids).is_empty());
assert!(find_refs("chat://", &ids).is_empty());
assert!(find_refs("no address here", &ids).is_empty());
assert!(find_refs(&uri(ids[0]), &[]).is_empty());
}
#[test]
fn accepts_a_full_uuid_in_the_address() {
let ids = ids();
let text = "chat://1a2b3c4d-0000-4000-8000-000000000001!";
let found = find_refs(text, &ids);
assert_eq!(found.len(), 1);
assert_eq!(found[0].chat, ids[0]);
assert!(!text[found[0].range.clone()].ends_with('!'));
}
#[test]
fn survives_non_ascii_neighbours() {
let ids = ids();
let text = format!("см. беседу {} — там числа", uri(ids[0]));
let found = find_refs(&text, &ids);
assert_eq!(found.len(), 1);
assert_eq!(&text[found[0].range.clone()], "chat://1a2b3c4d");
}
#[test]
fn another_scheme_is_left_alone() {
let ids = ids();
assert!(find_refs("https://example.com/1a2b3c4d", &ids).is_empty());
}
}