| 1 | use std::collections::HashMap; |
| 2 | use std::env; |
| 3 | use std::io::{self, BufRead, BufReader, Write}; |
| 4 | use std::os::unix::net::UnixStream; |
| 5 | use std::path::PathBuf; |
| 6 | |
| 7 | use garwarp_ipc::{ |
| 8 | ControlRequest, ControlResponse, DEFAULT_CONTROL_SOCKET, DEFAULT_RUNTIME_SUBDIR, |
| 9 | PROTOCOL_VERSION, RequestTransitionTarget, |
| 10 | }; |
| 11 | use zbus::{ |
| 12 | blocking::Connection, |
| 13 | zvariant::{OwnedObjectPath, OwnedValue}, |
| 14 | }; |
| 15 | |
| 16 | const BACKEND_DBUS_NAME: &str = "org.freedesktop.impl.portal.desktop.garwarp"; |
| 17 | const BACKEND_OBJECT_PATH: &str = "/org/freedesktop/portal/desktop"; |
| 18 | const PORTAL_RESPONSE_FAILED: u32 = 2; |
| 19 | |
| 20 | fn main() { |
| 21 | let args: Vec<String> = env::args().collect(); |
| 22 | let command = match parse_command(&args[1..]) { |
| 23 | Ok(command) => command, |
| 24 | Err(error) => { |
| 25 | eprintln!("garwarpctl error: {error}"); |
| 26 | print_help(); |
| 27 | std::process::exit(1); |
| 28 | } |
| 29 | }; |
| 30 | |
| 31 | if let Err(error) = run(command) { |
| 32 | eprintln!("garwarpctl error: {error}"); |
| 33 | std::process::exit(1); |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 38 | enum Command { |
| 39 | Status, |
| 40 | Stop, |
| 41 | List, |
| 42 | PortalSmoke, |
| 43 | Inspect { |
| 44 | id: String, |
| 45 | }, |
| 46 | Version, |
| 47 | Help, |
| 48 | Begin { |
| 49 | id: String, |
| 50 | sender: String, |
| 51 | app_id: Option<String>, |
| 52 | parent_window: Option<String>, |
| 53 | }, |
| 54 | Transition { |
| 55 | id: String, |
| 56 | sender: String, |
| 57 | app_id: Option<String>, |
| 58 | target: RequestTransitionTarget, |
| 59 | }, |
| 60 | } |
| 61 | |
| 62 | fn parse_command(args: &[String]) -> Result<Command, String> { |
| 63 | match args { |
| 64 | [] => Ok(Command::Status), |
| 65 | [command] if command == "status" => Ok(Command::Status), |
| 66 | [command] if command == "stop" => Ok(Command::Stop), |
| 67 | [command] if command == "list" => Ok(Command::List), |
| 68 | [command] if command == "portal-smoke" => Ok(Command::PortalSmoke), |
| 69 | [command, id] if command == "inspect" => Ok(Command::Inspect { id: id.clone() }), |
| 70 | [command] if command == "version" || command == "--version" || command == "-V" => { |
| 71 | Ok(Command::Version) |
| 72 | } |
| 73 | [command] if command == "help" || command == "--help" || command == "-h" => { |
| 74 | Ok(Command::Help) |
| 75 | } |
| 76 | [command, id, sender] if command == "begin" => Ok(Command::Begin { |
| 77 | id: id.clone(), |
| 78 | sender: sender.clone(), |
| 79 | app_id: None, |
| 80 | parent_window: None, |
| 81 | }), |
| 82 | [command, id, sender, app_id] if command == "begin" => Ok(Command::Begin { |
| 83 | id: id.clone(), |
| 84 | sender: sender.clone(), |
| 85 | app_id: optional_value(app_id), |
| 86 | parent_window: None, |
| 87 | }), |
| 88 | [command, id, sender, app_id, parent_window] if command == "begin" => Ok(Command::Begin { |
| 89 | id: id.clone(), |
| 90 | sender: sender.clone(), |
| 91 | app_id: optional_value(app_id), |
| 92 | parent_window: optional_value(parent_window), |
| 93 | }), |
| 94 | [command, id, sender, state] if command == "transition" => Ok(Command::Transition { |
| 95 | id: id.clone(), |
| 96 | sender: sender.clone(), |
| 97 | app_id: None, |
| 98 | target: parse_transition_target(state)?, |
| 99 | }), |
| 100 | [command, id, sender, state, app_id] if command == "transition" => { |
| 101 | Ok(Command::Transition { |
| 102 | id: id.clone(), |
| 103 | sender: sender.clone(), |
| 104 | app_id: optional_value(app_id), |
| 105 | target: parse_transition_target(state)?, |
| 106 | }) |
| 107 | } |
| 108 | [command, id, sender] if command == "await" => Ok(Command::Transition { |
| 109 | id: id.clone(), |
| 110 | sender: sender.clone(), |
| 111 | app_id: None, |
| 112 | target: RequestTransitionTarget::AwaitingUser, |
| 113 | }), |
| 114 | [command, id, sender, app_id] if command == "await" => Ok(Command::Transition { |
| 115 | id: id.clone(), |
| 116 | sender: sender.clone(), |
| 117 | app_id: optional_value(app_id), |
| 118 | target: RequestTransitionTarget::AwaitingUser, |
| 119 | }), |
| 120 | [command, id, sender] if command == "fulfill" => Ok(Command::Transition { |
| 121 | id: id.clone(), |
| 122 | sender: sender.clone(), |
| 123 | app_id: None, |
| 124 | target: RequestTransitionTarget::Fulfilled, |
| 125 | }), |
| 126 | [command, id, sender, app_id] if command == "fulfill" => Ok(Command::Transition { |
| 127 | id: id.clone(), |
| 128 | sender: sender.clone(), |
| 129 | app_id: optional_value(app_id), |
| 130 | target: RequestTransitionTarget::Fulfilled, |
| 131 | }), |
| 132 | [command, id, sender] if command == "cancel" => Ok(Command::Transition { |
| 133 | id: id.clone(), |
| 134 | sender: sender.clone(), |
| 135 | app_id: None, |
| 136 | target: RequestTransitionTarget::Cancelled, |
| 137 | }), |
| 138 | [command, id, sender, app_id] if command == "cancel" => Ok(Command::Transition { |
| 139 | id: id.clone(), |
| 140 | sender: sender.clone(), |
| 141 | app_id: optional_value(app_id), |
| 142 | target: RequestTransitionTarget::Cancelled, |
| 143 | }), |
| 144 | [command, id, sender] if command == "fail" => Ok(Command::Transition { |
| 145 | id: id.clone(), |
| 146 | sender: sender.clone(), |
| 147 | app_id: None, |
| 148 | target: RequestTransitionTarget::Failed, |
| 149 | }), |
| 150 | [command, id, sender, app_id] if command == "fail" => Ok(Command::Transition { |
| 151 | id: id.clone(), |
| 152 | sender: sender.clone(), |
| 153 | app_id: optional_value(app_id), |
| 154 | target: RequestTransitionTarget::Failed, |
| 155 | }), |
| 156 | _ => Err("unknown command or invalid arguments".to_string()), |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | fn parse_transition_target(value: &str) -> Result<RequestTransitionTarget, String> { |
| 161 | match value { |
| 162 | "awaiting_user" => Ok(RequestTransitionTarget::AwaitingUser), |
| 163 | "fulfilled" => Ok(RequestTransitionTarget::Fulfilled), |
| 164 | "cancelled" => Ok(RequestTransitionTarget::Cancelled), |
| 165 | "failed" => Ok(RequestTransitionTarget::Failed), |
| 166 | _ => Err(format!("unsupported transition state: {value}")), |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | fn optional_value(value: &str) -> Option<String> { |
| 171 | if value == "-" || value.is_empty() { |
| 172 | None |
| 173 | } else { |
| 174 | Some(value.to_string()) |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | fn run(command: Command) -> io::Result<()> { |
| 179 | match command { |
| 180 | Command::Status => { |
| 181 | let response = send_request(ControlRequest::Status)?; |
| 182 | match response { |
| 183 | ControlResponse::Status(status) => { |
| 184 | println!("protocol={}", status.protocol_version); |
| 185 | println!("health={}", status.health.as_str()); |
| 186 | println!("in_flight={}", status.in_flight_requests); |
| 187 | println!("total={}", status.total_requests); |
| 188 | println!("terminal={}", status.terminal_requests); |
| 189 | Ok(()) |
| 190 | } |
| 191 | ControlResponse::Error { code, reason } => Err(io::Error::other(format!( |
| 192 | "daemon error: code={code} reason={reason}" |
| 193 | ))), |
| 194 | other => Err(io::Error::new( |
| 195 | io::ErrorKind::InvalidData, |
| 196 | format!("unexpected response: {other:?}"), |
| 197 | )), |
| 198 | } |
| 199 | } |
| 200 | Command::Stop => { |
| 201 | let response = send_request(ControlRequest::Stop)?; |
| 202 | match response { |
| 203 | ControlResponse::AckStopping => { |
| 204 | println!("stopping"); |
| 205 | Ok(()) |
| 206 | } |
| 207 | ControlResponse::Error { code, reason } => Err(io::Error::other(format!( |
| 208 | "daemon error: code={code} reason={reason}" |
| 209 | ))), |
| 210 | other => Err(io::Error::new( |
| 211 | io::ErrorKind::InvalidData, |
| 212 | format!("unexpected response: {other:?}"), |
| 213 | )), |
| 214 | } |
| 215 | } |
| 216 | Command::List => { |
| 217 | let response = send_request(ControlRequest::ListRequests)?; |
| 218 | match response { |
| 219 | ControlResponse::RequestList { ids } => { |
| 220 | if ids.is_empty() { |
| 221 | println!("ids=-"); |
| 222 | } else { |
| 223 | for id in ids { |
| 224 | println!("id={id}"); |
| 225 | } |
| 226 | } |
| 227 | Ok(()) |
| 228 | } |
| 229 | ControlResponse::Error { code, reason } => Err(io::Error::other(format!( |
| 230 | "daemon error: code={code} reason={reason}" |
| 231 | ))), |
| 232 | other => Err(io::Error::new( |
| 233 | io::ErrorKind::InvalidData, |
| 234 | format!("unexpected response: {other:?}"), |
| 235 | )), |
| 236 | } |
| 237 | } |
| 238 | Command::PortalSmoke => run_portal_smoke(), |
| 239 | Command::Inspect { id } => { |
| 240 | let response = send_request(ControlRequest::InspectRequest { id })?; |
| 241 | match response { |
| 242 | ControlResponse::RequestSnapshot { |
| 243 | id, |
| 244 | state, |
| 245 | sender, |
| 246 | app_id, |
| 247 | parent_window, |
| 248 | } => { |
| 249 | println!("id={id}"); |
| 250 | println!("state={state}"); |
| 251 | println!("sender={sender}"); |
| 252 | println!("app_id={}", app_id.unwrap_or_else(|| "-".to_string())); |
| 253 | println!( |
| 254 | "parent_window={}", |
| 255 | parent_window.unwrap_or_else(|| "-".to_string()) |
| 256 | ); |
| 257 | Ok(()) |
| 258 | } |
| 259 | ControlResponse::Error { code, reason } => Err(io::Error::other(format!( |
| 260 | "daemon error: code={code} reason={reason}" |
| 261 | ))), |
| 262 | other => Err(io::Error::new( |
| 263 | io::ErrorKind::InvalidData, |
| 264 | format!("unexpected response: {other:?}"), |
| 265 | )), |
| 266 | } |
| 267 | } |
| 268 | Command::Begin { |
| 269 | id, |
| 270 | sender, |
| 271 | app_id, |
| 272 | parent_window, |
| 273 | } => { |
| 274 | let response = send_request(ControlRequest::BeginRequest { |
| 275 | id, |
| 276 | sender, |
| 277 | app_id, |
| 278 | parent_window, |
| 279 | })?; |
| 280 | match response { |
| 281 | ControlResponse::AckRequest { id, state } => { |
| 282 | println!("id={id}"); |
| 283 | println!("state={state}"); |
| 284 | Ok(()) |
| 285 | } |
| 286 | ControlResponse::Error { code, reason } => Err(io::Error::other(format!( |
| 287 | "daemon error: code={code} reason={reason}" |
| 288 | ))), |
| 289 | other => Err(io::Error::new( |
| 290 | io::ErrorKind::InvalidData, |
| 291 | format!("unexpected response: {other:?}"), |
| 292 | )), |
| 293 | } |
| 294 | } |
| 295 | Command::Transition { |
| 296 | id, |
| 297 | sender, |
| 298 | app_id, |
| 299 | target, |
| 300 | } => { |
| 301 | let response = send_request(ControlRequest::TransitionRequest { |
| 302 | id, |
| 303 | sender, |
| 304 | app_id, |
| 305 | target, |
| 306 | })?; |
| 307 | match response { |
| 308 | ControlResponse::AckRequest { id, state } => { |
| 309 | println!("id={id}"); |
| 310 | println!("state={state}"); |
| 311 | Ok(()) |
| 312 | } |
| 313 | ControlResponse::Error { code, reason } => Err(io::Error::other(format!( |
| 314 | "daemon error: code={code} reason={reason}" |
| 315 | ))), |
| 316 | other => Err(io::Error::new( |
| 317 | io::ErrorKind::InvalidData, |
| 318 | format!("unexpected response: {other:?}"), |
| 319 | )), |
| 320 | } |
| 321 | } |
| 322 | Command::Version => { |
| 323 | println!("garwarpctl protocol v{PROTOCOL_VERSION}"); |
| 324 | Ok(()) |
| 325 | } |
| 326 | Command::Help => { |
| 327 | print_help(); |
| 328 | Ok(()) |
| 329 | } |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | fn run_portal_smoke() -> io::Result<()> { |
| 334 | let connection = Connection::session() |
| 335 | .map_err(|error| io::Error::other(format!("failed to connect to session bus: {error}")))?; |
| 336 | let sender = connection |
| 337 | .unique_name() |
| 338 | .ok_or_else(|| io::Error::other("connection missing unique bus name"))? |
| 339 | .as_str() |
| 340 | .to_string(); |
| 341 | let sender_segment = sender_to_handle_segment(&sender)?; |
| 342 | |
| 343 | let screenshot_handle = request_handle(&sender_segment, "smoke_screenshot")?; |
| 344 | let screenshot_response = call_portal_screenshot(&connection, screenshot_handle)?; |
| 345 | println!("screenshot_response={screenshot_response}"); |
| 346 | |
| 347 | let open_file_handle = request_handle(&sender_segment, "smoke_open_file")?; |
| 348 | let open_file_response = call_portal_open_file(&connection, open_file_handle)?; |
| 349 | println!("open_file_response={open_file_response}"); |
| 350 | |
| 351 | let choose_handle = request_handle(&sender_segment, "smoke_choose_app")?; |
| 352 | let choose_response = call_portal_choose_application(&connection, choose_handle.clone())?; |
| 353 | println!("choose_application_response={choose_response}"); |
| 354 | |
| 355 | call_portal_update_choices_expect_invalid_transition(&connection, choose_handle)?; |
| 356 | println!("update_choices=invalid_transition"); |
| 357 | println!("portal_smoke=ok"); |
| 358 | Ok(()) |
| 359 | } |
| 360 | |
| 361 | fn sender_to_handle_segment(sender: &str) -> io::Result<String> { |
| 362 | let sender = sender.strip_prefix(':').ok_or_else(|| { |
| 363 | io::Error::new( |
| 364 | io::ErrorKind::InvalidInput, |
| 365 | format!("invalid sender unique name: {sender}"), |
| 366 | ) |
| 367 | })?; |
| 368 | if sender.is_empty() { |
| 369 | return Err(io::Error::new( |
| 370 | io::ErrorKind::InvalidInput, |
| 371 | "invalid sender unique name: empty".to_string(), |
| 372 | )); |
| 373 | } |
| 374 | |
| 375 | let mut segment = String::with_capacity(sender.len()); |
| 376 | for ch in sender.chars() { |
| 377 | if ch.is_ascii_alphanumeric() { |
| 378 | segment.push(ch); |
| 379 | continue; |
| 380 | } |
| 381 | if matches!(ch, '.' | '_' | '-') { |
| 382 | segment.push('_'); |
| 383 | continue; |
| 384 | } |
| 385 | return Err(io::Error::new( |
| 386 | io::ErrorKind::InvalidInput, |
| 387 | format!("unsupported sender character: {ch}"), |
| 388 | )); |
| 389 | } |
| 390 | |
| 391 | Ok(segment) |
| 392 | } |
| 393 | |
| 394 | fn request_handle(sender_segment: &str, token: &str) -> io::Result<OwnedObjectPath> { |
| 395 | let path = format!("{BACKEND_OBJECT_PATH}/request/{sender_segment}/{token}"); |
| 396 | OwnedObjectPath::try_from(path.clone()).map_err(|error| { |
| 397 | io::Error::new( |
| 398 | io::ErrorKind::InvalidInput, |
| 399 | format!("invalid request-handle path {path}: {error}"), |
| 400 | ) |
| 401 | }) |
| 402 | } |
| 403 | |
| 404 | fn empty_options() -> HashMap<String, OwnedValue> { |
| 405 | HashMap::new() |
| 406 | } |
| 407 | |
| 408 | fn call_portal_screenshot(connection: &Connection, handle: OwnedObjectPath) -> io::Result<u32> { |
| 409 | let message = connection |
| 410 | .call_method( |
| 411 | Some(BACKEND_DBUS_NAME), |
| 412 | BACKEND_OBJECT_PATH, |
| 413 | Some("org.freedesktop.impl.portal.Screenshot"), |
| 414 | "Screenshot", |
| 415 | &(handle, "org.test.App", "", empty_options()), |
| 416 | ) |
| 417 | .map_err(|error| io::Error::other(format!("screenshot call failed: {error}")))?; |
| 418 | expect_failed_response(message) |
| 419 | } |
| 420 | |
| 421 | fn call_portal_open_file(connection: &Connection, handle: OwnedObjectPath) -> io::Result<u32> { |
| 422 | let message = connection |
| 423 | .call_method( |
| 424 | Some(BACKEND_DBUS_NAME), |
| 425 | BACKEND_OBJECT_PATH, |
| 426 | Some("org.freedesktop.impl.portal.FileChooser"), |
| 427 | "OpenFile", |
| 428 | &(handle, "org.test.App", "", "Open", empty_options()), |
| 429 | ) |
| 430 | .map_err(|error| io::Error::other(format!("open file call failed: {error}")))?; |
| 431 | expect_failed_response(message) |
| 432 | } |
| 433 | |
| 434 | fn call_portal_choose_application( |
| 435 | connection: &Connection, |
| 436 | handle: OwnedObjectPath, |
| 437 | ) -> io::Result<u32> { |
| 438 | let choices = vec!["org.test.Viewer".to_string()]; |
| 439 | let message = connection |
| 440 | .call_method( |
| 441 | Some(BACKEND_DBUS_NAME), |
| 442 | BACKEND_OBJECT_PATH, |
| 443 | Some("org.freedesktop.impl.portal.AppChooser"), |
| 444 | "ChooseApplication", |
| 445 | &(handle, "org.test.App", "", choices, empty_options()), |
| 446 | ) |
| 447 | .map_err(|error| io::Error::other(format!("choose application call failed: {error}")))?; |
| 448 | expect_failed_response(message) |
| 449 | } |
| 450 | |
| 451 | fn call_portal_update_choices_expect_invalid_transition( |
| 452 | connection: &Connection, |
| 453 | handle: OwnedObjectPath, |
| 454 | ) -> io::Result<()> { |
| 455 | let choices = vec!["org.test.Viewer".to_string()]; |
| 456 | match connection.call_method( |
| 457 | Some(BACKEND_DBUS_NAME), |
| 458 | BACKEND_OBJECT_PATH, |
| 459 | Some("org.freedesktop.impl.portal.AppChooser"), |
| 460 | "UpdateChoices", |
| 461 | &(handle, choices), |
| 462 | ) { |
| 463 | Ok(_) => Err(io::Error::other( |
| 464 | "expected UpdateChoices to fail with invalid_transition".to_string(), |
| 465 | )), |
| 466 | Err(error) => { |
| 467 | let text = error.to_string(); |
| 468 | if !text.contains("invalid_transition") { |
| 469 | return Err(io::Error::other(format!( |
| 470 | "unexpected UpdateChoices error: {text}" |
| 471 | ))); |
| 472 | } |
| 473 | Ok(()) |
| 474 | } |
| 475 | } |
| 476 | } |
| 477 | |
| 478 | fn expect_failed_response(message: zbus::message::Message) -> io::Result<u32> { |
| 479 | let (response, results): (u32, HashMap<String, OwnedValue>) = message |
| 480 | .body() |
| 481 | .deserialize() |
| 482 | .map_err(|error| io::Error::other(format!("failed to decode method reply: {error}")))?; |
| 483 | if response != PORTAL_RESPONSE_FAILED { |
| 484 | return Err(io::Error::other(format!( |
| 485 | "unexpected portal response code: {response}" |
| 486 | ))); |
| 487 | } |
| 488 | if !results.is_empty() { |
| 489 | return Err(io::Error::other( |
| 490 | "expected empty results map for placeholder implementation".to_string(), |
| 491 | )); |
| 492 | } |
| 493 | Ok(response) |
| 494 | } |
| 495 | |
| 496 | fn send_request(request: ControlRequest) -> io::Result<ControlResponse> { |
| 497 | let socket_path = control_socket_path(); |
| 498 | let mut stream = UnixStream::connect(&socket_path)?; |
| 499 | let line = request.as_line(); |
| 500 | stream.write_all(line.as_bytes())?; |
| 501 | stream.write_all(b"\n")?; |
| 502 | stream.flush()?; |
| 503 | |
| 504 | let mut reader = BufReader::new(stream); |
| 505 | let mut line = String::new(); |
| 506 | reader.read_line(&mut line)?; |
| 507 | ControlResponse::parse_line(&line) |
| 508 | .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) |
| 509 | } |
| 510 | |
| 511 | fn control_socket_path() -> PathBuf { |
| 512 | runtime_dir().join(DEFAULT_CONTROL_SOCKET) |
| 513 | } |
| 514 | |
| 515 | fn runtime_dir() -> PathBuf { |
| 516 | let base = env::var_os("XDG_RUNTIME_DIR") |
| 517 | .map(PathBuf::from) |
| 518 | .unwrap_or_else(env::temp_dir); |
| 519 | base.join(DEFAULT_RUNTIME_SUBDIR) |
| 520 | } |
| 521 | |
| 522 | fn print_help() { |
| 523 | println!("garwarpctl <command>"); |
| 524 | println!("commands:"); |
| 525 | println!(" status (default)"); |
| 526 | println!(" stop"); |
| 527 | println!(" list"); |
| 528 | println!(" portal-smoke"); |
| 529 | println!(" inspect <id>"); |
| 530 | println!(" begin <id> <sender> [app_id|-] [parent_window|-]"); |
| 531 | println!(" transition <id> <sender> <awaiting_user|fulfilled|cancelled|failed> [app_id|-]"); |
| 532 | println!(" await|fulfill|cancel|fail <id> <sender> [app_id|-]"); |
| 533 | println!(" version"); |
| 534 | println!(" help"); |
| 535 | } |
| 536 | |
| 537 | #[cfg(test)] |
| 538 | mod tests { |
| 539 | use super::{ |
| 540 | Command, optional_value, parse_command, parse_transition_target, sender_to_handle_segment, |
| 541 | }; |
| 542 | use garwarp_ipc::RequestTransitionTarget; |
| 543 | |
| 544 | #[test] |
| 545 | fn status_is_default_command() { |
| 546 | assert_eq!( |
| 547 | parse_command(&[]).expect("status should be default"), |
| 548 | Command::Status |
| 549 | ); |
| 550 | } |
| 551 | |
| 552 | #[test] |
| 553 | fn parse_begin_command_with_parent_window() { |
| 554 | let args = vec![ |
| 555 | "begin".to_string(), |
| 556 | "req-1".to_string(), |
| 557 | ":1.2".to_string(), |
| 558 | "org.test.App".to_string(), |
| 559 | "x11:0x2a".to_string(), |
| 560 | ]; |
| 561 | let command = parse_command(&args).expect("begin command should parse"); |
| 562 | assert_eq!( |
| 563 | command, |
| 564 | Command::Begin { |
| 565 | id: "req-1".to_string(), |
| 566 | sender: ":1.2".to_string(), |
| 567 | app_id: Some("org.test.App".to_string()), |
| 568 | parent_window: Some("x11:0x2a".to_string()), |
| 569 | } |
| 570 | ); |
| 571 | } |
| 572 | |
| 573 | #[test] |
| 574 | fn parse_transition_command() { |
| 575 | let args = vec![ |
| 576 | "transition".to_string(), |
| 577 | "req-1".to_string(), |
| 578 | ":1.2".to_string(), |
| 579 | "cancelled".to_string(), |
| 580 | ]; |
| 581 | let command = parse_command(&args).expect("transition command should parse"); |
| 582 | assert_eq!( |
| 583 | command, |
| 584 | Command::Transition { |
| 585 | id: "req-1".to_string(), |
| 586 | sender: ":1.2".to_string(), |
| 587 | app_id: None, |
| 588 | target: RequestTransitionTarget::Cancelled, |
| 589 | } |
| 590 | ); |
| 591 | } |
| 592 | |
| 593 | #[test] |
| 594 | fn parse_inspect_command() { |
| 595 | let args = vec!["inspect".to_string(), "req-1".to_string()]; |
| 596 | let command = parse_command(&args).expect("inspect command should parse"); |
| 597 | assert_eq!( |
| 598 | command, |
| 599 | Command::Inspect { |
| 600 | id: "req-1".to_string() |
| 601 | } |
| 602 | ); |
| 603 | } |
| 604 | |
| 605 | #[test] |
| 606 | fn parse_list_command() { |
| 607 | let args = vec!["list".to_string()]; |
| 608 | let command = parse_command(&args).expect("list command should parse"); |
| 609 | assert_eq!(command, Command::List); |
| 610 | } |
| 611 | |
| 612 | #[test] |
| 613 | fn parse_portal_smoke_command() { |
| 614 | let args = vec!["portal-smoke".to_string()]; |
| 615 | let command = parse_command(&args).expect("portal-smoke command should parse"); |
| 616 | assert_eq!(command, Command::PortalSmoke); |
| 617 | } |
| 618 | |
| 619 | #[test] |
| 620 | fn parse_transition_target_rejects_unknown_state() { |
| 621 | let parsed = parse_transition_target("bogus"); |
| 622 | assert!(parsed.is_err()); |
| 623 | } |
| 624 | |
| 625 | #[test] |
| 626 | fn optional_value_uses_dash_as_none() { |
| 627 | assert_eq!(optional_value("-"), None); |
| 628 | assert_eq!( |
| 629 | optional_value("org.test.App"), |
| 630 | Some("org.test.App".to_string()) |
| 631 | ); |
| 632 | } |
| 633 | |
| 634 | #[test] |
| 635 | fn sender_segment_is_derived_from_unique_name() { |
| 636 | let segment = sender_to_handle_segment(":1.42").expect("segment should parse"); |
| 637 | assert_eq!(segment, "1_42"); |
| 638 | } |
| 639 | } |
| 640 |