返回 CodeWhale
user_registry.rs
根目录 / crates / tui / src / commands / user_registry.rs
1 //! Dedicated registry for user-defined markdown slash commands.
2 //!
3 //! This module owns the user-command boundary. Built-in command metadata and
4 //! dispatch remain in the normal command registry; user commands are loaded
5 //! from markdown files into this registry and are attempted before built-ins.
6
7 use std::collections::{HashMap, HashSet};
8 use std::path::{Path, PathBuf};
9 use std::sync::{OnceLock, RwLock};
10 use std::time::{Duration, SystemTime};
11
12 use crate::tui::app::{App, AppAction, HuntVerdict};
13
14 use super::CommandResult;
15 use super::user_commands;
16
17 static USER_COMMAND_REGISTRY: OnceLock<RwLock<UserCommandRegistryState>> = OnceLock::new();
18
19 #[derive(Debug, Clone, Default)]
20 struct UserCommandRegistryState {
21 initialized: bool,
22 workspace: Option<PathBuf>,
23 command_dirs_snapshot: Vec<CommandDirSnapshot>,
24 registry: UserCommandRegistry,
25 }
26
27 #[derive(Debug, Clone, PartialEq, Eq)]
28 struct CommandDirSnapshot {
29 path: PathBuf,
30 modified: Option<SystemTime>,
31 files: Vec<CommandFileSnapshot>,
32 }
33
34 #[derive(Debug, Clone, PartialEq, Eq)]
35 struct CommandFileSnapshot {
36 path: PathBuf,
37 modified: Option<SystemTime>,
38 len: u64,
39 }
40
41 #[derive(Debug, Clone, PartialEq, Eq)]
42 pub struct UserCommandMetadata {
43 pub name: String,
44 pub body: String,
45 pub description: Option<String>,
46 pub usage: Option<String>,
47 pub arguments: Option<String>,
48 pub argument_hint: Option<String>,
49 pub allowed_tools: Option<Vec<String>>,
50 pub pausable: bool,
51 pub aliases: Vec<String>,
52 pub hidden: bool,
53 }
54
55 impl UserCommandMetadata {
56 /// User-facing invocation syntax. `argument-hint` remains the legacy
57 /// fallback for existing command files; `arguments` is the final fallback
58 /// when no complete `usage` string is supplied.
59 pub(crate) fn display_usage(&self) -> Option<&str> {
60 [&self.usage, &self.argument_hint, &self.arguments]
61 .into_iter()
62 .filter_map(Option::as_deref)
63 .find(|value| !value.trim().is_empty())
64 .map(str::trim)
65 }
66
67 /// Whether selecting this command should leave the composer open for
68 /// arguments. These fields describe presentation only; dispatch keeps the
69 /// existing permissive `$ARGUMENTS`/`$1` template semantics.
70 pub(crate) fn takes_arguments(&self) -> bool {
71 self.arguments
72 .as_deref()
73 .is_some_and(|value| !value.trim().is_empty())
74 // Preserve the legacy contract exactly: the presence of
75 // `argument-hint`, including an explicitly empty value, made the
76 // palette insert rather than immediately execute the command.
77 || self.argument_hint.is_some()
78 || self
79 .usage
80 .as_deref()
81 .is_some_and(|usage| usage_describes_arguments(&self.name, usage))
82 }
83 }
84
85 #[derive(Debug, Clone, PartialEq, Eq)]
86 pub struct LoadError {
87 pub path: PathBuf,
88 pub message: String,
89 }
90
91 #[derive(Debug, Clone, Default)]
92 pub struct UserCommandRegistry {
93 commands: HashMap<String, UserCommandMetadata>,
94 aliases: HashMap<String, String>,
95 load_errors: Vec<LoadError>,
96 invalid_commands: HashMap<String, String>,
97 }
98
99 impl UserCommandRegistry {
100 pub fn new() -> Self {
101 Self::default()
102 }
103
104 pub fn load(workspace: Option<&Path>) -> Self {
105 // The user_commands module is the permanent lower-level file scanning
106 // and parsing boundary; this registry owns metadata, shadowing, and
107 // dispatch. See docs/architecture/command-dispatch.md.
108 Self::load_with_sources(
109 &user_commands::commands_dirs(workspace),
110 &user_commands::workflow_dirs(workspace),
111 )
112 }
113
114 pub(crate) fn load_with_sources(md_dirs: &[PathBuf], workflow_dirs: &[PathBuf]) -> Self {
115 let mut registry = Self::load_from_paths(md_dirs);
116
117 // Saved workflows become slash commands after explicit .md commands,
118 // so a hand-written command with the same name always wins without a
119 // noisy duplicate-definition warning.
120 let mut workflow_entries: Vec<(String, String, PathBuf)> = Vec::new();
121 for dir in workflow_dirs {
122 for (name, content, path) in user_commands::load_workflow_commands_from_dir(dir) {
123 if registry.get(&name).is_none()
124 && !workflow_entries
125 .iter()
126 .any(|(existing, _, _)| *existing == name)
127 {
128 workflow_entries.push((name, content, path));
129 }
130 }
131 }
132 registry.load_from_entries(workflow_entries);
133 registry
134 }
135
136 pub(crate) fn load_from_paths(paths: &[PathBuf]) -> Self {
137 let mut loaded = Vec::new();
138 let mut seen = HashSet::new();
139 let mut registry = Self::new();
140
141 for dir in paths {
142 let mut directory_commands = user_commands::load_commands_from_dir(dir);
143 directory_commands.sort_by(|a, b| a.0.cmp(&b.0));
144 for (name, content) in directory_commands {
145 let canonical = normalize_name(&name);
146 if seen.insert(canonical.clone()) {
147 loaded.push((name, content, dir.join(format!("{canonical}.md"))));
148 } else {
149 registry.record_load_error(
150 dir.join(format!("{canonical}.md")),
151 format!(
152 "User command '/{canonical}' is defined more than once; using the first definition"
153 ),
154 );
155 }
156 }
157 }
158 registry.load_from_entries(loaded);
159 registry
160 }
161
162 #[cfg(test)]
163 pub fn from_loaded(commands: Vec<(String, String)>) -> Self {
164 let mut registry = Self::new();
165 let loaded = commands
166 .into_iter()
167 .map(|(name, content)| {
168 let path = PathBuf::from(format!("{}.md", normalize_name(&name)));
169 (name, content, path)
170 })
171 .collect();
172 registry.load_from_entries(loaded);
173 registry
174 }
175
176 fn load_from_entries(&mut self, commands: Vec<(String, String, PathBuf)>) {
177 let parsed_commands = commands
178 .into_iter()
179 .map(|(name, content, path)| {
180 let (metadata, errors) = parse_metadata(name, &content, &path);
181 (metadata, errors, path)
182 })
183 .collect::<Vec<_>>();
184 let canonical_names = parsed_commands
185 .iter()
186 .map(|(metadata, _, _)| metadata.name.clone())
187 .collect::<HashSet<_>>();
188
189 for (mut metadata, errors, path) in parsed_commands {
190 for error in &errors {
191 self.record_load_error(error.path.clone(), error.message.clone());
192 }
193
194 if self.commands.contains_key(&metadata.name) {
195 self.record_load_error(
196 path.clone(),
197 format!(
198 "User command '/{}' is defined more than once; using the first definition",
199 metadata.name
200 ),
201 );
202 continue;
203 }
204
205 // A malformed losing duplicate must not poison the valid command
206 // that already won precedence. Only the selected definition owns
207 // the dispatch-time error for its canonical name and aliases.
208 for error in errors {
209 self.invalid_commands
210 .entry(metadata.name.clone())
211 .or_insert(error.message);
212 }
213
214 let mut accepted_aliases = Vec::with_capacity(metadata.aliases.len());
215 for alias in &metadata.aliases {
216 let alias = alias.to_ascii_lowercase();
217 if canonical_names.contains(&alias) {
218 self.record_load_error(
219 path.clone(),
220 format!(
221 "User command alias '/{alias}' for '/{}' duplicates canonical user command '/{alias}'; ignoring this alias",
222 metadata.name
223 ),
224 );
225 continue;
226 }
227 if let Some(existing) = self.aliases.get(&alias) {
228 self.record_load_error(
229 path.clone(),
230 format!(
231 "User command alias '/{alias}' for '/{}' duplicates user command '/{existing}'; using the first alias definition",
232 metadata.name
233 ),
234 );
235 continue;
236 }
237 self.aliases.insert(alias.clone(), metadata.name.clone());
238 accepted_aliases.push(alias);
239 }
240 // Discovery surfaces consume metadata directly. Keep it aligned
241 // with the dispatch map so a rejected alias is never advertised
242 // by help, command palettes, or slash completion.
243 metadata.aliases = accepted_aliases;
244
245 self.commands.insert(metadata.name.clone(), metadata);
246 }
247 }
248
249 fn record_load_error(&mut self, path: PathBuf, message: String) {
250 self.load_errors.push(LoadError { path, message });
251 }
252
253 pub fn get(&self, name: &str) -> Option<&UserCommandMetadata> {
254 let key = normalize_name(name);
255 self.commands.get(&key).or_else(|| {
256 self.aliases
257 .get(&key)
258 .and_then(|canonical| self.commands.get(canonical))
259 })
260 }
261
262 #[cfg(test)]
263 pub fn get_by_alias(&self, alias: &str) -> Option<&UserCommandMetadata> {
264 let key = normalize_name(alias);
265 self.aliases
266 .get(&key)
267 .and_then(|canonical| self.commands.get(canonical))
268 }
269
270 #[cfg(test)]
271 pub fn names(&self) -> Vec<String> {
272 let mut names: Vec<String> = self.commands.keys().cloned().collect();
273 names.sort();
274 names
275 }
276
277 pub fn iter(&self) -> impl Iterator<Item = &UserCommandMetadata> {
278 self.commands.values()
279 }
280
281 #[cfg(test)]
282 pub fn is_valid(&self) -> bool {
283 self.load_errors.is_empty()
284 }
285
286 #[cfg(test)]
287 pub fn load_errors(&self) -> &[LoadError] {
288 &self.load_errors
289 }
290
291 fn dispatch_error(&self, name: &str) -> Option<String> {
292 let key = normalize_name(name);
293 self.invalid_commands.get(&key).cloned().or_else(|| {
294 self.aliases
295 .get(&key)
296 .and_then(|canonical| self.invalid_commands.get(canonical))
297 .cloned()
298 })
299 }
300 }
301
302 fn parse_metadata(
303 name: String,
304 content: &str,
305 path: &Path,
306 ) -> (UserCommandMetadata, Vec<LoadError>) {
307 let filename_name = normalize_name(&name);
308 let (metadata, body) = user_commands::parse_frontmatter(content);
309 let mut command = UserCommandMetadata {
310 name: filename_name.clone(),
311 body: body.to_string(),
312 description: None,
313 usage: None,
314 arguments: None,
315 argument_hint: None,
316 allowed_tools: None,
317 pausable: false,
318 aliases: Vec::new(),
319 hidden: false,
320 };
321 let mut configured_name = None;
322
323 for (key, value) in metadata {
324 match key.as_str() {
325 "name" => configured_name = Some(value),
326 "description" => command.description = Some(value),
327 "usage" => command.usage = Some(value),
328 "arguments" => command.arguments = Some(value),
329 "argument-hint" => command.argument_hint = Some(value),
330 "allowed-tools" => {
331 command.allowed_tools = Some(user_commands::parse_allowed_tools(&value));
332 }
333 "pausable" => command.pausable = value.trim().eq_ignore_ascii_case("true"),
334 "aliases" | "alias" => {
335 command.aliases = value
336 .split(',')
337 .map(normalize_name)
338 .filter(|alias| !alias.is_empty())
339 .collect();
340 }
341 "hidden" => command.hidden = value.trim().eq_ignore_ascii_case("true"),
342 _ => {}
343 }
344 }
345
346 let mut errors = Vec::new();
347 if let Some(configured_name) = configured_name {
348 if let Some(normalized) = normalize_configured_name(&configured_name) {
349 command.name = normalized;
350 } else {
351 errors.push(LoadError {
352 path: path.to_path_buf(),
353 message: format!(
354 "User command '/{filename_name}' has invalid frontmatter name {configured_name:?}; expected one slash-command token"
355 ),
356 });
357 }
358 }
359 errors.extend(validate_command_content(&command.name, content, path));
360
361 (command, errors)
362 }
363
364 fn validate_command_content(canonical: &str, content: &str, path: &Path) -> Vec<LoadError> {
365 let mut errors = Vec::new();
366 if canonical.is_empty() {
367 errors.push(LoadError {
368 path: path.to_path_buf(),
369 message: "User command has an empty command name".to_string(),
370 });
371 }
372 if content.trim().is_empty() {
373 errors.push(LoadError {
374 path: path.to_path_buf(),
375 message: format!("User command '/{canonical}' is empty"),
376 });
377 }
378
379 let Some(first_line_end) = content.find('\n') else {
380 return errors;
381 };
382 let first = content[..first_line_end].trim_end_matches('\r');
383 if !is_frontmatter_delimiter(first.trim()) {
384 return errors;
385 }
386
387 let mut saw_closing = false;
388 for raw_line in content[first_line_end + 1..].split_inclusive('\n') {
389 let line = raw_line.trim_end_matches(['\r', '\n']);
390 let trimmed = line.trim();
391 if is_frontmatter_delimiter(trimmed) {
392 saw_closing = true;
393 break;
394 }
395 if trimmed.is_empty() {
396 continue;
397 }
398 if let Some((key, _)) = line.split_once(':')
399 && !key.trim().is_empty()
400 {
401 continue;
402 }
403 errors.push(LoadError {
404 path: path.to_path_buf(),
405 message: format!(
406 "User command '/{canonical}' has invalid frontmatter line {trimmed:?}; expected key: value"
407 ),
408 });
409 break;
410 }
411
412 if !saw_closing {
413 errors.push(LoadError {
414 path: path.to_path_buf(),
415 message: format!(
416 "User command '/{canonical}' has invalid frontmatter; missing closing --- delimiter"
417 ),
418 });
419 }
420
421 errors
422 }
423
424 fn is_frontmatter_delimiter(value: &str) -> bool {
425 value.chars().all(|ch| ch == '-') && value.len() >= 3
426 }
427
428 fn normalize_name(name: &str) -> String {
429 name.trim().trim_start_matches('/').to_ascii_lowercase()
430 }
431
432 fn normalize_configured_name(name: &str) -> Option<String> {
433 let name = name.trim();
434 let name = name.strip_prefix('/').unwrap_or(name);
435 (!name.is_empty() && !name.contains('/') && !name.contains(char::is_whitespace))
436 .then(|| name.to_ascii_lowercase())
437 }
438
439 fn usage_describes_arguments(name: &str, usage: &str) -> bool {
440 let usage = usage.trim();
441 if usage.is_empty() {
442 return false;
443 }
444 let bare_usage = usage.trim_start_matches('/');
445 !bare_usage.eq_ignore_ascii_case(name)
446 }
447
448 fn normalize_workspace(workspace: Option<&Path>) -> Option<PathBuf> {
449 workspace.map(Path::to_path_buf)
450 }
451
452 fn command_dirs_snapshot(workspace: Option<&Path>) -> Vec<CommandDirSnapshot> {
453 user_commands::commands_dirs(workspace)
454 .into_iter()
455 .map(|path| snapshot_dir(path, |name| name.ends_with(".md")))
456 .chain(
457 user_commands::workflow_dirs(workspace)
458 .into_iter()
459 .map(|path| {
460 snapshot_dir(path, |name| {
461 name.ends_with(user_commands::WORKFLOW_SOURCE_SUFFIX)
462 })
463 }),
464 )
465 .collect()
466 }
467
468 fn snapshot_dir(path: PathBuf, matches: impl Fn(&str) -> bool) -> CommandDirSnapshot {
469 let modified = std::fs::metadata(&path)
470 .and_then(|metadata| metadata.modified())
471 .ok();
472 let mut files = Vec::new();
473 if let Ok(entries) = std::fs::read_dir(&path) {
474 for entry in entries.flatten() {
475 let file_path = entry.path();
476 let Some(file_name) = file_path.file_name().and_then(|name| name.to_str()) else {
477 continue;
478 };
479 if !matches(file_name) {
480 continue;
481 }
482 let Ok(metadata) = entry.metadata() else {
483 continue;
484 };
485 files.push(CommandFileSnapshot {
486 path: file_path,
487 modified: metadata.modified().ok(),
488 len: metadata.len(),
489 });
490 }
491 }
492 files.sort_by(|a, b| a.path.cmp(&b.path));
493 CommandDirSnapshot {
494 path,
495 modified,
496 files,
497 }
498 }
499
500 fn registry_lock() -> &'static RwLock<UserCommandRegistryState> {
501 USER_COMMAND_REGISTRY.get_or_init(|| RwLock::new(UserCommandRegistryState::default()))
502 }
503
504 fn registry_needs_reload(
505 guard: &UserCommandRegistryState,
506 workspace: &Option<PathBuf>,
507 snapshot: &[CommandDirSnapshot],
508 ) -> bool {
509 !guard.initialized || guard.workspace != *workspace || guard.command_dirs_snapshot != snapshot
510 }
511
512 #[cfg(test)]
513 pub fn reload(workspace: Option<&Path>) {
514 let workspace = normalize_workspace(workspace);
515 let snapshot = command_dirs_snapshot(workspace.as_deref());
516 reload_with_snapshot(workspace, snapshot);
517 }
518
519 #[cfg(test)]
520 fn reload_with_snapshot(workspace: Option<PathBuf>, snapshot: Vec<CommandDirSnapshot>) {
521 let replacement = UserCommandRegistry::load(workspace.as_deref());
522 let mut guard = registry_lock()
523 .write()
524 .expect("user command registry lock poisoned");
525 guard.initialized = true;
526 guard.workspace = workspace;
527 guard.command_dirs_snapshot = snapshot;
528 guard.registry = replacement;
529 }
530
531 #[cfg(test)]
532 pub fn current_registry() -> UserCommandRegistry {
533 registry_lock()
534 .read()
535 .expect("user command registry lock poisoned")
536 .registry
537 .clone()
538 }
539
540 #[cfg(test)]
541 pub fn registry_for_workspace(workspace: Option<&Path>) -> UserCommandRegistry {
542 with_registry_for_workspace(workspace, Clone::clone)
543 }
544
545 pub fn with_registry_for_workspace<R>(
546 workspace: Option<&Path>,
547 f: impl FnOnce(&UserCommandRegistry) -> R,
548 ) -> R {
549 let workspace = normalize_workspace(workspace);
550 let snapshot = command_dirs_snapshot(workspace.as_deref());
551 let lock = registry_lock();
552 {
553 let guard = lock.read().expect("user command registry lock poisoned");
554 if !registry_needs_reload(&guard, &workspace, &snapshot) {
555 return f(&guard.registry);
556 }
557 }
558
559 let replacement = UserCommandRegistry::load(workspace.as_deref());
560 let mut guard = lock.write().expect("user command registry lock poisoned");
561 if registry_needs_reload(&guard, &workspace, &snapshot) {
562 guard.initialized = true;
563 guard.workspace = workspace;
564 guard.command_dirs_snapshot = snapshot;
565 guard.registry = replacement;
566 }
567 f(&guard.registry)
568 }
569
570 pub fn try_dispatch(app: &mut App, input: &str) -> Option<CommandResult> {
571 let parts: Vec<&str> = input.trim().splitn(2, ' ').collect();
572 let command = normalize_name(parts.first().copied().unwrap_or_default());
573 let args = parts.get(1).copied().unwrap_or("").trim();
574
575 let (dispatch_error, metadata) =
576 with_registry_for_workspace(Some(&app.workspace), |registry| {
577 (
578 registry.dispatch_error(&command),
579 registry.get(&command).cloned(),
580 )
581 });
582 if let Some(error) = dispatch_error {
583 return Some(CommandResult::error(error));
584 }
585
586 let metadata = metadata?;
587
588 app.hunt.quarry = None;
589 app.hunt.started_at = None;
590 app.hunt.verdict = HuntVerdict::Hunting;
591 app.hunt.token_budget = None;
592 app.hunt.tokens_used = 0;
593 app.hunt.time_used_seconds = 0;
594 app.hunt.continuation_count = 0;
595 app.active_allowed_tools = None;
596 app.pausable = false;
597 app.paused = false;
598 app.paused_quarry = None;
599 let mut todos_cleared = false;
600 for _ in 0..10 {
601 if let Ok(mut todos) = app.todos.try_lock() {
602 todos.clear();
603 todos_cleared = true;
604 break;
605 }
606 std::thread::sleep(Duration::from_millis(1));
607 }
608 if !todos_cleared {
609 tracing::warn!(target: "commands", "todos lock contended or poisoned — previous todos not cleared");
610 }
611
612 let mut plan_cleared = false;
613 for _ in 0..10 {
614 if let Ok(mut plan) = app.plan_state.try_lock() {
615 *plan = crate::tools::plan::PlanState::default();
616 plan_cleared = true;
617 break;
618 }
619 std::thread::sleep(Duration::from_millis(1));
620 }
621 if !plan_cleared {
622 tracing::warn!(target: "commands", "plan_state lock contended or poisoned — previous plan not cleared");
623 }
624
625 if let Some(description) = metadata.description.clone() {
626 app.hunt.quarry = Some(description);
627 app.hunt.started_at = Some(std::time::Instant::now());
628 }
629 if let Some(tools) = metadata.allowed_tools.clone() {
630 app.active_allowed_tools = Some(tools);
631 }
632 app.pausable = metadata.pausable;
633
634 let message = user_commands::apply_template(&metadata.body, args);
635 Some(CommandResult::action(AppAction::SendMessage(message)))
636 }
637
638 #[cfg(test)]
639 mod tests {
640 use super::*;
641 use tempfile::TempDir;
642
643 #[test]
644 fn saved_workflows_become_arg_taking_slash_commands() {
645 let tmp = TempDir::new().expect("tempdir");
646 let workflow_dir = tmp.path().join("workflows");
647 std::fs::create_dir_all(&workflow_dir).expect("workflow dir");
648 std::fs::write(
649 workflow_dir.join("pr-review.workflow.js"),
650 "// Review a PR across dimensions and verify findings\nphase('scan');\n",
651 )
652 .expect("write workflow");
653
654 let registry =
655 UserCommandRegistry::load_with_sources(&[], std::slice::from_ref(&workflow_dir));
656 let command = registry.get("pr-review").expect("workflow command");
657 assert_eq!(
658 command.description.as_deref(),
659 Some("Review a PR across dimensions and verify findings")
660 );
661 assert!(command.takes_arguments(), "workflows accept custom args");
662 assert!(
663 command.body.contains("source_path=")
664 && command.body.contains(
665 &workflow_dir
666 .join("pr-review.workflow.js")
667 .display()
668 .to_string()
669 ),
670 "body must point the workflow tool at the saved source: {}",
671 command.body
672 );
673 assert!(
674 command.body.contains("$ARGUMENTS"),
675 "slash arguments must forward into the run: {}",
676 command.body
677 );
678 }
679
680 #[test]
681 fn explicit_md_commands_shadow_same_named_workflows_quietly() {
682 let tmp = TempDir::new().expect("tempdir");
683 let md_dir = tmp.path().join("commands");
684 let workflow_dir = tmp.path().join("workflows");
685 std::fs::create_dir_all(&md_dir).expect("md dir");
686 std::fs::create_dir_all(&workflow_dir).expect("workflow dir");
687 std::fs::write(md_dir.join("triage.md"), "hand-written triage $ARGUMENTS")
688 .expect("write md command");
689 std::fs::write(workflow_dir.join("triage.workflow.js"), "phase('x');\n")
690 .expect("write workflow");
691
692 let registry = UserCommandRegistry::load_with_sources(&[md_dir], &[workflow_dir]);
693 let command = registry.get("triage").expect("command");
694 assert_eq!(command.body, "hand-written triage $ARGUMENTS");
695 assert!(
696 registry.load_errors().is_empty(),
697 "shadowing a workflow is silent, not a duplicate-definition warning: {:?}",
698 registry.load_errors()
699 );
700 }
701
702 #[test]
703 fn registry_loads_markdown_metadata() {
704 let registry = UserCommandRegistry::from_loaded(vec![(
705 "review".to_string(),
706 "---\ndescription: Review code\nusage: /review <file>\narguments: <file>\nargument-hint: <legacy-file>\nallowed-tools: read, grep\npausable: true\n---\nReview $ARGUMENTS".to_string(),
707 )]);
708
709 let command = registry.get("review").expect("command loaded");
710 assert_eq!(command.description.as_deref(), Some("Review code"));
711 assert_eq!(command.usage.as_deref(), Some("/review <file>"));
712 assert_eq!(command.arguments.as_deref(), Some("<file>"));
713 assert_eq!(command.argument_hint.as_deref(), Some("<legacy-file>"));
714 assert_eq!(command.display_usage(), Some("/review <file>"));
715 assert!(command.takes_arguments());
716 assert_eq!(
717 command.allowed_tools,
718 Some(vec!["read".to_string(), "grep".to_string()])
719 );
720 assert!(command.pausable);
721 assert_eq!(command.body, "Review $ARGUMENTS");
722 }
723
724 #[test]
725 fn frontmatter_name_replaces_filename_canonical_name() {
726 let registry = UserCommandRegistry::from_loaded(vec![(
727 "workflow-file".to_string(),
728 "---\nname: /Review-Target\ndescription: Review target\n---\nreview $ARGUMENTS"
729 .to_string(),
730 )]);
731
732 let command = registry.get("review-target").expect("renamed command");
733 assert_eq!(command.name, "review-target");
734 assert_eq!(command.body, "review $ARGUMENTS");
735 assert!(
736 registry.get("workflow-file").is_none(),
737 "the filename is only a default; retaining it requires an explicit alias"
738 );
739 }
740
741 #[test]
742 fn filename_remains_the_default_name_without_frontmatter_override() {
743 let registry = UserCommandRegistry::from_loaded(vec![(
744 "Filename-Default".to_string(),
745 "plain body".to_string(),
746 )]);
747
748 assert_eq!(registry.names(), vec!["filename-default"]);
749 assert_eq!(
750 registry.get("/filename-default").unwrap().body,
751 "plain body"
752 );
753 }
754
755 #[test]
756 fn registry_names_are_sorted() {
757 let registry = UserCommandRegistry::from_loaded(vec![
758 ("zeta".to_string(), "Z".to_string()),
759 ("alpha".to_string(), "A".to_string()),
760 ]);
761 assert_eq!(registry.names(), vec!["alpha", "zeta"]);
762 }
763
764 #[test]
765 fn registry_loads_from_paths_with_first_name_wins() {
766 let first = TempDir::new().unwrap();
767 let second = TempDir::new().unwrap();
768 std::fs::write(first.path().join("shadow.md"), "first").unwrap();
769 std::fs::write(second.path().join("shadow.md"), "second").unwrap();
770
771 let registry = UserCommandRegistry::load_from_paths(&[
772 first.path().to_path_buf(),
773 second.path().to_path_buf(),
774 ]);
775
776 assert_eq!(registry.get("shadow").unwrap().body, "first");
777 }
778
779 #[test]
780 fn frontmatter_name_collision_uses_directory_then_filename_precedence() {
781 let first = TempDir::new().unwrap();
782 let second = TempDir::new().unwrap();
783 std::fs::write(
784 first.path().join("z-workspace.md"),
785 "---\nname: shared\n---\nworkspace body",
786 )
787 .unwrap();
788 std::fs::write(
789 second.path().join("a-global.md"),
790 "---\nname: shared\n---\nglobal body",
791 )
792 .unwrap();
793
794 let registry = UserCommandRegistry::load_from_paths(&[
795 first.path().to_path_buf(),
796 second.path().to_path_buf(),
797 ]);
798
799 assert_eq!(registry.get("shared").unwrap().body, "workspace body");
800 assert!(registry.load_errors().iter().any(|error| {
801 error.message.contains("User command '/shared'")
802 && error.message.contains("defined more than once")
803 }));
804 }
805
806 #[test]
807 fn alias_lookup_uses_metadata_aliases() {
808 let registry = UserCommandRegistry::from_loaded(vec![(
809 "canonical".to_string(),
810 "---\naliases: short, other\n---\nBody".to_string(),
811 )]);
812 assert_eq!(registry.get_by_alias("short").unwrap().name, "canonical");
813 assert_eq!(registry.get("/other").unwrap().body, "Body");
814 }
815
816 #[test]
817 fn reload_and_current_registry_compile_sentinel() {
818 reload(None);
819 let registry = current_registry();
820 assert!(registry.is_valid());
821 }
822
823 fn write_workspace_command(workspace: &Path, name: &str, content: &str) {
824 let dir = workspace.join(".codewhale").join("commands");
825 std::fs::create_dir_all(&dir).expect("create commands dir");
826 std::fs::write(dir.join(format!("{name}.md")), content).expect("write command");
827 }
828
829 fn test_app(workspace: PathBuf) -> App {
830 let options = crate::tui::app::TuiOptions {
831 ..crate::test_support::test_tui_options(workspace)
832 };
833 App::new(options, &crate::config::Config::default())
834 }
835
836 fn sent_message(result: CommandResult) -> String {
837 match result.action {
838 Some(AppAction::SendMessage(message)) => message,
839 other => panic!("expected SendMessage action, got {other:?}"),
840 }
841 }
842
843 #[test]
844 fn dispatch_prefers_user_command_over_builtin_with_same_name() {
845 let tmp = TempDir::new().unwrap();
846 write_workspace_command(tmp.path(), "help", "custom help $ARGUMENTS");
847 let mut app = test_app(tmp.path().to_path_buf());
848
849 let result = crate::commands::execute("/help links", &mut app);
850
851 assert!(!result.is_error);
852 assert_eq!(sent_message(result), "custom help links");
853 }
854
855 #[test]
856 fn dispatch_prefers_user_alias_over_builtin_alias() {
857 let tmp = TempDir::new().unwrap();
858 write_workspace_command(
859 tmp.path(),
860 "attach-review",
861 "---\nalias: image\n---\ncustom alias $ARGUMENTS",
862 );
863 let mut app = test_app(tmp.path().to_path_buf());
864
865 let result = crate::commands::execute("/image screenshot.png", &mut app);
866
867 assert!(!result.is_error, "{:?}", result.message);
868 assert_eq!(sent_message(result), "custom alias screenshot.png");
869 }
870
871 #[test]
872 fn hidden_user_commands_still_dispatch_directly() {
873 let tmp = TempDir::new().unwrap();
874 write_workspace_command(
875 tmp.path(),
876 "internal-workflow",
877 "---\nname: secret\nhidden: true\ndescription: Internal workflow\n---\nsecret $ARGUMENTS",
878 );
879 let mut app = test_app(tmp.path().to_path_buf());
880
881 let result = crate::commands::execute("/secret now", &mut app);
882
883 assert!(!result.is_error);
884 assert_eq!(sent_message(result), "secret now");
885 assert_eq!(app.hunt.quarry.as_deref(), Some("Internal workflow"));
886 }
887
888 #[test]
889 fn dispatch_uses_frontmatter_name_arguments_and_allowed_tools() {
890 let tmp = TempDir::new().unwrap();
891 write_workspace_command(
892 tmp.path(),
893 "deploy-workflow",
894 "---\nname: ship\nusage: /ship <target>\narguments: <target>\nallowed-tools: Read_File, Grep_Files\n---\nship $1 with $ARGUMENTS",
895 );
896 let mut app = test_app(tmp.path().to_path_buf());
897
898 let result = crate::commands::execute("/ship moon base", &mut app);
899
900 assert!(!result.is_error, "{:?}", result.message);
901 assert_eq!(sent_message(result), "ship moon with moon base");
902 assert_eq!(
903 app.active_allowed_tools,
904 Some(vec!["read_file".to_string(), "grep_files".to_string()])
905 );
906 assert!(
907 try_dispatch(&mut app, "/deploy-workflow").is_none(),
908 "the source filename must not remain an implicit dispatch alias"
909 );
910 }
911
912 #[test]
913 fn empty_allowed_tools_frontmatter_blocks_all_tools() {
914 let tmp = TempDir::new().unwrap();
915 write_workspace_command(
916 tmp.path(),
917 "locked",
918 "---\nallowed-tools: \"\"\n---\nrun nothing",
919 );
920 let mut app = test_app(tmp.path().to_path_buf());
921
922 let result = crate::commands::execute("/locked", &mut app);
923
924 assert!(!result.is_error);
925 assert_eq!(app.active_allowed_tools, Some(Vec::new()));
926 }
927
928 #[test]
929 fn dispatch_clears_previous_command_state() {
930 let tmp = TempDir::new().unwrap();
931 write_workspace_command(tmp.path(), "plain", "plain command");
932 let mut app = test_app(tmp.path().to_path_buf());
933
934 app.hunt.quarry = Some("old objective".to_string());
935 app.hunt.started_at = Some(std::time::Instant::now());
936 app.hunt.verdict = crate::tui::app::HuntVerdict::Escaped;
937 app.hunt.token_budget = Some(42);
938 app.hunt.tokens_used = 100;
939 app.hunt.time_used_seconds = 5;
940 app.hunt.continuation_count = 2;
941 app.active_allowed_tools = Some(vec!["bash".to_string()]);
942 app.pausable = true;
943 app.paused = true;
944 app.paused_quarry = Some("old objective".to_string());
945 {
946 let mut todos = app.todos.try_lock().expect("todos lock");
947 todos.add(
948 "leftover task".to_string(),
949 crate::tools::todo::TodoStatus::Pending,
950 );
951 }
952 {
953 let mut plan = app.plan_state.try_lock().expect("plan_state lock");
954 plan.update(crate::tools::plan::UpdatePlanArgs {
955 title: Some("leftover plan".to_string()),
956 objective: Some("old goal".to_string()),
957 ..Default::default()
958 });
959 }
960
961 let result = crate::commands::execute("/plain", &mut app);
962
963 assert!(!result.is_error);
964 assert_eq!(app.hunt.quarry, None);
965 assert_eq!(app.hunt.started_at, None);
966 assert_eq!(app.hunt.verdict, crate::tui::app::HuntVerdict::Hunting);
967 assert_eq!(app.hunt.token_budget, None);
968 assert_eq!(app.hunt.tokens_used, 0);
969 assert_eq!(app.hunt.time_used_seconds, 0);
970 assert_eq!(app.hunt.continuation_count, 0);
971 assert_eq!(app.active_allowed_tools, None);
972 assert!(!app.pausable);
973 assert!(!app.paused);
974 assert!(app.paused_quarry.is_none());
975 assert!(
976 app.todos
977 .try_lock()
978 .expect("todos lock")
979 .snapshot()
980 .items
981 .is_empty(),
982 "previous command's todos must be cleared on new command dispatch"
983 );
984 assert!(
985 app.plan_state
986 .try_lock()
987 .expect("plan_state lock")
988 .snapshot()
989 .is_empty(),
990 "previous command's plan must be cleared on new command dispatch"
991 );
992 }
993
994 #[test]
995 fn duplicate_user_alias_keeps_first_command_and_records_user_command_error() {
996 let registry = UserCommandRegistry::from_loaded(vec![
997 (
998 "first".to_string(),
999 "---\nalias: shared\n---\nfirst body".to_string(),
1000 ),
1001 (
1002 "second".to_string(),
1003 "---\nalias: shared\n---\nsecond body".to_string(),
1004 ),
1005 ]);
1006
1007 let command = registry.get("shared").expect("alias resolves");
1008 assert_eq!(command.name, "first");
1009 assert_eq!(command.body, "first body");
1010 assert_eq!(command.aliases, ["shared"]);
1011 assert!(
1012 registry.get("second").unwrap().aliases.is_empty(),
1013 "the losing command must not advertise an alias it does not own"
1014 );
1015 assert!(
1016 registry.load_errors().iter().any(|error| error
1017 .message
1018 .contains("User command alias '/shared'")
1019 && error.message.contains("/second")),
1020 "duplicate alias should be recorded as a user-command load error: {:?}",
1021 registry.load_errors()
1022 );
1023 }
1024
1025 #[test]
1026 fn alias_conflicting_with_canonical_user_command_is_rejected_consistently() {
1027 let registry = UserCommandRegistry::from_loaded(vec![
1028 (
1029 "alpha".to_string(),
1030 "---\nalias: beta\n---\nalpha body".to_string(),
1031 ),
1032 (
1033 "renamed-beta".to_string(),
1034 "---\nname: beta\n---\nbeta body".to_string(),
1035 ),
1036 ]);
1037
1038 let command = registry.get("beta").expect("canonical command resolves");
1039 assert_eq!(command.name, "beta");
1040 assert_eq!(command.body, "beta body");
1041 assert!(
1042 registry.get("alpha").unwrap().aliases.is_empty(),
1043 "a canonical-name collision must be absent from alias metadata"
1044 );
1045 assert!(
1046 registry.load_errors().iter().any(|error| error
1047 .message
1048 .contains("User command alias '/beta'")
1049 && error
1050 .message
1051 .contains("duplicates canonical user command '/beta'")),
1052 "alias/canonical conflict should be recorded: {:?}",
1053 registry.load_errors()
1054 );
1055 }
1056
1057 #[test]
1058 fn duplicate_user_command_name_records_user_command_error() {
1059 let registry = UserCommandRegistry::from_loaded(vec![
1060 ("review".to_string(), "first".to_string()),
1061 ("review".to_string(), "second".to_string()),
1062 ]);
1063
1064 assert_eq!(registry.get("review").unwrap().body, "first");
1065 assert!(
1066 registry
1067 .load_errors()
1068 .iter()
1069 .any(|error| error.message.contains("User command '/review'")
1070 && error.message.contains("defined more than once")),
1071 "duplicate name should be recorded as a user-command load error: {:?}",
1072 registry.load_errors()
1073 );
1074 }
1075
1076 #[test]
1077 fn malformed_losing_name_override_does_not_poison_valid_winner() {
1078 let registry = UserCommandRegistry::from_loaded(vec![
1079 (
1080 "first-file".to_string(),
1081 "---\nname: shared\n---\nfirst body".to_string(),
1082 ),
1083 (
1084 "second-file".to_string(),
1085 "---\nname: shared\nnot valid frontmatter\n---\nsecond body".to_string(),
1086 ),
1087 ]);
1088
1089 assert_eq!(registry.get("shared").unwrap().body, "first body");
1090 assert_eq!(registry.dispatch_error("shared"), None);
1091 assert!(registry.load_errors().iter().any(|error| {
1092 error.message.contains("invalid frontmatter") && error.path.ends_with("second-file.md")
1093 }));
1094 assert!(registry.load_errors().iter().any(|error| {
1095 error.message.contains("defined more than once")
1096 && error.path.ends_with("second-file.md")
1097 }));
1098 }
1099
1100 #[test]
1101 fn invalid_frontmatter_dispatch_returns_user_command_error_without_builtin_fallback() {
1102 let tmp = TempDir::new().unwrap();
1103 write_workspace_command(
1104 tmp.path(),
1105 "help",
1106 "---\ndescription: Custom help\nnot valid yaml\n---\ncustom help",
1107 );
1108 let mut app = test_app(tmp.path().to_path_buf());
1109
1110 let result = crate::commands::execute("/help", &mut app);
1111
1112 assert!(result.is_error);
1113 let message = result.message.expect("error message");
1114 assert!(message.contains("User command '/help'"), "{message}");
1115 assert!(message.contains("invalid frontmatter"), "{message}");
1116 }
1117
1118 #[test]
1119 fn malformed_file_is_recoverable_and_valid_sibling_still_dispatches() {
1120 let tmp = TempDir::new().unwrap();
1121 write_workspace_command(
1122 tmp.path(),
1123 "broken",
1124 "---\ndescription: Broken\nnot valid frontmatter\n---\nbroken body",
1125 );
1126 write_workspace_command(
1127 tmp.path(),
1128 "healthy",
1129 "---\ndescription: Healthy\n---\nhealthy $ARGUMENTS",
1130 );
1131 let mut app = test_app(tmp.path().to_path_buf());
1132
1133 let healthy = crate::commands::execute("/healthy now", &mut app);
1134 assert!(!healthy.is_error, "{:?}", healthy.message);
1135 assert_eq!(sent_message(healthy), "healthy now");
1136
1137 let broken = crate::commands::execute("/broken", &mut app);
1138 assert!(broken.is_error);
1139 assert!(
1140 broken
1141 .message
1142 .as_deref()
1143 .is_some_and(|message| message.contains("invalid frontmatter"))
1144 );
1145 }
1146
1147 #[test]
1148 fn invalid_frontmatter_name_is_recoverable_under_filename_default() {
1149 let registry = UserCommandRegistry::from_loaded(vec![(
1150 "recoverable".to_string(),
1151 "---\nname: two words\n---\nbody".to_string(),
1152 )]);
1153
1154 assert!(registry.get("recoverable").is_some());
1155 assert!(registry.dispatch_error("recoverable").is_some());
1156 assert!(registry.load_errors().iter().any(|error| {
1157 error
1158 .message
1159 .contains("invalid frontmatter name \"two words\"")
1160 }));
1161 }
1162
1163 #[test]
1164 fn frontmatter_line_with_empty_key_is_invalid() {
1165 let registry = UserCommandRegistry::from_loaded(vec![(
1166 "bad".to_string(),
1167 "---\n: value\n---\nbody".to_string(),
1168 )]);
1169
1170 assert!(
1171 registry.load_errors().iter().any(|error| error
1172 .message
1173 .contains("invalid frontmatter line \": value\"")),
1174 "empty frontmatter key should be invalid: {:?}",
1175 registry.load_errors()
1176 );
1177 }
1178
1179 #[test]
1180 fn registry_reloads_when_existing_command_file_changes() {
1181 let tmp = TempDir::new().unwrap();
1182 write_workspace_command(tmp.path(), "live", "first");
1183
1184 assert_eq!(
1185 registry_for_workspace(Some(tmp.path()))
1186 .get("live")
1187 .unwrap()
1188 .body,
1189 "first"
1190 );
1191
1192 write_workspace_command(tmp.path(), "live", "second body with different length");
1193
1194 assert_eq!(
1195 registry_for_workspace(Some(tmp.path()))
1196 .get("live")
1197 .unwrap()
1198 .body,
1199 "second body with different length"
1200 );
1201 }
1202
1203 #[test]
1204 fn empty_user_command_dispatch_returns_user_command_error() {
1205 let tmp = TempDir::new().unwrap();
1206 write_workspace_command(tmp.path(), "empty", "\n\t ");
1207 let mut app = test_app(tmp.path().to_path_buf());
1208
1209 let result = crate::commands::execute("/empty", &mut app);
1210
1211 assert!(result.is_error);
1212 let message = result.message.expect("error message");
1213 assert!(message.contains("User command '/empty'"), "{message}");
1214 assert!(message.contains("empty"), "{message}");
1215 }
1216 }
1217
1217 lines RUST