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