返回 CodeWhale
remote_env.rs
根目录 / crates / tui / src / commands / groups / session / remote_env.rs
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
239 lines RUST