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::PaginationInfo;
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 pagination: PaginationInfo::default(),
210 }
211 }
212
213 #[test]
214 fn test_apply_group_mapping() {
215 let mut commands = vec![
216 make_command("User Management", "getUser"),
217 make_command("User Management", "createUser"),
218 ];
219 let mapping = CommandMapping {
220 groups: HashMap::from([("User Management".to_string(), "users".to_string())]),
221 operations: HashMap::new(),
222 };
223
224 let result = apply_command_mapping(&mut commands, &mapping).unwrap();
225 assert!(result.warnings.is_empty());
226 assert_eq!(commands[0].display_group, Some("users".to_string()));
227 assert_eq!(commands[1].display_group, Some("users".to_string()));
228 }
229
230 #[test]
231 fn test_apply_operation_mapping() {
232 let mut commands = vec![make_command("users", "getUserById")];
233 let mapping = CommandMapping {
234 groups: HashMap::new(),
235 operations: HashMap::from([(
236 "getUserById".to_string(),
237 OperationMapping {
238 name: Some("fetch".to_string()),
239 group: Some("accounts".to_string()),
240 aliases: vec!["get".to_string(), "show".to_string()],
241 hidden: false,
242 },
243 )]),
244 };
245
246 let result = apply_command_mapping(&mut commands, &mapping).unwrap();
247 assert!(result.warnings.is_empty());
248 assert_eq!(commands[0].display_name, Some("fetch".to_string()));
249 assert_eq!(commands[0].display_group, Some("accounts".to_string()));
250 assert_eq!(commands[0].aliases, vec!["get", "show"]);
251 }
252
253 #[test]
254 fn test_hidden_operation() {
255 let mut commands = vec![make_command("users", "deleteUser")];
256 let mapping = CommandMapping {
257 groups: HashMap::new(),
258 operations: HashMap::from([(
259 "deleteUser".to_string(),
260 OperationMapping {
261 hidden: true,
262 ..Default::default()
263 },
264 )]),
265 };
266
267 let result = apply_command_mapping(&mut commands, &mapping).unwrap();
268 assert!(result.warnings.is_empty());
269 assert!(commands[0].hidden);
270 }
271
272 #[test]
273 fn test_stale_group_mapping_warns() {
274 let mut commands = vec![make_command("users", "getUser")];
275 let mapping = CommandMapping {
276 groups: HashMap::from([("NonExistentTag".to_string(), "nope".to_string())]),
277 operations: HashMap::new(),
278 };
279
280 let result = apply_command_mapping(&mut commands, &mapping).unwrap();
281 assert_eq!(result.warnings.len(), 1);
282 assert!(result.warnings[0].contains("NonExistentTag"));
283 }
284
285 #[test]
286 fn test_stale_operation_mapping_warns() {
287 let mut commands = vec![make_command("users", "getUser")];
288 let mapping = CommandMapping {
289 groups: HashMap::new(),
290 operations: HashMap::from([("nonExistentOp".to_string(), OperationMapping::default())]),
291 };
292
293 let result = apply_command_mapping(&mut commands, &mapping).unwrap();
294 assert_eq!(result.warnings.len(), 1);
295 assert!(result.warnings[0].contains("nonExistentOp"));
296 }
297
298 #[test]
299 fn test_collision_detection_same_name() {
300 let mut commands = vec![
301 make_command("users", "getUser"),
302 make_command("users", "fetchUser"),
303 ];
304 let mapping = CommandMapping {
305 groups: HashMap::new(),
306 operations: HashMap::from([(
307 "fetchUser".to_string(),
308 OperationMapping {
309 name: Some("get-user".to_string()),
310 ..Default::default()
311 },
312 )]),
313 };
314
315 let result = apply_command_mapping(&mut commands, &mapping);
316 assert!(result.is_err());
317 let err_msg = result.unwrap_err().to_string();
318 assert!(err_msg.contains("collision"), "Error: {err_msg}");
319 }
320
321 #[test]
322 fn test_collision_detection_alias_vs_name() {
323 let mut commands = vec![
324 make_command("users", "getUser"),
325 make_command("users", "fetchUser"),
326 ];
327 let mapping = CommandMapping {
328 groups: HashMap::new(),
329 operations: HashMap::from([(
330 "fetchUser".to_string(),
331 OperationMapping {
332 aliases: vec!["get-user".to_string()],
333 ..Default::default()
334 },
335 )]),
336 };
337
338 let result = apply_command_mapping(&mut commands, &mapping);
339 assert!(result.is_err());
340 let err_msg = result.unwrap_err().to_string();
341 assert!(err_msg.contains("collision"), "Error: {err_msg}");
342 }
343
344 #[test]
345 fn test_reserved_group_name_rejected() {
346 let mut commands = vec![make_command("users", "getUser")];
347 let mapping = CommandMapping {
348 groups: HashMap::from([("users".to_string(), "config".to_string())]),
349 operations: HashMap::new(),
350 };
351
352 let result = apply_command_mapping(&mut commands, &mapping);
353 assert!(result.is_err());
354 let err_msg = result.unwrap_err().to_string();
355 assert!(err_msg.contains("config"), "Error: {err_msg}");
356 }
357
358 #[test]
359 fn test_operation_group_overrides_tag_group() {
360 let mut commands = vec![make_command("User Management", "getUser")];
361 let mapping = CommandMapping {
362 groups: HashMap::from([("User Management".to_string(), "users".to_string())]),
363 operations: HashMap::from([(
364 "getUser".to_string(),
365 OperationMapping {
366 group: Some("accounts".to_string()),
367 ..Default::default()
368 },
369 )]),
370 };
371
372 let result = apply_command_mapping(&mut commands, &mapping).unwrap();
373 assert!(result.warnings.is_empty());
374 assert_eq!(commands[0].display_group, Some("accounts".to_string()));
376 }
377
378 #[test]
379 fn test_no_mapping_leaves_commands_unchanged() {
380 let mut commands = vec![make_command("users", "getUser")];
381 let mapping = CommandMapping::default();
382
383 let result = apply_command_mapping(&mut commands, &mapping).unwrap();
384 assert!(result.warnings.is_empty());
385 assert_eq!(commands[0].display_group, None);
386 assert_eq!(commands[0].display_name, None);
387 assert!(commands[0].aliases.is_empty());
388 assert!(!commands[0].hidden);
389 }
390
391 #[test]
392 fn test_empty_name_uses_default_group() {
393 let mut cmd = make_command("", "getUser");
395 cmd.name = String::new();
396 cmd.tags = vec![];
397 let mut commands = vec![cmd];
398 let mapping = CommandMapping::default();
399
400 let result = apply_command_mapping(&mut commands, &mapping).unwrap();
401 assert!(result.warnings.is_empty());
402 assert_eq!(
404 super::effective_group(&commands[0]),
405 crate::constants::DEFAULT_GROUP
406 );
407 }
408
409 #[test]
410 fn test_empty_operation_id_uses_method() {
411 let mut cmd = make_command("users", "");
412 cmd.operation_id = String::new();
413 cmd.method = "POST".to_string();
414 let mut commands = vec![cmd];
415 let mapping = CommandMapping::default();
416
417 let result = apply_command_mapping(&mut commands, &mapping).unwrap();
418 assert!(result.warnings.is_empty());
419 assert_eq!(super::effective_name(&commands[0]), "post");
420 }
421}