1use crate::cache::models::CachedCommand;
7use crate::config::models::CommandMapping;
8use crate::error::Error;
9use crate::utils::to_kebab_case;
10use std::collections::{HashMap, HashSet};
11
12const RESERVED_GROUP_NAMES: &[&str] = &["config", "search", "exec", "docs", "overview"];
14
15#[derive(Debug)]
17pub struct MappingResult {
18 pub warnings: Vec<String>,
20}
21
22pub fn apply_command_mapping(
33 commands: &mut [CachedCommand],
34 mapping: &CommandMapping,
35) -> Result<MappingResult, Error> {
36 let mut warnings = Vec::new();
37
38 let mut used_group_keys: HashSet<String> = HashSet::new();
40 let mut used_operation_keys: HashSet<String> = HashSet::new();
41
42 for command in commands.iter_mut() {
43 let first_tag = command
45 .tags
46 .first()
47 .map_or_else(|| command.name.clone(), Clone::clone);
48 if let Some(display_group) = mapping.groups.get(first_tag.as_str()) {
49 command.display_group = Some(display_group.clone());
50 used_group_keys.insert(first_tag);
51 }
52
53 let Some(op_mapping) = mapping.operations.get(&command.operation_id) else {
55 continue;
56 };
57 used_operation_keys.insert(command.operation_id.clone());
58 apply_operation_mapping(command, op_mapping);
59 }
60
61 for key in mapping.groups.keys() {
63 if !used_group_keys.contains(key) {
64 warnings.push(format!(
65 "Command mapping: group mapping for tag '{key}' did not match any operations"
66 ));
67 }
68 }
69
70 for key in mapping.operations.keys() {
72 if !used_operation_keys.contains(key) {
73 warnings.push(format!(
74 "Command mapping: operation mapping for '{key}' did not match any operations"
75 ));
76 }
77 }
78
79 validate_no_collisions(commands)?;
81
82 Ok(MappingResult { warnings })
83}
84
85fn apply_operation_mapping(
87 command: &mut CachedCommand,
88 op_mapping: &crate::config::models::OperationMapping,
89) {
90 command.display_name.clone_from(&op_mapping.name);
91 if op_mapping.group.is_some() {
93 command.display_group.clone_from(&op_mapping.group);
94 }
95 if !op_mapping.aliases.is_empty() {
96 command.aliases.clone_from(&op_mapping.aliases);
97 }
98 command.hidden = op_mapping.hidden;
99}
100
101fn effective_group(command: &CachedCommand) -> String {
106 command.display_group.as_ref().map_or_else(
107 || {
108 if command.name.is_empty() {
109 crate::constants::DEFAULT_GROUP.to_string()
110 } else {
111 to_kebab_case(&command.name)
112 }
113 },
114 |g| to_kebab_case(g),
115 )
116}
117
118fn effective_name(command: &CachedCommand) -> String {
123 command.display_name.as_ref().map_or_else(
124 || {
125 if command.operation_id.is_empty() {
126 command.method.to_lowercase()
127 } else {
128 to_kebab_case(&command.operation_id)
129 }
130 },
131 |n| to_kebab_case(n),
132 )
133}
134
135fn validate_no_collisions(commands: &[CachedCommand]) -> Result<(), Error> {
138 let mut seen: HashMap<(String, String), &str> = HashMap::new();
140
141 for command in commands {
142 let group = effective_group(command);
143 let name = effective_name(command);
144
145 if RESERVED_GROUP_NAMES.contains(&group.as_str()) {
147 return Err(Error::invalid_config(format!(
148 "Command mapping collision: group name '{group}' (from operation '{}') \
149 conflicts with built-in command '{group}'",
150 command.operation_id
151 )));
152 }
153
154 let key = (group.clone(), name.clone());
156 if let Some(existing_op) = seen.get(&key) {
157 return Err(Error::invalid_config(format!(
158 "Command mapping collision: operations '{}' and '{}' both resolve to '{} {}'",
159 existing_op, command.operation_id, key.0, key.1
160 )));
161 }
162 seen.insert(key, &command.operation_id);
163
164 for alias in &command.aliases {
166 let alias_kebab = to_kebab_case(alias);
167 let alias_key = (group.clone(), alias_kebab.clone());
168 if let Some(existing_op) = seen.get(&alias_key) {
169 return Err(Error::invalid_config(format!(
170 "Command mapping collision: alias '{alias_kebab}' for operation '{}' \
171 conflicts with '{}' in group '{group}'",
172 command.operation_id, existing_op
173 )));
174 }
175 seen.insert(alias_key, &command.operation_id);
176 }
177 }
178
179 Ok(())
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use crate::cache::models::CachedCommand;
186 use crate::config::models::{CommandMapping, OperationMapping};
187 use std::collections::HashMap;
188
189 fn make_command(tag: &str, operation_id: &str) -> CachedCommand {
190 CachedCommand {
191 name: tag.to_string(),
192 description: None,
193 summary: None,
194 operation_id: operation_id.to_string(),
195 method: "GET".to_string(),
196 path: format!("/{tag}"),
197 parameters: vec![],
198 request_body: None,
199 responses: vec![],
200 security_requirements: vec![],
201 tags: vec![tag.to_string()],
202 deprecated: false,
203 external_docs_url: None,
204 examples: vec![],
205 display_group: None,
206 display_name: None,
207 aliases: vec![],
208 hidden: false,
209 }
210 }
211
212 #[test]
213 fn test_apply_group_mapping() {
214 let mut commands = vec![
215 make_command("User Management", "getUser"),
216 make_command("User Management", "createUser"),
217 ];
218 let mapping = CommandMapping {
219 groups: HashMap::from([("User Management".to_string(), "users".to_string())]),
220 operations: HashMap::new(),
221 };
222
223 let result = apply_command_mapping(&mut commands, &mapping).unwrap();
224 assert!(result.warnings.is_empty());
225 assert_eq!(commands[0].display_group, Some("users".to_string()));
226 assert_eq!(commands[1].display_group, Some("users".to_string()));
227 }
228
229 #[test]
230 fn test_apply_operation_mapping() {
231 let mut commands = vec![make_command("users", "getUserById")];
232 let mapping = CommandMapping {
233 groups: HashMap::new(),
234 operations: HashMap::from([(
235 "getUserById".to_string(),
236 OperationMapping {
237 name: Some("fetch".to_string()),
238 group: Some("accounts".to_string()),
239 aliases: vec!["get".to_string(), "show".to_string()],
240 hidden: false,
241 },
242 )]),
243 };
244
245 let result = apply_command_mapping(&mut commands, &mapping).unwrap();
246 assert!(result.warnings.is_empty());
247 assert_eq!(commands[0].display_name, Some("fetch".to_string()));
248 assert_eq!(commands[0].display_group, Some("accounts".to_string()));
249 assert_eq!(commands[0].aliases, vec!["get", "show"]);
250 }
251
252 #[test]
253 fn test_hidden_operation() {
254 let mut commands = vec![make_command("users", "deleteUser")];
255 let mapping = CommandMapping {
256 groups: HashMap::new(),
257 operations: HashMap::from([(
258 "deleteUser".to_string(),
259 OperationMapping {
260 hidden: true,
261 ..Default::default()
262 },
263 )]),
264 };
265
266 let result = apply_command_mapping(&mut commands, &mapping).unwrap();
267 assert!(result.warnings.is_empty());
268 assert!(commands[0].hidden);
269 }
270
271 #[test]
272 fn test_stale_group_mapping_warns() {
273 let mut commands = vec![make_command("users", "getUser")];
274 let mapping = CommandMapping {
275 groups: HashMap::from([("NonExistentTag".to_string(), "nope".to_string())]),
276 operations: HashMap::new(),
277 };
278
279 let result = apply_command_mapping(&mut commands, &mapping).unwrap();
280 assert_eq!(result.warnings.len(), 1);
281 assert!(result.warnings[0].contains("NonExistentTag"));
282 }
283
284 #[test]
285 fn test_stale_operation_mapping_warns() {
286 let mut commands = vec![make_command("users", "getUser")];
287 let mapping = CommandMapping {
288 groups: HashMap::new(),
289 operations: HashMap::from([("nonExistentOp".to_string(), OperationMapping::default())]),
290 };
291
292 let result = apply_command_mapping(&mut commands, &mapping).unwrap();
293 assert_eq!(result.warnings.len(), 1);
294 assert!(result.warnings[0].contains("nonExistentOp"));
295 }
296
297 #[test]
298 fn test_collision_detection_same_name() {
299 let mut commands = vec![
300 make_command("users", "getUser"),
301 make_command("users", "fetchUser"),
302 ];
303 let mapping = CommandMapping {
304 groups: HashMap::new(),
305 operations: HashMap::from([(
306 "fetchUser".to_string(),
307 OperationMapping {
308 name: Some("get-user".to_string()),
309 ..Default::default()
310 },
311 )]),
312 };
313
314 let result = apply_command_mapping(&mut commands, &mapping);
315 assert!(result.is_err());
316 let err_msg = result.unwrap_err().to_string();
317 assert!(err_msg.contains("collision"), "Error: {err_msg}");
318 }
319
320 #[test]
321 fn test_collision_detection_alias_vs_name() {
322 let mut commands = vec![
323 make_command("users", "getUser"),
324 make_command("users", "fetchUser"),
325 ];
326 let mapping = CommandMapping {
327 groups: HashMap::new(),
328 operations: HashMap::from([(
329 "fetchUser".to_string(),
330 OperationMapping {
331 aliases: vec!["get-user".to_string()],
332 ..Default::default()
333 },
334 )]),
335 };
336
337 let result = apply_command_mapping(&mut commands, &mapping);
338 assert!(result.is_err());
339 let err_msg = result.unwrap_err().to_string();
340 assert!(err_msg.contains("collision"), "Error: {err_msg}");
341 }
342
343 #[test]
344 fn test_reserved_group_name_rejected() {
345 let mut commands = vec![make_command("users", "getUser")];
346 let mapping = CommandMapping {
347 groups: HashMap::from([("users".to_string(), "config".to_string())]),
348 operations: HashMap::new(),
349 };
350
351 let result = apply_command_mapping(&mut commands, &mapping);
352 assert!(result.is_err());
353 let err_msg = result.unwrap_err().to_string();
354 assert!(err_msg.contains("config"), "Error: {err_msg}");
355 }
356
357 #[test]
358 fn test_operation_group_overrides_tag_group() {
359 let mut commands = vec![make_command("User Management", "getUser")];
360 let mapping = CommandMapping {
361 groups: HashMap::from([("User Management".to_string(), "users".to_string())]),
362 operations: HashMap::from([(
363 "getUser".to_string(),
364 OperationMapping {
365 group: Some("accounts".to_string()),
366 ..Default::default()
367 },
368 )]),
369 };
370
371 let result = apply_command_mapping(&mut commands, &mapping).unwrap();
372 assert!(result.warnings.is_empty());
373 assert_eq!(commands[0].display_group, Some("accounts".to_string()));
375 }
376
377 #[test]
378 fn test_no_mapping_leaves_commands_unchanged() {
379 let mut commands = vec![make_command("users", "getUser")];
380 let mapping = CommandMapping::default();
381
382 let result = apply_command_mapping(&mut commands, &mapping).unwrap();
383 assert!(result.warnings.is_empty());
384 assert_eq!(commands[0].display_group, None);
385 assert_eq!(commands[0].display_name, None);
386 assert!(commands[0].aliases.is_empty());
387 assert!(!commands[0].hidden);
388 }
389
390 #[test]
391 fn test_empty_name_uses_default_group() {
392 let mut cmd = make_command("", "getUser");
394 cmd.name = String::new();
395 cmd.tags = vec![];
396 let mut commands = vec![cmd];
397 let mapping = CommandMapping::default();
398
399 let result = apply_command_mapping(&mut commands, &mapping).unwrap();
400 assert!(result.warnings.is_empty());
401 assert_eq!(
403 super::effective_group(&commands[0]),
404 crate::constants::DEFAULT_GROUP
405 );
406 }
407
408 #[test]
409 fn test_empty_operation_id_uses_method() {
410 let mut cmd = make_command("users", "");
411 cmd.operation_id = String::new();
412 cmd.method = "POST".to_string();
413 let mut commands = vec![cmd];
414 let mapping = CommandMapping::default();
415
416 let result = apply_command_mapping(&mut commands, &mapping).unwrap();
417 assert!(result.warnings.is_empty());
418 assert_eq!(super::effective_name(&commands[0]), "post");
419 }
420}