user_slash_command.rs

   1use anyhow::{Result, anyhow};
   2use collections::{HashMap, HashSet};
   3use fs::Fs;
   4use futures::StreamExt;
   5use gpui::{Context, EventEmitter, Task};
   6use std::borrow::Cow;
   7use std::path::{Path, PathBuf};
   8use std::sync::Arc;
   9use std::time::Duration;
  10
  11/// An error that occurred while loading a command file.
  12#[derive(Debug, Clone)]
  13pub struct CommandLoadError {
  14    /// The path to the file that failed to load
  15    pub path: PathBuf,
  16    /// The base path of the commands directory (used to derive command name)
  17    pub base_path: PathBuf,
  18    /// A description of the error
  19    pub message: String,
  20}
  21
  22impl CommandLoadError {
  23    /// Derives the command name from the file path, similar to how successful commands are named.
  24    /// Returns None if the command name cannot be determined (e.g., for directory errors).
  25    pub fn command_name(&self) -> Option<String> {
  26        let base_name = self.path.file_stem()?.to_string_lossy().into_owned();
  27
  28        // Only derive command name for .md files
  29        if self.path.extension().is_none_or(|ext| ext != "md") {
  30            return None;
  31        }
  32
  33        let namespace = self
  34            .path
  35            .parent()
  36            .and_then(|parent| parent.strip_prefix(&self.base_path).ok())
  37            .filter(|rel| !rel.as_os_str().is_empty())
  38            .map(|rel| {
  39                rel.to_string_lossy()
  40                    .replace(std::path::MAIN_SEPARATOR, "/")
  41            });
  42
  43        let name = match &namespace {
  44            Some(namespace) => format!("{}:{}", namespace.replace('/', ":"), base_name),
  45            None => base_name,
  46        };
  47
  48        Some(name)
  49    }
  50}
  51
  52impl std::fmt::Display for CommandLoadError {
  53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  54        write!(
  55            f,
  56            "Failed to load {}: {}",
  57            self.path.display(),
  58            self.message
  59        )
  60    }
  61}
  62
  63/// Result of loading commands, including any errors encountered.
  64#[derive(Debug, Default, Clone)]
  65pub struct CommandLoadResult {
  66    /// Successfully loaded commands
  67    pub commands: Vec<UserSlashCommand>,
  68    /// Errors encountered while loading commands
  69    pub errors: Vec<CommandLoadError>,
  70}
  71
  72/// The scope of a user-defined slash command.
  73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
  74pub enum CommandScope {
  75    /// Project-specific command from .zed/commands/
  76    Project,
  77    /// User-wide command from config_dir()/commands/
  78    User,
  79}
  80
  81/// A user-defined slash command loaded from a markdown file.
  82#[derive(Debug, Clone, PartialEq)]
  83pub struct UserSlashCommand {
  84    /// The command name for invocation.
  85    /// For commands in subdirectories, this is prefixed: "namespace:name" (e.g., "frontend:component")
  86    /// For commands in the root, this is just the filename without .md extension.
  87    pub name: Arc<str>,
  88    /// The template content from the file
  89    pub template: Arc<str>,
  90    /// The namespace (subdirectory path, if any), used for description display
  91    pub namespace: Option<Arc<str>>,
  92    /// The full path to the command file
  93    pub path: PathBuf,
  94    /// Whether this is a project or user command
  95    pub scope: CommandScope,
  96}
  97
  98impl UserSlashCommand {
  99    /// Returns a description string for display in completions.
 100    pub fn description(&self) -> String {
 101        String::new()
 102    }
 103
 104    /// Returns true if this command has any placeholders ($1, $2, etc. or $ARGUMENTS)
 105    pub fn requires_arguments(&self) -> bool {
 106        has_placeholders(&self.template)
 107    }
 108}
 109
 110fn command_base_path(command: &UserSlashCommand) -> PathBuf {
 111    let mut base_path = command.path.clone();
 112    base_path.pop();
 113    if let Some(namespace) = &command.namespace {
 114        for segment in namespace.split('/') {
 115            if segment.is_empty() {
 116                continue;
 117            }
 118            if !base_path.pop() {
 119                break;
 120            }
 121        }
 122    }
 123    base_path
 124}
 125
 126impl CommandLoadError {
 127    pub fn from_command(command: &UserSlashCommand, message: String) -> Self {
 128        Self {
 129            path: command.path.clone(),
 130            base_path: command_base_path(command),
 131            message,
 132        }
 133    }
 134}
 135
 136/// Parsed user command from input text
 137#[derive(Debug, Clone, PartialEq)]
 138pub struct ParsedUserCommand<'a> {
 139    pub name: &'a str,
 140    pub raw_arguments: &'a str,
 141}
 142
 143/// Returns the path to the user commands directory.
 144pub fn user_commands_dir() -> PathBuf {
 145    paths::config_dir().join("commands")
 146}
 147
 148/// Returns the path to the project commands directory for a given worktree root.
 149pub fn project_commands_dir(worktree_root: &Path) -> PathBuf {
 150    worktree_root.join(".zed").join("commands")
 151}
 152
 153/// Events emitted by SlashCommandRegistry
 154#[derive(Debug, Clone)]
 155#[allow(dead_code)] // Infrastructure for future caching implementation
 156pub enum SlashCommandRegistryEvent {
 157    /// Commands have been reloaded
 158    CommandsChanged,
 159}
 160
 161/// A registry that caches user-defined slash commands and watches for changes.
 162/// Currently used in tests; will be integrated into the UI layer for caching.
 163#[allow(dead_code)]
 164pub struct SlashCommandRegistry {
 165    fs: Arc<dyn Fs>,
 166    commands: HashMap<String, UserSlashCommand>,
 167    errors: Vec<CommandLoadError>,
 168    worktree_roots: Vec<PathBuf>,
 169    _watch_task: Option<Task<()>>,
 170}
 171
 172impl EventEmitter<SlashCommandRegistryEvent> for SlashCommandRegistry {}
 173
 174#[allow(dead_code)]
 175impl SlashCommandRegistry {
 176    /// Creates a new registry and starts loading commands.
 177    pub fn new(fs: Arc<dyn Fs>, worktree_roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
 178        let mut this = Self {
 179            fs,
 180            commands: HashMap::default(),
 181            errors: Vec::new(),
 182            worktree_roots,
 183            _watch_task: None,
 184        };
 185
 186        this.start_watching(cx);
 187        this.reload(cx);
 188
 189        this
 190    }
 191
 192    /// Returns all loaded commands.
 193    pub fn commands(&self) -> &HashMap<String, UserSlashCommand> {
 194        &self.commands
 195    }
 196
 197    /// Returns any errors from the last load.
 198    pub fn errors(&self) -> &[CommandLoadError] {
 199        &self.errors
 200    }
 201
 202    /// Updates the worktree roots and reloads commands.
 203    pub fn set_worktree_roots(&mut self, roots: Vec<PathBuf>, cx: &mut Context<Self>) {
 204        if self.worktree_roots != roots {
 205            self.worktree_roots = roots;
 206            self.start_watching(cx);
 207            self.reload(cx);
 208        }
 209    }
 210
 211    /// Manually triggers a reload of all commands.
 212    pub fn reload(&mut self, cx: &mut Context<Self>) {
 213        let fs = self.fs.clone();
 214        let worktree_roots = self.worktree_roots.clone();
 215
 216        cx.spawn(async move |this, cx| {
 217            let result = load_all_commands_async(&fs, &worktree_roots).await;
 218            this.update(cx, |this, cx| {
 219                this.commands = commands_to_map(&result.commands);
 220                this.errors = result.errors;
 221                cx.emit(SlashCommandRegistryEvent::CommandsChanged);
 222            })
 223        })
 224        .detach_and_log_err(cx);
 225    }
 226
 227    fn start_watching(&mut self, cx: &mut Context<Self>) {
 228        let fs = self.fs.clone();
 229        let worktree_roots = self.worktree_roots.clone();
 230
 231        let task = cx.spawn(async move |this, cx| {
 232            let user_dir = user_commands_dir();
 233            let mut dirs_to_watch = vec![user_dir];
 234            for root in &worktree_roots {
 235                dirs_to_watch.push(project_commands_dir(root));
 236            }
 237
 238            let mut watch_streams = Vec::new();
 239            for dir in &dirs_to_watch {
 240                let (stream, _watcher) = fs.watch(dir, Duration::from_millis(100)).await;
 241                watch_streams.push(stream);
 242            }
 243
 244            let mut combined = futures::stream::select_all(watch_streams);
 245
 246            while let Some(events) = combined.next().await {
 247                let should_reload = events.iter().any(|event| {
 248                    event.path.extension().is_some_and(|ext| ext == "md")
 249                        || event.kind == Some(fs::PathEventKind::Created)
 250                        || event.kind == Some(fs::PathEventKind::Removed)
 251                });
 252
 253                if should_reload {
 254                    let result = load_all_commands_async(&fs, &worktree_roots).await;
 255                    let _ = this.update(cx, |this, cx| {
 256                        this.commands = commands_to_map(&result.commands);
 257                        this.errors = result.errors;
 258                        cx.emit(SlashCommandRegistryEvent::CommandsChanged);
 259                    });
 260                }
 261            }
 262        });
 263
 264        self._watch_task = Some(task);
 265    }
 266}
 267
 268/// Loads all commands (both project and user) for given worktree roots asynchronously.
 269pub async fn load_all_commands_async(
 270    fs: &Arc<dyn Fs>,
 271    worktree_roots: &[PathBuf],
 272) -> CommandLoadResult {
 273    let mut result = CommandLoadResult::default();
 274    let mut seen_commands: HashMap<String, PathBuf> = HashMap::default();
 275
 276    // Load project commands first
 277    for root in worktree_roots {
 278        let commands_path = project_commands_dir(root);
 279        let project_result =
 280            load_commands_from_path_async(fs, &commands_path, CommandScope::Project).await;
 281        result.errors.extend(project_result.errors);
 282        for cmd in project_result.commands {
 283            if let Some(existing_path) = seen_commands.get(&*cmd.name) {
 284                result.errors.push(CommandLoadError {
 285                    path: cmd.path.clone(),
 286                    base_path: commands_path.clone(),
 287                    message: format!(
 288                        "Command '{}' is ambiguous: also defined at {}",
 289                        cmd.name,
 290                        existing_path.display()
 291                    ),
 292                });
 293            } else {
 294                seen_commands.insert(cmd.name.to_string(), cmd.path.clone());
 295                result.commands.push(cmd);
 296            }
 297        }
 298    }
 299
 300    // Load user commands
 301    let user_commands_path = user_commands_dir();
 302    let user_result =
 303        load_commands_from_path_async(fs, &user_commands_path, CommandScope::User).await;
 304    result.errors.extend(user_result.errors);
 305    for cmd in user_result.commands {
 306        if let Some(existing_path) = seen_commands.get(&*cmd.name) {
 307            result.errors.push(CommandLoadError {
 308                path: cmd.path.clone(),
 309                base_path: user_commands_path.clone(),
 310                message: format!(
 311                    "Command '{}' is ambiguous: also defined at {}",
 312                    cmd.name,
 313                    existing_path.display()
 314                ),
 315            });
 316        } else {
 317            seen_commands.insert(cmd.name.to_string(), cmd.path.clone());
 318            result.commands.push(cmd);
 319        }
 320    }
 321
 322    result
 323}
 324
 325async fn load_commands_from_path_async(
 326    fs: &Arc<dyn Fs>,
 327    commands_path: &Path,
 328    scope: CommandScope,
 329) -> CommandLoadResult {
 330    let mut result = CommandLoadResult::default();
 331
 332    if !fs.is_dir(commands_path).await {
 333        return result;
 334    }
 335
 336    load_commands_from_dir_async(fs, commands_path, commands_path, scope, &mut result).await;
 337    result
 338}
 339
 340fn load_commands_from_dir_async<'a>(
 341    fs: &'a Arc<dyn Fs>,
 342    base_path: &'a Path,
 343    current_path: &'a Path,
 344    scope: CommandScope,
 345    result: &'a mut CommandLoadResult,
 346) -> futures::future::BoxFuture<'a, ()> {
 347    Box::pin(async move {
 348        let entries = match fs.read_dir(current_path).await {
 349            Ok(entries) => entries,
 350            Err(e) => {
 351                result.errors.push(CommandLoadError {
 352                    path: current_path.to_path_buf(),
 353                    base_path: base_path.to_path_buf(),
 354                    message: format!("Failed to read directory: {}", e),
 355                });
 356                return;
 357            }
 358        };
 359
 360        let entries: Vec<_> = entries.collect().await;
 361
 362        for entry in entries {
 363            let path = match entry {
 364                Ok(path) => path,
 365                Err(e) => {
 366                    result.errors.push(CommandLoadError {
 367                        path: current_path.to_path_buf(),
 368                        base_path: base_path.to_path_buf(),
 369                        message: format!("Failed to read directory entry: {}", e),
 370                    });
 371                    continue;
 372                }
 373            };
 374
 375            if fs.is_dir(&path).await {
 376                load_commands_from_dir_async(fs, base_path, &path, scope, result).await;
 377            } else if path.extension().is_some_and(|ext| ext == "md") {
 378                match load_command_file_async(fs, base_path, &path, scope).await {
 379                    Ok(Some(command)) => result.commands.push(command),
 380                    Ok(None) => {} // Empty file, skip silently
 381                    Err(e) => {
 382                        result.errors.push(CommandLoadError {
 383                            path: path.clone(),
 384                            base_path: base_path.to_path_buf(),
 385                            message: e.to_string(),
 386                        });
 387                    }
 388                }
 389            }
 390        }
 391    })
 392}
 393
 394async fn load_command_file_async(
 395    fs: &Arc<dyn Fs>,
 396    base_path: &Path,
 397    file_path: &Path,
 398    scope: CommandScope,
 399) -> Result<Option<UserSlashCommand>> {
 400    let base_name = match file_path.file_stem() {
 401        Some(stem) => stem.to_string_lossy().into_owned(),
 402        None => return Ok(None),
 403    };
 404
 405    let template = fs.load(file_path).await?;
 406    if template.is_empty() {
 407        return Ok(None);
 408    }
 409    if template.trim().is_empty() {
 410        return Err(anyhow!("Command file contains only whitespace"));
 411    }
 412
 413    let namespace = file_path
 414        .parent()
 415        .and_then(|parent| parent.strip_prefix(base_path).ok())
 416        .filter(|rel| !rel.as_os_str().is_empty())
 417        .map(|rel| {
 418            rel.to_string_lossy()
 419                .replace(std::path::MAIN_SEPARATOR, "/")
 420        });
 421
 422    // Build the full command name: "namespace:basename" or just "basename"
 423    let name = match &namespace {
 424        Some(namespace) => format!("{}:{}", namespace.replace('/', ":"), base_name),
 425        None => base_name,
 426    };
 427
 428    Ok(Some(UserSlashCommand {
 429        name: name.into(),
 430        template: template.into(),
 431        namespace: namespace.map(|s| s.into()),
 432        path: file_path.to_path_buf(),
 433        scope,
 434    }))
 435}
 436
 437/// Converts a list of UserSlashCommand to a HashMap for quick lookup.
 438/// The key is the command name.
 439pub fn commands_to_map(commands: &[UserSlashCommand]) -> HashMap<String, UserSlashCommand> {
 440    let mut map = HashMap::default();
 441    for cmd in commands {
 442        map.insert(cmd.name.to_string(), cmd.clone());
 443    }
 444    map
 445}
 446
 447fn has_error_for_command(errors: &[CommandLoadError], name: &str) -> bool {
 448    errors
 449        .iter()
 450        .any(|error| error.command_name().as_deref() == Some(name))
 451}
 452
 453fn server_conflict_message(name: &str) -> String {
 454    format!(
 455        "Command '{}' conflicts with server-provided /{}",
 456        name, name
 457    )
 458}
 459
 460pub fn apply_server_command_conflicts(
 461    commands: &mut Vec<UserSlashCommand>,
 462    errors: &mut Vec<CommandLoadError>,
 463    server_command_names: &HashSet<String>,
 464) {
 465    commands.retain(|command| {
 466        if server_command_names.contains(command.name.as_ref()) {
 467            if !has_error_for_command(errors, command.name.as_ref()) {
 468                errors.push(CommandLoadError::from_command(
 469                    command,
 470                    server_conflict_message(command.name.as_ref()),
 471                ));
 472            }
 473            false
 474        } else {
 475            true
 476        }
 477    });
 478}
 479
 480pub fn apply_server_command_conflicts_to_map(
 481    commands: &mut HashMap<String, UserSlashCommand>,
 482    errors: &mut Vec<CommandLoadError>,
 483    server_command_names: &HashSet<String>,
 484) {
 485    commands.retain(|name, command| {
 486        if server_command_names.contains(name) {
 487            if !has_error_for_command(errors, name) {
 488                errors.push(CommandLoadError::from_command(
 489                    command,
 490                    server_conflict_message(name),
 491                ));
 492            }
 493            false
 494        } else {
 495            true
 496        }
 497    });
 498}
 499
 500/// Parses a line of input to extract a user command invocation.
 501/// Returns None if the line doesn't start with a slash command.
 502pub fn try_parse_user_command(line: &str) -> Option<ParsedUserCommand<'_>> {
 503    let line = line.trim_start();
 504    if !line.starts_with('/') {
 505        return None;
 506    }
 507
 508    let after_slash = &line[1..];
 509    let (name, raw_arguments) = if let Some(space_idx) = after_slash.find(char::is_whitespace) {
 510        let name = &after_slash[..space_idx];
 511        let rest = &after_slash[space_idx..].trim_start();
 512        (name, *rest)
 513    } else {
 514        (after_slash, "")
 515    };
 516
 517    if name.is_empty() {
 518        return None;
 519    }
 520
 521    Some(ParsedUserCommand {
 522        name,
 523        raw_arguments,
 524    })
 525}
 526
 527/// Parses command arguments, supporting quoted strings.
 528/// - Unquoted arguments are space-separated
 529/// - Quoted arguments can contain spaces: "multi word arg"
 530/// - Escape sequences: \" for literal quote, \\ for backslash, \n for newline
 531pub fn parse_arguments(input: &str) -> Result<Vec<Cow<'_, str>>> {
 532    let mut arguments = Vec::new();
 533    let mut chars = input.char_indices().peekable();
 534
 535    while let Some((start_idx, c)) = chars.next() {
 536        if c.is_whitespace() {
 537            continue;
 538        }
 539
 540        if c == '"' {
 541            let mut result = String::new();
 542            let mut closed = false;
 543
 544            while let Some((_, ch)) = chars.next() {
 545                if ch == '\\' {
 546                    if let Some((_, next_ch)) = chars.next() {
 547                        match next_ch {
 548                            '"' => result.push('"'),
 549                            '\\' => result.push('\\'),
 550                            'n' => result.push('\n'),
 551                            other => {
 552                                return Err(anyhow!("Unknown escape sequence: \\{}", other));
 553                            }
 554                        }
 555                    } else {
 556                        return Err(anyhow!("Unexpected end of input after backslash"));
 557                    }
 558                } else if ch == '"' {
 559                    closed = true;
 560                    break;
 561                } else {
 562                    result.push(ch);
 563                }
 564            }
 565
 566            if !closed {
 567                return Err(anyhow!("Unclosed quote in command arguments"));
 568            }
 569
 570            arguments.push(Cow::Owned(result));
 571        } else {
 572            let mut end_idx = start_idx + c.len_utf8();
 573            while let Some(&(idx, ch)) = chars.peek() {
 574                if ch.is_whitespace() {
 575                    break;
 576                }
 577                if ch == '"' {
 578                    return Err(anyhow!("Quote in middle of unquoted argument"));
 579                }
 580                end_idx = idx + ch.len_utf8();
 581                chars.next();
 582            }
 583
 584            arguments.push(Cow::Borrowed(&input[start_idx..end_idx]));
 585        }
 586    }
 587
 588    Ok(arguments)
 589}
 590
 591/// Checks if a template has any placeholders ($1, $2, etc. or $ARGUMENTS)
 592pub fn has_placeholders(template: &str) -> bool {
 593    count_positional_placeholders(template) > 0 || template.contains("$ARGUMENTS")
 594}
 595
 596/// Counts the highest positional placeholder number in the template.
 597/// For example, "$1 and $3" returns 3.
 598pub fn count_positional_placeholders(template: &str) -> usize {
 599    let mut max_placeholder = 0;
 600    let mut chars = template.chars().peekable();
 601
 602    while let Some(c) = chars.next() {
 603        if c == '\\' {
 604            chars.next();
 605            continue;
 606        }
 607        if c == '$' {
 608            let mut num_str = String::new();
 609            while let Some(&next_c) = chars.peek() {
 610                if next_c.is_ascii_digit() {
 611                    num_str.push(next_c);
 612                    chars.next();
 613                } else {
 614                    break;
 615                }
 616            }
 617            if !num_str.is_empty() {
 618                if let Ok(n) = num_str.parse::<usize>() {
 619                    max_placeholder = max_placeholder.max(n);
 620                }
 621            }
 622        }
 623    }
 624
 625    max_placeholder
 626}
 627
 628/// Validates that arguments match the template's placeholders.
 629/// Templates can use $ARGUMENTS (all args as one string) or $1, $2, etc. (positional).
 630pub fn validate_arguments(
 631    command_name: &str,
 632    template: &str,
 633    arguments: &[Cow<'_, str>],
 634) -> Result<()> {
 635    if template.is_empty() {
 636        return Err(anyhow!("Template cannot be empty"));
 637    }
 638
 639    let has_arguments_placeholder = template.contains("$ARGUMENTS");
 640    let positional_count = count_positional_placeholders(template);
 641
 642    if has_arguments_placeholder {
 643        // $ARGUMENTS accepts any number of arguments (including zero)
 644        // But if there are also positional placeholders, validate those
 645        if positional_count > 0 && arguments.len() < positional_count {
 646            return Err(anyhow!(
 647                "The /{} command requires {} positional {}, but only {} {} provided",
 648                command_name,
 649                positional_count,
 650                if positional_count == 1 {
 651                    "argument"
 652                } else {
 653                    "arguments"
 654                },
 655                arguments.len(),
 656                if arguments.len() == 1 { "was" } else { "were" }
 657            ));
 658        }
 659        return Ok(());
 660    }
 661
 662    if positional_count == 0 && !arguments.is_empty() {
 663        return Err(anyhow!(
 664            "The /{} command accepts no arguments, but {} {} provided",
 665            command_name,
 666            arguments.len(),
 667            if arguments.len() == 1 { "was" } else { "were" }
 668        ));
 669    }
 670
 671    if arguments.len() < positional_count {
 672        return Err(anyhow!(
 673            "The /{} command requires {} {}, but only {} {} provided",
 674            command_name,
 675            positional_count,
 676            if positional_count == 1 {
 677                "argument"
 678            } else {
 679                "arguments"
 680            },
 681            arguments.len(),
 682            if arguments.len() == 1 { "was" } else { "were" }
 683        ));
 684    }
 685
 686    if arguments.len() > positional_count {
 687        return Err(anyhow!(
 688            "The /{} command accepts {} {}, but {} {} provided",
 689            command_name,
 690            positional_count,
 691            if positional_count == 1 {
 692                "argument"
 693            } else {
 694                "arguments"
 695            },
 696            arguments.len(),
 697            if arguments.len() == 1 { "was" } else { "were" }
 698        ));
 699    }
 700
 701    Ok(())
 702}
 703
 704/// Expands a template by substituting placeholders with arguments.
 705/// - $ARGUMENTS is replaced with all arguments as a single string
 706/// - $1, $2, etc. are replaced with positional arguments
 707/// - \$ produces literal $, \" produces literal ", \n produces newline
 708pub fn expand_template(
 709    template: &str,
 710    arguments: &[Cow<'_, str>],
 711    raw_arguments: &str,
 712) -> Result<String> {
 713    let mut result = String::with_capacity(template.len());
 714    let mut chars = template.char_indices().peekable();
 715
 716    while let Some((_, c)) = chars.next() {
 717        if c == '\\' {
 718            if let Some((_, next_c)) = chars.next() {
 719                match next_c {
 720                    '$' => result.push('$'),
 721                    '"' => result.push('"'),
 722                    '\\' => result.push('\\'),
 723                    'n' => result.push('\n'),
 724                    other => {
 725                        return Err(anyhow!("Unknown escape sequence: \\{}", other));
 726                    }
 727                }
 728            }
 729        } else if c == '$' {
 730            // Check for $ARGUMENTS first
 731            let remaining: String = chars.clone().map(|(_, c)| c).collect();
 732            if remaining.starts_with("ARGUMENTS") {
 733                result.push_str(raw_arguments);
 734                // Skip "ARGUMENTS"
 735                for _ in 0..9 {
 736                    chars.next();
 737                }
 738            } else {
 739                // Check for positional placeholder $N
 740                let mut num_str = String::new();
 741                while let Some(&(_, next_c)) = chars.peek() {
 742                    if next_c.is_ascii_digit() {
 743                        num_str.push(next_c);
 744                        chars.next();
 745                    } else {
 746                        break;
 747                    }
 748                }
 749                if !num_str.is_empty() {
 750                    let n: usize = num_str.parse()?;
 751                    if n == 0 {
 752                        return Err(anyhow!(
 753                            "Placeholder $0 is invalid; placeholders start at $1"
 754                        ));
 755                    }
 756                    if let Some(arg) = arguments.get(n - 1) {
 757                        result.push_str(arg);
 758                    } else {
 759                        return Err(anyhow!("Missing argument for placeholder ${}", n));
 760                    }
 761                } else {
 762                    result.push('$');
 763                }
 764            }
 765        } else {
 766            result.push(c);
 767        }
 768    }
 769
 770    Ok(result)
 771}
 772
 773/// Expands a user slash command, validating arguments and performing substitution.
 774pub fn expand_user_slash_command(
 775    command_name: &str,
 776    template: &str,
 777    arguments: &[Cow<'_, str>],
 778    raw_arguments: &str,
 779) -> Result<String> {
 780    validate_arguments(command_name, template, arguments)?;
 781    expand_template(template, arguments, raw_arguments)
 782}
 783
 784/// Attempts to expand a user slash command from input text.
 785/// Returns Ok(None) if the input is not a user command or the command doesn't exist.
 786/// Returns Err if the command exists but expansion fails (e.g., missing arguments).
 787pub fn try_expand_from_commands(
 788    line: &str,
 789    commands: &HashMap<String, UserSlashCommand>,
 790) -> Result<Option<String>> {
 791    let Some(parsed) = try_parse_user_command(line) else {
 792        return Ok(None);
 793    };
 794
 795    let Some(command) = commands.get(parsed.name) else {
 796        return Ok(None);
 797    };
 798
 799    let arguments = parse_arguments(parsed.raw_arguments)?;
 800    let expanded = expand_user_slash_command(
 801        parsed.name,
 802        &command.template,
 803        &arguments,
 804        parsed.raw_arguments,
 805    )?;
 806    Ok(Some(expanded))
 807}
 808
 809#[cfg(test)]
 810mod tests {
 811    use super::*;
 812    use fs::{FakeFs, Fs, RemoveOptions};
 813    use gpui::{AppContext as _, TestAppContext};
 814    use serde_json::json;
 815    use std::sync::Arc;
 816    use text::Rope;
 817    use util::path;
 818
 819    // ==================== Parsing Tests ====================
 820
 821    #[test]
 822    fn test_try_parse_user_command() {
 823        assert_eq!(
 824            try_parse_user_command("/review"),
 825            Some(ParsedUserCommand {
 826                name: "review",
 827                raw_arguments: ""
 828            })
 829        );
 830
 831        assert_eq!(
 832            try_parse_user_command("/review arg1 arg2"),
 833            Some(ParsedUserCommand {
 834                name: "review",
 835                raw_arguments: "arg1 arg2"
 836            })
 837        );
 838
 839        assert_eq!(
 840            try_parse_user_command("/cmd \"multi word\" simple"),
 841            Some(ParsedUserCommand {
 842                name: "cmd",
 843                raw_arguments: "\"multi word\" simple"
 844            })
 845        );
 846
 847        assert_eq!(try_parse_user_command("not a command"), None);
 848        assert_eq!(try_parse_user_command(""), None);
 849        assert_eq!(try_parse_user_command("/"), None);
 850    }
 851
 852    #[test]
 853    fn test_parse_arguments_simple_unquoted() {
 854        let args = parse_arguments("foo bar").unwrap();
 855        assert_eq!(args, vec!["foo", "bar"]);
 856    }
 857
 858    #[test]
 859    fn test_parse_arguments_quoted() {
 860        let args = parse_arguments("\"foo bar\"").unwrap();
 861        assert_eq!(args, vec!["foo bar"]);
 862    }
 863
 864    #[test]
 865    fn test_parse_arguments_mixed() {
 866        let args = parse_arguments("\"foo bar\" baz \"qux\"").unwrap();
 867        assert_eq!(args, vec!["foo bar", "baz", "qux"]);
 868    }
 869
 870    #[test]
 871    fn test_parse_arguments_escaped_quotes() {
 872        let args = parse_arguments("\"foo \\\"bar\\\" baz\"").unwrap();
 873        assert_eq!(args, vec!["foo \"bar\" baz"]);
 874    }
 875
 876    #[test]
 877    fn test_parse_arguments_escaped_backslash() {
 878        let args = parse_arguments("\"foo\\\\bar\"").unwrap();
 879        assert_eq!(args, vec!["foo\\bar"]);
 880    }
 881
 882    #[test]
 883    fn test_parse_arguments_unclosed_quote_error() {
 884        let result = parse_arguments("\"foo");
 885        assert!(result.is_err());
 886        assert!(result.unwrap_err().to_string().contains("Unclosed quote"));
 887    }
 888
 889    #[test]
 890    fn test_parse_arguments_quote_in_middle_error() {
 891        let result = parse_arguments("foo\"bar");
 892        assert!(result.is_err());
 893        assert!(result.unwrap_err().to_string().contains("Quote in middle"));
 894    }
 895
 896    #[test]
 897    fn test_parse_arguments_unknown_escape_error() {
 898        let result = parse_arguments("\"\\x\"");
 899        assert!(result.is_err());
 900        assert!(result.unwrap_err().to_string().contains("Unknown escape"));
 901    }
 902
 903    #[test]
 904    fn test_parse_arguments_newline_escape() {
 905        let args = parse_arguments("\"line1\\nline2\"").unwrap();
 906        assert_eq!(args, vec!["line1\nline2"]);
 907    }
 908
 909    // ==================== Placeholder Tests ====================
 910
 911    #[test]
 912    fn test_count_positional_placeholders() {
 913        assert_eq!(count_positional_placeholders("Hello $1"), 1);
 914        assert_eq!(count_positional_placeholders("$1 and $2"), 2);
 915        assert_eq!(count_positional_placeholders("$1 $1"), 1);
 916        assert_eq!(count_positional_placeholders("$2 then $1"), 2);
 917        assert_eq!(count_positional_placeholders("no placeholders"), 0);
 918        assert_eq!(count_positional_placeholders("\\$1 escaped"), 0);
 919        assert_eq!(count_positional_placeholders("$10 big number"), 10);
 920    }
 921
 922    #[test]
 923    fn test_has_placeholders() {
 924        assert!(has_placeholders("Hello $1"));
 925        assert!(has_placeholders("$ARGUMENTS"));
 926        assert!(has_placeholders("prefix $ARGUMENTS suffix"));
 927        assert!(!has_placeholders("no placeholders"));
 928        assert!(!has_placeholders("\\$1 escaped"));
 929    }
 930
 931    // ==================== Template Expansion Tests ====================
 932
 933    #[test]
 934    fn test_expand_template_basic() {
 935        let args = vec![Cow::Borrowed("world")];
 936        let result = expand_template("Hello $1", &args, "world").unwrap();
 937        assert_eq!(result, "Hello world");
 938    }
 939
 940    #[test]
 941    fn test_expand_template_multiple_placeholders() {
 942        let args = vec![Cow::Borrowed("a"), Cow::Borrowed("b")];
 943        let result = expand_template("$1 and $2", &args, "a b").unwrap();
 944        assert_eq!(result, "a and b");
 945    }
 946
 947    #[test]
 948    fn test_expand_template_repeated_placeholder() {
 949        let args = vec![Cow::Borrowed("x")];
 950        let result = expand_template("$1 $1", &args, "x").unwrap();
 951        assert_eq!(result, "x x");
 952    }
 953
 954    #[test]
 955    fn test_expand_template_out_of_order() {
 956        let args = vec![Cow::Borrowed("a"), Cow::Borrowed("b")];
 957        let result = expand_template("$2 then $1", &args, "a b").unwrap();
 958        assert_eq!(result, "b then a");
 959    }
 960
 961    #[test]
 962    fn test_expand_template_escape_sequences() {
 963        let args: Vec<Cow<'_, str>> = vec![];
 964        assert_eq!(
 965            expand_template("line1\\nline2", &args, "").unwrap(),
 966            "line1\nline2"
 967        );
 968        assert_eq!(
 969            expand_template("cost is \\$1", &args, "").unwrap(),
 970            "cost is $1"
 971        );
 972        assert_eq!(
 973            expand_template("say \\\"hi\\\"", &args, "").unwrap(),
 974            "say \"hi\""
 975        );
 976        assert_eq!(
 977            expand_template("path\\\\file", &args, "").unwrap(),
 978            "path\\file"
 979        );
 980    }
 981
 982    #[test]
 983    fn test_expand_template_arguments_placeholder() {
 984        let args = vec![Cow::Borrowed("foo"), Cow::Borrowed("bar")];
 985        let result = expand_template("All args: $ARGUMENTS", &args, "foo bar").unwrap();
 986        assert_eq!(result, "All args: foo bar");
 987    }
 988
 989    #[test]
 990    fn test_expand_template_arguments_with_positional() {
 991        let args = vec![Cow::Borrowed("first"), Cow::Borrowed("second")];
 992        let result = expand_template("First: $1, All: $ARGUMENTS", &args, "first second").unwrap();
 993        assert_eq!(result, "First: first, All: first second");
 994    }
 995
 996    #[test]
 997    fn test_expand_template_arguments_empty() {
 998        let args: Vec<Cow<'_, str>> = vec![];
 999        let result = expand_template("Args: $ARGUMENTS", &args, "").unwrap();
1000        assert_eq!(result, "Args: ");
1001    }
1002
1003    // ==================== Validation Tests ====================
1004
1005    #[test]
1006    fn test_validate_arguments_exact_match() {
1007        let args = vec![Cow::Borrowed("a"), Cow::Borrowed("b")];
1008        let result = validate_arguments("test", "$1 $2", &args);
1009        assert!(result.is_ok());
1010    }
1011
1012    #[test]
1013    fn test_validate_arguments_missing_args() {
1014        let args = vec![Cow::Borrowed("a")];
1015        let result = validate_arguments("foo", "$1 $2", &args);
1016        assert!(result.is_err());
1017        let err = result.unwrap_err().to_string();
1018        assert!(err.contains("/foo"));
1019        assert!(err.contains("requires 2 arguments"));
1020    }
1021
1022    #[test]
1023    fn test_validate_arguments_extra_args() {
1024        let args = vec![Cow::Borrowed("a"), Cow::Borrowed("b")];
1025        let result = validate_arguments("foo", "$1", &args);
1026        assert!(result.is_err());
1027        let err = result.unwrap_err().to_string();
1028        assert!(err.contains("accepts 1 argument"));
1029    }
1030
1031    #[test]
1032    fn test_validate_arguments_no_placeholders() {
1033        // No args expected, none provided - OK
1034        let args: Vec<Cow<'_, str>> = vec![];
1035        assert!(validate_arguments("test", "no placeholders", &args).is_ok());
1036
1037        // No args expected but some provided - Error
1038        let args = vec![Cow::Borrowed("unexpected")];
1039        let result = validate_arguments("test", "no placeholders", &args);
1040        assert!(result.is_err());
1041        assert!(
1042            result
1043                .unwrap_err()
1044                .to_string()
1045                .contains("accepts no arguments")
1046        );
1047    }
1048
1049    #[test]
1050    fn test_validate_arguments_empty_template() {
1051        let args: Vec<Cow<'_, str>> = vec![];
1052        let result = validate_arguments("test", "", &args);
1053        assert!(result.is_err());
1054        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
1055    }
1056
1057    #[test]
1058    fn test_validate_arguments_with_arguments_placeholder() {
1059        // $ARGUMENTS accepts any number of arguments including zero
1060        let args: Vec<Cow<'_, str>> = vec![];
1061        assert!(validate_arguments("test", "Do: $ARGUMENTS", &args).is_ok());
1062
1063        let args = vec![Cow::Borrowed("a"), Cow::Borrowed("b"), Cow::Borrowed("c")];
1064        assert!(validate_arguments("test", "Do: $ARGUMENTS", &args).is_ok());
1065    }
1066
1067    #[test]
1068    fn test_validate_arguments_mixed_placeholders() {
1069        // Both $ARGUMENTS and positional - need at least the positional ones
1070        let args = vec![Cow::Borrowed("first")];
1071        assert!(validate_arguments("test", "$1 then $ARGUMENTS", &args).is_ok());
1072
1073        let args: Vec<Cow<'_, str>> = vec![];
1074        assert!(validate_arguments("test", "$1 then $ARGUMENTS", &args).is_err());
1075    }
1076
1077    // ==================== Integration Tests ====================
1078
1079    #[test]
1080    fn test_expand_user_slash_command() {
1081        let result = expand_user_slash_command(
1082            "review",
1083            "Please review: $1",
1084            &[Cow::Borrowed("security")],
1085            "security",
1086        )
1087        .unwrap();
1088        assert_eq!(result, "Please review: security");
1089    }
1090
1091    #[test]
1092    fn test_try_expand_from_commands() {
1093        let commands = vec![
1094            UserSlashCommand {
1095                name: "greet".into(),
1096                template: "Hello, world!".into(),
1097                namespace: None,
1098                path: PathBuf::from("/greet.md"),
1099                scope: CommandScope::User,
1100            },
1101            UserSlashCommand {
1102                name: "review".into(),
1103                template: "Review this for: $1".into(),
1104                namespace: None,
1105                path: PathBuf::from("/review.md"),
1106                scope: CommandScope::User,
1107            },
1108            UserSlashCommand {
1109                name: "search".into(),
1110                template: "Search: $ARGUMENTS".into(),
1111                namespace: None,
1112                path: PathBuf::from("/search.md"),
1113                scope: CommandScope::User,
1114            },
1115        ];
1116        let map = commands_to_map(&commands);
1117
1118        // Command without arguments
1119        assert_eq!(
1120            try_expand_from_commands("/greet", &map).unwrap(),
1121            Some("Hello, world!".to_string())
1122        );
1123
1124        // Command with positional argument
1125        assert_eq!(
1126            try_expand_from_commands("/review security", &map).unwrap(),
1127            Some("Review this for: security".to_string())
1128        );
1129
1130        // Command with $ARGUMENTS
1131        assert_eq!(
1132            try_expand_from_commands("/search foo bar baz", &map).unwrap(),
1133            Some("Search: foo bar baz".to_string())
1134        );
1135
1136        // Unknown command returns None
1137        assert_eq!(try_expand_from_commands("/unknown", &map).unwrap(), None);
1138
1139        // Not a command returns None
1140        assert_eq!(try_expand_from_commands("just text", &map).unwrap(), None);
1141    }
1142
1143    #[test]
1144    fn test_try_expand_from_commands_missing_args() {
1145        let commands = vec![UserSlashCommand {
1146            name: "review".into(),
1147            template: "Review: $1".into(),
1148            namespace: None,
1149            path: PathBuf::from("/review.md"),
1150            scope: CommandScope::User,
1151        }];
1152        let map = commands_to_map(&commands);
1153
1154        let result = try_expand_from_commands("/review", &map);
1155        assert!(result.is_err());
1156        assert!(
1157            result
1158                .unwrap_err()
1159                .to_string()
1160                .contains("requires 1 argument")
1161        );
1162    }
1163
1164    // ==================== Edge Case Tests ====================
1165
1166    #[test]
1167    fn test_unicode_command_names() {
1168        // Test that unicode in command names works
1169        let result = try_parse_user_command("/日本語 arg1");
1170        assert!(result.is_some());
1171        let parsed = result.unwrap();
1172        assert_eq!(parsed.name, "日本語");
1173        assert_eq!(parsed.raw_arguments, "arg1");
1174    }
1175
1176    #[test]
1177    fn test_unicode_in_arguments() {
1178        let args = parse_arguments("\"こんにちは\" 世界").unwrap();
1179        assert_eq!(args, vec!["こんにちは", "世界"]);
1180    }
1181
1182    #[test]
1183    fn test_unicode_in_template() {
1184        let args = vec![Cow::Borrowed("名前")];
1185        let result = expand_template("こんにちは、$1さん!", &args, "名前").unwrap();
1186        assert_eq!(result, "こんにちは、名前さん!");
1187    }
1188
1189    #[test]
1190    fn test_command_name_with_emoji() {
1191        // Emoji can be multi-codepoint, test they're handled correctly
1192        let result = try_parse_user_command("/🚀deploy fast");
1193        assert!(result.is_some());
1194        let parsed = result.unwrap();
1195        assert_eq!(parsed.name, "🚀deploy");
1196        assert_eq!(parsed.raw_arguments, "fast");
1197
1198        // Emoji in arguments
1199        let args = parse_arguments("🎉 \"🎊 party\"").unwrap();
1200        assert_eq!(args, vec!["🎉", "🎊 party"]);
1201    }
1202
1203    #[test]
1204    fn test_many_placeholders() {
1205        // Test template with many placeholders
1206        let template = "$1 $2 $3 $4 $5 $6 $7 $8 $9 $10";
1207        assert_eq!(count_positional_placeholders(template), 10);
1208
1209        let args: Vec<Cow<'_, str>> = (1..=10).map(|i| Cow::Owned(i.to_string())).collect();
1210        let result = expand_template(template, &args, "1 2 3 4 5 6 7 8 9 10").unwrap();
1211        assert_eq!(result, "1 2 3 4 5 6 7 8 9 10");
1212    }
1213
1214    #[test]
1215    fn test_placeholder_zero_is_invalid() {
1216        let args = vec![Cow::Borrowed("a")];
1217        let result = expand_template("$0", &args, "a");
1218        assert!(result.is_err());
1219        assert!(result.unwrap_err().to_string().contains("$0 is invalid"));
1220    }
1221
1222    #[test]
1223    fn test_dollar_sign_without_number() {
1224        // Bare $ should be preserved
1225        let args: Vec<Cow<'_, str>> = vec![];
1226        let result = expand_template("cost is $", &args, "").unwrap();
1227        assert_eq!(result, "cost is $");
1228    }
1229
1230    #[test]
1231    fn test_consecutive_whitespace_in_arguments() {
1232        let args = parse_arguments("  a    b   c  ").unwrap();
1233        assert_eq!(args, vec!["a", "b", "c"]);
1234    }
1235
1236    #[test]
1237    fn test_empty_input() {
1238        let args = parse_arguments("").unwrap();
1239        assert!(args.is_empty());
1240
1241        let args = parse_arguments("   ").unwrap();
1242        assert!(args.is_empty());
1243    }
1244
1245    #[test]
1246    fn test_command_load_error_command_name() {
1247        let error = CommandLoadError {
1248            path: PathBuf::from(path!("/commands/tools/git/commit.md")),
1249            base_path: PathBuf::from(path!("/commands")),
1250            message: "Failed".into(),
1251        };
1252        assert_eq!(error.command_name().as_deref(), Some("tools:git:commit"));
1253
1254        let non_md_error = CommandLoadError {
1255            path: PathBuf::from(path!("/commands/readme.txt")),
1256            base_path: PathBuf::from(path!("/commands")),
1257            message: "Failed".into(),
1258        };
1259        assert_eq!(non_md_error.command_name(), None);
1260    }
1261
1262    #[test]
1263    fn test_apply_server_command_conflicts() {
1264        let mut commands = vec![
1265            UserSlashCommand {
1266                name: "help".into(),
1267                template: "Help text".into(),
1268                namespace: None,
1269                path: PathBuf::from(path!("/commands/help.md")),
1270                scope: CommandScope::User,
1271            },
1272            UserSlashCommand {
1273                name: "review".into(),
1274                template: "Review $1".into(),
1275                namespace: None,
1276                path: PathBuf::from(path!("/commands/review.md")),
1277                scope: CommandScope::User,
1278            },
1279        ];
1280        let mut errors = Vec::new();
1281        let server_command_names = HashSet::from_iter(["help".to_string()]);
1282
1283        apply_server_command_conflicts(&mut commands, &mut errors, &server_command_names);
1284
1285        assert_eq!(commands.len(), 1);
1286        assert_eq!(commands[0].name.as_ref(), "review");
1287        assert_eq!(errors.len(), 1);
1288        assert_eq!(errors[0].command_name().as_deref(), Some("help"));
1289        assert!(errors[0].message.contains("conflicts"));
1290    }
1291
1292    #[test]
1293    fn test_apply_server_command_conflicts_to_map() {
1294        let command = UserSlashCommand {
1295            name: "tools:git:commit".into(),
1296            template: "Commit".into(),
1297            namespace: Some("tools/git".into()),
1298            path: PathBuf::from(path!("/commands/tools/git/commit.md")),
1299            scope: CommandScope::User,
1300        };
1301        let mut commands = HashMap::default();
1302        commands.insert(command.name.to_string(), command.clone());
1303        let mut errors = Vec::new();
1304        let server_command_names = HashSet::from_iter([command.name.to_string()]);
1305
1306        apply_server_command_conflicts_to_map(&mut commands, &mut errors, &server_command_names);
1307
1308        assert!(commands.is_empty());
1309        assert_eq!(errors.len(), 1);
1310        assert_eq!(
1311            errors[0].command_name().as_deref(),
1312            Some("tools:git:commit")
1313        );
1314    }
1315
1316    // ==================== Async File Loading Tests with FakeFs ====================
1317
1318    #[gpui::test]
1319    async fn test_load_commands_from_empty_dir(cx: &mut TestAppContext) {
1320        let fs = FakeFs::new(cx.executor());
1321        fs.insert_tree(path!("/commands"), json!({})).await;
1322        let fs: Arc<dyn Fs> = fs;
1323
1324        let result =
1325            load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
1326                .await;
1327
1328        assert!(result.commands.is_empty());
1329        assert!(result.errors.is_empty());
1330    }
1331
1332    #[gpui::test]
1333    async fn test_load_commands_from_nonexistent_dir(cx: &mut TestAppContext) {
1334        let fs = FakeFs::new(cx.executor());
1335        fs.insert_tree(path!("/"), json!({})).await;
1336        let fs: Arc<dyn Fs> = fs;
1337
1338        let result = load_commands_from_path_async(
1339            &fs,
1340            Path::new(path!("/nonexistent")),
1341            CommandScope::User,
1342        )
1343        .await;
1344
1345        assert!(result.commands.is_empty());
1346        assert!(result.errors.is_empty());
1347    }
1348
1349    #[gpui::test]
1350    async fn test_load_single_command(cx: &mut TestAppContext) {
1351        let fs = FakeFs::new(cx.executor());
1352        fs.insert_tree(
1353            path!("/commands"),
1354            json!({
1355                "review.md": "Please review: $1"
1356            }),
1357        )
1358        .await;
1359        let fs: Arc<dyn Fs> = fs;
1360
1361        let result =
1362            load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
1363                .await;
1364
1365        assert!(result.errors.is_empty());
1366        assert_eq!(result.commands.len(), 1);
1367        let cmd = &result.commands[0];
1368        assert_eq!(cmd.name.as_ref(), "review");
1369        assert_eq!(cmd.template.as_ref(), "Please review: $1");
1370        assert!(cmd.namespace.is_none());
1371        assert_eq!(cmd.scope, CommandScope::User);
1372    }
1373
1374    #[gpui::test]
1375    async fn test_load_commands_with_namespace(cx: &mut TestAppContext) {
1376        let fs = FakeFs::new(cx.executor());
1377        fs.insert_tree(
1378            path!("/commands"),
1379            json!({
1380                "frontend": {
1381                    "component.md": "Create component: $1"
1382                }
1383            }),
1384        )
1385        .await;
1386        let fs: Arc<dyn Fs> = fs;
1387
1388        let result =
1389            load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
1390                .await;
1391
1392        assert!(result.errors.is_empty());
1393        assert_eq!(result.commands.len(), 1);
1394        let cmd = &result.commands[0];
1395        assert_eq!(cmd.name.as_ref(), "frontend:component");
1396        assert_eq!(cmd.namespace.as_ref().map(|s| s.as_ref()), Some("frontend"));
1397    }
1398
1399    #[gpui::test]
1400    async fn test_load_commands_nested_namespace(cx: &mut TestAppContext) {
1401        let fs = FakeFs::new(cx.executor());
1402        fs.insert_tree(
1403            path!("/commands"),
1404            json!({
1405                "tools": {
1406                    "git": {
1407                        "commit.md": "Git commit: $ARGUMENTS"
1408                    }
1409                }
1410            }),
1411        )
1412        .await;
1413        let fs: Arc<dyn Fs> = fs;
1414
1415        let result =
1416            load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
1417                .await;
1418
1419        assert!(result.errors.is_empty());
1420        assert_eq!(result.commands.len(), 1);
1421        let cmd = &result.commands[0];
1422        assert_eq!(cmd.name.as_ref(), "tools:git:commit");
1423        assert_eq!(
1424            cmd.namespace.as_ref().map(|s| s.as_ref()),
1425            Some("tools/git")
1426        );
1427    }
1428
1429    #[gpui::test]
1430    async fn test_deeply_nested_namespace(cx: &mut TestAppContext) {
1431        let fs = FakeFs::new(cx.executor());
1432        fs.insert_tree(
1433            path!("/commands"),
1434            json!({
1435                "a": {
1436                    "b": {
1437                        "c": {
1438                            "d": {
1439                                "e": {
1440                                    "deep.md": "Very deep command"
1441                                }
1442                            }
1443                        }
1444                    }
1445                }
1446            }),
1447        )
1448        .await;
1449        let fs: Arc<dyn Fs> = fs;
1450
1451        let result =
1452            load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
1453                .await;
1454
1455        assert!(result.errors.is_empty());
1456        assert_eq!(result.commands.len(), 1);
1457        let cmd = &result.commands[0];
1458        assert_eq!(cmd.name.as_ref(), "a:b:c:d:e:deep");
1459        assert_eq!(
1460            cmd.namespace.as_ref().map(|s| s.as_ref()),
1461            Some("a/b/c/d/e")
1462        );
1463    }
1464
1465    #[gpui::test]
1466    async fn test_load_commands_empty_file_ignored(cx: &mut TestAppContext) {
1467        let fs = FakeFs::new(cx.executor());
1468        fs.insert_tree(
1469            path!("/commands"),
1470            json!({
1471                "empty.md": "",
1472                "valid.md": "Hello!"
1473            }),
1474        )
1475        .await;
1476        let fs: Arc<dyn Fs> = fs;
1477
1478        let result =
1479            load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
1480                .await;
1481
1482        assert!(result.errors.is_empty());
1483        assert_eq!(result.commands.len(), 1);
1484        assert_eq!(result.commands[0].name.as_ref(), "valid");
1485    }
1486
1487    #[gpui::test]
1488    async fn test_load_commands_non_md_files_ignored(cx: &mut TestAppContext) {
1489        let fs = FakeFs::new(cx.executor());
1490        fs.insert_tree(
1491            path!("/commands"),
1492            json!({
1493                "command.md": "Valid command",
1494                "readme.txt": "Not a command",
1495                "script.sh": "Also not a command"
1496            }),
1497        )
1498        .await;
1499        let fs: Arc<dyn Fs> = fs;
1500
1501        let result =
1502            load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
1503                .await;
1504
1505        assert!(result.errors.is_empty());
1506        assert_eq!(result.commands.len(), 1);
1507        assert_eq!(result.commands[0].name.as_ref(), "command");
1508    }
1509
1510    #[gpui::test]
1511    async fn test_load_project_commands(cx: &mut TestAppContext) {
1512        let fs = FakeFs::new(cx.executor());
1513        fs.insert_tree(
1514            path!("/project"),
1515            json!({
1516                ".zed": {
1517                    "commands": {
1518                        "build.md": "Build the project"
1519                    }
1520                }
1521            }),
1522        )
1523        .await;
1524        let fs: Arc<dyn Fs> = fs;
1525
1526        let commands_path = project_commands_dir(Path::new(path!("/project")));
1527        let result =
1528            load_commands_from_path_async(&fs, &commands_path, CommandScope::Project).await;
1529
1530        assert!(result.errors.is_empty());
1531        assert_eq!(result.commands.len(), 1);
1532        assert_eq!(result.commands[0].name.as_ref(), "build");
1533        assert_eq!(result.commands[0].scope, CommandScope::Project);
1534    }
1535
1536    #[gpui::test]
1537    async fn test_load_all_commands_no_duplicates(cx: &mut TestAppContext) {
1538        let fs = FakeFs::new(cx.executor());
1539        fs.insert_tree(
1540            path!("/project1"),
1541            json!({
1542                ".zed": {
1543                    "commands": {
1544                        "review.md": "Project 1 review"
1545                    }
1546                }
1547            }),
1548        )
1549        .await;
1550        fs.insert_tree(
1551            path!("/project2"),
1552            json!({
1553                ".zed": {
1554                    "commands": {
1555                        "build.md": "Project 2 build"
1556                    }
1557                }
1558            }),
1559        )
1560        .await;
1561        let fs: Arc<dyn Fs> = fs;
1562
1563        let result = load_all_commands_async(
1564            &fs,
1565            &[
1566                PathBuf::from(path!("/project1")),
1567                PathBuf::from(path!("/project2")),
1568            ],
1569        )
1570        .await;
1571
1572        assert!(result.errors.is_empty());
1573        assert_eq!(result.commands.len(), 2);
1574        let names: Vec<&str> = result.commands.iter().map(|c| c.name.as_ref()).collect();
1575        assert!(names.contains(&"review"));
1576        assert!(names.contains(&"build"));
1577    }
1578
1579    #[gpui::test]
1580    async fn test_load_all_commands_duplicate_error(cx: &mut TestAppContext) {
1581        let fs = FakeFs::new(cx.executor());
1582        fs.insert_tree(
1583            path!("/project1"),
1584            json!({
1585                ".zed": {
1586                    "commands": {
1587                        "deploy.md": "Deploy from project 1"
1588                    }
1589                }
1590            }),
1591        )
1592        .await;
1593        fs.insert_tree(
1594            path!("/project2"),
1595            json!({
1596                ".zed": {
1597                    "commands": {
1598                        "deploy.md": "Deploy from project 2"
1599                    }
1600                }
1601            }),
1602        )
1603        .await;
1604        let fs: Arc<dyn Fs> = fs;
1605
1606        let result = load_all_commands_async(
1607            &fs,
1608            &[
1609                PathBuf::from(path!("/project1")),
1610                PathBuf::from(path!("/project2")),
1611            ],
1612        )
1613        .await;
1614
1615        // Should have one command and one error
1616        assert_eq!(result.commands.len(), 1);
1617        assert_eq!(result.errors.len(), 1);
1618        assert!(result.errors[0].message.contains("ambiguous"));
1619        assert!(result.errors[0].message.contains("deploy"));
1620    }
1621
1622    #[gpui::test]
1623    async fn test_registry_loads_commands(cx: &mut TestAppContext) {
1624        let fs = FakeFs::new(cx.executor());
1625        fs.insert_tree(
1626            path!("/project"),
1627            json!({
1628                ".zed": {
1629                    "commands": {
1630                        "test.md": "Test command"
1631                    }
1632                }
1633            }),
1634        )
1635        .await;
1636        let fs: Arc<dyn Fs> = fs;
1637
1638        let registry = cx.new(|cx| {
1639            SlashCommandRegistry::new(fs.clone(), vec![PathBuf::from(path!("/project"))], cx)
1640        });
1641
1642        // Wait for async load
1643        cx.run_until_parked();
1644
1645        registry.read_with(cx, |registry: &SlashCommandRegistry, _cx| {
1646            assert!(registry.errors().is_empty());
1647            assert!(registry.commands().contains_key("test"));
1648        });
1649    }
1650
1651    #[gpui::test]
1652    async fn test_registry_updates_worktree_roots(cx: &mut TestAppContext) {
1653        let fs = FakeFs::new(cx.executor());
1654        fs.insert_tree(
1655            path!("/project1"),
1656            json!({
1657                ".zed": {
1658                    "commands": {
1659                        "cmd1.md": "Command 1"
1660                    }
1661                }
1662            }),
1663        )
1664        .await;
1665        fs.insert_tree(
1666            path!("/project2"),
1667            json!({
1668                ".zed": {
1669                    "commands": {
1670                        "cmd2.md": "Command 2"
1671                    }
1672                }
1673            }),
1674        )
1675        .await;
1676        let fs: Arc<dyn Fs> = fs;
1677
1678        let registry = cx.new(|cx| {
1679            SlashCommandRegistry::new(fs.clone(), vec![PathBuf::from(path!("/project1"))], cx)
1680        });
1681
1682        cx.run_until_parked();
1683
1684        registry.read_with(cx, |registry: &SlashCommandRegistry, _cx| {
1685            assert!(registry.commands().contains_key("cmd1"));
1686            assert!(!registry.commands().contains_key("cmd2"));
1687        });
1688
1689        // Update worktree roots
1690        registry.update(cx, |registry: &mut SlashCommandRegistry, cx| {
1691            registry.set_worktree_roots(
1692                vec![
1693                    PathBuf::from(path!("/project1")),
1694                    PathBuf::from(path!("/project2")),
1695                ],
1696                cx,
1697            );
1698        });
1699
1700        cx.run_until_parked();
1701
1702        registry.read_with(cx, |registry: &SlashCommandRegistry, _cx| {
1703            assert!(registry.commands().contains_key("cmd1"));
1704            assert!(registry.commands().contains_key("cmd2"));
1705        });
1706    }
1707
1708    #[gpui::test]
1709    async fn test_registry_reloads_on_file_change(cx: &mut TestAppContext) {
1710        let fs = FakeFs::new(cx.executor());
1711        fs.insert_tree(
1712            path!("/project"),
1713            json!({
1714                ".zed": {
1715                    "commands": {
1716                        "original.md": "Original command"
1717                    }
1718                }
1719            }),
1720        )
1721        .await;
1722        let fs: Arc<dyn Fs> = fs.clone();
1723
1724        let registry = cx.new(|cx| {
1725            SlashCommandRegistry::new(fs.clone(), vec![PathBuf::from(path!("/project"))], cx)
1726        });
1727
1728        // Wait for initial load
1729        cx.run_until_parked();
1730
1731        registry.read_with(cx, |registry, _cx| {
1732            assert_eq!(registry.commands().len(), 1);
1733            assert!(registry.commands().contains_key("original"));
1734        });
1735
1736        // Add a new command file
1737        fs.save(
1738            Path::new(path!("/project/.zed/commands/new.md")),
1739            &Rope::from("New command"),
1740            text::LineEnding::Unix,
1741        )
1742        .await
1743        .unwrap();
1744
1745        // Wait for watcher to process the change
1746        cx.run_until_parked();
1747
1748        registry.read_with(cx, |registry, _cx| {
1749            assert_eq!(registry.commands().len(), 2);
1750            assert!(registry.commands().contains_key("original"));
1751            assert!(registry.commands().contains_key("new"));
1752        });
1753
1754        // Remove a command file
1755        fs.remove_file(
1756            Path::new(path!("/project/.zed/commands/original.md")),
1757            RemoveOptions::default(),
1758        )
1759        .await
1760        .unwrap();
1761
1762        // Wait for watcher to process the change
1763        cx.run_until_parked();
1764
1765        registry.read_with(cx, |registry, _cx| {
1766            assert_eq!(registry.commands().len(), 1);
1767            assert!(!registry.commands().contains_key("original"));
1768            assert!(registry.commands().contains_key("new"));
1769        });
1770
1771        // Modify an existing command
1772        fs.save(
1773            Path::new(path!("/project/.zed/commands/new.md")),
1774            &Rope::from("Updated content"),
1775            text::LineEnding::Unix,
1776        )
1777        .await
1778        .unwrap();
1779
1780        cx.run_until_parked();
1781
1782        registry.read_with(cx, |registry, _cx| {
1783            let cmd = registry.commands().get("new").unwrap();
1784            assert_eq!(cmd.template.as_ref(), "Updated content");
1785        });
1786    }
1787
1788    #[gpui::test]
1789    async fn test_concurrent_command_loading(cx: &mut TestAppContext) {
1790        let fs = FakeFs::new(cx.executor());
1791        fs.insert_tree(
1792            path!("/project"),
1793            json!({
1794                ".zed": {
1795                    "commands": {
1796                        "cmd1.md": "Command 1",
1797                        "cmd2.md": "Command 2",
1798                        "cmd3.md": "Command 3"
1799                    }
1800                }
1801            }),
1802        )
1803        .await;
1804        let fs: Arc<dyn Fs> = fs;
1805        let worktree_roots = vec![PathBuf::from(path!("/project"))];
1806
1807        // Spawn multiple load tasks concurrently
1808        let fs1 = fs.clone();
1809        let roots1 = worktree_roots.clone();
1810        let task1 = cx
1811            .executor()
1812            .spawn(async move { load_all_commands_async(&fs1, &roots1).await });
1813
1814        let fs2 = fs.clone();
1815        let roots2 = worktree_roots.clone();
1816        let task2 = cx
1817            .executor()
1818            .spawn(async move { load_all_commands_async(&fs2, &roots2).await });
1819
1820        let fs3 = fs.clone();
1821        let roots3 = worktree_roots.clone();
1822        let task3 = cx
1823            .executor()
1824            .spawn(async move { load_all_commands_async(&fs3, &roots3).await });
1825
1826        // Wait for all tasks to complete
1827        let (result1, result2, result3) = futures::join!(task1, task2, task3);
1828
1829        // All should succeed with the same results
1830        assert!(result1.errors.is_empty());
1831        assert!(result2.errors.is_empty());
1832        assert!(result3.errors.is_empty());
1833
1834        assert_eq!(result1.commands.len(), 3);
1835        assert_eq!(result2.commands.len(), 3);
1836        assert_eq!(result3.commands.len(), 3);
1837    }
1838
1839    // ==================== Symlink Handling Tests ====================
1840
1841    #[gpui::test]
1842    async fn test_load_commands_from_symlinked_directory(cx: &mut TestAppContext) {
1843        let fs = FakeFs::new(cx.executor());
1844
1845        // Create the actual commands directory with a command
1846        fs.insert_tree(
1847            path!("/actual_commands"),
1848            json!({
1849                "review.md": "Please review: $1"
1850            }),
1851        )
1852        .await;
1853
1854        // Create a symlink from /commands to /actual_commands
1855        fs.insert_tree(path!("/"), json!({})).await;
1856        fs.create_symlink(
1857            Path::new(path!("/commands")),
1858            PathBuf::from(path!("/actual_commands")),
1859        )
1860        .await
1861        .unwrap();
1862
1863        let fs: Arc<dyn Fs> = fs;
1864
1865        let result =
1866            load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
1867                .await;
1868
1869        assert!(result.errors.is_empty());
1870        assert_eq!(result.commands.len(), 1);
1871        assert_eq!(result.commands[0].name.as_ref(), "review");
1872    }
1873
1874    #[gpui::test]
1875    async fn test_load_commands_from_symlinked_file(cx: &mut TestAppContext) {
1876        let fs = FakeFs::new(cx.executor());
1877
1878        // Create the actual command file
1879        fs.insert_tree(
1880            path!("/actual"),
1881            json!({
1882                "real_review.md": "Review command content: $1"
1883            }),
1884        )
1885        .await;
1886
1887        // Create commands directory with a symlink to the file
1888        fs.insert_tree(path!("/commands"), json!({})).await;
1889        fs.create_symlink(
1890            Path::new(path!("/commands/review.md")),
1891            PathBuf::from(path!("/actual/real_review.md")),
1892        )
1893        .await
1894        .unwrap();
1895
1896        let fs: Arc<dyn Fs> = fs;
1897
1898        let result =
1899            load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
1900                .await;
1901
1902        assert!(result.errors.is_empty());
1903        assert_eq!(result.commands.len(), 1);
1904        assert_eq!(result.commands[0].name.as_ref(), "review");
1905        assert_eq!(
1906            result.commands[0].template.as_ref(),
1907            "Review command content: $1"
1908        );
1909    }
1910
1911    #[gpui::test]
1912    async fn test_load_commands_claude_symlink_pattern(cx: &mut TestAppContext) {
1913        // Simulates the common pattern of symlinking ~/.claude/commands/ to zed's commands dir
1914        let fs = FakeFs::new(cx.executor());
1915
1916        // Create Claude's commands directory structure
1917        fs.insert_tree(
1918            path!("/home/user/.claude/commands"),
1919            json!({
1920                "explain.md": "Explain this code: $ARGUMENTS",
1921                "refactor": {
1922                    "extract.md": "Extract method: $1"
1923                }
1924            }),
1925        )
1926        .await;
1927
1928        // Create Zed config dir with symlink to Claude's commands
1929        fs.insert_tree(path!("/home/user/.config/zed"), json!({}))
1930            .await;
1931        fs.create_symlink(
1932            Path::new(path!("/home/user/.config/zed/commands")),
1933            PathBuf::from(path!("/home/user/.claude/commands")),
1934        )
1935        .await
1936        .unwrap();
1937
1938        let fs: Arc<dyn Fs> = fs;
1939
1940        let result = load_commands_from_path_async(
1941            &fs,
1942            Path::new(path!("/home/user/.config/zed/commands")),
1943            CommandScope::User,
1944        )
1945        .await;
1946
1947        assert!(result.errors.is_empty());
1948        assert_eq!(result.commands.len(), 2);
1949
1950        let names: Vec<&str> = result.commands.iter().map(|c| c.name.as_ref()).collect();
1951        assert!(names.contains(&"explain"));
1952        assert!(names.contains(&"refactor:extract"));
1953    }
1954
1955    #[gpui::test]
1956    async fn test_symlink_to_parent_directory_skipped(cx: &mut TestAppContext) {
1957        let fs = FakeFs::new(cx.executor());
1958
1959        // Create a directory structure with a symlink pointing outside the commands dir
1960        // This tests that symlinks to directories outside the command tree are handled
1961        fs.insert_tree(
1962            path!("/commands"),
1963            json!({
1964                "valid.md": "Valid command"
1965            }),
1966        )
1967        .await;
1968
1969        // Create a separate directory
1970        fs.insert_tree(
1971            path!("/other"),
1972            json!({
1973                "external.md": "External command"
1974            }),
1975        )
1976        .await;
1977
1978        // Create a symlink from /commands/external -> /other
1979        fs.create_symlink(
1980            Path::new(path!("/commands/external")),
1981            PathBuf::from(path!("/other")),
1982        )
1983        .await
1984        .unwrap();
1985
1986        let fs: Arc<dyn Fs> = fs;
1987
1988        let result =
1989            load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
1990                .await;
1991
1992        // Should have loaded both the valid command and the external one via symlink
1993        assert!(result.commands.iter().any(|c| c.name.as_ref() == "valid"));
1994        assert!(
1995            result
1996                .commands
1997                .iter()
1998                .any(|c| c.name.as_ref() == "external:external")
1999        );
2000    }
2001
2002    // ==================== Permission/Error Handling Tests ====================
2003
2004    #[gpui::test]
2005    async fn test_load_commands_reports_directory_read_errors(cx: &mut TestAppContext) {
2006        let fs = FakeFs::new(cx.executor());
2007
2008        // Create base directory but no commands subdirectory
2009        fs.insert_tree(path!("/"), json!({})).await;
2010
2011        let fs: Arc<dyn Fs> = fs;
2012
2013        // Try to load from a path that exists but isn't a directory
2014        // First create a file where we expect a directory
2015        fs.create_file(Path::new(path!("/commands")), fs::CreateOptions::default())
2016            .await
2017            .unwrap();
2018
2019        let result =
2020            load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
2021                .await;
2022
2023        // Should return empty since /commands is a file, not a directory
2024        assert!(result.commands.is_empty());
2025    }
2026
2027    #[gpui::test]
2028    async fn test_load_all_commands_aggregates_errors(cx: &mut TestAppContext) {
2029        let fs = FakeFs::new(cx.executor());
2030
2031        // Create two projects with duplicate command names
2032        fs.insert_tree(
2033            path!("/project1"),
2034            json!({
2035                ".zed": {
2036                    "commands": {
2037                        "build.md": "Build 1"
2038                    }
2039                }
2040            }),
2041        )
2042        .await;
2043        fs.insert_tree(
2044            path!("/project2"),
2045            json!({
2046                ".zed": {
2047                    "commands": {
2048                        "build.md": "Build 2"
2049                    }
2050                }
2051            }),
2052        )
2053        .await;
2054        fs.insert_tree(
2055            path!("/project3"),
2056            json!({
2057                ".zed": {
2058                    "commands": {
2059                        "build.md": "Build 3"
2060                    }
2061                }
2062            }),
2063        )
2064        .await;
2065
2066        let fs: Arc<dyn Fs> = fs;
2067
2068        let result = load_all_commands_async(
2069            &fs,
2070            &[
2071                PathBuf::from(path!("/project1")),
2072                PathBuf::from(path!("/project2")),
2073                PathBuf::from(path!("/project3")),
2074            ],
2075        )
2076        .await;
2077
2078        // Should have 1 command (first one) and 2 errors (for duplicates)
2079        assert_eq!(result.commands.len(), 1);
2080        assert_eq!(result.errors.len(), 2);
2081
2082        // All errors should mention "ambiguous"
2083        for error in &result.errors {
2084            assert!(error.message.contains("ambiguous"));
2085        }
2086    }
2087
2088    #[gpui::test]
2089    async fn test_mixed_valid_and_empty_files(cx: &mut TestAppContext) {
2090        let fs = FakeFs::new(cx.executor());
2091
2092        fs.insert_tree(
2093            path!("/commands"),
2094            json!({
2095                "valid.md": "Valid command",
2096                "empty.md": "",
2097                "whitespace_only.md": "   ",
2098                "another_valid.md": "Another valid"
2099            }),
2100        )
2101        .await;
2102
2103        let fs: Arc<dyn Fs> = fs;
2104
2105        let result =
2106            load_commands_from_path_async(&fs, Path::new(path!("/commands")), CommandScope::User)
2107                .await;
2108
2109        // Empty file is ignored, whitespace-only is an error
2110        assert_eq!(result.commands.len(), 2);
2111        assert_eq!(result.errors.len(), 1);
2112        assert!(result.errors[0].message.contains("whitespace"));
2113        assert_eq!(
2114            result.errors[0].command_name().as_deref(),
2115            Some("whitespace_only")
2116        );
2117    }
2118}