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}