1use anyhow::{Context, Result, bail};
19use std::ffi::OsString;
20use std::path::Path;
21use std::process::Command;
22
23#[cfg(unix)]
24use std::os::unix::ffi::OsStrExt;
25
26use crate::cli::app::AppOpenArgs;
27
28const DEFAULT_BUNDLE_ID: &str = "com.mitchfultz.ralph";
29const GUI_CLI_BIN_ENV: &str = "RALPH_BIN_PATH";
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32struct OpenCommandSpec {
33 program: OsString,
34 args: Vec<OsString>,
35}
36
37impl OpenCommandSpec {
38 fn to_command(&self) -> Command {
39 let mut cmd = Command::new(&self.program);
40 cmd.args(&self.args);
41 cmd
42 }
43}
44
45fn plan_open_command(
46 is_macos: bool,
47 args: &AppOpenArgs,
48 cli_executable: Option<&Path>,
49) -> Result<OpenCommandSpec> {
50 if !is_macos {
51 bail!("`ralph app open` is macOS-only.");
52 }
53
54 if args.path.is_some() && args.bundle_id.is_some() {
55 bail!("--path and --bundle-id cannot be used together.");
56 }
57
58 let mut args_out: Vec<OsString> = Vec::new();
59 if let Some(cli_executable) = cli_executable {
60 args_out.push(OsString::from("--env"));
61 args_out.push(env_assignment_for_path(cli_executable));
62 }
63
64 if let Some(path) = args.path.as_deref() {
65 ensure_exists(path)?;
66 args_out.push(OsString::from(path));
67 return Ok(OpenCommandSpec {
68 program: OsString::from("open"),
69 args: args_out,
70 });
71 }
72
73 let bundle_id = args
74 .bundle_id
75 .as_deref()
76 .unwrap_or(DEFAULT_BUNDLE_ID)
77 .trim();
78 if bundle_id.is_empty() {
79 bail!("Bundle id is empty.");
80 }
81
82 args_out.push(OsString::from("-b"));
83 args_out.push(OsString::from(bundle_id));
84
85 Ok(OpenCommandSpec {
86 program: OsString::from("open"),
87 args: args_out,
88 })
89}
90
91fn ensure_exists(path: &Path) -> Result<()> {
92 if path.exists() {
93 return Ok(());
94 }
95
96 bail!("Path does not exist: {}", path.display());
97}
98
99fn plan_url_command(workspace: &Path) -> Result<OpenCommandSpec> {
101 let encoded_path = percent_encode_path(workspace);
102 let url = format!("ralph://open?workspace={}", encoded_path);
103
104 Ok(OpenCommandSpec {
105 program: OsString::from("open"),
106 args: vec![OsString::from(url)],
107 })
108}
109
110fn current_executable_for_gui() -> Option<std::path::PathBuf> {
111 let exe = std::env::current_exe().ok()?;
112 if exe.exists() { Some(exe) } else { None }
113}
114
115#[cfg(unix)]
116fn env_assignment_for_path(path: &Path) -> OsString {
117 use std::os::unix::ffi::{OsStrExt, OsStringExt};
118
119 let mut bytes = Vec::from(format!("{GUI_CLI_BIN_ENV}=").as_bytes());
120 bytes.extend_from_slice(path.as_os_str().as_bytes());
121 OsString::from_vec(bytes)
122}
123
124#[cfg(not(unix))]
125fn env_assignment_for_path(path: &Path) -> OsString {
126 OsString::from(format!("{GUI_CLI_BIN_ENV}={}", path.to_string_lossy()))
127}
128
129#[cfg(unix)]
131fn percent_encode_path(path: &Path) -> String {
132 percent_encode(path.as_os_str().as_bytes())
133}
134
135#[cfg(not(unix))]
137fn percent_encode_path(path: &Path) -> String {
138 percent_encode(path.to_string_lossy().as_bytes())
140}
141
142fn percent_encode(input: &[u8]) -> String {
144 let mut result = String::with_capacity(input.len() * 3);
145 for &byte in input {
146 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~' | b'/') {
148 result.push(byte as char);
149 } else {
150 result.push('%');
151 result.push_str(&format!("{:02X}", byte));
152 }
153 }
154 result
155}
156
157fn resolve_workspace_path(args: &AppOpenArgs) -> Result<Option<std::path::PathBuf>> {
158 if let Some(ref workspace) = args.workspace {
159 if !workspace.exists() {
160 bail!("Workspace path does not exist: {}", workspace.display());
161 }
162 return Ok(Some(workspace.clone()));
163 }
164
165 Ok(std::env::current_dir().ok().filter(|path| path.exists()))
166}
167
168pub fn open(args: AppOpenArgs) -> Result<()> {
175 let cli_executable = current_executable_for_gui();
176
177 let spec = if let Some(workspace_path) = resolve_workspace_path(&args)? {
178 plan_url_command(&workspace_path)?
179 } else {
180 plan_open_command(cfg!(target_os = "macos"), &args, cli_executable.as_deref())?
181 };
182
183 let output = spec
184 .to_command()
185 .output()
186 .context("spawn macOS `open` command for app launch")?;
187
188 if !output.status.success() {
189 let stderr = String::from_utf8_lossy(&output.stderr);
190 bail!(
191 "Failed to launch app (exit status: {}). {}",
192 output.status,
193 stderr.trim()
194 );
195 }
196
197 Ok(())
198}
199
200#[cfg(test)]
201mod tests {
202 use super::{
203 DEFAULT_BUNDLE_ID, GUI_CLI_BIN_ENV, env_assignment_for_path, percent_encode,
204 percent_encode_path, plan_open_command, plan_url_command, resolve_workspace_path,
205 };
206 use crate::cli::app::AppOpenArgs;
207 use std::ffi::{OsStr, OsString};
208 use std::path::PathBuf;
209
210 #[test]
211 fn plan_open_command_non_macos_errors() {
212 let args = AppOpenArgs {
213 bundle_id: None,
214 path: None,
215 workspace: None,
216 };
217
218 let err = plan_open_command(false, &args, None).expect_err("expected error");
219 assert!(
220 err.to_string().to_lowercase().contains("macos-only"),
221 "unexpected error: {err:#}"
222 );
223 }
224
225 #[test]
226 fn plan_open_command_default_bundle_id_uses_open_b() -> anyhow::Result<()> {
227 let args = AppOpenArgs {
228 bundle_id: None,
229 path: None,
230 workspace: None,
231 };
232
233 let spec = plan_open_command(true, &args, None)?;
234 assert_eq!(spec.program, OsString::from("open"));
235 assert_eq!(
236 spec.args,
237 vec![
238 OsStr::new("-b").to_os_string(),
239 OsStr::new(DEFAULT_BUNDLE_ID).to_os_string()
240 ]
241 );
242 Ok(())
243 }
244
245 #[test]
246 fn plan_open_command_bundle_id_override_uses_open_b() -> anyhow::Result<()> {
247 let args = AppOpenArgs {
248 bundle_id: Some("com.example.override".to_string()),
249 path: None,
250 workspace: None,
251 };
252
253 let spec = plan_open_command(true, &args, None)?;
254 assert_eq!(spec.program, OsString::from("open"));
255 assert_eq!(
256 spec.args,
257 vec![
258 OsStr::new("-b").to_os_string(),
259 OsStr::new("com.example.override").to_os_string()
260 ]
261 );
262 Ok(())
263 }
264
265 #[test]
266 fn plan_open_command_path_uses_open_path() -> anyhow::Result<()> {
267 let temp = tempfile::tempdir()?;
268 let app_dir = temp.path().join("Ralph.app");
269 std::fs::create_dir_all(&app_dir)?;
270
271 let args = AppOpenArgs {
272 bundle_id: None,
273 path: Some(app_dir.clone()),
274 workspace: None,
275 };
276
277 let spec = plan_open_command(true, &args, None)?;
278 assert_eq!(spec.program, OsString::from("open"));
279 assert_eq!(spec.args, vec![app_dir.as_os_str().to_os_string()]);
280 Ok(())
281 }
282
283 #[test]
284 fn plan_open_command_path_missing_errors() {
285 let args = AppOpenArgs {
286 bundle_id: None,
287 path: Some(PathBuf::from("/definitely/not/a/real/path/Ralph.app")),
288 workspace: None,
289 };
290
291 let err = plan_open_command(true, &args, None).expect_err("expected error");
292 assert!(
293 err.to_string().to_lowercase().contains("does not exist"),
294 "unexpected error: {err:#}"
295 );
296 }
297
298 #[test]
299 fn plan_url_command_encodes_workspace() -> anyhow::Result<()> {
300 let workspace = PathBuf::from("/Users/test/my project");
301 let spec = plan_url_command(&workspace)?;
302
303 assert_eq!(spec.program, OsString::from("open"));
304 assert_eq!(spec.args.len(), 1);
305
306 let url = spec.args[0].to_str().unwrap();
307 assert!(url.starts_with("ralph://open?workspace="));
308 assert!(
309 url.contains("my%20project"),
310 "space should be percent-encoded"
311 );
312 Ok(())
313 }
314
315 #[test]
316 fn plan_url_command_handles_special_chars() -> anyhow::Result<()> {
317 let workspace = PathBuf::from("/path/with&special=chars");
318 let spec = plan_url_command(&workspace)?;
319
320 let url = spec.args[0].to_str().unwrap();
321 assert!(url.contains("%26"), "& should be encoded as %26");
322 assert!(url.contains("%3D"), "= should be encoded as %3D");
323 Ok(())
324 }
325
326 #[test]
327 fn percent_encode_preserves_unreserved_chars() {
328 let input = b"abc-_.~/123";
329 let encoded = percent_encode(input);
330 assert_eq!(encoded, "abc-_.~/123");
331 }
332
333 #[test]
334 fn percent_encode_encodes_reserved_chars() {
335 let input = b"hello world";
336 let encoded = percent_encode(input);
337 assert_eq!(encoded, "hello%20world");
338 }
339
340 #[test]
341 fn percent_encode_encodes_unicode() {
342 let input = "test/文件".as_bytes();
343 let encoded = percent_encode(input);
344 assert!(encoded.starts_with("test/"));
345 assert!(encoded.len() > "test/文件".len()); }
347
348 #[test]
349 fn percent_encode_path_handles_spaces() {
350 let path = PathBuf::from("/Users/test/my project");
351 let encoded = percent_encode_path(&path);
352 assert!(encoded.contains("%20"), "spaces should be encoded as %20");
353 assert!(
354 !encoded.contains(' '),
355 "result should not contain literal spaces"
356 );
357 }
358
359 #[test]
360 fn percent_encode_path_preserves_path_structure() {
361 let path = PathBuf::from("/path/to/directory");
362 let encoded = percent_encode_path(&path);
363 assert!(encoded.starts_with("/path/to/"));
364 assert!(encoded.contains('/'));
365 }
366
367 #[test]
368 fn plan_open_command_includes_cli_env_when_provided() -> anyhow::Result<()> {
369 let args = AppOpenArgs {
370 bundle_id: None,
371 path: None,
372 workspace: None,
373 };
374 let cli = PathBuf::from("/tmp/ralph-bin");
375
376 let spec = plan_open_command(true, &args, Some(&cli))?;
377 assert_eq!(spec.program, OsString::from("open"));
378 assert!(spec.args.len() >= 4);
379 assert_eq!(spec.args[0], OsString::from("--env"));
380 assert_eq!(spec.args[1], env_assignment_for_path(&cli));
381 assert_eq!(spec.args[2], OsString::from("-b"));
382 assert_eq!(spec.args[3], OsString::from(DEFAULT_BUNDLE_ID));
383 Ok(())
384 }
385
386 #[test]
387 fn plan_url_command_never_includes_cli_param() -> anyhow::Result<()> {
388 let workspace = PathBuf::from("/Users/test/workspace");
389 let spec = plan_url_command(&workspace)?;
390
391 let url = spec.args[0].to_string_lossy();
392 assert!(url.starts_with("ralph://open?workspace="));
393 assert!(!url.contains("&cli="));
394 Ok(())
395 }
396
397 #[test]
398 fn env_assignment_prefixes_variable_name() {
399 let cli = PathBuf::from("/tmp/ralph");
400 let assignment = env_assignment_for_path(&cli);
401 let text = assignment.to_string_lossy();
402 assert!(text.starts_with(&format!("{GUI_CLI_BIN_ENV}=")));
403 assert!(text.ends_with("/tmp/ralph"));
404 }
405
406 #[test]
407 fn resolve_workspace_path_prefers_explicit_workspace() -> anyhow::Result<()> {
408 let temp = tempfile::tempdir()?;
409 let args = AppOpenArgs {
410 bundle_id: None,
411 path: None,
412 workspace: Some(temp.path().to_path_buf()),
413 };
414
415 let resolved = resolve_workspace_path(&args)?;
416 assert_eq!(resolved.as_deref(), Some(temp.path()));
417 Ok(())
418 }
419
420 #[test]
421 fn resolve_workspace_path_errors_for_missing_workspace() {
422 let args = AppOpenArgs {
423 bundle_id: None,
424 path: None,
425 workspace: Some(PathBuf::from("/definitely/not/a/real/workspace")),
426 };
427
428 let err = resolve_workspace_path(&args).expect_err("expected error");
429 assert!(
430 err.to_string().contains("Workspace path does not exist"),
431 "unexpected error: {err:#}"
432 );
433 }
434}