返回 CodeWhale
epic_dispatch_acceptance.rs
根目录 / crates / tui / src / commands / epic_dispatch_acceptance.rs
1 //! Focused Gherkin acceptance evidence for FEAT-011 dispatch precedence and
2 //! error semantics. Bound through separate scenario-level cucumber worlds
3 //! that prove AT-004 through AT-007 with the live dispatch entry point.
4
5 use cucumber::{World as _, given, then, when, writer::Stats as _};
6 use tempfile::TempDir;
7
8 use crate::commands::{self, CommandResult};
9 use crate::config::Config;
10 use crate::tui::app::{App, TuiOptions};
11
12 // --- FEAT-011 dispatch precedence constants ---
13
14 const DISPATCH_FEATURE_NAME: &str = "FEAT-011 Dispatch Precedence And Error Semantics";
15 const DISPATCH_FEATURE_PATH: &str = concat!(
16 env!("CARGO_MANIFEST_DIR"),
17 "/tests/features/feat-011-dispatch-precedence.feature"
18 );
19
20 const AT004_SCENARIO: &str = "AT-004 User command shadows built-in canonical name";
21 const AT005_SCENARIO: &str = "AT-005 User command shadows built-in alias";
22 const AT006_SCENARIO: &str = "AT-006 Absent user command falls back to built-in";
23 const AT007_SCENARIO: &str = "AT-007 Invalid user command produces user error without fallback";
24
25 // --- Shared helpers ---
26
27 fn create_dispatch_app(tmpdir: &TempDir) -> App {
28 let options = TuiOptions {
29 skills_dir: tmpdir.path().join("skills"),
30 memory_path: tmpdir.path().join("memory.md"),
31 notes_path: tmpdir.path().join("notes.txt"),
32 mcp_config_path: tmpdir.path().join("mcp.json"),
33 ..crate::test_support::test_tui_options(tmpdir.path())
34 };
35 App::new(options, &Config::default())
36 }
37
38 fn write_user_command(tmpdir: &TempDir, name: &str, content: &str) {
39 // Workspace commands load only in a trusted workspace.
40 crate::test_support::trust_workspace(tmpdir.path());
41 let commands_dir = tmpdir.path().join(".codewhale").join("commands");
42 std::fs::create_dir_all(commands_dir).expect("create commands dir");
43 let path = tmpdir
44 .path()
45 .join(".codewhale")
46 .join("commands")
47 .join(format!("{name}.md"));
48 std::fs::write(path, content).expect("write user command");
49 }
50
51 fn sent_message(result: &CommandResult) -> String {
52 match &result.action {
53 Some(crate::tui::app::AppAction::SendMessage(message)) => message.clone(),
54 other => panic!("expected SendMessage action, got {other:?}"),
55 }
56 }
57
58 // --- AT-004: User command shadows built-in canonical name ---
59
60 #[derive(cucumber::World)]
61 #[world(init = Self::new)]
62 struct DispatchWorld004 {
63 tmpdir: Option<TempDir>,
64 app: Option<Box<App>>,
65 result: Option<CommandResult>,
66 }
67
68 impl std::fmt::Debug for DispatchWorld004 {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 f.debug_struct("DispatchWorld004")
71 .field("has_tmpdir", &self.tmpdir.is_some())
72 .field("has_app", &self.app.is_some())
73 .field("has_result", &self.result.is_some())
74 .finish()
75 }
76 }
77
78 impl DispatchWorld004 {
79 fn new() -> Self {
80 Self {
81 tmpdir: None,
82 app: None,
83 result: None,
84 }
85 }
86 }
87
88 #[given("a workspace with a user command shadowing a built-in canonical name")]
89 fn at004_given_shadow_canonical(world: &mut DispatchWorld004) {
90 let tmpdir = TempDir::new().expect("AT-004 TempDir");
91 write_user_command(
92 &tmpdir,
93 "help",
94 "---\ndescription: Custom help\n---\ncustom help $ARGUMENTS",
95 );
96 let mut app = create_dispatch_app(&tmpdir);
97 app.workspace = tmpdir.path().to_path_buf();
98 commands::user_registry::reload(Some(tmpdir.path()));
99 world.tmpdir = Some(tmpdir);
100 world.app = Some(Box::new(app));
101 }
102
103 #[when(regex = r#"^the user runs "/help config"$"#)]
104 fn at004_when_run_shadowed(world: &mut DispatchWorld004) {
105 let app = world.app.as_deref_mut().expect("app should exist");
106 let result = commands::execute("/help config", app);
107 world.result = Some(result);
108 }
109
110 #[then("the user command executes instead of the built-in")]
111 fn at004_then_user_executes(world: &mut DispatchWorld004) {
112 let result = world.result.as_ref().expect("result should exist");
113 assert!(
114 !result.is_error,
115 "user command should succeed: {:?}",
116 result.message
117 );
118 assert_eq!(
119 sent_message(result),
120 "custom help config",
121 "user command should produce custom content"
122 );
123 }
124
125 #[then("no built-in /help side effect occurs")]
126 fn at004_then_no_builtin(world: &mut DispatchWorld004) {
127 let result = world.result.as_ref().expect("result should exist");
128 assert!(!result.is_error, "no error");
129 match &result.action {
130 Some(crate::tui::app::AppAction::SendMessage(message)) => {
131 assert!(
132 message.contains("custom help"),
133 "message should contain user command content: {message}"
134 );
135 }
136 other => panic!("expected SendMessage, got {other:?}"),
137 }
138 }
139
140 #[tokio::test(flavor = "current_thread")]
141 async fn feat011_at004_user_command_shadows_builtin_canonical_name() {
142 let writer = DispatchWorld004::cucumber()
143 .fail_on_skipped()
144 .with_default_cli()
145 .filter_run(DISPATCH_FEATURE_PATH, move |feature, _, scenario| {
146 feature.name == DISPATCH_FEATURE_NAME && scenario.name == AT004_SCENARIO
147 })
148 .await;
149 assert_eq!(
150 writer.failed_steps(),
151 0,
152 "scenario failed: {AT004_SCENARIO}"
153 );
154 assert_eq!(
155 writer.skipped_steps(),
156 0,
157 "scenario skipped steps: {AT004_SCENARIO}"
158 );
159 assert_eq!(
160 writer.passed_steps(),
161 4,
162 "scenario did not run: {AT004_SCENARIO}"
163 );
164 }
165
166 // --- AT-005: User command shadows built-in alias ---
167
168 #[derive(cucumber::World)]
169 #[world(init = Self::new)]
170 struct DispatchWorld005 {
171 tmpdir: Option<TempDir>,
172 app: Option<Box<App>>,
173 alias_result: Option<CommandResult>,
174 canonical_result: Option<CommandResult>,
175 }
176
177 impl std::fmt::Debug for DispatchWorld005 {
178 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179 f.debug_struct("DispatchWorld005")
180 .field("has_tmpdir", &self.tmpdir.is_some())
181 .field("has_app", &self.app.is_some())
182 .field("has_alias_result", &self.alias_result.is_some())
183 .field("has_canonical_result", &self.canonical_result.is_some())
184 .finish()
185 }
186 }
187
188 impl DispatchWorld005 {
189 fn new() -> Self {
190 Self {
191 tmpdir: None,
192 app: None,
193 alias_result: None,
194 canonical_result: None,
195 }
196 }
197 }
198
199 #[given("a workspace with a user command shadowing a built-in alias")]
200 fn at005_given_shadow_alias(world: &mut DispatchWorld005) {
201 let tmpdir = TempDir::new().expect("AT-005 TempDir");
202 // /links has alias /dashboard and /api. Create a user command that
203 // shadows the /dashboard alias.
204 write_user_command(
205 &tmpdir,
206 "attach-review",
207 "---\nalias: dashboard\n---\ncustom dashboard $ARGUMENTS",
208 );
209 let mut app = create_dispatch_app(&tmpdir);
210 app.workspace = tmpdir.path().to_path_buf();
211 commands::user_registry::reload(Some(tmpdir.path()));
212 world.tmpdir = Some(tmpdir);
213 world.app = Some(Box::new(app));
214 }
215
216 #[when("the user runs the shadowed alias")]
217 fn at005_when_run_alias(world: &mut DispatchWorld005) {
218 let app = world.app.as_deref_mut().expect("app should exist");
219 // Use /dashboard which is shadowed by the user command's alias.
220 let alias_result = commands::execute("/dashboard", app);
221 world.alias_result = Some(alias_result);
222
223 // Also test that the built-in canonical name (/links) still works.
224 let canonical_result = commands::execute("/links", app);
225 world.canonical_result = Some(canonical_result);
226 }
227
228 #[then("the user command executes")]
229 fn at005_then_user_executes(world: &mut DispatchWorld005) {
230 let result = world
231 .alias_result
232 .as_ref()
233 .expect("alias result should exist");
234 assert!(!result.is_error, "user command dispatch should succeed");
235 assert_eq!(
236 sent_message(result),
237 "custom dashboard ",
238 "user alias should produce custom content"
239 );
240 }
241
242 #[then("the built-in canonical name remains reachable")]
243 fn at005_then_canonical_reachable(world: &mut DispatchWorld005) {
244 let result = world
245 .canonical_result
246 .as_ref()
247 .expect("canonical result should exist");
248 assert!(!result.is_error, "canonical built-in should still work");
249 assert!(
250 result
251 .message
252 .as_deref()
253 .is_some_and(|msg| msg.contains("https://")),
254 "canonical /links should return platform links: {:?}",
255 result.message
256 );
257 }
258
259 #[tokio::test(flavor = "current_thread")]
260 async fn feat011_at005_user_command_shadows_builtin_alias() {
261 let writer = DispatchWorld005::cucumber()
262 .fail_on_skipped()
263 .with_default_cli()
264 .filter_run(DISPATCH_FEATURE_PATH, move |feature, _, scenario| {
265 feature.name == DISPATCH_FEATURE_NAME && scenario.name == AT005_SCENARIO
266 })
267 .await;
268 assert_eq!(
269 writer.failed_steps(),
270 0,
271 "scenario failed: {AT005_SCENARIO}"
272 );
273 assert_eq!(
274 writer.skipped_steps(),
275 0,
276 "scenario skipped steps: {AT005_SCENARIO}"
277 );
278 assert_eq!(
279 writer.passed_steps(),
280 4,
281 "scenario did not run: {AT005_SCENARIO}"
282 );
283 }
284
285 // --- AT-006: Absent user command falls back to built-in ---
286
287 #[derive(cucumber::World)]
288 #[world(init = Self::new)]
289 struct DispatchWorld006 {
290 tmpdir: Option<TempDir>,
291 app: Option<Box<App>>,
292 after_removal_result: Option<CommandResult>,
293 }
294
295 impl std::fmt::Debug for DispatchWorld006 {
296 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297 f.debug_struct("DispatchWorld006")
298 .field("has_tmpdir", &self.tmpdir.is_some())
299 .field("has_app", &self.app.is_some())
300 .field("has_result", &self.after_removal_result.is_some())
301 .finish()
302 }
303 }
304
305 impl DispatchWorld006 {
306 fn new() -> Self {
307 Self {
308 tmpdir: None,
309 app: None,
310 after_removal_result: None,
311 }
312 }
313 }
314
315 #[given("a workspace with a previously loaded user command")]
316 fn at006_given_loaded_user_command(world: &mut DispatchWorld006) {
317 let tmpdir = TempDir::new().expect("AT-006 TempDir");
318 write_user_command(&tmpdir, "help", "user help");
319 let mut app = create_dispatch_app(&tmpdir);
320 app.workspace = tmpdir.path().to_path_buf();
321 commands::user_registry::reload(Some(tmpdir.path()));
322
323 // Verify user command dispatches first
324 let initial_result = commands::execute("/help config", &mut app);
325 assert!(
326 matches!(
327 &initial_result.action,
328 Some(crate::tui::app::AppAction::SendMessage(_))
329 ),
330 "user command should dispatch initially"
331 );
332
333 world.tmpdir = Some(tmpdir);
334 world.app = Some(Box::new(app));
335 }
336
337 #[when("the user command file is removed and the command is invoked again")]
338 fn at006_when_removed_and_invoked(world: &mut DispatchWorld006) {
339 let app = world.app.as_deref_mut().expect("app should exist");
340 let tmpdir = world.tmpdir.as_ref().expect("tmpdir should exist");
341 let command_path = tmpdir
342 .path()
343 .join(".codewhale")
344 .join("commands")
345 .join("help.md");
346
347 // Remove the user command file
348 std::fs::remove_file(&command_path).expect("remove user command file");
349 commands::user_registry::reload(Some(tmpdir.path()));
350
351 // Invoke the (now absent) command — should fall back to built-in
352 let result = commands::execute("/help config", app);
353 world.after_removal_result = Some(result);
354 }
355
356 #[then("the built-in command executes without a user-command error message")]
357 fn at006_then_builtin_executes(world: &mut DispatchWorld006) {
358 let result = world
359 .after_removal_result
360 .as_ref()
361 .expect("result should exist");
362 assert!(!result.is_error, "built-in fallback should not error");
363 let message = result.message.as_deref().unwrap_or("");
364 // The built-in /help config message should mention the config command.
365 assert!(
366 message.contains("config"),
367 "built-in /help should handle the command: {message}"
368 );
369 // No user-command error text should appear.
370 assert!(
371 !message.contains("User command"),
372 "should not contain user-command error: {message}"
373 );
374 }
375
376 #[tokio::test(flavor = "current_thread")]
377 async fn feat011_at006_absent_user_command_falls_back_to_builtin() {
378 let writer = DispatchWorld006::cucumber()
379 .fail_on_skipped()
380 .with_default_cli()
381 .filter_run(DISPATCH_FEATURE_PATH, move |feature, _, scenario| {
382 feature.name == DISPATCH_FEATURE_NAME && scenario.name == AT006_SCENARIO
383 })
384 .await;
385 assert_eq!(
386 writer.failed_steps(),
387 0,
388 "scenario failed: {AT006_SCENARIO}"
389 );
390 assert_eq!(
391 writer.skipped_steps(),
392 0,
393 "scenario skipped steps: {AT006_SCENARIO}"
394 );
395 assert_eq!(
396 writer.passed_steps(),
397 3,
398 "scenario did not run: {AT006_SCENARIO}"
399 );
400 }
401
402 // --- AT-007: Invalid user command produces user error without fallback ---
403
404 #[derive(cucumber::World)]
405 #[world(init = Self::new)]
406 struct DispatchWorld007 {
407 tmpdir: Option<TempDir>,
408 app: Option<Box<App>>,
409 result: Option<CommandResult>,
410 }
411
412 impl std::fmt::Debug for DispatchWorld007 {
413 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414 f.debug_struct("DispatchWorld007")
415 .field("has_tmpdir", &self.tmpdir.is_some())
416 .field("has_app", &self.app.is_some())
417 .field("has_result", &self.result.is_some())
418 .finish()
419 }
420 }
421
422 impl DispatchWorld007 {
423 fn new() -> Self {
424 Self {
425 tmpdir: None,
426 app: None,
427 result: None,
428 }
429 }
430 }
431
432 #[given("a workspace with an invalid user command")]
433 fn at007_given_invalid_command(world: &mut DispatchWorld007) {
434 let tmpdir = TempDir::new().expect("AT-007 TempDir");
435 // Invalid frontmatter (not valid YAML) on a name that shadows a built-in.
436 write_user_command(
437 &tmpdir,
438 "help",
439 "---\ndescription: Custom help\nnot valid yaml\n---\ncustom help",
440 );
441 let mut app = create_dispatch_app(&tmpdir);
442 app.workspace = tmpdir.path().to_path_buf();
443 commands::user_registry::reload(Some(tmpdir.path()));
444 world.tmpdir = Some(tmpdir);
445 world.app = Some(Box::new(app));
446 }
447
448 #[when("the user runs the invalid command")]
449 fn at007_when_run_invalid(world: &mut DispatchWorld007) {
450 let app = world.app.as_deref_mut().expect("app should exist");
451 let result = commands::execute("/help", app);
452 world.result = Some(result);
453 }
454
455 #[then("a user-command-specific error is returned")]
456 fn at007_then_user_error(world: &mut DispatchWorld007) {
457 let result = world.result.as_ref().expect("result should exist");
458 assert!(result.is_error, "invalid command should produce error");
459 let message = result
460 .message
461 .as_deref()
462 .expect("error message should exist");
463 assert!(
464 message.contains("User command"),
465 "error should identify the user command: {message}"
466 );
467 assert!(
468 message.contains("invalid frontmatter"),
469 "error should describe the problem: {message}"
470 );
471 }
472
473 #[then("no built-in fallback occurs")]
474 fn at007_then_no_fallback(world: &mut DispatchWorld007) {
475 let result = world.result.as_ref().expect("result should exist");
476 assert!(result.is_error, "result should remain an error");
477 // The built-in /help would return a success result. An error result
478 // with a user-command-specific message proves no built-in fallback.
479 let message = result.message.as_deref().expect("error message");
480 assert!(
481 !message.contains("Type /help for available commands"),
482 "should not suggest built-in help: {message}"
483 );
484 }
485
486 #[tokio::test(flavor = "current_thread")]
487 async fn feat011_at007_invalid_user_command_produces_user_error_without_fallback() {
488 let writer = DispatchWorld007::cucumber()
489 .fail_on_skipped()
490 .with_default_cli()
491 .filter_run(DISPATCH_FEATURE_PATH, move |feature, _, scenario| {
492 feature.name == DISPATCH_FEATURE_NAME && scenario.name == AT007_SCENARIO
493 })
494 .await;
495 assert_eq!(
496 writer.failed_steps(),
497 0,
498 "scenario failed: {AT007_SCENARIO}"
499 );
500 assert_eq!(
501 writer.skipped_steps(),
502 0,
503 "scenario skipped steps: {AT007_SCENARIO}"
504 );
505 assert_eq!(
506 writer.passed_steps(),
507 4,
508 "scenario did not run: {AT007_SCENARIO}"
509 );
510 }
511
511 lines RUST