| 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 |