Skip to main content

tools_kit/
name.rs

1use std::{collections::BTreeSet, fmt, hash::Hash, str::FromStr};
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
4
5use crate::ToolError;
6
7const MAX_TOOL_NAME_BYTES: usize = 128;
8pub(crate) const TOOL_NAME_NAMESPACE_SEPARATOR: &str = "__";
9
10/// Portable executable tool identifier.
11///
12/// # Examples
13///
14/// ```
15/// use tools_kit::declaration::ToolName;
16/// let name = ToolName::new("lookup_record")?;
17/// assert_eq!(name.as_str(), "lookup_record");
18/// assert!(ToolName::new("bad name").is_err());
19///
20/// # Ok::<(), tools_kit::ToolError>(())
21/// ```
22///
23#[derive(Clone, Debug)]
24pub struct ToolName {
25    inner: ToolNameInner,
26}
27
28#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
29enum ToolNameInner {
30    Local { rendered: String },
31    Mcp { rendered: String, native: String },
32}
33
34/// Private context for allocating rendered MCP names.
35#[derive(Debug)]
36pub(crate) struct McpToolNameContext {
37    namespace: Option<String>,
38    rendered_names: BTreeSet<String>,
39}
40
41impl McpToolNameContext {
42    pub(crate) fn new(namespace: Option<&ToolName>) -> Result<Self, ToolError> {
43        let namespace = namespace.map(|name| name.as_str().to_owned());
44        if let Some(namespace) = &namespace {
45            Self::validate_namespace(namespace)?;
46        }
47        Ok(Self {
48            namespace,
49            rendered_names: BTreeSet::new(),
50        })
51    }
52
53    fn allocate_rendered_name(&mut self, native_name: &str) -> Result<String, ToolError> {
54        let preferred_leaf = Self::sanitize_native_leaf(native_name);
55        if let Some(rendered) = self.try_reserve_leaf(&preferred_leaf)? {
56            return Ok(rendered);
57        }
58
59        let hash = Self::stable_short_hash(native_name);
60        for counter in 0_u32.. {
61            let suffix = if counter == 0 {
62                format!("_{hash}")
63            } else {
64                format!("_{hash}_{counter}")
65            };
66            if let Some(leaf) =
67                Self::truncate_leaf_with_suffix(&preferred_leaf, &suffix, self.leaf_budget())
68                && let Some(rendered) = self.try_reserve_leaf(&leaf)?
69            {
70                return Ok(rendered);
71            }
72        }
73
74        Err(ToolError::InvalidConfiguration {
75            message: "failed to allocate a rendered MCP tool name".to_owned(),
76        })
77    }
78
79    fn leaf_budget(&self) -> usize {
80        match &self.namespace {
81            Some(namespace) => MAX_TOOL_NAME_BYTES
82                .saturating_sub(namespace.len())
83                .saturating_sub(TOOL_NAME_NAMESPACE_SEPARATOR.len()),
84            None => MAX_TOOL_NAME_BYTES,
85        }
86    }
87
88    fn try_reserve_leaf(&mut self, leaf: &str) -> Result<Option<String>, ToolError> {
89        if leaf.is_empty() {
90            return Ok(None);
91        }
92        let rendered = match &self.namespace {
93            Some(namespace) => {
94                format!("{namespace}{TOOL_NAME_NAMESPACE_SEPARATOR}{leaf}")
95            }
96            None => leaf.to_owned(),
97        };
98        ToolName::validate_rendered(&rendered)?;
99        if self.rendered_names.insert(rendered.clone()) {
100            Ok(Some(rendered))
101        } else {
102            Ok(None)
103        }
104    }
105
106    fn validate_namespace(value: &str) -> Result<(), ToolError> {
107        ToolName::validate_rendered(value)?;
108        let minimum_fallback_leaf = "tool_00000000";
109        if value.len() + TOOL_NAME_NAMESPACE_SEPARATOR.len() + minimum_fallback_leaf.len()
110            > MAX_TOOL_NAME_BYTES
111        {
112            return Err(ToolName::invalid(
113                "tool namespaces must leave room for fallback tool names",
114            ));
115        }
116        Ok(())
117    }
118
119    fn sanitize_native_leaf(native_name: &str) -> String {
120        let leaf = native_name
121            .rsplit(['/', ':', '.'])
122            .next()
123            .unwrap_or(native_name)
124            .trim();
125        let mut sanitized = String::with_capacity(leaf.len());
126        let mut previous_separator = false;
127        for byte in leaf.bytes() {
128            let rendered = if byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') {
129                previous_separator = false;
130                byte as char
131            } else {
132                if previous_separator {
133                    continue;
134                }
135                previous_separator = true;
136                '_'
137            };
138            sanitized.push(rendered);
139        }
140        let sanitized = sanitized.trim_matches('_').to_owned();
141        if sanitized.is_empty() {
142            "tool".to_owned()
143        } else {
144            sanitized
145        }
146    }
147
148    fn stable_short_hash(value: &str) -> String {
149        let mut hash = 0xcbf2_9ce4_8422_2325_u64;
150        for byte in value.as_bytes() {
151            hash ^= u64::from(*byte);
152            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
153        }
154        format!("{:08x}", hash as u32)
155    }
156
157    fn truncate_leaf_with_suffix(leaf: &str, suffix: &str, budget: usize) -> Option<String> {
158        if suffix.len() > budget {
159            return None;
160        }
161        let max_prefix = budget - suffix.len();
162        let mut prefix = String::new();
163        for ch in leaf.chars() {
164            if prefix.len() + ch.len_utf8() > max_prefix {
165                break;
166            }
167            prefix.push(ch);
168        }
169        let prefix = prefix.trim_end_matches(['_', '-']);
170        let prefix = if prefix.is_empty() { "tool" } else { prefix };
171        Some(format!("{prefix}{suffix}"))
172    }
173}
174
175impl ToolName {
176    /// Creates a caller-authored executable tool name.
177    ///
178    /// # Errors
179    ///
180    /// Returns an error when the rendered name is empty, too long, or contains
181    /// characters outside the Kit's portable tool-name subset.
182    ///
183    /// # Examples
184    ///
185    /// ```
186    /// use tools_kit::declaration::ToolName;
187    /// let name = ToolName::new("lookup_record")?;
188    /// assert_eq!(name.as_str(), "lookup_record");
189    /// assert!(ToolName::new("bad name").is_err());
190    ///
191    /// # Ok::<(), tools_kit::ToolError>(())
192    /// ```
193    ///
194    pub fn new(value: impl Into<String>) -> Result<Self, ToolError> {
195        Self::local(value)
196    }
197
198    pub(crate) fn local(value: impl Into<String>) -> Result<Self, ToolError> {
199        let rendered = value.into();
200        Self::validate_rendered(&rendered)?;
201        Ok(Self {
202            inner: ToolNameInner::Local { rendered },
203        })
204    }
205
206    pub(crate) fn mcp(
207        context: &mut McpToolNameContext,
208        native_name: impl Into<String>,
209    ) -> Result<Self, ToolError> {
210        let native = native_name.into();
211        if native.trim().is_empty() {
212            return Err(ToolError::InvalidConfiguration {
213                message: "MCP tool names must not be empty".to_owned(),
214            });
215        }
216        let rendered = context.allocate_rendered_name(&native)?;
217        Ok(Self {
218            inner: ToolNameInner::Mcp { rendered, native },
219        })
220    }
221
222    /// Returns the provider-safe rendered name.
223    ///
224    /// # Examples
225    ///
226    /// ```
227    /// use tools_kit::declaration::ToolName;
228    /// let name = ToolName::new("lookup_record")?;
229    /// assert_eq!(name.as_str(), "lookup_record");
230    /// assert!(ToolName::new("bad name").is_err());
231    ///
232    /// # Ok::<(), tools_kit::ToolError>(())
233    /// ```
234    ///
235    #[must_use]
236    pub fn as_str(&self) -> &str {
237        match &self.inner {
238            ToolNameInner::Local { rendered } | ToolNameInner::Mcp { rendered, .. } => rendered,
239        }
240    }
241
242    pub(crate) fn mcp_native_name(&self) -> Option<&str> {
243        match &self.inner {
244            ToolNameInner::Local { .. } => None,
245            ToolNameInner::Mcp { native, .. } => Some(native),
246        }
247    }
248
249    fn validate_rendered(value: &str) -> Result<(), ToolError> {
250        if value.is_empty() {
251            return Err(Self::invalid("tool names must not be empty"));
252        }
253        if value.len() > MAX_TOOL_NAME_BYTES {
254            return Err(Self::invalid("tool names must not exceed 128 bytes"));
255        }
256        if value.trim() != value {
257            return Err(Self::invalid(
258                "tool names must not contain leading or trailing whitespace",
259            ));
260        }
261        if !value
262            .bytes()
263            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
264        {
265            return Err(Self::invalid(
266                "tool names may contain only ASCII letters, digits, `_`, and `-`",
267            ));
268        }
269        Ok(())
270    }
271
272    fn invalid(message: impl Into<String>) -> ToolError {
273        ToolError::InvalidConfiguration {
274            message: message.into(),
275        }
276    }
277}
278
279impl PartialEq for ToolName {
280    fn eq(&self, other: &Self) -> bool {
281        self.as_str() == other.as_str()
282    }
283}
284
285impl Eq for ToolName {}
286
287impl PartialOrd for ToolName {
288    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
289        Some(self.cmp(other))
290    }
291}
292
293impl Ord for ToolName {
294    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
295        self.as_str().cmp(other.as_str())
296    }
297}
298
299impl Hash for ToolName {
300    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
301        self.as_str().hash(state);
302    }
303}
304
305impl fmt::Display for ToolName {
306    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307        f.write_str(self.as_str())
308    }
309}
310
311impl FromStr for ToolName {
312    type Err = ToolError;
313
314    fn from_str(value: &str) -> Result<Self, Self::Err> {
315        Self::local(value)
316    }
317}
318
319impl TryFrom<String> for ToolName {
320    type Error = ToolError;
321
322    fn try_from(value: String) -> Result<Self, Self::Error> {
323        Self::local(value)
324    }
325}
326
327impl TryFrom<&str> for ToolName {
328    type Error = ToolError;
329
330    fn try_from(value: &str) -> Result<Self, Self::Error> {
331        Self::local(value)
332    }
333}
334
335impl Serialize for ToolName {
336    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
337    where
338        S: Serializer,
339    {
340        serializer.serialize_str(self.as_str())
341    }
342}
343
344impl<'de> Deserialize<'de> for ToolName {
345    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
346    where
347        D: Deserializer<'de>,
348    {
349        let value = String::deserialize(deserializer)?;
350        Self::local(value).map_err(D::Error::custom)
351    }
352}