pack_day_host_handoff.rs (21519B)
1 use std::io; 2 use std::path::{Component, Path, PathBuf}; 3 #[cfg(target_os = "macos")] 4 use std::process::Command; 5 6 use radroots_app_view::{PackDayExportArtifactKind, PackDayExportBundle, PackDayHostHandoffKind}; 7 use thiserror::Error; 8 9 #[derive(Clone, Debug, Eq, PartialEq)] 10 pub struct PackDayHostHandoffCommandPlan { 11 pub kind: PackDayHostHandoffKind, 12 pub target_path: PathBuf, 13 pub command_program: &'static str, 14 pub command_args: Vec<String>, 15 } 16 17 #[derive(Clone, Debug, Eq, PartialEq)] 18 struct PackDayHostHandoffCommandResult { 19 success: bool, 20 exit_code: Option<i32>, 21 stderr: String, 22 } 23 24 impl PackDayHostHandoffCommandResult { 25 #[cfg(test)] 26 fn succeeded() -> Self { 27 Self { 28 success: true, 29 exit_code: Some(0), 30 stderr: String::new(), 31 } 32 } 33 34 #[cfg(test)] 35 fn failed(exit_code: Option<i32>, stderr: impl Into<String>) -> Self { 36 Self { 37 success: false, 38 exit_code, 39 stderr: stderr.into(), 40 } 41 } 42 } 43 44 #[derive(Debug, Error)] 45 pub enum PackDayHostHandoffError { 46 #[error("pack day export bundle directory does not exist: {path}")] 47 MissingBundleDirectory { path: PathBuf }, 48 #[error("pack day export bundle is missing required artifact {artifact_kind:?} for {kind:?}")] 49 MissingArtifactReference { 50 kind: PackDayHostHandoffKind, 51 artifact_kind: PackDayExportArtifactKind, 52 }, 53 #[error("pack day export artifact path is invalid for {kind:?}: {relative_path}")] 54 InvalidArtifactRelativePath { 55 kind: PackDayHostHandoffKind, 56 relative_path: String, 57 }, 58 #[error("pack day host handoff target does not exist for {kind:?}: {path}")] 59 MissingTargetPath { 60 kind: PackDayHostHandoffKind, 61 path: PathBuf, 62 }, 63 #[error("pack day host handoff target must be a file for {kind:?}: {path}")] 64 InvalidTargetFile { 65 kind: PackDayHostHandoffKind, 66 path: PathBuf, 67 }, 68 #[error("pack day host handoff is only supported on macos")] 69 UnsupportedPlatform, 70 #[error("failed to launch macos host command {program} for {kind:?}: {source}")] 71 CommandLaunch { 72 kind: PackDayHostHandoffKind, 73 program: String, 74 source: io::Error, 75 }, 76 #[error("macos host command {program} for {kind:?} exited with code {exit_code:?}: {stderr}")] 77 CommandFailed { 78 kind: PackDayHostHandoffKind, 79 program: String, 80 exit_code: Option<i32>, 81 stderr: String, 82 }, 83 } 84 85 impl PartialEq for PackDayHostHandoffError { 86 fn eq(&self, other: &Self) -> bool { 87 match (self, other) { 88 ( 89 Self::MissingBundleDirectory { path: left }, 90 Self::MissingBundleDirectory { path: right }, 91 ) => left == right, 92 ( 93 Self::MissingArtifactReference { 94 kind: left_kind, 95 artifact_kind: left_artifact, 96 }, 97 Self::MissingArtifactReference { 98 kind: right_kind, 99 artifact_kind: right_artifact, 100 }, 101 ) => left_kind == right_kind && left_artifact == right_artifact, 102 ( 103 Self::InvalidArtifactRelativePath { 104 kind: left_kind, 105 relative_path: left_path, 106 }, 107 Self::InvalidArtifactRelativePath { 108 kind: right_kind, 109 relative_path: right_path, 110 }, 111 ) => left_kind == right_kind && left_path == right_path, 112 ( 113 Self::MissingTargetPath { 114 kind: left_kind, 115 path: left_path, 116 }, 117 Self::MissingTargetPath { 118 kind: right_kind, 119 path: right_path, 120 }, 121 ) => left_kind == right_kind && left_path == right_path, 122 ( 123 Self::InvalidTargetFile { 124 kind: left_kind, 125 path: left_path, 126 }, 127 Self::InvalidTargetFile { 128 kind: right_kind, 129 path: right_path, 130 }, 131 ) => left_kind == right_kind && left_path == right_path, 132 (Self::UnsupportedPlatform, Self::UnsupportedPlatform) => true, 133 ( 134 Self::CommandLaunch { 135 kind: left_kind, 136 program: left_program, 137 source: left_source, 138 }, 139 Self::CommandLaunch { 140 kind: right_kind, 141 program: right_program, 142 source: right_source, 143 }, 144 ) => { 145 left_kind == right_kind 146 && left_program == right_program 147 && left_source.kind() == right_source.kind() 148 && left_source.to_string() == right_source.to_string() 149 } 150 ( 151 Self::CommandFailed { 152 kind: left_kind, 153 program: left_program, 154 exit_code: left_code, 155 stderr: left_stderr, 156 }, 157 Self::CommandFailed { 158 kind: right_kind, 159 program: right_program, 160 exit_code: right_code, 161 stderr: right_stderr, 162 }, 163 ) => { 164 left_kind == right_kind 165 && left_program == right_program 166 && left_code == right_code 167 && left_stderr == right_stderr 168 } 169 _ => false, 170 } 171 } 172 } 173 174 impl Eq for PackDayHostHandoffError {} 175 176 pub fn plan_pack_day_host_handoff( 177 bundle: &PackDayExportBundle, 178 kind: PackDayHostHandoffKind, 179 ) -> Result<PackDayHostHandoffCommandPlan, PackDayHostHandoffError> { 180 let bundle_directory = PathBuf::from(&bundle.bundle_directory); 181 if !bundle_directory.is_dir() { 182 return Err(PackDayHostHandoffError::MissingBundleDirectory { 183 path: bundle_directory, 184 }); 185 } 186 187 let target_path = match kind.artifact_kind() { 188 None => bundle_directory.clone(), 189 Some(artifact_kind) => resolve_bundle_artifact_path(bundle, artifact_kind, kind)?, 190 }; 191 192 let command_args = match kind { 193 PackDayHostHandoffKind::RevealBundle => { 194 vec!["-R".to_owned(), target_path.to_string_lossy().into_owned()] 195 } 196 PackDayHostHandoffKind::OpenPackSheet 197 | PackDayHostHandoffKind::OpenPickupRoster 198 | PackDayHostHandoffKind::OpenCustomerLabels => { 199 vec![target_path.to_string_lossy().into_owned()] 200 } 201 }; 202 203 Ok(PackDayHostHandoffCommandPlan { 204 kind, 205 target_path, 206 command_program: "open", 207 command_args, 208 }) 209 } 210 211 pub fn execute_pack_day_host_handoff_plan( 212 plan: &PackDayHostHandoffCommandPlan, 213 ) -> Result<(), PackDayHostHandoffError> { 214 #[cfg(target_os = "macos")] 215 { 216 execute_pack_day_host_handoff_plan_with(plan, run_macos_host_command) 217 } 218 219 #[cfg(not(target_os = "macos"))] 220 { 221 let _ = plan; 222 Err(PackDayHostHandoffError::UnsupportedPlatform) 223 } 224 } 225 226 fn resolve_bundle_artifact_path( 227 bundle: &PackDayExportBundle, 228 artifact_kind: PackDayExportArtifactKind, 229 kind: PackDayHostHandoffKind, 230 ) -> Result<PathBuf, PackDayHostHandoffError> { 231 let Some(artifact) = bundle 232 .artifacts 233 .iter() 234 .find(|artifact| artifact.kind == artifact_kind) 235 else { 236 return Err(PackDayHostHandoffError::MissingArtifactReference { 237 kind, 238 artifact_kind, 239 }); 240 }; 241 242 let relative_path = Path::new(&artifact.relative_path); 243 if relative_path.is_absolute() 244 || relative_path.components().any(|component| { 245 matches!( 246 component, 247 Component::ParentDir | Component::RootDir | Component::Prefix(_) 248 ) 249 }) 250 { 251 return Err(PackDayHostHandoffError::InvalidArtifactRelativePath { 252 kind, 253 relative_path: artifact.relative_path.clone(), 254 }); 255 } 256 257 let path = PathBuf::from(&bundle.bundle_directory).join(relative_path); 258 if !path.exists() { 259 return Err(PackDayHostHandoffError::MissingTargetPath { kind, path }); 260 } 261 if !path.is_file() { 262 return Err(PackDayHostHandoffError::InvalidTargetFile { kind, path }); 263 } 264 265 Ok(path) 266 } 267 268 fn execute_pack_day_host_handoff_plan_with( 269 plan: &PackDayHostHandoffCommandPlan, 270 run_command: impl FnOnce( 271 &PackDayHostHandoffCommandPlan, 272 ) -> Result<PackDayHostHandoffCommandResult, io::Error>, 273 ) -> Result<(), PackDayHostHandoffError> { 274 let result = run_command(plan).map_err(|source| PackDayHostHandoffError::CommandLaunch { 275 kind: plan.kind, 276 program: plan.command_program.to_owned(), 277 source, 278 })?; 279 280 if result.success { 281 return Ok(()); 282 } 283 284 Err(PackDayHostHandoffError::CommandFailed { 285 kind: plan.kind, 286 program: plan.command_program.to_owned(), 287 exit_code: result.exit_code, 288 stderr: result.stderr, 289 }) 290 } 291 292 #[cfg(target_os = "macos")] 293 fn run_macos_host_command( 294 plan: &PackDayHostHandoffCommandPlan, 295 ) -> Result<PackDayHostHandoffCommandResult, io::Error> { 296 let output = Command::new(plan.command_program) 297 .args(&plan.command_args) 298 .output()?; 299 300 Ok(PackDayHostHandoffCommandResult { 301 success: output.status.success(), 302 exit_code: output.status.code(), 303 stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(), 304 }) 305 } 306 307 #[cfg(test)] 308 mod tests { 309 use super::{ 310 PackDayHostHandoffCommandResult, PackDayHostHandoffError, 311 execute_pack_day_host_handoff_plan_with, plan_pack_day_host_handoff, 312 }; 313 use radroots_app_view::{ 314 PackDayExportArtifact, PackDayExportArtifactKind, PackDayExportBundle, 315 PackDayHostHandoffKind, 316 }; 317 use std::fs; 318 use std::io; 319 use std::path::PathBuf; 320 use uuid::Uuid; 321 322 struct TestDirectory { 323 path: PathBuf, 324 } 325 326 impl TestDirectory { 327 fn new() -> Self { 328 let path = std::env::temp_dir().join(format!( 329 "radroots_app_pack_day_host_handoff_{}", 330 Uuid::new_v4() 331 )); 332 fs::create_dir_all(&path).expect("test directory should create"); 333 Self { path } 334 } 335 336 fn path(&self) -> &PathBuf { 337 &self.path 338 } 339 } 340 341 impl Drop for TestDirectory { 342 fn drop(&mut self) { 343 let _ = fs::remove_dir_all(&self.path); 344 } 345 } 346 347 fn sample_bundle(bundle_directory: &PathBuf) -> PackDayExportBundle { 348 PackDayExportBundle { 349 fulfillment_window_id: radroots_app_view::FulfillmentWindowId::new(), 350 export_instance_id: radroots_app_view::PackDayExportInstanceId::new(), 351 generated_at_utc: "2026-04-23T15:00:00Z".to_owned(), 352 bundle_directory: bundle_directory.to_string_lossy().into_owned(), 353 artifacts: vec![ 354 PackDayExportArtifact { 355 kind: PackDayExportArtifactKind::PackSheet, 356 relative_path: "pack_sheet.txt".to_owned(), 357 }, 358 PackDayExportArtifact { 359 kind: PackDayExportArtifactKind::PickupRoster, 360 relative_path: "pickup_roster.txt".to_owned(), 361 }, 362 PackDayExportArtifact { 363 kind: PackDayExportArtifactKind::CustomerLabels, 364 relative_path: "customer_labels.txt".to_owned(), 365 }, 366 ], 367 } 368 } 369 370 fn write_artifact(bundle_directory: &PathBuf, file_name: &str) -> PathBuf { 371 let path = bundle_directory.join(file_name); 372 fs::write(&path, file_name).expect("artifact should write"); 373 path 374 } 375 376 #[test] 377 fn reveal_bundle_plan_uses_open_reveal_for_the_bundle_directory() { 378 let temp_dir = TestDirectory::new(); 379 let bundle = sample_bundle(temp_dir.path()); 380 381 let plan = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::RevealBundle) 382 .expect("reveal plan should build"); 383 384 assert_eq!(plan.kind, PackDayHostHandoffKind::RevealBundle); 385 assert_eq!(plan.target_path, temp_dir.path().clone()); 386 assert_eq!(plan.command_program, "open"); 387 assert_eq!( 388 plan.command_args, 389 vec![ 390 "-R".to_owned(), 391 temp_dir.path().to_string_lossy().into_owned(), 392 ] 393 ); 394 } 395 396 #[test] 397 fn open_pack_sheet_plan_targets_the_exported_pack_sheet() { 398 let temp_dir = TestDirectory::new(); 399 let pack_sheet_path = write_artifact(temp_dir.path(), "pack_sheet.txt"); 400 let bundle = sample_bundle(temp_dir.path()); 401 402 let plan = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::OpenPackSheet) 403 .expect("open plan should build"); 404 405 assert_eq!(plan.kind, PackDayHostHandoffKind::OpenPackSheet); 406 assert_eq!(plan.target_path, pack_sheet_path.clone()); 407 assert_eq!(plan.command_program, "open"); 408 assert_eq!( 409 plan.command_args, 410 vec![pack_sheet_path.to_string_lossy().into_owned()] 411 ); 412 } 413 414 #[test] 415 fn open_pickup_roster_plan_targets_the_exported_pickup_roster() { 416 let temp_dir = TestDirectory::new(); 417 let pickup_roster_path = write_artifact(temp_dir.path(), "pickup_roster.txt"); 418 let bundle = sample_bundle(temp_dir.path()); 419 420 let plan = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::OpenPickupRoster) 421 .expect("open pickup roster plan should build"); 422 423 assert_eq!(plan.kind, PackDayHostHandoffKind::OpenPickupRoster); 424 assert_eq!(plan.target_path, pickup_roster_path.clone()); 425 assert_eq!(plan.command_program, "open"); 426 assert_eq!( 427 plan.command_args, 428 vec![pickup_roster_path.to_string_lossy().into_owned()] 429 ); 430 } 431 432 #[test] 433 fn open_customer_labels_plan_targets_the_exported_customer_labels() { 434 let temp_dir = TestDirectory::new(); 435 let customer_labels_path = write_artifact(temp_dir.path(), "customer_labels.txt"); 436 let bundle = sample_bundle(temp_dir.path()); 437 438 let plan = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::OpenCustomerLabels) 439 .expect("open customer labels plan should build"); 440 441 assert_eq!(plan.kind, PackDayHostHandoffKind::OpenCustomerLabels); 442 assert_eq!(plan.target_path, customer_labels_path.clone()); 443 assert_eq!(plan.command_program, "open"); 444 assert_eq!( 445 plan.command_args, 446 vec![customer_labels_path.to_string_lossy().into_owned()] 447 ); 448 } 449 450 #[test] 451 fn planning_fails_when_the_bundle_directory_is_missing() { 452 let bundle_directory = std::env::temp_dir().join(format!( 453 "radroots_app_pack_day_host_handoff_missing_{}", 454 Uuid::new_v4() 455 )); 456 let bundle = sample_bundle(&bundle_directory); 457 458 let error = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::RevealBundle) 459 .expect_err("missing bundle directory should fail"); 460 461 assert_eq!( 462 error, 463 PackDayHostHandoffError::MissingBundleDirectory { 464 path: bundle_directory, 465 } 466 ); 467 } 468 469 #[test] 470 fn planning_fails_when_pack_sheet_reference_is_missing() { 471 let temp_dir = TestDirectory::new(); 472 let mut bundle = sample_bundle(temp_dir.path()); 473 bundle 474 .artifacts 475 .retain(|artifact| artifact.kind != PackDayExportArtifactKind::PackSheet); 476 477 let error = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::OpenPackSheet) 478 .expect_err("missing pack sheet artifact should fail"); 479 480 assert_eq!( 481 error, 482 PackDayHostHandoffError::MissingArtifactReference { 483 kind: PackDayHostHandoffKind::OpenPackSheet, 484 artifact_kind: PackDayExportArtifactKind::PackSheet, 485 } 486 ); 487 } 488 489 #[test] 490 fn planning_fails_when_pickup_roster_reference_is_missing() { 491 let temp_dir = TestDirectory::new(); 492 let mut bundle = sample_bundle(temp_dir.path()); 493 bundle 494 .artifacts 495 .retain(|artifact| artifact.kind != PackDayExportArtifactKind::PickupRoster); 496 497 let error = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::OpenPickupRoster) 498 .expect_err("missing pickup roster artifact should fail"); 499 500 assert_eq!( 501 error, 502 PackDayHostHandoffError::MissingArtifactReference { 503 kind: PackDayHostHandoffKind::OpenPickupRoster, 504 artifact_kind: PackDayExportArtifactKind::PickupRoster, 505 } 506 ); 507 } 508 509 #[test] 510 fn planning_fails_when_pack_sheet_relative_path_is_invalid() { 511 let temp_dir = TestDirectory::new(); 512 let mut bundle = sample_bundle(temp_dir.path()); 513 bundle.artifacts[0].relative_path = "../pack_sheet.txt".to_owned(); 514 515 let error = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::OpenPackSheet) 516 .expect_err("invalid relative path should fail"); 517 518 assert_eq!( 519 error, 520 PackDayHostHandoffError::InvalidArtifactRelativePath { 521 kind: PackDayHostHandoffKind::OpenPackSheet, 522 relative_path: "../pack_sheet.txt".to_owned(), 523 } 524 ); 525 } 526 527 #[test] 528 fn planning_fails_when_customer_labels_target_is_missing_on_disk() { 529 let temp_dir = TestDirectory::new(); 530 let bundle = sample_bundle(temp_dir.path()); 531 532 let error = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::OpenCustomerLabels) 533 .expect_err("missing customer labels file should fail"); 534 535 assert_eq!( 536 error, 537 PackDayHostHandoffError::MissingTargetPath { 538 kind: PackDayHostHandoffKind::OpenCustomerLabels, 539 path: temp_dir.path().join("customer_labels.txt"), 540 } 541 ); 542 } 543 544 #[test] 545 fn execution_classifies_command_launch_failures() { 546 let temp_dir = TestDirectory::new(); 547 let bundle = sample_bundle(temp_dir.path()); 548 let plan = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::RevealBundle) 549 .expect("reveal plan should build"); 550 551 let error = execute_pack_day_host_handoff_plan_with(&plan, |_| { 552 Err(io::Error::new(io::ErrorKind::NotFound, "open missing")) 553 }) 554 .expect_err("launch failure should classify"); 555 556 assert!(matches!( 557 error, 558 PackDayHostHandoffError::CommandLaunch { 559 kind: PackDayHostHandoffKind::RevealBundle, 560 .. 561 } 562 )); 563 } 564 565 #[test] 566 fn execution_classifies_nonzero_exit_failures() { 567 let temp_dir = TestDirectory::new(); 568 let bundle = sample_bundle(temp_dir.path()); 569 let plan = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::RevealBundle) 570 .expect("reveal plan should build"); 571 572 let error = execute_pack_day_host_handoff_plan_with(&plan, |_| { 573 Ok(PackDayHostHandoffCommandResult::failed( 574 Some(1), 575 "finder unavailable", 576 )) 577 }) 578 .expect_err("nonzero exit should classify"); 579 580 assert_eq!( 581 error, 582 PackDayHostHandoffError::CommandFailed { 583 kind: PackDayHostHandoffKind::RevealBundle, 584 program: "open".to_owned(), 585 exit_code: Some(1), 586 stderr: "finder unavailable".to_owned(), 587 } 588 ); 589 } 590 591 #[test] 592 fn execution_classifies_nonzero_exit_failures_for_customer_labels() { 593 let temp_dir = TestDirectory::new(); 594 write_artifact(temp_dir.path(), "customer_labels.txt"); 595 let bundle = sample_bundle(temp_dir.path()); 596 let plan = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::OpenCustomerLabels) 597 .expect("customer labels plan should build"); 598 599 let error = execute_pack_day_host_handoff_plan_with(&plan, |_| { 600 Ok(PackDayHostHandoffCommandResult::failed( 601 Some(1), 602 "labels unavailable", 603 )) 604 }) 605 .expect_err("nonzero exit should classify"); 606 607 assert_eq!( 608 error, 609 PackDayHostHandoffError::CommandFailed { 610 kind: PackDayHostHandoffKind::OpenCustomerLabels, 611 program: "open".to_owned(), 612 exit_code: Some(1), 613 stderr: "labels unavailable".to_owned(), 614 } 615 ); 616 } 617 618 #[test] 619 fn execution_accepts_successful_runs() { 620 let temp_dir = TestDirectory::new(); 621 let bundle = sample_bundle(temp_dir.path()); 622 let plan = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::RevealBundle) 623 .expect("reveal plan should build"); 624 625 let result = execute_pack_day_host_handoff_plan_with(&plan, |_| { 626 Ok(PackDayHostHandoffCommandResult::succeeded()) 627 }); 628 629 assert_eq!(result, Ok(())); 630 } 631 }