use std::collections::HashSet; use crate::code_spans::code_span_ranges; /// Extract unique `@username` mentions from raw markdown input. /// Skips mentions inside inline code (backtick-wrapped). pub fn extract_mentions(input: &str) -> Vec { static MENTION_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| regex_lite::Regex::new(r"@([A-Za-z0-9_-]+)").unwrap()); let stripped = crate::code_spans::strip_code_spans(input); let mut seen = HashSet::new(); let mut result = Vec::new(); for caps in MENTION_RE.captures_iter(&stripped) { let username = caps[1].to_string(); if seen.insert(username.clone()) { result.push(username); } } result } /// Replace `@username` with markdown profile links for valid usernames. /// /// `url_template` uses `{username}` as placeholder. For example: /// `/p/my-community/u/{username}` becomes `/p/my-community/u/alice`. /// /// Unknown usernames are left as plain text. pub fn resolve_mentions( input: &str, valid_usernames: &HashSet, url_template: &str, ) -> String { static MENTION_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| regex_lite::Regex::new(r"@([A-Za-z0-9_-]+)").unwrap()); let mut result = String::with_capacity(input.len()); let mut pos = 0; for (code_start, code_end) in code_span_ranges(input) { let before = &input[pos..code_start]; result.push_str(&replace_mentions(before, valid_usernames, url_template, &MENTION_RE)); result.push_str(&input[code_start..code_end]); pos = code_end; } let tail = &input[pos..]; result.push_str(&replace_mentions(tail, valid_usernames, url_template, &MENTION_RE)); result } fn replace_mentions( text: &str, valid_usernames: &HashSet, url_template: &str, re: ®ex_lite::Regex, ) -> String { re.replace_all(text, |caps: ®ex_lite::Captures| { let username = &caps[1]; if valid_usernames.contains(username) { let url = url_template.replace("{username}", username); format!("[@{username}]({url})") } else { caps[0].to_string() } }) .to_string() } #[cfg(test)] mod tests { use super::*; #[test] fn extract_basic() { let usernames = extract_mentions("Hello @alice and @bob!"); assert_eq!(usernames, vec!["alice", "bob"]); } #[test] fn extract_deduplicates() { let usernames = extract_mentions("@alice said @alice agrees"); assert_eq!(usernames, vec!["alice"]); } #[test] fn extract_skips_inline_code() { let usernames = extract_mentions("Hello `@notreal` and @real"); assert_eq!(usernames, vec!["real"]); } #[test] fn extract_skips_fenced_code() { let usernames = extract_mentions("text\n```\n@inside\n```\n@outside"); assert_eq!(usernames, vec!["outside"]); } #[test] fn extract_empty() { let usernames = extract_mentions("no mentions here"); assert!(usernames.is_empty()); } #[test] fn extract_with_hyphens_underscores() { let usernames = extract_mentions("@user-name @user_name"); assert_eq!(usernames, vec!["user-name", "user_name"]); } #[test] fn resolve_valid_replaced() { let valid: HashSet = ["alice"].iter().map(|s| s.to_string()).collect(); let result = resolve_mentions("Hello @alice!", &valid, "/p/test-community/u/{username}"); assert_eq!(result, "Hello [@alice](/p/test-community/u/alice)!"); } #[test] fn resolve_unknown_left_alone() { let valid: HashSet = HashSet::new(); let result = resolve_mentions("Hello @unknown!", &valid, "/u/{username}"); assert_eq!(result, "Hello @unknown!"); } #[test] fn resolve_in_code_not_replaced() { let valid: HashSet = ["alice"].iter().map(|s| s.to_string()).collect(); let result = resolve_mentions("Use `@alice` in code", &valid, "/u/{username}"); assert_eq!(result, "Use `@alice` in code"); } #[test] fn resolve_mixed_valid_invalid() { let valid: HashSet = ["alice"].iter().map(|s| s.to_string()).collect(); let result = resolve_mentions("@alice and @unknown", &valid, "/p/slug/u/{username}"); assert_eq!(result, "[@alice](/p/slug/u/alice) and @unknown"); } #[test] fn resolve_custom_url_template() { let valid: HashSet = ["bob"].iter().map(|s| s.to_string()).collect(); let result = resolve_mentions("Hi @bob", &valid, "/users/{username}/profile"); assert_eq!(result, "Hi [@bob](/users/bob/profile)"); } }