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::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        // Operation-level group override wins
375        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        // A command with empty name should use DEFAULT_GROUP, not empty string
394        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        // Verify effective_group resolves to DEFAULT_GROUP for collision detection
403        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}