mentions_hashtags/lib.rs
1/// 🔎 Social Mentions and Hashtags Extractor
2///
3/// A lightweight utility for extracting unique `@mentions` and `#hashtags` from social media-style text.
4///
5/// Optimized for real-world use cases on platforms like YouTube, TikTok, Instagram, and Twitter.
6///
7/// # Overview
8///
9/// - Extracts both mentions (e.g., `@MrBeast`) and hashtags (e.g., `#fyp`) using regular expressions
10/// - Ensures uniqueness with deduplication via `HashSet`
11/// - Case-insensitive matching but preserves original casing
12/// - Supports optional parsing (mentions-only, hashtags-only, or both)
13pub mod mentions_hashtags {
14 use regex::Regex;
15 use std::collections::HashSet;
16 use std::error::Error;
17
18 /// Represents the result of parsing social text for mentions and hashtags.
19 ///
20 /// # Fields
21 /// - `mentions`: A list of unique `@username` strings
22 /// - `hashtags`: A list of unique `#topic` strings
23 #[derive(Debug, Default)]
24 pub struct MentionsHashtags {
25 pub mentions: Vec<String>,
26 pub hashtags: Vec<String>,
27 }
28
29 /// Parses the given description and extracts mentions and/or hashtags.
30 ///
31 /// # Arguments
32 ///
33 /// - `description`: The input text (e.g., social media caption or comment)
34 /// - `mentions`: Whether to extract `@mentions`
35 /// - `hashtags`: Whether to extract `#hashtags`
36 ///
37 /// # Returns
38 /// A `Result` containing a `MentionsHashtags` struct with parsed values.
39 ///
40 /// # Behavior
41 /// - If both `mentions` and `hashtags` are false, returns empty vectors.
42 /// - Extracted values are **unique** and maintain original case.
43 ///
44 /// # Examples
45 /// ```
46 /// use mentions_hashtags_rs::mentions_hashtags::parse_mentions_hashtags;
47 ///
48 /// let text = "@MrBeast check out the #fyp and #Challenge2025!";
49 /// let result = parse_mentions_hashtags(text, true, true).unwrap();
50 /// assert!(result.mentions.contains(&"@MrBeast".to_string()));
51 /// assert!(result.hashtags.contains(&"#fyp".to_string()));
52 /// assert!(result.hashtags.contains(&"#Challenge2025".to_string()));
53 /// ```
54 pub fn parse_mentions_hashtags(
55 description: &str,
56 mentions: bool,
57 hashtags: bool,
58 ) -> Result<MentionsHashtags, Box<dyn Error>> {
59 let mut mentions_hashtags = MentionsHashtags::default();
60
61 if !mentions && !hashtags {
62 return Ok(mentions_hashtags);
63 }
64 if mentions {
65 mentions_hashtags.mentions = parse_mentions(description)?;
66 }
67 if hashtags {
68 mentions_hashtags.hashtags = parse_hashtags(description)?;
69 }
70
71 Ok(mentions_hashtags)
72 }
73
74 /// Extracts unique `@mentions` from the input text.
75 ///
76 /// # Arguments
77 /// - `description`: The input text (e.g., TikTok or YouTube description)
78 ///
79 /// # Returns
80 /// A `Result` containing a `Vec<String>` of unique mentions.
81 ///
82 /// # Behavior
83 /// - Matches alphanumeric usernames including `_`, `-`, and `.`
84 /// - Preserves original casing (e.g., `@PewDiePie`, `@pewdiepie` both included if present)
85 ///
86 /// # Examples
87 /// ```
88 /// use mentions_hashtags_rs::mentions_hashtags::parse_mentions;
89 ///
90 /// let result = parse_mentions("@charlidamelio @Khaby.Lame").unwrap();
91 /// assert!(result.contains(&"@charlidamelio".to_string()));
92 /// assert!(result.contains(&"@Khaby.Lame".to_string()));
93 /// ```
94 pub fn parse_mentions(description: &str) -> Result<Vec<String>, Box<dyn Error>> {
95 let matches = Regex::new(r"(?i)@[a-zA-Z0-9_\-.]+")?;
96 let unique_mentions: HashSet<String> = matches
97 .find_iter(description)
98 .map(|m| m.as_str().to_string())
99 .collect();
100 Ok(unique_mentions.into_iter().collect())
101 }
102
103 /// Extracts unique `#hashtags` from the input text.
104 ///
105 /// # Arguments
106 /// - `description`: The input text (e.g., Instagram caption or Shorts comment)
107 ///
108 /// # Returns
109 /// A `Result` containing a `Vec<String>` of unique hashtags.
110 ///
111 /// # Behavior
112 /// - Matches alphanumeric hashtags including `_`, `-`, and `.`
113 /// - Preserves original casing (e.g., `#Music` and `#music` both included if present)
114 ///
115 /// # Examples
116 /// ```
117 /// use mentions_hashtags_rs::mentions_hashtags::parse_hashtags;
118 ///
119 /// let result = parse_hashtags("#fyp #CapCut #go_crazy.").unwrap();
120 /// assert!(result.contains(&"#CapCut".to_string()));
121 /// assert!(result.contains(&"#go_crazy.".to_string()));
122 /// ```
123 pub fn parse_hashtags(description: &str) -> Result<Vec<String>, Box<dyn Error>> {
124 let matches = Regex::new(r"(?i)#[a-zA-Z0-9_\-.]+")?;
125 let unique_hashtags: HashSet<String> = matches
126 .find_iter(description)
127 .map(|x| x.as_str().to_string())
128 .collect();
129 Ok(unique_hashtags.into_iter().collect())
130 }
131}
132
133
134#[cfg(test)]
135mod tests {
136 use super::mentions_hashtags::*;
137 use std::collections::HashSet;
138
139 // === Mentions Tests ===
140 #[test]
141 fn test_youtube_mentions() {
142 let result = parse_mentions("@MrBeast @EmmaChamberlain @PewDiePie").unwrap();
143 let expected: HashSet<_> = ["@MrBeast", "@EmmaChamberlain", "@PewDiePie"]
144 .iter()
145 .map(|s| s.to_string())
146 .collect();
147 assert_eq!(result.into_iter().collect::<HashSet<_>>(), expected);
148 }
149
150 #[test]
151 fn test_tiktok_mentions_with_duplicates() {
152 let result = parse_mentions("@charlidamelio @charlidamelio @Khaby.Lame").unwrap();
153 let expected: HashSet<_> = ["@charlidamelio", "@Khaby.Lame"]
154 .iter()
155 .map(|s| s.to_string())
156 .collect();
157 assert_eq!(result.into_iter().collect::<HashSet<_>>(), expected);
158 }
159
160 #[test]
161 fn test_mentions_case_insensitive_but_preserved() {
162 let result = parse_mentions("@AddisonRae @addisonrae").unwrap();
163 assert!(result.contains(&"@AddisonRae".to_string()));
164 assert!(result.contains(&"@addisonrae".to_string()));
165 assert_eq!(result.len(), 2); // preserves case
166 }
167
168 #[test]
169 fn test_mentions_empty_input() {
170 let result = parse_mentions("").unwrap();
171 assert!(result.is_empty());
172 }
173
174 // === Hashtags Tests ===
175 #[test]
176 fn test_tiktok_hashtags_trending() {
177 let result = parse_hashtags("#fyp #trending #CapCut #viral").unwrap();
178 let expected: HashSet<_> = ["#fyp", "#trending", "#CapCut", "#viral"]
179 .iter()
180 .map(|s| s.to_string())
181 .collect();
182 assert_eq!(result.into_iter().collect::<HashSet<_>>(), expected);
183 }
184
185 #[test]
186 fn test_youtube_hashtags_mixed_case() {
187 let result = parse_hashtags("#Shorts #YouTubeShorts #Music #music").unwrap();
188 let expected: HashSet<_> = ["#Shorts", "#YouTubeShorts", "#Music", "#music"]
189 .iter()
190 .map(|s| s.to_string())
191 .collect();
192 assert_eq!(result.into_iter().collect::<HashSet<_>>(), expected);
193 }
194
195 #[test]
196 fn test_hashtags_with_punctuation() {
197 let result = parse_hashtags("Try this! #Challenge-2025 #fun.time #go_crazy.").unwrap();
198 let expected: HashSet<_> = ["#Challenge-2025", "#fun.time", "#go_crazy."]
199 .iter()
200 .map(|s| s.to_string())
201 .collect();
202 assert_eq!(result.into_iter().collect::<HashSet<_>>(), expected);
203 }
204
205 #[test]
206 fn test_hashtags_empty_input() {
207 let result = parse_hashtags("").unwrap();
208 assert!(result.is_empty());
209 }
210
211 // === Combined Parser ===
212 #[test]
213 fn test_parse_both_mentions_and_hashtags() {
214 let result = parse_mentions_hashtags(
215 "@MrBeast just posted a new video! #fyp #MrBeastChallenge",
216 true,
217 true,
218 )
219 .unwrap();
220
221 assert!(result.mentions.contains(&"@MrBeast".to_string()));
222 assert!(result.hashtags.contains(&"#fyp".to_string()));
223 assert!(result.hashtags.contains(&"#MrBeastChallenge".to_string()));
224 }
225
226 #[test]
227 fn test_parse_none_enabled() {
228 let result = parse_mentions_hashtags("@Khaby.Lame #viral", false, false).unwrap();
229 assert!(result.mentions.is_empty());
230 assert!(result.hashtags.is_empty());
231 }
232}