/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { SlashCommand } from '../ui/commands/types.js'; import { CommandKind } from '../ui/commands/types.js'; import type { CommandConflict } from './types.js'; /** * Internal registry to track commands and conflicts during resolution. */ class CommandRegistry { readonly commandMap = new Map(); readonly conflictsMap = new Map(); readonly firstEncounters = new Map(); get finalCommands(): SlashCommand[] { return Array.from(this.commandMap.values()); } get conflicts(): CommandConflict[] { return Array.from(this.conflictsMap.values()); } } /** * Resolves name conflicts among slash commands. * * Rules: * 1. Built-in commands always keep the original name. * 2. All other types are prefixed with their source name (e.g. user.name). * 3. If multiple non-built-in commands conflict, all of them are renamed. */ export class SlashCommandResolver { /** * Orchestrates conflict resolution by applying renaming rules to ensures * every command has a unique name. */ static resolve(allCommands: SlashCommand[]): { finalCommands: SlashCommand[]; conflicts: CommandConflict[]; } { const registry = new CommandRegistry(); for (const cmd of allCommands) { const originalName = cmd.name; let finalName = originalName; if (registry.firstEncounters.has(originalName)) { // We've already seen a command with this name, so resolve the conflict. finalName = this.handleConflict(cmd, registry); } else { // Track the first claimant to report them as the conflict reason later. registry.firstEncounters.set(originalName, cmd); } // Store under final name, ensuring the command object reflects it. registry.commandMap.set(finalName, { ...cmd, name: finalName, }); } return { finalCommands: registry.finalCommands, conflicts: registry.conflicts, }; } /** * Resolves a name collision by deciding which command keeps the name and which is renamed. * * @param incoming The command currently being processed that has a name collision. * @param registry The internal state of the resolution process. * @returns The final name to be assigned to the `incoming` command. */ private static handleConflict( incoming: SlashCommand, registry: CommandRegistry, ): string { const collidingName = incoming.name; const originalClaimant = registry.firstEncounters.get(collidingName)!; // Incoming built-in takes priority. Prefix any existing owner. if (incoming.kind === CommandKind.BUILT_IN) { this.prefixExistingCommand(collidingName, incoming, registry); return collidingName; } // Incoming non-built-in is renamed to its source-prefixed version. const renamedName = this.getRenamedName( incoming.name, this.getPrefix(incoming), registry.commandMap, ); this.trackConflict( registry.conflictsMap, collidingName, originalClaimant, incoming, renamedName, ); // Prefix current owner as well if it isn't a built-in. this.prefixExistingCommand(collidingName, incoming, registry); return renamedName; } /** * Safely renames the command currently occupying a name in the registry. * * @param name The name of the command to prefix. * @param reason The incoming command that is causing the prefixing. * @param registry The internal state of the resolution process. */ private static prefixExistingCommand( name: string, reason: SlashCommand, registry: CommandRegistry, ): void { const currentOwner = registry.commandMap.get(name); // Only non-built-in commands can be prefixed. if (!currentOwner || currentOwner.kind === CommandKind.BUILT_IN) { return; } // Determine the new name for the owner using its source prefix. const renamedName = this.getRenamedName( currentOwner.name, this.getPrefix(currentOwner), registry.commandMap, ); // Update the registry: remove the old name and add the owner under the new name. registry.commandMap.delete(name); const renamedOwner = { ...currentOwner, name: renamedName }; registry.commandMap.set(renamedName, renamedOwner); // Record the conflict so the user can be notified of the prefixing. this.trackConflict( registry.conflictsMap, name, reason, currentOwner, renamedName, ); } /** * Generates a unique name using numeric suffixes if needed. */ private static getRenamedName( name: string, prefix: string | undefined, commandMap: Map, ): string { const base = prefix ? `${prefix}.${name}` : name; let renamedName = base; let suffix = 1; while (commandMap.has(renamedName)) { renamedName = `${base}${suffix}`; suffix++; } return renamedName; } /** * Returns a suitable prefix for a conflicting command. */ private static getPrefix(cmd: SlashCommand): string | undefined { switch (cmd.kind) { case CommandKind.EXTENSION_FILE: return cmd.extensionName; case CommandKind.MCP_PROMPT: return cmd.mcpServerName; case CommandKind.USER_FILE: return 'user'; case CommandKind.WORKSPACE_FILE: return 'workspace'; default: return undefined; } } /** * Logs a conflict event. */ private static trackConflict( conflictsMap: Map, originalName: string, reason: SlashCommand, displacedCommand: SlashCommand, renamedTo: string, ) { if (!conflictsMap.has(originalName)) { conflictsMap.set(originalName, { name: originalName, losers: [], }); } conflictsMap.get(originalName)!.losers.push({ command: displacedCommand, renamedTo, reason, }); } }