mcp_compressor_core/cli/mapping.rs
1//! CLI name-mapping utilities.
2//!
3//! Mirrors `mcp_compressor/cli_tools.py`:
4//! - [`tool_name_to_subcommand`] — converts MCP tool names to kebab-case CLI subcommands
5//! - [`sanitize_cli_name`] — sanitizes arbitrary strings into safe CLI command names
6
7/// Convert a `snake_case` or `camelCase` MCP tool name to a `kebab-case` CLI subcommand.
8///
9/// # Rules
10///
11/// 1. Insert a hyphen before each uppercase-to-lowercase camelCase transition.
12/// 2. Replace all underscores with hyphens.
13/// 3. Lowercase the entire result.
14///
15/// # Examples
16///
17/// | Input | Output |
18/// |---|---|
19/// | `get_confluence_page` | `get-confluence-page` |
20/// | `getConfluencePage` | `get-confluence-page` |
21/// | `createJiraIssue` | `create-jira-issue` |
22/// | `fetch` | `fetch` |
23/// | `getjiraissue` | `getjiraissue` |
24pub fn tool_name_to_subcommand(tool_name: &str) -> String {
25 let mut out = String::new();
26 let mut previous_was_lower_or_digit = false;
27
28 for ch in tool_name.chars() {
29 if ch == '_' {
30 out.push('-');
31 previous_was_lower_or_digit = false;
32 } else if ch.is_ascii_uppercase() {
33 if previous_was_lower_or_digit {
34 out.push('-');
35 }
36 out.push(ch.to_ascii_lowercase());
37 previous_was_lower_or_digit = false;
38 } else {
39 out.push(ch.to_ascii_lowercase());
40 previous_was_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit();
41 }
42 }
43
44 out
45}
46
47/// Convert a kebab-case CLI subcommand back to a `snake_case` MCP tool name.
48pub fn subcommand_to_tool_name(subcommand: &str) -> String {
49 subcommand.replace('-', "_")
50}
51
52/// Sanitize an arbitrary string into a safe CLI command / script name.
53///
54/// # Rules (applied in order)
55///
56/// 1. Lowercase the entire string.
57/// 2. Replace every character not in `[a-z0-9_-]` with `-`.
58/// 3. Collapse consecutive `[-_]` sequences into a single `-`.
59/// 4. Strip leading and trailing `-` and `_`.
60/// 5. If the result is empty, use `"mcp"`.
61/// 6. If the result starts with a digit, prepend `"mcp-"`.
62///
63/// # Examples
64///
65/// | Input | Output |
66/// |---|---|
67/// | `"My Server!"` | `"my-server"` |
68/// | `"atlassian-labs"` | `"atlassian-labs"` |
69/// | `" spaces "` | `"spaces"` |
70/// | `""` | `"mcp"` |
71/// | `"123abc"` | `"mcp-123abc"` |
72/// | `"multi spaces"` | `"multi-spaces"` |
73pub fn sanitize_cli_name(name: &str) -> String {
74 let mut out = String::new();
75 let mut pending_separator: Option<char> = None;
76
77 for ch in name.to_ascii_lowercase().chars() {
78 if ch.is_ascii_alphanumeric() {
79 if let Some(separator) = pending_separator.take() {
80 if !out.is_empty() {
81 out.push(separator);
82 }
83 }
84 out.push(ch);
85 } else if ch == '_' || ch == '-' {
86 pending_separator = Some(match pending_separator {
87 Some(_) => '-',
88 None => ch,
89 });
90 } else {
91 pending_separator = Some(match pending_separator {
92 Some(_) => '-',
93 None => '-',
94 });
95 }
96 }
97
98 let mut sanitized = out.trim_matches(['-', '_']).to_string();
99 if sanitized.is_empty() {
100 sanitized = "mcp".to_string();
101 }
102 if sanitized.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
103 sanitized = format!("mcp-{sanitized}");
104 }
105 sanitized
106}
107
108// ---------------------------------------------------------------------------
109// Tests
110// ---------------------------------------------------------------------------
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 // ------------------------------------------------------------------
117 // tool_name_to_subcommand
118 // ------------------------------------------------------------------
119
120 /// snake_case → kebab-case (basic case, most common input).
121 #[test]
122 fn subcommand_snake_to_kebab() {
123 assert_eq!(tool_name_to_subcommand("get_confluence_page"), "get-confluence-page");
124 }
125
126 /// A single-word tool name is unchanged.
127 #[test]
128 fn subcommand_single_word() {
129 assert_eq!(tool_name_to_subcommand("fetch"), "fetch");
130 }
131
132 /// Two-word snake_case name.
133 #[test]
134 fn subcommand_two_word_snake() {
135 assert_eq!(tool_name_to_subcommand("list_resources"), "list-resources");
136 }
137
138 /// Trailing version numbers in snake_case are preserved.
139 #[test]
140 fn subcommand_snake_with_version() {
141 assert_eq!(tool_name_to_subcommand("my_tool_v2"), "my-tool-v2");
142 }
143
144 /// camelCase → kebab-case (two-word).
145 #[test]
146 fn subcommand_camel_two_word() {
147 assert_eq!(tool_name_to_subcommand("getConfluencePage"), "get-confluence-page");
148 }
149
150 /// camelCase → kebab-case (three-word with acronym-like capitalisation).
151 #[test]
152 fn subcommand_camel_three_word() {
153 assert_eq!(tool_name_to_subcommand("createJiraIssue"), "create-jira-issue");
154 }
155
156 /// An all-lowercase string with no separators is returned unchanged.
157 /// (No camelCase transitions → no splits.)
158 #[test]
159 fn subcommand_all_lowercase_no_splits() {
160 assert_eq!(tool_name_to_subcommand("getjiraissue"), "getjiraissue");
161 }
162
163 /// An already-lowercase tool name with a trailing number is left intact.
164 #[test]
165 fn subcommand_snake_trailing_number() {
166 assert_eq!(tool_name_to_subcommand("list_resources_v2"), "list-resources-v2");
167 }
168
169 // ------------------------------------------------------------------
170 // subcommand_to_tool_name (inverse)
171 // ------------------------------------------------------------------
172
173 /// kebab-case → snake_case round-trip.
174 #[test]
175 fn inverse_kebab_to_snake() {
176 assert_eq!(subcommand_to_tool_name("get-confluence-page"), "get_confluence_page");
177 }
178
179 /// Single-word round-trip.
180 #[test]
181 fn inverse_single_word() {
182 assert_eq!(subcommand_to_tool_name("fetch"), "fetch");
183 }
184
185 // ------------------------------------------------------------------
186 // sanitize_cli_name
187 // ------------------------------------------------------------------
188
189 /// Spaces and special characters are replaced with hyphens.
190 #[test]
191 fn sanitize_spaces_and_special_chars() {
192 assert_eq!(sanitize_cli_name("My Server!"), "my-server");
193 }
194
195 /// Already-valid hyphen-separated names are unchanged.
196 #[test]
197 fn sanitize_already_valid() {
198 assert_eq!(sanitize_cli_name("atlassian-labs"), "atlassian-labs");
199 }
200
201 /// Leading and trailing whitespace is stripped (becomes hyphens, then stripped).
202 #[test]
203 fn sanitize_leading_trailing_spaces() {
204 assert_eq!(sanitize_cli_name(" spaces "), "spaces");
205 }
206
207 /// An empty string yields the fallback "mcp".
208 #[test]
209 fn sanitize_empty_yields_mcp() {
210 assert_eq!(sanitize_cli_name(""), "mcp");
211 }
212
213 /// A string composed entirely of invalid characters yields "mcp".
214 #[test]
215 fn sanitize_all_invalid_yields_mcp() {
216 assert_eq!(sanitize_cli_name("!!!"), "mcp");
217 }
218
219 /// A name starting with a digit gets the "mcp-" prefix.
220 #[test]
221 fn sanitize_digit_start_gets_prefix() {
222 assert_eq!(sanitize_cli_name("123abc"), "mcp-123abc");
223 }
224
225 /// Multiple consecutive spaces collapse to a single hyphen.
226 #[test]
227 fn sanitize_multiple_spaces_collapse() {
228 assert_eq!(sanitize_cli_name("multi spaces"), "multi-spaces");
229 }
230
231 /// A single underscore is preserved as an underscore.
232 #[test]
233 fn sanitize_single_underscore_preserved() {
234 assert_eq!(sanitize_cli_name("hello_world"), "hello_world");
235 }
236
237 /// Consecutive underscores collapse to a single hyphen.
238 #[test]
239 fn sanitize_consecutive_underscores_collapse() {
240 assert_eq!(sanitize_cli_name("hello__world"), "hello-world");
241 }
242
243 /// Mixed consecutive separators (underscore + hyphen) collapse.
244 #[test]
245 fn sanitize_mixed_consecutive_separators() {
246 assert_eq!(sanitize_cli_name("hello_-world"), "hello-world");
247 }
248
249 /// Upper-case letters are lowercased.
250 #[test]
251 fn sanitize_uppercase_lowercased() {
252 assert_eq!(sanitize_cli_name("Hello_World"), "hello_world");
253 }
254}