| 1 | //! Review command: activate review skill and send a target immediately. |
| 2 | //! |
| 3 | //! FEAT-022 Phase 4: portable contextual dispatch. The host performs the |
| 4 | //! discovery + side effects (`CommandSkillGroupContext::run_review`); the |
| 5 | //! portable handler composes the exact error text and the `SendMessage` action. |
| 6 | |
| 7 | use codewhale_command_contract::facets::{CommandSkillGroupContext, ReviewOutcome}; |
| 8 | use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; |
| 9 | use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; |
| 10 | |
| 11 | use crate::commands::CommandResult; |
| 12 | use crate::tui::app::AppAction; |
| 13 | |
| 14 | /// Render the review warnings suffix (baseline `warnings_suffix`). |
| 15 | fn warnings_suffix(warnings: &[String]) -> String { |
| 16 | if warnings.is_empty() { |
| 17 | return String::new(); |
| 18 | } |
| 19 | |
| 20 | format!("\n\nWarnings:\n- {}", warnings.join("\n- ")) |
| 21 | } |
| 22 | |
| 23 | pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { |
| 24 | name: "review", |
| 25 | aliases: &["shencha"], |
| 26 | usage: "/review <target>", |
| 27 | description_key: "cmd_review_description", |
| 28 | }; |
| 29 | |
| 30 | pub(in crate::commands) struct ReviewCmd; |
| 31 | |
| 32 | impl RegisterCommand<CommandResult> for ReviewCmd { |
| 33 | fn info() -> &'static CommandInfo { |
| 34 | &COMMAND_INFO |
| 35 | } |
| 36 | |
| 37 | fn handler() -> CommandHandler<CommandResult> { |
| 38 | CommandHandler::Contextual { |
| 39 | capabilities: codewhale_command_contract::handler::CommandCapabilities::SKILL_GROUP, |
| 40 | handler: review_contextual, |
| 41 | } |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | /// Contextual `/review` dispatch: exactly the skill-group facet. The baseline |
| 46 | /// command never refreshed the shared skill cache, so `/review` must not |
| 47 | /// request the unrelated SKILLS facet. |
| 48 | fn review_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult { |
| 49 | let mut parts = contexts.into_parts(); |
| 50 | let Some(skill_group) = parts.skill_group.as_deref_mut() else { |
| 51 | return CommandResult::error("Command capability unavailable: skill_group"); |
| 52 | }; |
| 53 | review(skill_group, arg) |
| 54 | } |
| 55 | |
| 56 | /// Portable `/review` dispatch — byte-identical to the baseline handler. |
| 57 | /// |
| 58 | /// The host performs discovery, warning merge, session-message insertion, and |
| 59 | /// active-skill mutation (`run_review`); the handler validates the target, |
| 60 | /// renders the not-found error, and emits the `SendMessage` action. The |
| 61 | /// baseline success path renders no message and does not refresh the cache. |
| 62 | fn review(group: &mut dyn CommandSkillGroupContext, arg: Option<&str>) -> CommandResult { |
| 63 | let target = arg.unwrap_or("").trim(); |
| 64 | if target.is_empty() { |
| 65 | return CommandResult::error("Usage: /review <target>"); |
| 66 | } |
| 67 | |
| 68 | match group.run_review() { |
| 69 | Ok(ReviewOutcome::Ready) => { |
| 70 | CommandResult::action(AppAction::SendMessage(target.to_string())) |
| 71 | } |
| 72 | Ok(ReviewOutcome::NotFound { |
| 73 | skills_dir, |
| 74 | global_dir, |
| 75 | warnings, |
| 76 | }) => { |
| 77 | let warnings = warnings_suffix(&warnings); |
| 78 | CommandResult::error(format!( |
| 79 | "Review skill not found in {} or {}. Create ~/.codewhale/skills/review/SKILL.md.{}", |
| 80 | skills_dir, global_dir, warnings |
| 81 | )) |
| 82 | } |
| 83 | Err(err) => CommandResult::error(err), |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | #[cfg(test)] |
| 88 | mod tests { |
| 89 | use super::*; |
| 90 | use codewhale_command_contract::facets::{ |
| 91 | CommandApprovalState, RemoteRegistryOutcome, SkillActivationError, SkillMutationReceipt, |
| 92 | SkillRecommendation, SkillSyncOutcome, SkillTargetScope, SnapshotEntry, |
| 93 | }; |
| 94 | |
| 95 | struct FakeSkillGroup { |
| 96 | review: Result<ReviewOutcome, String>, |
| 97 | approval: CommandApprovalState, |
| 98 | } |
| 99 | impl FakeSkillGroup { |
| 100 | fn ready() -> Self { |
| 101 | Self { |
| 102 | review: Ok(ReviewOutcome::Ready), |
| 103 | approval: CommandApprovalState { |
| 104 | yolo: true, |
| 105 | trust_mode: false, |
| 106 | }, |
| 107 | } |
| 108 | } |
| 109 | } |
| 110 | impl CommandSkillGroupContext for FakeSkillGroup { |
| 111 | fn skill_registry_projection( |
| 112 | &self, |
| 113 | ) -> codewhale_command_contract::facets::SkillRegistryProjection { |
| 114 | unimplemented!("not used by review tests") |
| 115 | } |
| 116 | fn activate_skill( |
| 117 | &mut self, |
| 118 | _name: &str, |
| 119 | ) -> Result<codewhale_command_contract::facets::SkillActivationOutcome, SkillActivationError> |
| 120 | { |
| 121 | unimplemented!("not used by review tests") |
| 122 | } |
| 123 | fn install_skill( |
| 124 | &mut self, |
| 125 | _scope: Option<SkillTargetScope>, |
| 126 | _spec: &str, |
| 127 | ) -> Result<SkillMutationReceipt, String> { |
| 128 | unimplemented!("not used by review tests") |
| 129 | } |
| 130 | fn update_skill( |
| 131 | &mut self, |
| 132 | _scope: Option<SkillTargetScope>, |
| 133 | _name: &str, |
| 134 | ) -> Result<SkillMutationReceipt, String> { |
| 135 | unimplemented!("not used by review tests") |
| 136 | } |
| 137 | fn uninstall_skill( |
| 138 | &mut self, |
| 139 | _scope: Option<SkillTargetScope>, |
| 140 | _name: &str, |
| 141 | ) -> Result<SkillMutationReceipt, String> { |
| 142 | unimplemented!("not used by review tests") |
| 143 | } |
| 144 | fn trust_skill( |
| 145 | &mut self, |
| 146 | _scope: Option<SkillTargetScope>, |
| 147 | _name: &str, |
| 148 | ) -> Result<SkillMutationReceipt, String> { |
| 149 | unimplemented!("not used by review tests") |
| 150 | } |
| 151 | fn fetch_remote_registry(&mut self) -> Result<RemoteRegistryOutcome, String> { |
| 152 | unimplemented!("not used by review tests") |
| 153 | } |
| 154 | fn recommend_skills(&mut self, _task: &str) -> Result<Vec<SkillRecommendation>, String> { |
| 155 | unimplemented!("not used by review tests") |
| 156 | } |
| 157 | fn sync_registry(&mut self) -> Result<SkillSyncOutcome, String> { |
| 158 | unimplemented!("not used by review tests") |
| 159 | } |
| 160 | fn run_review(&mut self) -> Result<ReviewOutcome, String> { |
| 161 | self.review.clone() |
| 162 | } |
| 163 | fn snapshot_list(&mut self, _limit: usize) -> Result<Vec<SnapshotEntry>, String> { |
| 164 | unimplemented!("not used by review tests") |
| 165 | } |
| 166 | fn restore_snapshot(&mut self, _id: &str) -> Result<(), String> { |
| 167 | unimplemented!("not used by review tests") |
| 168 | } |
| 169 | fn approval_state(&self) -> CommandApprovalState { |
| 170 | self.approval |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | #[test] |
| 175 | fn review_without_target_prints_usage() { |
| 176 | let mut group = FakeSkillGroup::ready(); |
| 177 | let result = review(&mut group, None); |
| 178 | assert!(result.is_error); |
| 179 | assert!(result.message.unwrap().contains("Usage: /review")); |
| 180 | } |
| 181 | |
| 182 | #[test] |
| 183 | fn review_ready_sends_target_without_skills_context() { |
| 184 | let mut group = FakeSkillGroup::ready(); |
| 185 | let contexts = CommandContexts::empty().with_skill_group(&mut group); |
| 186 | let result = review_contextual(contexts, Some("file.rs")); |
| 187 | assert!(result.message.is_none()); |
| 188 | assert!(matches!( |
| 189 | result.action, |
| 190 | Some(AppAction::SendMessage(ref t)) if t == "file.rs" |
| 191 | )); |
| 192 | } |
| 193 | |
| 194 | #[test] |
| 195 | fn review_not_found_renders_exact_error_with_warnings() { |
| 196 | let mut group = FakeSkillGroup::ready(); |
| 197 | group.review = Ok(ReviewOutcome::NotFound { |
| 198 | skills_dir: "/ws/skills".to_string(), |
| 199 | global_dir: "/home/u/.codewhale/skills".to_string(), |
| 200 | warnings: vec!["one warning".to_string()], |
| 201 | }); |
| 202 | let result = review(&mut group, Some("file.rs")); |
| 203 | assert!(result.is_error); |
| 204 | let msg = result.message.unwrap(); |
| 205 | assert!( |
| 206 | msg.contains( |
| 207 | "Review skill not found in /ws/skills or /home/u/.codewhale/skills. Create ~/.codewhale/skills/review/SKILL.md." |
| 208 | ), |
| 209 | "{msg}" |
| 210 | ); |
| 211 | assert!(msg.contains("Warnings:\n- one warning"), "{msg}"); |
| 212 | } |
| 213 | |
| 214 | #[test] |
| 215 | fn review_missing_facet_errors_are_safe() { |
| 216 | let result = review_contextual(CommandContexts::empty(), Some("file.rs")); |
| 217 | assert!(result.is_error); |
| 218 | assert_eq!( |
| 219 | result.message.unwrap(), |
| 220 | "Error: Command capability unavailable: skill_group" |
| 221 | ); |
| 222 | } |
| 223 | } |
| 224 |