use serde::{Deserialize, Serialize}; /// Parsed mention from `@[type:id:label]` IR format. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Mention { pub mention_type: String, pub target_id: String, pub label: String, } /// Parse all `@[type:id:label]` mentions from content. /// Returns deduplicated mentions in order of first appearance. pub fn parse_mentions(content: &str) -> Vec { let mut mentions = Vec::new(); let mut seen = std::collections::HashSet::new(); // Simple manual parser for @[type:id:label] let bytes = content.as_bytes(); let len = bytes.len(); let mut i = 0; while i < len { // Look for "@[" if i + 2 < len && bytes[i] == b'@' && bytes[i + 1] == b'[' { let start = i + 2; // after "@[" // Find first ':'' after start if let Some(type_end) = content[start..].find(':') { let mention_type = &content[start..start + type_end]; let after_type = start + type_end + 1; // after first ':' // Find second ':' (between id and label) if let Some(id_end) = content[after_type..].find(':') { let target_id = &content[after_type..after_type + id_end]; let after_id = after_type + id_end + 1; // after second ':' // Find closing ']' if let Some(close) = content[after_id..].find(']') { let label = &content[after_id..after_id + close]; if !mention_type.is_empty() && !target_id.is_empty() { let key = format!("{}:{}", mention_type, target_id); if seen.insert(key) { mentions.push(Mention { mention_type: mention_type.to_string(), target_id: target_id.to_string(), label: label.to_string(), }); } } i = after_id + close + 1; // skip past ']' continue; } } } } i += 1; } mentions } /// Extract unique target IDs of a specific mention type. pub fn extract_mention_ids( mentions: &[Mention], mention_type: &str, ) -> Vec { mentions .iter() .filter(|m| m.mention_type == mention_type) .map(|m| m.target_id.clone()) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_single_room_mention() { let input = "hey check out @[room:abc123:general]"; let mentions = parse_mentions(input); assert_eq!(mentions.len(), 1); assert_eq!(mentions[0].mention_type, "room"); assert_eq!(mentions[0].target_id, "abc123"); assert_eq!(mentions[0].label, "general"); } #[test] fn test_parse_single_repo_mention() { let input = "look at @[repo:my-repo:my-repo]"; let mentions = parse_mentions(input); assert_eq!(mentions.len(), 1); assert_eq!(mentions[0].mention_type, "repo"); assert_eq!(mentions[0].target_id, "my-repo"); } #[test] fn test_parse_multiple_mentions() { let input = "compare @[repo:backend:backend] with @[repo:frontend:frontend]"; let mentions = parse_mentions(input); assert_eq!(mentions.len(), 2); assert_eq!(mentions[0].target_id, "backend"); assert_eq!(mentions[1].target_id, "frontend"); } #[test] fn test_deduplicate() { let input = "look at @[repo:a:a] and also @[repo:a:a] please"; let mentions = parse_mentions(input); assert_eq!(mentions.len(), 1); } #[test] fn test_no_mentions() { let input = "hello world no mentions here"; let mentions = parse_mentions(input); assert_eq!(mentions.len(), 0); } #[test] fn test_mixed_mentions() { let input = "@[user:abc:John] and @[room:xyz:general] and @[repo:r:r]"; let mentions = parse_mentions(input); assert_eq!(mentions.len(), 3); } #[test] fn test_incomplete_mention_ignored() { let input = "this @[incomplete is just text"; let mentions = parse_mentions(input); assert_eq!(mentions.len(), 0); } #[test] fn test_empty_input() { let mentions = parse_mentions(""); assert_eq!(mentions.len(), 0); } #[test] fn test_extract_mention_ids() { let input = "@[repo:a:a] and @[room:b:general] and @[repo:c:c]"; let mentions = parse_mentions(input); let repo_ids = extract_mention_ids(&mentions, "repo"); assert_eq!(repo_ids, vec!["a", "c"]); } }