| 1 |
use std::collections::HashSet; |
| 2 |
|
| 3 |
use crate::code_spans::code_span_ranges; |
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
pub fn extract_mentions(input: &str) -> Vec<String> { |
| 8 |
static MENTION_RE: std::sync::LazyLock<regex_lite::Regex> = |
| 9 |
std::sync::LazyLock::new(|| regex_lite::Regex::new(r"@([A-Za-z0-9_-]+)").unwrap()); |
| 10 |
|
| 11 |
let stripped = crate::code_spans::strip_code_spans(input); |
| 12 |
let mut seen = HashSet::new(); |
| 13 |
let mut result = Vec::new(); |
| 14 |
for caps in MENTION_RE.captures_iter(&stripped) { |
| 15 |
let username = caps[1].to_string(); |
| 16 |
if seen.insert(username.clone()) { |
| 17 |
result.push(username); |
| 18 |
} |
| 19 |
} |
| 20 |
result |
| 21 |
} |
| 22 |
|
| 23 |
|
| 24 |
|
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
|
| 29 |
pub fn resolve_mentions( |
| 30 |
input: &str, |
| 31 |
valid_usernames: &HashSet<String>, |
| 32 |
url_template: &str, |
| 33 |
) -> String { |
| 34 |
static MENTION_RE: std::sync::LazyLock<regex_lite::Regex> = |
| 35 |
std::sync::LazyLock::new(|| regex_lite::Regex::new(r"@([A-Za-z0-9_-]+)").unwrap()); |
| 36 |
|
| 37 |
let mut result = String::with_capacity(input.len()); |
| 38 |
let mut pos = 0; |
| 39 |
|
| 40 |
for (code_start, code_end) in code_span_ranges(input) { |
| 41 |
let before = &input[pos..code_start]; |
| 42 |
result.push_str(&replace_mentions(before, valid_usernames, url_template, &MENTION_RE)); |
| 43 |
result.push_str(&input[code_start..code_end]); |
| 44 |
pos = code_end; |
| 45 |
} |
| 46 |
let tail = &input[pos..]; |
| 47 |
result.push_str(&replace_mentions(tail, valid_usernames, url_template, &MENTION_RE)); |
| 48 |
|
| 49 |
result |
| 50 |
} |
| 51 |
|
| 52 |
fn replace_mentions( |
| 53 |
text: &str, |
| 54 |
valid_usernames: &HashSet<String>, |
| 55 |
url_template: &str, |
| 56 |
re: ®ex_lite::Regex, |
| 57 |
) -> String { |
| 58 |
re.replace_all(text, |caps: ®ex_lite::Captures| { |
| 59 |
let username = &caps[1]; |
| 60 |
if valid_usernames.contains(username) { |
| 61 |
let url = url_template.replace("{username}", username); |
| 62 |
format!("[@{username}]({url})") |
| 63 |
} else { |
| 64 |
caps[0].to_string() |
| 65 |
} |
| 66 |
}) |
| 67 |
.to_string() |
| 68 |
} |
| 69 |
|
| 70 |
#[cfg(test)] |
| 71 |
mod tests { |
| 72 |
use super::*; |
| 73 |
|
| 74 |
#[test] |
| 75 |
fn extract_basic() { |
| 76 |
let usernames = extract_mentions("Hello @alice and @bob!"); |
| 77 |
assert_eq!(usernames, vec!["alice", "bob"]); |
| 78 |
} |
| 79 |
|
| 80 |
#[test] |
| 81 |
fn extract_deduplicates() { |
| 82 |
let usernames = extract_mentions("@alice said @alice agrees"); |
| 83 |
assert_eq!(usernames, vec!["alice"]); |
| 84 |
} |
| 85 |
|
| 86 |
#[test] |
| 87 |
fn extract_skips_inline_code() { |
| 88 |
let usernames = extract_mentions("Hello `@notreal` and @real"); |
| 89 |
assert_eq!(usernames, vec!["real"]); |
| 90 |
} |
| 91 |
|
| 92 |
#[test] |
| 93 |
fn extract_skips_fenced_code() { |
| 94 |
let usernames = extract_mentions("text\n```\n@inside\n```\n@outside"); |
| 95 |
assert_eq!(usernames, vec!["outside"]); |
| 96 |
} |
| 97 |
|
| 98 |
#[test] |
| 99 |
fn extract_empty() { |
| 100 |
let usernames = extract_mentions("no mentions here"); |
| 101 |
assert!(usernames.is_empty()); |
| 102 |
} |
| 103 |
|
| 104 |
#[test] |
| 105 |
fn extract_with_hyphens_underscores() { |
| 106 |
let usernames = extract_mentions("@user-name @user_name"); |
| 107 |
assert_eq!(usernames, vec!["user-name", "user_name"]); |
| 108 |
} |
| 109 |
|
| 110 |
#[test] |
| 111 |
fn resolve_valid_replaced() { |
| 112 |
let valid: HashSet<String> = ["alice"].iter().map(|s| s.to_string()).collect(); |
| 113 |
let result = resolve_mentions("Hello @alice!", &valid, "/p/test-community/u/{username}"); |
| 114 |
assert_eq!(result, "Hello [@alice](/p/test-community/u/alice)!"); |
| 115 |
} |
| 116 |
|
| 117 |
#[test] |
| 118 |
fn resolve_unknown_left_alone() { |
| 119 |
let valid: HashSet<String> = HashSet::new(); |
| 120 |
let result = resolve_mentions("Hello @unknown!", &valid, "/u/{username}"); |
| 121 |
assert_eq!(result, "Hello @unknown!"); |
| 122 |
} |
| 123 |
|
| 124 |
#[test] |
| 125 |
fn resolve_in_code_not_replaced() { |
| 126 |
let valid: HashSet<String> = ["alice"].iter().map(|s| s.to_string()).collect(); |
| 127 |
let result = resolve_mentions("Use `@alice` in code", &valid, "/u/{username}"); |
| 128 |
assert_eq!(result, "Use `@alice` in code"); |
| 129 |
} |
| 130 |
|
| 131 |
#[test] |
| 132 |
fn resolve_mixed_valid_invalid() { |
| 133 |
let valid: HashSet<String> = ["alice"].iter().map(|s| s.to_string()).collect(); |
| 134 |
let result = resolve_mentions("@alice and @unknown", &valid, "/p/slug/u/{username}"); |
| 135 |
assert_eq!(result, "[@alice](/p/slug/u/alice) and @unknown"); |
| 136 |
} |
| 137 |
|
| 138 |
#[test] |
| 139 |
fn resolve_custom_url_template() { |
| 140 |
let valid: HashSet<String> = ["bob"].iter().map(|s| s.to_string()).collect(); |
| 141 |
let result = resolve_mentions("Hi @bob", &valid, "/users/{username}/profile"); |
| 142 |
assert_eq!(result, "Hi [@bob](/users/bob/profile)"); |
| 143 |
} |
| 144 |
} |
| 145 |
|