Skip to main content

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}