| 1 | //! `/remote-env` — portable handler over the session-control + presentation |
| 2 | //! facets. Opens the hosted Work launcher without taking source custody. |
| 3 | |
| 4 | use super::CommandResult; |
| 5 | use codewhale_command_contract::facets::{ |
| 6 | CommandPresentationContext, CommandSessionControlContext, |
| 7 | }; |
| 8 | use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; |
| 9 | use codewhale_command_contract::metadata::{ |
| 10 | CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand, |
| 11 | }; |
| 12 | |
| 13 | pub(in crate::commands) struct RemoteEnvCmd; |
| 14 | |
| 15 | // --------------------------------------------------------------------------- |
| 16 | // FEAT-024 Phase 4 (D3/D6/D7): portable contextual registration and handler. |
| 17 | // `/remote-env` is the only control command with runtime localization and |
| 18 | // therefore the only one receiving PRESENTATION. The handler translates the |
| 19 | // exact stable keys with the baseline placeholder sets; Git/URL machinery and |
| 20 | // credential-safe target resolution stay behind `resolve_hosted_work_target`. |
| 21 | // --------------------------------------------------------------------------- |
| 22 | |
| 23 | pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo { |
| 24 | name: "remote-env", |
| 25 | aliases: &[], |
| 26 | usage: "/remote-env [open]", |
| 27 | description_key: "cmd_remote_env_description", |
| 28 | }; |
| 29 | |
| 30 | impl ContractRegisterCommand<CommandResult> for RemoteEnvCmd { |
| 31 | fn info() -> &'static ContractInfo { |
| 32 | &CONTRACT_INFO |
| 33 | } |
| 34 | fn handler() -> CommandHandler<CommandResult> { |
| 35 | CommandHandler::Contextual { |
| 36 | capabilities: codewhale_command_contract::handler::CommandCapabilities::SESSION_CONTROL |
| 37 | .union(codewhale_command_contract::handler::CommandCapabilities::PRESENTATION), |
| 38 | handler: remote_env_contextual, |
| 39 | } |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | pub(in crate::commands) fn remote_env_contextual( |
| 44 | contexts: CommandContexts<'_>, |
| 45 | arg: Option<&str>, |
| 46 | ) -> CommandResult { |
| 47 | let mut parts = contexts.into_parts(); |
| 48 | let Some(control) = parts.control.as_deref_mut() else { |
| 49 | return CommandResult::error("Command capability unavailable: session_control".to_string()); |
| 50 | }; |
| 51 | let Some(presentation) = parts.presentation.as_deref_mut() else { |
| 52 | return CommandResult::error("Command capability unavailable: presentation".to_string()); |
| 53 | }; |
| 54 | remote_env_portable(control, presentation, arg) |
| 55 | } |
| 56 | |
| 57 | pub(in crate::commands) fn remote_env_portable( |
| 58 | control: &mut dyn CommandSessionControlContext, |
| 59 | presentation: &mut dyn CommandPresentationContext, |
| 60 | arg: Option<&str>, |
| 61 | ) -> CommandResult { |
| 62 | match arg.map(str::trim).filter(|value| !value.is_empty()) { |
| 63 | None => match presentation.translate( |
| 64 | "cmd_remote_env_overview", |
| 65 | &[("command", "/remote-env open")], |
| 66 | ) { |
| 67 | Ok(copy) => CommandResult::message(copy), |
| 68 | Err(error) => CommandResult::error(error), |
| 69 | }, |
| 70 | Some("open") => open_hosted_work(control, presentation), |
| 71 | Some(_) => match presentation.translate( |
| 72 | "cmd_remote_env_source_custody_policy", |
| 73 | &[("command", "/remote-env open")], |
| 74 | ) { |
| 75 | Ok(copy) => CommandResult::error(copy), |
| 76 | Err(error) => CommandResult::error(error), |
| 77 | }, |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | fn open_hosted_work( |
| 82 | control: &dyn CommandSessionControlContext, |
| 83 | presentation: &mut dyn CommandPresentationContext, |
| 84 | ) -> CommandResult { |
| 85 | let Some(target) = control.resolve_hosted_work_target() else { |
| 86 | return match presentation.translate( |
| 87 | "cmd_remote_env_unavailable", |
| 88 | &[("command", "/remote-env open"), ("origin", "origin")], |
| 89 | ) { |
| 90 | Ok(copy) => CommandResult::error(copy), |
| 91 | Err(error) => CommandResult::error(error), |
| 92 | }; |
| 93 | }; |
| 94 | let message = match presentation.translate( |
| 95 | "cmd_remote_env_opening", |
| 96 | &[ |
| 97 | ("origin", "origin"), |
| 98 | ("url", &target.url), |
| 99 | ("repo", &target.repo), |
| 100 | ("branch", &target.branch), |
| 101 | ], |
| 102 | ) { |
| 103 | Ok(copy) => copy, |
| 104 | Err(error) => return CommandResult::error(error), |
| 105 | }; |
| 106 | let label = match presentation.translate("cmd_remote_env_browser_label", &[]) { |
| 107 | Ok(label) => label, |
| 108 | Err(error) => return CommandResult::error(error), |
| 109 | }; |
| 110 | CommandResult::with_message_and_action( |
| 111 | message, |
| 112 | crate::tui::app::AppAction::OpenExternalUrl { |
| 113 | url: target.url, |
| 114 | label, |
| 115 | }, |
| 116 | ) |
| 117 | } |
| 118 | |
| 119 | #[cfg(test)] |
| 120 | mod tests { |
| 121 | use super::super::control_test_support::message; |
| 122 | use super::*; |
| 123 | use codewhale_command_contract::facets::HostedWorkTarget; |
| 124 | use std::cell::RefCell; |
| 125 | |
| 126 | /// Deterministic fake presentation facet resolving the five remote-env |
| 127 | /// keys with exact placeholder-set enforcement like the real adapter. |
| 128 | #[derive(Default)] |
| 129 | struct FakePresentation { |
| 130 | calls: RefCell<Vec<(String, String)>>, |
| 131 | } |
| 132 | |
| 133 | impl CommandPresentationContext for FakePresentation { |
| 134 | fn translate(&self, key: &str, replacements: &[(&str, &str)]) -> Result<String, String> { |
| 135 | self.calls |
| 136 | .borrow_mut() |
| 137 | .push((key.to_string(), format!("{replacements:?}"))); |
| 138 | let base = match key { |
| 139 | "cmd_remote_env_overview" => "Overview {command}".to_string(), |
| 140 | "cmd_remote_env_opening" => { |
| 141 | "Opening {repo}/{branch} from {origin} at {url}".to_string() |
| 142 | } |
| 143 | "cmd_remote_env_unavailable" => "Unavailable {command} {origin}".to_string(), |
| 144 | "cmd_remote_env_source_custody_policy" => "Policy {command}".to_string(), |
| 145 | "cmd_remote_env_browser_label" => "Label".to_string(), |
| 146 | other => return Err(format!("unknown key {other}")), |
| 147 | }; |
| 148 | let mut out = base; |
| 149 | for (name, value) in replacements { |
| 150 | out = out.replace(&format!("{{{name}}}"), value); |
| 151 | } |
| 152 | Ok(out) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | fn fake_control( |
| 157 | target: Option<HostedWorkTarget>, |
| 158 | ) -> super::super::control_test_support::FakeControl { |
| 159 | super::super::control_test_support::FakeControl { |
| 160 | hosted: Some(target), |
| 161 | ..super::super::control_test_support::FakeControl::default() |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | #[test] |
| 166 | fn remote_env_overview_and_policy_branches_are_exact() { |
| 167 | let mut control = fake_control(None); |
| 168 | let mut presentation = FakePresentation::default(); |
| 169 | let overview = remote_env_portable(&mut control, &mut presentation, None); |
| 170 | assert!(!overview.is_error); |
| 171 | assert_eq!(message(&overview), "Overview /remote-env open"); |
| 172 | assert!(overview.action.is_none()); |
| 173 | |
| 174 | let mut control = fake_control(None); |
| 175 | let invalid = |
| 176 | remote_env_portable(&mut control, &mut FakePresentation::default(), Some("sync")); |
| 177 | assert!(invalid.is_error); |
| 178 | assert_eq!(message(&invalid), "Policy /remote-env open"); |
| 179 | assert!(invalid.action.is_none()); |
| 180 | } |
| 181 | |
| 182 | #[test] |
| 183 | fn remote_env_open_composes_exact_url_message_and_action() { |
| 184 | let mut control = fake_control(Some(HostedWorkTarget { |
| 185 | url: "https://app.codewhale.net/work?repo=A%2FB&branch=main".to_string(), |
| 186 | repo: "A/B".to_string(), |
| 187 | branch: "main".to_string(), |
| 188 | })); |
| 189 | let result = |
| 190 | remote_env_portable(&mut control, &mut FakePresentation::default(), Some("open")); |
| 191 | assert!(!result.is_error); |
| 192 | assert_eq!( |
| 193 | message(&result), |
| 194 | "Opening A/B/main from origin at https://app.codewhale.net/work?repo=A%2FB&branch=main" |
| 195 | ); |
| 196 | assert!(matches!( |
| 197 | result.action, |
| 198 | Some(crate::tui::app::AppAction::OpenExternalUrl { ref url, ref label }) |
| 199 | if url == "https://app.codewhale.net/work?repo=A%2FB&branch=main" && label == "Label" |
| 200 | )); |
| 201 | assert_eq!( |
| 202 | control.calls.borrow().as_slice(), |
| 203 | ["resolve_hosted_work_target"] |
| 204 | ); |
| 205 | |
| 206 | let mut control = fake_control(None); |
| 207 | let result = |
| 208 | remote_env_portable(&mut control, &mut FakePresentation::default(), Some("open")); |
| 209 | assert!(result.is_error); |
| 210 | assert_eq!(message(&result), "Unavailable /remote-env open origin"); |
| 211 | assert!(result.action.is_none()); |
| 212 | } |
| 213 | |
| 214 | #[test] |
| 215 | fn remote_env_missing_authority_fails_safely() { |
| 216 | let contexts = codewhale_command_contract::handler::CommandContexts::empty(); |
| 217 | let result = remote_env_contextual(contexts, None); |
| 218 | assert!(result.is_error); |
| 219 | assert_eq!( |
| 220 | message(&result), |
| 221 | "Command capability unavailable: session_control" |
| 222 | ); |
| 223 | |
| 224 | let mut control = fake_control(None); |
| 225 | let contexts = codewhale_command_contract::handler::CommandContexts::empty() |
| 226 | .with_control(&mut control); |
| 227 | let result = remote_env_contextual(contexts, None); |
| 228 | assert!(result.is_error); |
| 229 | assert_eq!( |
| 230 | message(&result), |
| 231 | "Command capability unavailable: presentation" |
| 232 | ); |
| 233 | assert!( |
| 234 | control.calls.borrow().is_empty(), |
| 235 | "translation authority is checked before command work" |
| 236 | ); |
| 237 | } |
| 238 | } |
| 239 |