Skip to main content

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}