use std::collections::HashMap; use std::collections::VecDeque; use codex_utils_plugins::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL; use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct LinkedMention { pub(crate) sigil: char, pub(crate) mention: String, pub(crate) path: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct DecodedHistoryText { pub(crate) text: String, pub(crate) mentions: Vec, } #[allow(dead_code)] pub(crate) fn encode_history_mentions(text: &str, mentions: &[LinkedMention]) -> String { if mentions.is_empty() || text.is_empty() { return text.to_string(); } let mut mentions_by_token: HashMap<(char, &str), VecDeque<&str>> = HashMap::new(); for mention in mentions { if !matches!( mention.sigil, TOOL_MENTION_SIGIL | PLUGIN_TEXT_MENTION_SIGIL ) { continue; } mentions_by_token .entry((mention.sigil, mention.mention.as_str())) .or_default() .push_back(mention.path.as_str()); } let bytes = text.as_bytes(); let mut out = String::with_capacity(text.len()); let mut index = 0usize; while index < bytes.len() { if matches!( bytes[index], byte if byte == TOOL_MENTION_SIGIL as u8 || byte == PLUGIN_TEXT_MENTION_SIGIL as u8 ) { let sigil = bytes[index] as char; if sigil == TOOL_MENTION_SIGIL || starts_plaintext_mention(text, index) { let name_start = index + 1; if let Some(first) = bytes.get(name_start) && is_mention_name_char(*first) { let mut name_end = name_start + 1; while let Some(next) = bytes.get(name_end) && is_mention_name_char(*next) { name_end += 1; } let name = &text[name_start..name_end]; if (sigil == TOOL_MENTION_SIGIL || ends_plaintext_mention(bytes, name_end)) && let Some(path) = mentions_by_token .get_mut(&(sigil, name)) .and_then(VecDeque::pop_front) { out.push('['); out.push(sigil); out.push_str(name); out.push_str("]("); out.push_str(path); out.push(')'); index = name_end; continue; } } } } let Some(ch) = text[index..].chars().next() else { break; }; out.push(ch); index += ch.len_utf8(); } out } pub(crate) fn decode_history_mentions_with_at_mentions( text: &str, at_mentions_enabled: bool, ) -> DecodedHistoryText { let bytes = text.as_bytes(); let mut out = String::with_capacity(text.len()); let mut mentions = Vec::new(); let mut index = 0usize; while index < bytes.len() { if bytes[index] == b'[' && let Some((sigil, name, path, end_index)) = parse_history_linked_mention(text, bytes, index, at_mentions_enabled) { out.push(sigil); out.push_str(name); mentions.push(LinkedMention { sigil, mention: name.to_string(), path: path.to_string(), }); index = end_index; continue; } let Some(ch) = text[index..].chars().next() else { break; }; out.push(ch); index += ch.len_utf8(); } DecodedHistoryText { text: out, mentions, } } fn parse_history_linked_mention<'a>( text: &'a str, text_bytes: &[u8], start: usize, at_mentions_enabled: bool, ) -> Option<(char, &'a str, &'a str, usize)> { // TUI historically wrote `$name`, but selected unified `@` mentions should preserve `@` on // history round-trip for any canonical tool path. if let Some((name, path, end_index)) = parse_linked_tool_mention(text, text_bytes, start, TOOL_MENTION_SIGIL) && !is_common_env_var(name) && is_tool_path(path) { return Some((TOOL_MENTION_SIGIL, name, path, end_index)); } if at_mentions_enabled { if let Some((name, path, end_index)) = parse_linked_tool_mention(text, text_bytes, start, PLUGIN_TEXT_MENTION_SIGIL) && !is_common_env_var(name) && is_tool_path(path) { return Some((PLUGIN_TEXT_MENTION_SIGIL, name, path, end_index)); } } else if let Some((name, path, end_index)) = parse_linked_tool_mention(text, text_bytes, start, PLUGIN_TEXT_MENTION_SIGIL) && !is_common_env_var(name) && path.starts_with("plugin://") { return Some((TOOL_MENTION_SIGIL, name, path, end_index)); } None } fn parse_linked_tool_mention<'a>( text: &'a str, text_bytes: &[u8], start: usize, sigil: char, ) -> Option<(&'a str, &'a str, usize)> { let sigil_index = start + 1; if text_bytes.get(sigil_index) != Some(&(sigil as u8)) { return None; } let name_start = sigil_index + 1; let first_name_byte = text_bytes.get(name_start)?; if !is_mention_name_char(*first_name_byte) { return None; } let mut name_end = name_start + 1; while let Some(next_byte) = text_bytes.get(name_end) && is_mention_name_char(*next_byte) { name_end += 1; } if text_bytes.get(name_end) != Some(&b']') { return None; } let mut path_start = name_end + 1; while let Some(next_byte) = text_bytes.get(path_start) && next_byte.is_ascii_whitespace() { path_start += 1; } if text_bytes.get(path_start) != Some(&b'(') { return None; } let mut path_end = path_start + 1; while let Some(next_byte) = text_bytes.get(path_end) && *next_byte != b')' { path_end += 1; } if text_bytes.get(path_end) != Some(&b')') { return None; } let path = text[path_start + 1..path_end].trim(); if path.is_empty() { return None; } let name = &text[name_start..name_end]; Some((name, path, path_end + 1)) } fn is_mention_name_char(byte: u8) -> bool { matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-') } fn starts_plaintext_mention(text: &str, index: usize) -> bool { if index == 0 { return true; } text.get(..index) .and_then(|prefix| prefix.chars().next_back()) .is_some_and(|ch| ch.is_whitespace() || !is_mention_name_char_char(ch)) } fn ends_plaintext_mention(text_bytes: &[u8], index: usize) -> bool { text_bytes.get(index).is_none_or(|byte| { byte.is_ascii_whitespace() || *byte == b'.' && text_bytes.get(index + 1).is_none_or(|next| { next.is_ascii_whitespace() || !next.is_ascii_alphanumeric() && *next != b'_' && *next != b'-' }) || !matches!(*byte, b'.' | b'/' | b'\\') && !byte.is_ascii_alphanumeric() && *byte != b'_' && *byte != b'-' }) } fn is_mention_name_char_char(ch: char) -> bool { ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-') } fn is_common_env_var(name: &str) -> bool { let upper = name.to_ascii_uppercase(); matches!( upper.as_str(), "PATH" | "HOME" | "USER" | "SHELL" | "PWD" | "TMPDIR" | "TEMP" | "TMP" | "LANG" | "TERM" | "XDG_CONFIG_HOME" ) } fn is_tool_path(path: &str) -> bool { path.starts_with("app://") || path.starts_with("mcp://") || path.starts_with("plugin://") || path.starts_with("skill://") || path .rsplit(['/', '\\']) .next() .is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md")) } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; #[test] fn decode_history_mentions_restores_visible_tokens() { let decoded = decode_history_mentions_with_at_mentions( "Use [$figma](app://figma-1), [$sample](plugin://sample@test), and [$figma](/tmp/figma/SKILL.md).", /*at_mentions_enabled*/ true, ); assert_eq!(decoded.text, "Use $figma, $sample, and $figma."); assert_eq!( decoded.mentions, vec![ LinkedMention { sigil: '$', mention: "figma".to_string(), path: "app://figma-1".to_string(), }, LinkedMention { sigil: '$', mention: "sample".to_string(), path: "plugin://sample@test".to_string(), }, LinkedMention { sigil: '$', mention: "figma".to_string(), path: "/tmp/figma/SKILL.md".to_string(), }, ] ); } #[test] fn decode_history_mentions_restores_plugin_links_with_at_sigil() { let decoded = decode_history_mentions_with_at_mentions( "Use [@sample](plugin://sample@test) and [$figma](app://figma-1).", /*at_mentions_enabled*/ true, ); assert_eq!(decoded.text, "Use @sample and $figma."); assert_eq!( decoded.mentions, vec![ LinkedMention { sigil: '@', mention: "sample".to_string(), path: "plugin://sample@test".to_string(), }, LinkedMention { sigil: '$', mention: "figma".to_string(), path: "app://figma-1".to_string(), }, ] ); } #[test] fn decode_history_mentions_without_at_mentions_uses_legacy_plugin_fallback() { let decoded = decode_history_mentions_with_at_mentions( "Use [@sample](plugin://sample@test) and [$figma](app://figma-1).", /*at_mentions_enabled*/ false, ); assert_eq!(decoded.text, "Use $sample and $figma."); assert_eq!( decoded.mentions, vec![ LinkedMention { sigil: '$', mention: "sample".to_string(), path: "plugin://sample@test".to_string(), }, LinkedMention { sigil: '$', mention: "figma".to_string(), path: "app://figma-1".to_string(), }, ] ); } #[test] fn decode_history_mentions_without_at_mentions_ignores_at_non_plugin_paths() { let decoded = decode_history_mentions_with_at_mentions( "Use [@figma](app://figma-1).", /*at_mentions_enabled*/ false, ); assert_eq!(decoded.text, "Use [@figma](app://figma-1)."); assert_eq!(decoded.mentions, Vec::::new()); } #[test] fn decode_history_mentions_restores_at_sigil_for_tool_paths() { let decoded = decode_history_mentions_with_at_mentions( "Use [@figma](app://figma-1).", /*at_mentions_enabled*/ true, ); assert_eq!(decoded.text, "Use @figma."); assert_eq!( decoded.mentions, vec![LinkedMention { sigil: '@', mention: "figma".to_string(), path: "app://figma-1".to_string(), }] ); } #[test] fn encode_history_mentions_links_bound_mentions_in_order() { let text = "$figma then $sample then $figma then $other"; let encoded = encode_history_mentions( text, &[ LinkedMention { sigil: '$', mention: "figma".to_string(), path: "app://figma-app".to_string(), }, LinkedMention { sigil: '$', mention: "sample".to_string(), path: "plugin://sample@test".to_string(), }, LinkedMention { sigil: '$', mention: "figma".to_string(), path: "/tmp/figma/SKILL.md".to_string(), }, ], ); assert_eq!( encoded, "[$figma](app://figma-app) then [$sample](plugin://sample@test) then [$figma](/tmp/figma/SKILL.md) then $other" ); } #[test] fn encode_history_mentions_links_dollar_mentions_after_punctuation() { let encoded = encode_history_mentions( "($figma)", &[LinkedMention { sigil: '$', mention: "figma".to_string(), path: "app://figma".to_string(), }], ); assert_eq!(encoded, "([$figma](app://figma))"); } #[test] fn encode_history_mentions_links_dollar_mentions_with_path_like_suffixes() { let mention = LinkedMention { sigil: '$', mention: "figma".to_string(), path: "app://figma".to_string(), }; assert_eq!( encode_history_mentions("$figma/docs", std::slice::from_ref(&mention)), "[$figma](app://figma)/docs" ); assert_eq!( encode_history_mentions("$figma.suffix", std::slice::from_ref(&mention)), "[$figma](app://figma).suffix" ); assert_eq!( encode_history_mentions("$figma\\docs", &[mention]), "[$figma](app://figma)\\docs" ); } #[test] fn encode_history_mentions_preserves_at_sigils() { let text = "@figma then @sample then $other"; let encoded = encode_history_mentions( text, &[ LinkedMention { sigil: '@', mention: "figma".to_string(), path: "/tmp/figma/SKILL.md".to_string(), }, LinkedMention { sigil: '@', mention: "sample".to_string(), path: "plugin://sample@test".to_string(), }, ], ); assert_eq!( encoded, "[@figma](/tmp/figma/SKILL.md) then [@sample](plugin://sample@test) then $other" ); } #[test] fn encode_history_mentions_links_both_sigils_for_same_name() { let text = "@figma then $figma"; let encoded = encode_history_mentions( text, &[ LinkedMention { sigil: '@', mention: "figma".to_string(), path: "plugin://figma@test".to_string(), }, LinkedMention { sigil: '$', mention: "figma".to_string(), path: "app://figma".to_string(), }, ], ); assert_eq!( encoded, "[@figma](plugin://figma@test) then [$figma](app://figma)" ); } #[test] fn encode_history_mentions_does_not_let_at_token_steal_later_tool_binding() { let text = "@figma then $figma"; let encoded = encode_history_mentions( text, &[LinkedMention { sigil: '$', mention: "figma".to_string(), path: "app://figma-app".to_string(), }], ); assert_eq!(encoded, "@figma then [$figma](app://figma-app)"); } #[test] fn encode_history_mentions_links_at_mentions_after_unicode_whitespace() { // Fix coverage: full-width space should remain a valid plaintext boundary for `@` links. let text = "foo @sample"; let encoded = encode_history_mentions( text, &[LinkedMention { sigil: '@', mention: "sample".to_string(), path: "plugin://sample@test".to_string(), }], ); assert_eq!(encoded, "foo [@sample](plugin://sample@test)"); } #[test] fn encode_history_mentions_links_sentence_ending_at_mentions() { let text = "Please ask @figma."; let encoded = encode_history_mentions( text, &[LinkedMention { sigil: '@', mention: "figma".to_string(), path: "/tmp/figma/SKILL.md".to_string(), }], ); assert_eq!(encoded, "Please ask [@figma](/tmp/figma/SKILL.md)."); } #[test] fn encode_history_mentions_links_parenthesized_at_mentions() { let text = "Please ask (@figma)"; let encoded = encode_history_mentions( text, &[LinkedMention { sigil: '@', mention: "figma".to_string(), path: "plugin://figma@test".to_string(), }], ); assert_eq!(encoded, "Please ask ([@figma](plugin://figma@test))"); } #[test] fn encode_history_mentions_skips_embedded_at_substrings() { let text = "foo@sample.com npx @sample/pkg then @sample"; let encoded = encode_history_mentions( text, &[LinkedMention { sigil: '@', mention: "sample".to_string(), path: "plugin://sample@test".to_string(), }], ); assert_eq!( encoded, "foo@sample.com npx @sample/pkg then [@sample](plugin://sample@test)" ); } }