Skip to main content

aperture_cli/config/
mapping.rs

1//! Command mapping application logic.
2//!
3//! Applies user-defined command mappings (group renames, operation renames,
4//! aliases, hidden flags) to cached commands after spec transformation.
5
6use 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
12/// Names reserved for built-in Aperture commands that cannot be used as group names.
13const RESERVED_GROUP_NAMES: &[&str] = &["config", "search", "exec", "docs", "overview"];
14
15/// Result of applying command mappings, including any warnings.
16#[derive(Debug)]
17pub struct MappingResult {
18    /// Warnings about stale or unused mappings
19    pub warnings: Vec<String>,
20}
21
22/// Applies a `CommandMapping` to a list of cached commands.
23///
24/// For each command:
25/// - If the command's first tag has a group mapping, sets `display_group`.
26/// - If the command's `operation_id` has an operation mapping, sets
27///   `display_name`, `display_group` (if specified), `aliases`, and `hidden`.
28///
29/// # Errors
30///
31/// Returns an error if the resulting mappings produce name collisions.
32pub fn apply_command_mapping(
33    commands: &mut [CachedCommand],
34    mapping: &CommandMapping,
35) -> Result<MappingResult, Error> {
36    let mut warnings = Vec::new();
37
38    // Track which mapping keys were actually used for stale detection
39    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        // Apply group mapping based on the command's first tag
44        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        // Apply operation mapping based on operation_id
54        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    // Detect stale group mappings
62    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    // Detect stale operation mappings
71    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 for collisions
80    validate_no_collisions(commands)?;
81
82    Ok(MappingResult { warnings })
83}
84
85/// Applies an individual operation mapping to a cached command.
86fn 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    // Operation-level group override takes precedence over tag-level
92    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
101/// Resolves the effective group name for a command, considering display overrides.
102///
103/// Mirrors the logic in `engine::generator::effective_group_name` to ensure
104/// collision detection matches the actual command tree.
105fn 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
118/// Resolves the effective subcommand name for a command, considering display overrides.
119///
120/// Mirrors the logic in `engine::generator::effective_subcommand_name` to ensure
121/// collision detection matches the actual command tree.
122fn 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
135/// Validates that no two commands resolve to the same (group, name) pair,
136/// and that aliases don't collide with names or other aliases within the same group.
137fn validate_no_collisions(commands: &[CachedCommand]) -> Result<(), Error> {
138    // Map from (group, name) → operation_id for collision detection
139    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        // Check reserved group names
146        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        // Check primary name collision
155        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        // Check alias collisions within the same group
165        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        // Operation-level group override wins
374        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        // A command with empty name should use DEFAULT_GROUP, not empty string
393        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        // Verify effective_group resolves to DEFAULT_GROUP for collision detection
402        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}