pack_day_print.rs (61497B)
1 use std::fmt::Write as _; 2 use std::fs; 3 use std::io; 4 use std::path::{Component, Path, PathBuf}; 5 #[cfg(target_os = "macos")] 6 use std::process::Command; 7 8 use radroots_app_state::PackDayBatchPrintRequest; 9 use radroots_app_view::{ 10 PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, PackDayExportArtifactKind, 11 PackDayExportBundle, PackDayExportInstanceId, PackDayPrintFailureKind, PackDayPrintKind, 12 PackDayPrintLabelStock, 13 }; 14 use thiserror::Error; 15 16 const CUSTOMER_LABEL_PREPARED_ASSET_ROOT: &str = "radroots_app_pack_day_print"; 17 const LETTER_MEDIA_OPTION: &str = "media=Letter"; 18 const LETTER_PAGE_WIDTH_POINTS: u16 = 612; 19 const LETTER_PAGE_HEIGHT_POINTS: u16 = 792; 20 const AVERY_5160_LABELS_PER_ROW: usize = 3; 21 const AVERY_5160_LABEL_ROWS_PER_PAGE: usize = 10; 22 const AVERY_5160_LABELS_PER_PAGE: usize = 23 AVERY_5160_LABELS_PER_ROW * AVERY_5160_LABEL_ROWS_PER_PAGE; 24 const AVERY_5160_COLUMN_PITCH_POINTS: f32 = 198.0; 25 const AVERY_5160_ROW_PITCH_POINTS: f32 = 72.0; 26 const AVERY_5160_LEFT_MARGIN_POINTS: f32 = 13.5; 27 const AVERY_5160_TOP_MARGIN_POINTS: f32 = 36.0; 28 const AVERY_5160_PAGE_HEIGHT_POINTS: f32 = 792.0; 29 const AVERY_5160_TEXT_LEFT_PADDING_POINTS: f32 = 9.0; 30 const AVERY_5160_TEXT_TOP_PADDING_POINTS: f32 = 11.0; 31 const AVERY_5160_TEXT_LEADING_POINTS: f32 = 10.0; 32 const AVERY_5160_TEXT_FONT_SIZE_POINTS: f32 = 9.0; 33 const AVERY_5160_MAX_CHARS_PER_LINE: usize = 32; 34 const AVERY_5160_MAX_LINES_PER_LABEL: usize = 6; 35 36 #[derive(Clone, Debug, Eq, PartialEq)] 37 pub struct PackDayPrintCommandPlan { 38 pub kind: PackDayPrintKind, 39 pub target_path: PathBuf, 40 pub command_program: &'static str, 41 pub command_args: Vec<String>, 42 } 43 44 #[derive(Clone, Debug, Eq, PartialEq)] 45 pub struct PackDayBatchPrintCommandPlan { 46 pub export_instance_id: PackDayExportInstanceId, 47 pub plans: Vec<PackDayPrintCommandPlan>, 48 } 49 50 #[derive(Clone, Debug, Eq, PartialEq)] 51 pub(crate) struct PackDayPrintCommandResult { 52 success: bool, 53 exit_code: Option<i32>, 54 stderr: String, 55 } 56 57 impl PackDayPrintCommandResult { 58 #[cfg(test)] 59 pub(crate) fn succeeded() -> Self { 60 Self { 61 success: true, 62 exit_code: Some(0), 63 stderr: String::new(), 64 } 65 } 66 67 #[cfg(test)] 68 pub(crate) fn failed(exit_code: Option<i32>, stderr: impl Into<String>) -> Self { 69 Self { 70 success: false, 71 exit_code, 72 stderr: stderr.into(), 73 } 74 } 75 } 76 77 #[derive(Debug, Error)] 78 pub enum PackDayPrintError { 79 #[error("pack day export bundle directory does not exist: {path}")] 80 MissingBundleDirectory { path: PathBuf }, 81 #[error("pack day export bundle is missing required artifact {artifact_kind:?} for {kind:?}")] 82 MissingArtifactReference { 83 kind: PackDayPrintKind, 84 artifact_kind: PackDayExportArtifactKind, 85 }, 86 #[error("pack day export artifact path is invalid for {kind:?}: {relative_path}")] 87 InvalidArtifactRelativePath { 88 kind: PackDayPrintKind, 89 relative_path: String, 90 }, 91 #[error("pack day print target does not exist for {kind:?}: {path}")] 92 MissingTargetPath { 93 kind: PackDayPrintKind, 94 path: PathBuf, 95 }, 96 #[error("pack day print target must be a file for {kind:?}: {path}")] 97 InvalidTargetFile { 98 kind: PackDayPrintKind, 99 path: PathBuf, 100 }, 101 #[error("failed to read pack day print source artifact {path} for {kind:?}: {source}")] 102 ReadSourceArtifact { 103 kind: PackDayPrintKind, 104 path: PathBuf, 105 source: io::Error, 106 }, 107 #[error("failed to create prepared print asset directory {path} for {kind:?}: {source}")] 108 CreatePreparedAssetDirectory { 109 kind: PackDayPrintKind, 110 path: PathBuf, 111 source: io::Error, 112 }, 113 #[error("failed to write prepared print asset {path} for {kind:?}: {source}")] 114 WritePreparedAsset { 115 kind: PackDayPrintKind, 116 path: PathBuf, 117 source: io::Error, 118 }, 119 #[error("customer label content exceeds the six-line Avery 5160 layout")] 120 CustomerLabelsAvery5160Overflow, 121 #[error("pack day print is only supported on macos")] 122 UnsupportedPlatform, 123 #[error("failed to launch macos print command {program} for {kind:?}: {source}")] 124 CommandLaunch { 125 kind: PackDayPrintKind, 126 program: String, 127 source: io::Error, 128 }, 129 #[error("macos print command {program} for {kind:?} exited with code {exit_code:?}: {stderr}")] 130 CommandFailed { 131 kind: PackDayPrintKind, 132 program: String, 133 exit_code: Option<i32>, 134 stderr: String, 135 }, 136 } 137 138 impl PartialEq for PackDayPrintError { 139 fn eq(&self, other: &Self) -> bool { 140 match (self, other) { 141 ( 142 Self::MissingBundleDirectory { path: left }, 143 Self::MissingBundleDirectory { path: right }, 144 ) => left == right, 145 ( 146 Self::MissingArtifactReference { 147 kind: left_kind, 148 artifact_kind: left_artifact, 149 }, 150 Self::MissingArtifactReference { 151 kind: right_kind, 152 artifact_kind: right_artifact, 153 }, 154 ) => left_kind == right_kind && left_artifact == right_artifact, 155 ( 156 Self::InvalidArtifactRelativePath { 157 kind: left_kind, 158 relative_path: left_path, 159 }, 160 Self::InvalidArtifactRelativePath { 161 kind: right_kind, 162 relative_path: right_path, 163 }, 164 ) => left_kind == right_kind && left_path == right_path, 165 ( 166 Self::MissingTargetPath { 167 kind: left_kind, 168 path: left_path, 169 }, 170 Self::MissingTargetPath { 171 kind: right_kind, 172 path: right_path, 173 }, 174 ) => left_kind == right_kind && left_path == right_path, 175 ( 176 Self::InvalidTargetFile { 177 kind: left_kind, 178 path: left_path, 179 }, 180 Self::InvalidTargetFile { 181 kind: right_kind, 182 path: right_path, 183 }, 184 ) => left_kind == right_kind && left_path == right_path, 185 ( 186 Self::ReadSourceArtifact { 187 kind: left_kind, 188 path: left_path, 189 source: left_source, 190 }, 191 Self::ReadSourceArtifact { 192 kind: right_kind, 193 path: right_path, 194 source: right_source, 195 }, 196 ) => { 197 left_kind == right_kind 198 && left_path == right_path 199 && io_errors_match(left_source, right_source) 200 } 201 ( 202 Self::CreatePreparedAssetDirectory { 203 kind: left_kind, 204 path: left_path, 205 source: left_source, 206 }, 207 Self::CreatePreparedAssetDirectory { 208 kind: right_kind, 209 path: right_path, 210 source: right_source, 211 }, 212 ) => { 213 left_kind == right_kind 214 && left_path == right_path 215 && io_errors_match(left_source, right_source) 216 } 217 ( 218 Self::WritePreparedAsset { 219 kind: left_kind, 220 path: left_path, 221 source: left_source, 222 }, 223 Self::WritePreparedAsset { 224 kind: right_kind, 225 path: right_path, 226 source: right_source, 227 }, 228 ) => { 229 left_kind == right_kind 230 && left_path == right_path 231 && io_errors_match(left_source, right_source) 232 } 233 (Self::CustomerLabelsAvery5160Overflow, Self::CustomerLabelsAvery5160Overflow) => true, 234 (Self::UnsupportedPlatform, Self::UnsupportedPlatform) => true, 235 ( 236 Self::CommandLaunch { 237 kind: left_kind, 238 program: left_program, 239 source: left_source, 240 }, 241 Self::CommandLaunch { 242 kind: right_kind, 243 program: right_program, 244 source: right_source, 245 }, 246 ) => { 247 left_kind == right_kind 248 && left_program == right_program 249 && left_source.kind() == right_source.kind() 250 && left_source.to_string() == right_source.to_string() 251 } 252 ( 253 Self::CommandFailed { 254 kind: left_kind, 255 program: left_program, 256 exit_code: left_code, 257 stderr: left_stderr, 258 }, 259 Self::CommandFailed { 260 kind: right_kind, 261 program: right_program, 262 exit_code: right_code, 263 stderr: right_stderr, 264 }, 265 ) => { 266 left_kind == right_kind 267 && left_program == right_program 268 && left_code == right_code 269 && left_stderr == right_stderr 270 } 271 _ => false, 272 } 273 } 274 } 275 276 impl Eq for PackDayPrintError {} 277 278 impl PackDayPrintError { 279 pub(crate) const fn failure_kind(&self) -> Option<PackDayPrintFailureKind> { 280 match self { 281 Self::CustomerLabelsAvery5160Overflow => { 282 Some(PackDayPrintFailureKind::CustomerLabelsAvery5160Overflow) 283 } 284 _ => None, 285 } 286 } 287 } 288 289 #[derive(Debug, Error, Eq, PartialEq)] 290 pub enum PackDayBatchPrintError { 291 #[error("pack day batch print request does not match the v1 artifact contract")] 292 InvalidRequest { 293 artifacts: Vec<PackDayBatchPrintArtifact>, 294 }, 295 #[error("pack day batch print command plan is empty")] 296 EmptyPlan, 297 #[error("pack day batch print preflight failed for {failed_artifact:?}: {source}")] 298 Preflight { 299 failed_artifact: Option<PackDayBatchPrintArtifact>, 300 source: PackDayPrintError, 301 }, 302 #[error("pack day batch print queue launch failed for {failed_artifact:?}: {source}")] 303 QueueLaunch { 304 submitted_artifacts: Vec<PackDayBatchPrintArtifact>, 305 failed_artifact: PackDayBatchPrintArtifact, 306 source: PackDayPrintError, 307 }, 308 #[error("pack day batch print queue exit failed for {failed_artifact:?}: {source}")] 309 QueueExit { 310 submitted_artifacts: Vec<PackDayBatchPrintArtifact>, 311 failed_artifact: PackDayBatchPrintArtifact, 312 source: PackDayPrintError, 313 }, 314 } 315 316 impl PackDayBatchPrintError { 317 pub(crate) fn failed_artifact(&self) -> Option<PackDayBatchPrintArtifact> { 318 match self { 319 Self::InvalidRequest { .. } | Self::EmptyPlan => None, 320 Self::Preflight { 321 failed_artifact, .. 322 } => *failed_artifact, 323 Self::QueueLaunch { 324 failed_artifact, .. 325 } 326 | Self::QueueExit { 327 failed_artifact, .. 328 } => Some(*failed_artifact), 329 } 330 } 331 332 pub(crate) fn failure_kind(&self) -> PackDayBatchPrintFailureKind { 333 match self { 334 Self::InvalidRequest { .. } | Self::EmptyPlan => { 335 PackDayBatchPrintFailureKind::Preflight 336 } 337 Self::Preflight { 338 source: PackDayPrintError::CustomerLabelsAvery5160Overflow, 339 .. 340 } => PackDayBatchPrintFailureKind::CustomerLabelsAvery5160Overflow, 341 Self::Preflight { .. } => PackDayBatchPrintFailureKind::Preflight, 342 Self::QueueLaunch { .. } => PackDayBatchPrintFailureKind::QueueLaunch, 343 Self::QueueExit { .. } => PackDayBatchPrintFailureKind::QueueExit, 344 } 345 } 346 } 347 348 fn io_errors_match(left: &io::Error, right: &io::Error) -> bool { 349 left.kind() == right.kind() && left.to_string() == right.to_string() 350 } 351 352 pub(crate) fn cleanup_prepared_customer_label_asset_root() -> io::Result<()> { 353 cleanup_prepared_customer_label_assets_at_path(prepared_customer_label_asset_root()) 354 } 355 356 pub(crate) fn cleanup_prepared_customer_label_assets_for_export_instance( 357 export_instance_id: PackDayExportInstanceId, 358 ) -> io::Result<()> { 359 cleanup_prepared_customer_label_assets_at_path( 360 prepared_customer_label_asset_directory_for_export_instance(export_instance_id), 361 ) 362 } 363 364 pub fn plan_pack_day_print( 365 bundle: &PackDayExportBundle, 366 kind: PackDayPrintKind, 367 ) -> Result<PackDayPrintCommandPlan, PackDayPrintError> { 368 let bundle_directory = PathBuf::from(&bundle.bundle_directory); 369 if !bundle_directory.is_dir() { 370 return Err(PackDayPrintError::MissingBundleDirectory { 371 path: bundle_directory, 372 }); 373 } 374 375 let (target_path, command_args) = match kind { 376 PackDayPrintKind::PrintPackSheet => { 377 let target_path = 378 resolve_bundle_artifact_path(bundle, PackDayExportArtifactKind::PackSheet, kind)?; 379 let command_args = vec![target_path.to_string_lossy().into_owned()]; 380 (target_path, command_args) 381 } 382 PackDayPrintKind::PrintPickupRoster => { 383 let target_path = resolve_bundle_artifact_path( 384 bundle, 385 PackDayExportArtifactKind::PickupRoster, 386 kind, 387 )?; 388 let command_args = vec![target_path.to_string_lossy().into_owned()]; 389 (target_path, command_args) 390 } 391 PackDayPrintKind::PrintCustomerLabels => { 392 let target_path = prepare_customer_label_stock_asset( 393 bundle, 394 PackDayPrintLabelStock::Avery5160Letter30Up, 395 )?; 396 let command_args = vec![ 397 "-o".to_owned(), 398 LETTER_MEDIA_OPTION.to_owned(), 399 target_path.to_string_lossy().into_owned(), 400 ]; 401 (target_path, command_args) 402 } 403 }; 404 405 Ok(PackDayPrintCommandPlan { 406 kind, 407 target_path, 408 command_program: "lp", 409 command_args, 410 }) 411 } 412 413 pub fn plan_pack_day_batch_print( 414 bundle: &PackDayExportBundle, 415 request: &PackDayBatchPrintRequest, 416 ) -> Result<PackDayBatchPrintCommandPlan, PackDayBatchPrintError> { 417 validate_pack_day_batch_print_request(bundle, request)?; 418 let mut plans = Vec::with_capacity(request.artifacts.len()); 419 420 for artifact in request.artifacts.iter().copied() { 421 let plan = plan_pack_day_print(bundle, artifact.print_kind).map_err(|source| { 422 PackDayBatchPrintError::Preflight { 423 failed_artifact: batch_preflight_failed_artifact(&source, artifact), 424 source, 425 } 426 })?; 427 plans.push(plan); 428 } 429 430 Ok(PackDayBatchPrintCommandPlan { 431 export_instance_id: request.export_instance_id, 432 plans, 433 }) 434 } 435 436 fn validate_pack_day_batch_print_request( 437 bundle: &PackDayExportBundle, 438 request: &PackDayBatchPrintRequest, 439 ) -> Result<(), PackDayBatchPrintError> { 440 let expected_artifacts = PackDayBatchPrintArtifact::all_v1(); 441 if request.fulfillment_window_id == bundle.fulfillment_window_id 442 && request.export_instance_id == bundle.export_instance_id 443 && request.artifacts.as_slice() == expected_artifacts.as_slice() 444 { 445 Ok(()) 446 } else { 447 Err(PackDayBatchPrintError::InvalidRequest { 448 artifacts: request.artifacts.clone(), 449 }) 450 } 451 } 452 453 const fn batch_preflight_failed_artifact( 454 error: &PackDayPrintError, 455 artifact: PackDayBatchPrintArtifact, 456 ) -> Option<PackDayBatchPrintArtifact> { 457 match error { 458 PackDayPrintError::MissingBundleDirectory { .. } => None, 459 _ => Some(artifact), 460 } 461 } 462 463 pub fn execute_pack_day_print_plan( 464 plan: &PackDayPrintCommandPlan, 465 ) -> Result<(), PackDayPrintError> { 466 #[cfg(target_os = "macos")] 467 { 468 execute_pack_day_print_plan_with(plan, run_macos_print_command) 469 } 470 471 #[cfg(not(target_os = "macos"))] 472 { 473 let _ = plan; 474 Err(PackDayPrintError::UnsupportedPlatform) 475 } 476 } 477 478 pub fn execute_pack_day_batch_print_plan( 479 plan: &PackDayBatchPrintCommandPlan, 480 ) -> Result<(), PackDayBatchPrintError> { 481 #[cfg(target_os = "macos")] 482 { 483 execute_pack_day_batch_print_plan_with(plan, run_macos_print_command) 484 } 485 486 #[cfg(not(target_os = "macos"))] 487 { 488 let Some(first_plan) = plan.plans.first() else { 489 return Err(PackDayBatchPrintError::EmptyPlan); 490 }; 491 Err(PackDayBatchPrintError::QueueLaunch { 492 submitted_artifacts: Vec::new(), 493 failed_artifact: PackDayBatchPrintArtifact::from_print_kind(first_plan.kind), 494 source: PackDayPrintError::UnsupportedPlatform, 495 }) 496 } 497 } 498 499 fn resolve_bundle_artifact_path( 500 bundle: &PackDayExportBundle, 501 artifact_kind: PackDayExportArtifactKind, 502 kind: PackDayPrintKind, 503 ) -> Result<PathBuf, PackDayPrintError> { 504 let Some(artifact) = bundle 505 .artifacts 506 .iter() 507 .find(|artifact| artifact.kind == artifact_kind) 508 else { 509 return Err(PackDayPrintError::MissingArtifactReference { 510 kind, 511 artifact_kind, 512 }); 513 }; 514 515 let relative_path = Path::new(&artifact.relative_path); 516 if relative_path.is_absolute() 517 || relative_path.components().any(|component| { 518 matches!( 519 component, 520 Component::ParentDir | Component::RootDir | Component::Prefix(_) 521 ) 522 }) 523 { 524 return Err(PackDayPrintError::InvalidArtifactRelativePath { 525 kind, 526 relative_path: artifact.relative_path.clone(), 527 }); 528 } 529 530 let path = PathBuf::from(&bundle.bundle_directory).join(relative_path); 531 if !path.exists() { 532 return Err(PackDayPrintError::MissingTargetPath { kind, path }); 533 } 534 if !path.is_file() { 535 return Err(PackDayPrintError::InvalidTargetFile { kind, path }); 536 } 537 538 Ok(path) 539 } 540 541 fn prepare_customer_label_stock_asset( 542 bundle: &PackDayExportBundle, 543 stock: PackDayPrintLabelStock, 544 ) -> Result<PathBuf, PackDayPrintError> { 545 let kind = PackDayPrintKind::PrintCustomerLabels; 546 let source_path = 547 resolve_bundle_artifact_path(bundle, PackDayExportArtifactKind::CustomerLabels, kind)?; 548 let source_contents = fs::read_to_string(&source_path).map_err(|source| { 549 PackDayPrintError::ReadSourceArtifact { 550 kind, 551 path: source_path.clone(), 552 source, 553 } 554 })?; 555 let prepared_asset = render_customer_label_stock_asset(&source_contents, stock)?; 556 let target_directory = prepared_customer_label_asset_directory(bundle); 557 fs::create_dir_all(&target_directory).map_err(|source| { 558 PackDayPrintError::CreatePreparedAssetDirectory { 559 kind, 560 path: target_directory.clone(), 561 source, 562 } 563 })?; 564 let target_path = prepared_customer_label_asset_path(bundle, stock); 565 fs::write(&target_path, prepared_asset).map_err(|source| { 566 let _ = 567 cleanup_prepared_customer_label_assets_for_export_instance(bundle.export_instance_id); 568 PackDayPrintError::WritePreparedAsset { 569 kind, 570 path: target_path.clone(), 571 source, 572 } 573 })?; 574 575 Ok(target_path) 576 } 577 578 pub(crate) fn prepared_customer_label_asset_root() -> PathBuf { 579 let root = std::env::temp_dir().join(CUSTOMER_LABEL_PREPARED_ASSET_ROOT); 580 581 #[cfg(test)] 582 { 583 root.join(format!("{:?}", std::thread::current().id())) 584 } 585 586 #[cfg(not(test))] 587 { 588 root 589 } 590 } 591 592 fn cleanup_prepared_customer_label_assets_at_path(path: PathBuf) -> io::Result<()> { 593 match fs::remove_dir_all(&path) { 594 Ok(()) => Ok(()), 595 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), 596 Err(error) => Err(error), 597 } 598 } 599 600 fn prepared_customer_label_asset_directory_for_export_instance( 601 export_instance_id: PackDayExportInstanceId, 602 ) -> PathBuf { 603 prepared_customer_label_asset_root().join(export_instance_id.to_string()) 604 } 605 606 fn prepared_customer_label_asset_directory(bundle: &PackDayExportBundle) -> PathBuf { 607 prepared_customer_label_asset_directory_for_export_instance(bundle.export_instance_id) 608 } 609 610 fn prepared_customer_label_asset_path( 611 bundle: &PackDayExportBundle, 612 stock: PackDayPrintLabelStock, 613 ) -> PathBuf { 614 prepared_customer_label_asset_directory(bundle) 615 .join(format!("customer_labels_{}.ps", stock.storage_key())) 616 } 617 618 fn render_customer_label_stock_asset( 619 source_contents: &str, 620 stock: PackDayPrintLabelStock, 621 ) -> Result<String, PackDayPrintError> { 622 match stock { 623 PackDayPrintLabelStock::Avery5160Letter30Up => { 624 render_avery_5160_customer_labels_postscript(parse_customer_label_blocks( 625 source_contents, 626 )) 627 } 628 } 629 } 630 631 fn parse_customer_label_blocks(source_contents: &str) -> Vec<Vec<String>> { 632 let blocks = source_contents 633 .trim() 634 .split("\n\n---\n\n") 635 .filter_map(|block| { 636 let lines = block 637 .lines() 638 .map(str::trim) 639 .filter(|line| !line.is_empty()) 640 .map(ToOwned::to_owned) 641 .collect::<Vec<_>>(); 642 (!lines.is_empty()).then_some(lines) 643 }) 644 .collect::<Vec<_>>(); 645 646 if blocks.is_empty() { 647 vec![vec!["No customer labels".to_owned()]] 648 } else { 649 blocks 650 } 651 } 652 653 fn render_avery_5160_customer_labels_postscript( 654 blocks: Vec<Vec<String>>, 655 ) -> Result<String, PackDayPrintError> { 656 let page_count = blocks.len().div_ceil(AVERY_5160_LABELS_PER_PAGE); 657 let mut rendered = String::new(); 658 659 let _ = writeln!(&mut rendered, "%!PS-Adobe-3.0"); 660 let _ = writeln!(&mut rendered, "%%Creator: radroots_app"); 661 let _ = writeln!(&mut rendered, "%%Pages: {page_count}"); 662 let _ = writeln!( 663 &mut rendered, 664 "%%BoundingBox: 0 0 {LETTER_PAGE_WIDTH_POINTS} {LETTER_PAGE_HEIGHT_POINTS}" 665 ); 666 let _ = writeln!( 667 &mut rendered, 668 "%%DocumentMedia: Letter {LETTER_PAGE_WIDTH_POINTS} {LETTER_PAGE_HEIGHT_POINTS} 0 () ()" 669 ); 670 let _ = writeln!(&mut rendered, "%%EndComments"); 671 672 for (page_index, page_blocks) in blocks.chunks(AVERY_5160_LABELS_PER_PAGE).enumerate() { 673 let page_number = page_index + 1; 674 let _ = writeln!(&mut rendered, "%%Page: {page_number} {page_number}"); 675 let _ = writeln!( 676 &mut rendered, 677 "<< /PageSize [{LETTER_PAGE_WIDTH_POINTS} {LETTER_PAGE_HEIGHT_POINTS}] >> setpagedevice" 678 ); 679 let _ = writeln!( 680 &mut rendered, 681 "/Courier findfont {} scalefont setfont", 682 AVERY_5160_TEXT_FONT_SIZE_POINTS 683 ); 684 685 for (slot_index, block) in page_blocks.iter().enumerate() { 686 let row = slot_index / AVERY_5160_LABELS_PER_ROW; 687 let column = slot_index % AVERY_5160_LABELS_PER_ROW; 688 let left = AVERY_5160_LEFT_MARGIN_POINTS 689 + (column as f32 * AVERY_5160_COLUMN_PITCH_POINTS) 690 + AVERY_5160_TEXT_LEFT_PADDING_POINTS; 691 let top = AVERY_5160_PAGE_HEIGHT_POINTS 692 - AVERY_5160_TOP_MARGIN_POINTS 693 - (row as f32 * AVERY_5160_ROW_PITCH_POINTS) 694 - AVERY_5160_TEXT_TOP_PADDING_POINTS; 695 696 for (line_index, line) in wrap_customer_label_block(block)?.into_iter().enumerate() { 697 let baseline = top - (line_index as f32 * AVERY_5160_TEXT_LEADING_POINTS); 698 let escaped = escape_postscript_text(&line); 699 let _ = writeln!( 700 &mut rendered, 701 "{left:.2} {baseline:.2} moveto ({escaped}) show" 702 ); 703 } 704 } 705 706 let _ = writeln!(&mut rendered, "showpage"); 707 } 708 709 Ok(rendered) 710 } 711 712 fn wrap_customer_label_block(lines: &[String]) -> Result<Vec<String>, PackDayPrintError> { 713 let mut wrapped = Vec::new(); 714 715 for line in lines { 716 for segment in wrap_customer_label_line(line) { 717 if wrapped.len() == AVERY_5160_MAX_LINES_PER_LABEL { 718 return Err(PackDayPrintError::CustomerLabelsAvery5160Overflow); 719 } 720 wrapped.push(segment); 721 } 722 } 723 724 Ok(wrapped) 725 } 726 727 fn wrap_customer_label_line(line: &str) -> Vec<String> { 728 let trimmed = line.trim(); 729 if trimmed.is_empty() { 730 return Vec::new(); 731 } 732 733 let mut wrapped = Vec::new(); 734 let mut current = String::new(); 735 736 for word in trimmed.split_whitespace() { 737 let word_len = word.chars().count(); 738 if word_len > AVERY_5160_MAX_CHARS_PER_LINE { 739 if !current.is_empty() { 740 wrapped.push(std::mem::take(&mut current)); 741 } 742 push_chunked_word(word, &mut wrapped); 743 continue; 744 } 745 746 if current.is_empty() { 747 current.push_str(word); 748 continue; 749 } 750 751 if current.chars().count() + 1 + word_len <= AVERY_5160_MAX_CHARS_PER_LINE { 752 current.push(' '); 753 current.push_str(word); 754 continue; 755 } 756 757 wrapped.push(std::mem::take(&mut current)); 758 current.push_str(word); 759 } 760 761 if !current.is_empty() { 762 wrapped.push(current); 763 } 764 765 wrapped 766 } 767 768 fn push_chunked_word(word: &str, wrapped: &mut Vec<String>) { 769 let mut chunk = String::new(); 770 771 for character in word.chars() { 772 if chunk.chars().count() == AVERY_5160_MAX_CHARS_PER_LINE { 773 wrapped.push(std::mem::take(&mut chunk)); 774 } 775 chunk.push(character); 776 } 777 778 if !chunk.is_empty() { 779 wrapped.push(chunk); 780 } 781 } 782 783 fn escape_postscript_text(line: &str) -> String { 784 let mut escaped = String::with_capacity(line.len()); 785 786 for character in line.chars() { 787 match character { 788 '(' | ')' | '\\' => { 789 escaped.push('\\'); 790 escaped.push(character); 791 } 792 '\n' | '\r' | '\t' => escaped.push(' '), 793 _ => escaped.push(character), 794 } 795 } 796 797 escaped 798 } 799 800 fn execute_pack_day_print_plan_with( 801 plan: &PackDayPrintCommandPlan, 802 run_command: impl FnOnce(&PackDayPrintCommandPlan) -> Result<PackDayPrintCommandResult, io::Error>, 803 ) -> Result<(), PackDayPrintError> { 804 let result = run_command(plan).map_err(|source| PackDayPrintError::CommandLaunch { 805 kind: plan.kind, 806 program: plan.command_program.to_owned(), 807 source, 808 })?; 809 810 if result.success { 811 return Ok(()); 812 } 813 814 Err(PackDayPrintError::CommandFailed { 815 kind: plan.kind, 816 program: plan.command_program.to_owned(), 817 exit_code: result.exit_code, 818 stderr: result.stderr, 819 }) 820 } 821 822 pub(crate) fn execute_pack_day_batch_print_plan_with( 823 plan: &PackDayBatchPrintCommandPlan, 824 mut run_command: impl FnMut( 825 &PackDayPrintCommandPlan, 826 ) -> Result<PackDayPrintCommandResult, io::Error>, 827 ) -> Result<(), PackDayBatchPrintError> { 828 let result = execute_pack_day_batch_print_sequence_with(plan, &mut run_command); 829 let _ = cleanup_prepared_customer_label_assets_for_export_instance(plan.export_instance_id); 830 result 831 } 832 833 fn execute_pack_day_batch_print_sequence_with( 834 plan: &PackDayBatchPrintCommandPlan, 835 run_command: &mut impl FnMut( 836 &PackDayPrintCommandPlan, 837 ) -> Result<PackDayPrintCommandResult, io::Error>, 838 ) -> Result<(), PackDayBatchPrintError> { 839 if plan.plans.is_empty() { 840 return Err(PackDayBatchPrintError::EmptyPlan); 841 } 842 843 let mut submitted_artifacts = Vec::new(); 844 845 for print_plan in &plan.plans { 846 let failed_artifact = PackDayBatchPrintArtifact::from_print_kind(print_plan.kind); 847 let result = 848 run_command(print_plan).map_err(|source| PackDayBatchPrintError::QueueLaunch { 849 submitted_artifacts: submitted_artifacts.clone(), 850 failed_artifact, 851 source: PackDayPrintError::CommandLaunch { 852 kind: print_plan.kind, 853 program: print_plan.command_program.to_owned(), 854 source, 855 }, 856 })?; 857 858 if !result.success { 859 return Err(PackDayBatchPrintError::QueueExit { 860 submitted_artifacts, 861 failed_artifact, 862 source: PackDayPrintError::CommandFailed { 863 kind: print_plan.kind, 864 program: print_plan.command_program.to_owned(), 865 exit_code: result.exit_code, 866 stderr: result.stderr, 867 }, 868 }); 869 } 870 871 submitted_artifacts.push(failed_artifact); 872 } 873 874 Ok(()) 875 } 876 877 #[cfg(target_os = "macos")] 878 fn run_macos_print_command( 879 plan: &PackDayPrintCommandPlan, 880 ) -> Result<PackDayPrintCommandResult, io::Error> { 881 let output = Command::new(plan.command_program) 882 .args(&plan.command_args) 883 .output()?; 884 885 Ok(PackDayPrintCommandResult { 886 success: output.status.success(), 887 exit_code: output.status.code(), 888 stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(), 889 }) 890 } 891 892 #[cfg(test)] 893 mod tests { 894 use super::{ 895 LETTER_MEDIA_OPTION, PackDayBatchPrintCommandPlan, PackDayBatchPrintError, 896 PackDayPrintCommandResult, PackDayPrintError, cleanup_prepared_customer_label_asset_root, 897 execute_pack_day_batch_print_plan_with, execute_pack_day_print_plan_with, 898 plan_pack_day_batch_print, plan_pack_day_print, prepared_customer_label_asset_directory, 899 prepared_customer_label_asset_path, prepared_customer_label_asset_root, 900 }; 901 use radroots_app_state::PackDayBatchPrintRequest; 902 use radroots_app_view::{ 903 PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, PackDayExportArtifact, 904 PackDayExportArtifactKind, PackDayExportBundle, PackDayExportInstanceId, PackDayPrintKind, 905 PackDayPrintLabelStock, 906 }; 907 use std::fs; 908 use std::io; 909 use std::path::PathBuf; 910 use uuid::Uuid; 911 912 struct TestDirectory { 913 path: PathBuf, 914 } 915 916 impl TestDirectory { 917 fn new() -> Self { 918 let path = std::env::temp_dir() 919 .join(format!("radroots_app_pack_day_print_{}", Uuid::new_v4())); 920 fs::create_dir_all(&path).expect("test directory should create"); 921 Self { path } 922 } 923 924 fn path(&self) -> &PathBuf { 925 &self.path 926 } 927 } 928 929 impl Drop for TestDirectory { 930 fn drop(&mut self) { 931 let _ = fs::remove_dir_all(&self.path); 932 } 933 } 934 935 fn sample_bundle(bundle_directory: &PathBuf) -> PackDayExportBundle { 936 PackDayExportBundle { 937 fulfillment_window_id: radroots_app_view::FulfillmentWindowId::new(), 938 export_instance_id: PackDayExportInstanceId::new(), 939 generated_at_utc: "2026-04-23T15:00:00Z".to_owned(), 940 bundle_directory: bundle_directory.to_string_lossy().into_owned(), 941 artifacts: vec![ 942 PackDayExportArtifact { 943 kind: PackDayExportArtifactKind::PackSheet, 944 relative_path: "pack_sheet.txt".to_owned(), 945 }, 946 PackDayExportArtifact { 947 kind: PackDayExportArtifactKind::PickupRoster, 948 relative_path: "pickup_roster.txt".to_owned(), 949 }, 950 PackDayExportArtifact { 951 kind: PackDayExportArtifactKind::CustomerLabels, 952 relative_path: "customer_labels.txt".to_owned(), 953 }, 954 ], 955 } 956 } 957 958 fn sample_batch_request(bundle: &PackDayExportBundle) -> PackDayBatchPrintRequest { 959 PackDayBatchPrintRequest::for_bundle(bundle) 960 } 961 962 fn write_artifact(bundle_directory: &PathBuf, file_name: &str) -> PathBuf { 963 let path = bundle_directory.join(file_name); 964 fs::write(&path, file_name).expect("artifact should write"); 965 path 966 } 967 968 fn write_all_artifacts(bundle_directory: &PathBuf) { 969 write_artifact(bundle_directory, "pack_sheet.txt"); 970 write_artifact(bundle_directory, "pickup_roster.txt"); 971 write_artifact(bundle_directory, "customer_labels.txt"); 972 } 973 974 #[test] 975 fn print_pack_sheet_plan_targets_the_exported_file_with_lp() { 976 let temp_dir = TestDirectory::new(); 977 let pack_sheet_path = write_artifact(temp_dir.path(), "pack_sheet.txt"); 978 let bundle = sample_bundle(temp_dir.path()); 979 980 let plan = plan_pack_day_print(&bundle, PackDayPrintKind::PrintPackSheet) 981 .expect("pack sheet print plan should build"); 982 983 assert_eq!(plan.kind, PackDayPrintKind::PrintPackSheet); 984 assert_eq!(plan.target_path, pack_sheet_path.clone()); 985 assert_eq!(plan.command_program, "lp"); 986 assert_eq!( 987 plan.command_args, 988 vec![pack_sheet_path.to_string_lossy().into_owned()] 989 ); 990 } 991 992 #[test] 993 fn print_pickup_roster_plan_targets_the_exported_file_with_lp() { 994 let temp_dir = TestDirectory::new(); 995 let pickup_roster_path = write_artifact(temp_dir.path(), "pickup_roster.txt"); 996 let bundle = sample_bundle(temp_dir.path()); 997 998 let plan = plan_pack_day_print(&bundle, PackDayPrintKind::PrintPickupRoster) 999 .expect("pickup roster print plan should build"); 1000 1001 assert_eq!(plan.kind, PackDayPrintKind::PrintPickupRoster); 1002 assert_eq!(plan.target_path, pickup_roster_path.clone()); 1003 assert_eq!(plan.command_program, "lp"); 1004 assert_eq!( 1005 plan.command_args, 1006 vec![pickup_roster_path.to_string_lossy().into_owned()] 1007 ); 1008 } 1009 1010 #[test] 1011 fn customer_labels_plan_derives_a_stock_aware_asset_outside_the_export_bundle() { 1012 let temp_dir = TestDirectory::new(); 1013 let source_path = temp_dir.path().join("customer_labels.txt"); 1014 fs::write( 1015 &source_path, 1016 "Willow farm\nCasey\nOrder: R-1001\nPickup: North barn\nWindow: 2026-04-23T16:00:00Z to 2026-04-23T19:00:00Z\n\n---\n\nWillow farm\nTaylor\nOrder: R-1002\nPickup: North barn\nWindow: 2026-04-23T16:00:00Z to 2026-04-23T19:00:00Z\n", 1017 ) 1018 .expect("customer labels should write"); 1019 let bundle = sample_bundle(temp_dir.path()); 1020 let prepared_path = prepared_customer_label_asset_path( 1021 &bundle, 1022 PackDayPrintLabelStock::Avery5160Letter30Up, 1023 ); 1024 1025 let plan = plan_pack_day_print(&bundle, PackDayPrintKind::PrintCustomerLabels) 1026 .expect("customer labels plan should build"); 1027 1028 assert_eq!(plan.kind, PackDayPrintKind::PrintCustomerLabels); 1029 assert_eq!(plan.target_path, prepared_path.clone()); 1030 assert_eq!(plan.command_program, "lp"); 1031 assert_eq!( 1032 plan.command_args, 1033 vec![ 1034 "-o".to_owned(), 1035 LETTER_MEDIA_OPTION.to_owned(), 1036 prepared_path.to_string_lossy().into_owned() 1037 ] 1038 ); 1039 assert!(plan.target_path.is_file()); 1040 assert!(!plan.target_path.starts_with(temp_dir.path())); 1041 assert!( 1042 plan.target_path 1043 .to_string_lossy() 1044 .contains(bundle.export_instance_id.to_string().as_str()) 1045 ); 1046 assert_eq!( 1047 fs::read_to_string(&source_path).expect("source labels should stay untouched"), 1048 "Willow farm\nCasey\nOrder: R-1001\nPickup: North barn\nWindow: 2026-04-23T16:00:00Z to 2026-04-23T19:00:00Z\n\n---\n\nWillow farm\nTaylor\nOrder: R-1002\nPickup: North barn\nWindow: 2026-04-23T16:00:00Z to 2026-04-23T19:00:00Z\n" 1049 ); 1050 1051 let prepared_contents = 1052 fs::read_to_string(&prepared_path).expect("prepared labels should render"); 1053 assert!(prepared_contents.contains("%!PS-Adobe-3.0")); 1054 assert!(prepared_contents.contains("%%Pages: 1")); 1055 assert!(prepared_contents.contains("%%DocumentMedia: Letter 612 792 0 () ()")); 1056 assert!(prepared_contents.contains("<< /PageSize [612 792] >> setpagedevice")); 1057 assert!(prepared_contents.contains("(Casey) show")); 1058 assert!(prepared_contents.contains("(Taylor) show")); 1059 assert!( 1060 prepared_contents.contains("(Order: R-1001) show") 1061 || prepared_contents.contains("(Order: R-1002) show") 1062 ); 1063 1064 let _ = fs::remove_dir_all(prepared_customer_label_asset_directory(&bundle)); 1065 } 1066 1067 #[test] 1068 fn customer_label_stock_assets_are_scoped_by_export_instance_id() { 1069 let temp_dir = TestDirectory::new(); 1070 fs::write( 1071 temp_dir.path().join("customer_labels.txt"), 1072 "Willow farm\nCasey\nOrder: R-1001\n", 1073 ) 1074 .expect("customer labels should write"); 1075 let bundle = sample_bundle(temp_dir.path()); 1076 let other_bundle = PackDayExportBundle { 1077 export_instance_id: PackDayExportInstanceId::from(Uuid::new_v4()), 1078 ..bundle.clone() 1079 }; 1080 1081 let plan = plan_pack_day_print(&bundle, PackDayPrintKind::PrintCustomerLabels) 1082 .expect("first customer labels plan should build"); 1083 let other_plan = plan_pack_day_print(&other_bundle, PackDayPrintKind::PrintCustomerLabels) 1084 .expect("second customer labels plan should build"); 1085 1086 assert_ne!(plan.target_path, other_plan.target_path); 1087 assert!(plan.target_path.is_file()); 1088 assert!(other_plan.target_path.is_file()); 1089 1090 let _ = fs::remove_dir_all(prepared_customer_label_asset_directory(&bundle)); 1091 let _ = fs::remove_dir_all(prepared_customer_label_asset_directory(&other_bundle)); 1092 } 1093 1094 #[test] 1095 fn customer_label_stock_preparation_classifies_directory_creation_failures() { 1096 let temp_dir = TestDirectory::new(); 1097 write_artifact(temp_dir.path(), "customer_labels.txt"); 1098 let bundle = sample_bundle(temp_dir.path()); 1099 let prepared_directory = prepared_customer_label_asset_directory(&bundle); 1100 if let Some(parent) = prepared_directory.parent() { 1101 fs::create_dir_all(parent).expect("prepared asset parent should create"); 1102 } 1103 fs::write(&prepared_directory, "blocked").expect("blocking file should write"); 1104 1105 let error = plan_pack_day_print(&bundle, PackDayPrintKind::PrintCustomerLabels) 1106 .expect_err("prepared directory failure should surface"); 1107 1108 match error { 1109 PackDayPrintError::CreatePreparedAssetDirectory { kind, path, source } => { 1110 assert_eq!(kind, PackDayPrintKind::PrintCustomerLabels); 1111 assert_eq!(path, prepared_directory); 1112 assert_eq!(source.kind(), io::ErrorKind::AlreadyExists); 1113 } 1114 other => panic!("unexpected error: {other:?}"), 1115 } 1116 1117 let _ = fs::remove_file(prepared_directory); 1118 } 1119 1120 #[test] 1121 fn customer_label_stock_preparation_classifies_write_failures() { 1122 let temp_dir = TestDirectory::new(); 1123 write_artifact(temp_dir.path(), "customer_labels.txt"); 1124 let bundle = sample_bundle(temp_dir.path()); 1125 let prepared_directory = prepared_customer_label_asset_directory(&bundle); 1126 fs::create_dir_all(&prepared_directory).expect("prepared directory should create"); 1127 let prepared_path = prepared_customer_label_asset_path( 1128 &bundle, 1129 PackDayPrintLabelStock::Avery5160Letter30Up, 1130 ); 1131 fs::create_dir_all(&prepared_path).expect("prepared asset directory should block writes"); 1132 1133 let error = plan_pack_day_print(&bundle, PackDayPrintKind::PrintCustomerLabels) 1134 .expect_err("prepared asset write failure should surface"); 1135 1136 match error { 1137 PackDayPrintError::WritePreparedAsset { kind, path, source } => { 1138 assert_eq!(kind, PackDayPrintKind::PrintCustomerLabels); 1139 assert_eq!(path, prepared_path); 1140 assert_eq!(source.kind(), io::ErrorKind::IsADirectory); 1141 } 1142 other => panic!("unexpected error: {other:?}"), 1143 } 1144 1145 assert!(!prepared_directory.exists()); 1146 } 1147 1148 #[test] 1149 fn customer_label_planning_rejects_avery_5160_overflow_without_creating_prepared_assets() { 1150 let temp_dir = TestDirectory::new(); 1151 fs::write( 1152 temp_dir.path().join("customer_labels.txt"), 1153 "Willow farm\nCasey\nOrder R-1001\nPickup barn\nThursday\nKeep cold\nOverflow note\n", 1154 ) 1155 .expect("overflowing customer labels should write"); 1156 let bundle = sample_bundle(temp_dir.path()); 1157 let prepared_directory = prepared_customer_label_asset_directory(&bundle); 1158 1159 let error = plan_pack_day_print(&bundle, PackDayPrintKind::PrintCustomerLabels) 1160 .expect_err("overflowing customer labels should fail"); 1161 1162 assert_eq!(error, PackDayPrintError::CustomerLabelsAvery5160Overflow); 1163 assert!(!prepared_directory.exists()); 1164 } 1165 1166 #[test] 1167 fn batch_preflight_plans_all_v1_artifacts_in_order() { 1168 let temp_dir = TestDirectory::new(); 1169 write_all_artifacts(temp_dir.path()); 1170 let bundle = sample_bundle(temp_dir.path()); 1171 let prepared_path = prepared_customer_label_asset_path( 1172 &bundle, 1173 PackDayPrintLabelStock::Avery5160Letter30Up, 1174 ); 1175 1176 let request = sample_batch_request(&bundle); 1177 1178 let plan = 1179 plan_pack_day_batch_print(&bundle, &request).expect("batch preflight should build"); 1180 1181 assert_eq!(plan.export_instance_id, bundle.export_instance_id); 1182 assert_eq!( 1183 plan.plans.iter().map(|plan| plan.kind).collect::<Vec<_>>(), 1184 request 1185 .artifacts 1186 .iter() 1187 .map(|artifact| artifact.print_kind) 1188 .collect::<Vec<_>>() 1189 ); 1190 assert_eq!( 1191 plan.plans 1192 .iter() 1193 .map(|plan| plan.command_program) 1194 .collect::<Vec<_>>(), 1195 vec!["lp", "lp", "lp"] 1196 ); 1197 assert_eq!( 1198 plan.plans[2].command_args, 1199 vec![ 1200 "-o".to_owned(), 1201 LETTER_MEDIA_OPTION.to_owned(), 1202 prepared_path.to_string_lossy().into_owned(), 1203 ] 1204 ); 1205 assert!(prepared_path.is_file()); 1206 1207 let _ = fs::remove_dir_all(prepared_customer_label_asset_directory(&bundle)); 1208 } 1209 1210 #[test] 1211 fn batch_preflight_rejects_empty_request_artifacts_before_preparing_labels() { 1212 let temp_dir = TestDirectory::new(); 1213 write_all_artifacts(temp_dir.path()); 1214 let bundle = sample_bundle(temp_dir.path()); 1215 let prepared_directory = prepared_customer_label_asset_directory(&bundle); 1216 let mut request = sample_batch_request(&bundle); 1217 request.artifacts.clear(); 1218 1219 let error = plan_pack_day_batch_print(&bundle, &request) 1220 .expect_err("empty request should fail preflight"); 1221 1222 assert_eq!( 1223 error, 1224 PackDayBatchPrintError::InvalidRequest { 1225 artifacts: Vec::new(), 1226 } 1227 ); 1228 assert_eq!(error.failed_artifact(), None); 1229 assert_eq!( 1230 error.failure_kind(), 1231 PackDayBatchPrintFailureKind::Preflight 1232 ); 1233 assert!(!prepared_directory.exists()); 1234 } 1235 1236 #[test] 1237 fn batch_preflight_rejects_out_of_order_request_artifacts_before_preparing_labels() { 1238 let temp_dir = TestDirectory::new(); 1239 write_all_artifacts(temp_dir.path()); 1240 let bundle = sample_bundle(temp_dir.path()); 1241 let prepared_directory = prepared_customer_label_asset_directory(&bundle); 1242 let mut request = sample_batch_request(&bundle); 1243 request.artifacts.swap(0, 1); 1244 let artifacts = request.artifacts.clone(); 1245 1246 let error = plan_pack_day_batch_print(&bundle, &request) 1247 .expect_err("out-of-order request should fail preflight"); 1248 1249 assert_eq!(error, PackDayBatchPrintError::InvalidRequest { artifacts }); 1250 assert_eq!(error.failed_artifact(), None); 1251 assert_eq!( 1252 error.failure_kind(), 1253 PackDayBatchPrintFailureKind::Preflight 1254 ); 1255 assert!(!prepared_directory.exists()); 1256 } 1257 1258 #[test] 1259 fn batch_preflight_rejects_duplicate_request_artifacts_before_preparing_labels() { 1260 let temp_dir = TestDirectory::new(); 1261 write_all_artifacts(temp_dir.path()); 1262 let bundle = sample_bundle(temp_dir.path()); 1263 let prepared_directory = prepared_customer_label_asset_directory(&bundle); 1264 let mut request = sample_batch_request(&bundle); 1265 request.artifacts[1] = request.artifacts[0]; 1266 let artifacts = request.artifacts.clone(); 1267 1268 let error = plan_pack_day_batch_print(&bundle, &request) 1269 .expect_err("duplicate request should fail preflight"); 1270 1271 assert_eq!(error, PackDayBatchPrintError::InvalidRequest { artifacts }); 1272 assert_eq!(error.failed_artifact(), None); 1273 assert_eq!( 1274 error.failure_kind(), 1275 PackDayBatchPrintFailureKind::Preflight 1276 ); 1277 assert!(!prepared_directory.exists()); 1278 } 1279 1280 #[test] 1281 fn batch_preflight_rejects_bundle_request_identity_mismatch() { 1282 let temp_dir = TestDirectory::new(); 1283 write_all_artifacts(temp_dir.path()); 1284 let bundle = sample_bundle(temp_dir.path()); 1285 let mut request = sample_batch_request(&bundle); 1286 request.export_instance_id = PackDayExportInstanceId::new(); 1287 let artifacts = request.artifacts.clone(); 1288 1289 let error = plan_pack_day_batch_print(&bundle, &request) 1290 .expect_err("request identity mismatch should fail preflight"); 1291 1292 assert_eq!(error, PackDayBatchPrintError::InvalidRequest { artifacts }); 1293 assert_eq!(error.failed_artifact(), None); 1294 assert_eq!( 1295 error.failure_kind(), 1296 PackDayBatchPrintFailureKind::Preflight 1297 ); 1298 assert!(!prepared_customer_label_asset_directory(&bundle).exists()); 1299 } 1300 1301 #[test] 1302 fn batch_preflight_fails_closed_when_a_required_artifact_reference_is_missing() { 1303 let temp_dir = TestDirectory::new(); 1304 write_all_artifacts(temp_dir.path()); 1305 let mut bundle = sample_bundle(temp_dir.path()); 1306 bundle 1307 .artifacts 1308 .retain(|artifact| artifact.kind != PackDayExportArtifactKind::PickupRoster); 1309 1310 let request = sample_batch_request(&bundle); 1311 1312 let error = plan_pack_day_batch_print(&bundle, &request) 1313 .expect_err("missing artifact should fail preflight"); 1314 1315 assert_eq!( 1316 error, 1317 PackDayBatchPrintError::Preflight { 1318 failed_artifact: Some(PackDayBatchPrintArtifact::from_print_kind( 1319 PackDayPrintKind::PrintPickupRoster, 1320 )), 1321 source: PackDayPrintError::MissingArtifactReference { 1322 kind: PackDayPrintKind::PrintPickupRoster, 1323 artifact_kind: PackDayExportArtifactKind::PickupRoster, 1324 }, 1325 } 1326 ); 1327 assert_eq!( 1328 error.failure_kind(), 1329 PackDayBatchPrintFailureKind::Preflight 1330 ); 1331 assert!(!prepared_customer_label_asset_directory(&bundle).exists()); 1332 } 1333 1334 #[test] 1335 fn batch_preflight_fails_closed_when_an_artifact_relative_path_is_invalid() { 1336 let temp_dir = TestDirectory::new(); 1337 write_all_artifacts(temp_dir.path()); 1338 let mut bundle = sample_bundle(temp_dir.path()); 1339 bundle.artifacts[0].relative_path = "../pack_sheet.txt".to_owned(); 1340 1341 let request = sample_batch_request(&bundle); 1342 1343 let error = plan_pack_day_batch_print(&bundle, &request) 1344 .expect_err("invalid artifact path should fail"); 1345 1346 assert_eq!( 1347 error, 1348 PackDayBatchPrintError::Preflight { 1349 failed_artifact: Some(PackDayBatchPrintArtifact::from_print_kind( 1350 PackDayPrintKind::PrintPackSheet, 1351 )), 1352 source: PackDayPrintError::InvalidArtifactRelativePath { 1353 kind: PackDayPrintKind::PrintPackSheet, 1354 relative_path: "../pack_sheet.txt".to_owned(), 1355 }, 1356 } 1357 ); 1358 assert_eq!( 1359 error.failed_artifact(), 1360 Some(PackDayBatchPrintArtifact::from_print_kind( 1361 PackDayPrintKind::PrintPackSheet, 1362 )) 1363 ); 1364 assert_eq!( 1365 error.failure_kind(), 1366 PackDayBatchPrintFailureKind::Preflight 1367 ); 1368 assert!(!prepared_customer_label_asset_directory(&bundle).exists()); 1369 } 1370 1371 #[test] 1372 fn batch_preflight_surfaces_avery_5160_overflow_without_creating_prepared_assets() { 1373 let temp_dir = TestDirectory::new(); 1374 write_artifact(temp_dir.path(), "pack_sheet.txt"); 1375 write_artifact(temp_dir.path(), "pickup_roster.txt"); 1376 fs::write( 1377 temp_dir.path().join("customer_labels.txt"), 1378 "Willow farm\nCasey\nOrder R-1001\nPickup barn\nThursday\nKeep cold\nOverflow note\n", 1379 ) 1380 .expect("overflowing customer labels should write"); 1381 let bundle = sample_bundle(temp_dir.path()); 1382 1383 let request = sample_batch_request(&bundle); 1384 1385 let error = plan_pack_day_batch_print(&bundle, &request) 1386 .expect_err("overflowing customer labels should fail batch preflight"); 1387 1388 assert_eq!( 1389 error, 1390 PackDayBatchPrintError::Preflight { 1391 failed_artifact: Some(PackDayBatchPrintArtifact::from_print_kind( 1392 PackDayPrintKind::PrintCustomerLabels, 1393 )), 1394 source: PackDayPrintError::CustomerLabelsAvery5160Overflow, 1395 } 1396 ); 1397 assert_eq!( 1398 error.failure_kind(), 1399 PackDayBatchPrintFailureKind::CustomerLabelsAvery5160Overflow 1400 ); 1401 assert!(!prepared_customer_label_asset_directory(&bundle).exists()); 1402 } 1403 1404 #[test] 1405 fn batch_execution_submits_all_v1_artifacts_in_order_and_cleans_prepared_assets() { 1406 let temp_dir = TestDirectory::new(); 1407 write_all_artifacts(temp_dir.path()); 1408 let bundle = sample_bundle(temp_dir.path()); 1409 let prepared_directory = prepared_customer_label_asset_directory(&bundle); 1410 let request = sample_batch_request(&bundle); 1411 let plan = 1412 plan_pack_day_batch_print(&bundle, &request).expect("batch preflight should build"); 1413 let mut submitted = Vec::new(); 1414 1415 execute_pack_day_batch_print_plan_with(&plan, |print_plan| { 1416 submitted.push(print_plan.kind); 1417 Ok(PackDayPrintCommandResult::succeeded()) 1418 }) 1419 .expect("batch execution should succeed"); 1420 1421 assert_eq!( 1422 submitted, 1423 vec![ 1424 PackDayPrintKind::PrintPackSheet, 1425 PackDayPrintKind::PrintPickupRoster, 1426 PackDayPrintKind::PrintCustomerLabels, 1427 ] 1428 ); 1429 assert!(!prepared_directory.exists()); 1430 } 1431 1432 #[test] 1433 fn batch_execution_rejects_empty_command_plan_without_submitting_artifacts() { 1434 let plan = PackDayBatchPrintCommandPlan { 1435 export_instance_id: PackDayExportInstanceId::new(), 1436 plans: Vec::new(), 1437 }; 1438 let mut submitted = false; 1439 1440 let error = execute_pack_day_batch_print_plan_with(&plan, |_| { 1441 submitted = true; 1442 Ok(PackDayPrintCommandResult::succeeded()) 1443 }) 1444 .expect_err("empty command plan should fail"); 1445 1446 assert_eq!(error, PackDayBatchPrintError::EmptyPlan); 1447 assert_eq!(error.failed_artifact(), None); 1448 assert_eq!( 1449 error.failure_kind(), 1450 PackDayBatchPrintFailureKind::Preflight 1451 ); 1452 assert!(!submitted); 1453 } 1454 1455 #[test] 1456 fn batch_execution_stops_on_first_queue_launch_failure() { 1457 let temp_dir = TestDirectory::new(); 1458 write_all_artifacts(temp_dir.path()); 1459 let bundle = sample_bundle(temp_dir.path()); 1460 let prepared_directory = prepared_customer_label_asset_directory(&bundle); 1461 let request = sample_batch_request(&bundle); 1462 let plan = 1463 plan_pack_day_batch_print(&bundle, &request).expect("batch preflight should build"); 1464 let mut submitted = Vec::new(); 1465 1466 let error = execute_pack_day_batch_print_plan_with(&plan, |print_plan| { 1467 submitted.push(print_plan.kind); 1468 match print_plan.kind { 1469 PackDayPrintKind::PrintPackSheet => Ok(PackDayPrintCommandResult::succeeded()), 1470 PackDayPrintKind::PrintPickupRoster => Err(io::Error::new( 1471 io::ErrorKind::PermissionDenied, 1472 "lp launch denied", 1473 )), 1474 PackDayPrintKind::PrintCustomerLabels => { 1475 panic!("batch should stop before customer labels") 1476 } 1477 } 1478 }) 1479 .expect_err("launch failure should stop batch execution"); 1480 1481 assert_eq!( 1482 submitted, 1483 vec![ 1484 PackDayPrintKind::PrintPackSheet, 1485 PackDayPrintKind::PrintPickupRoster, 1486 ] 1487 ); 1488 assert_eq!( 1489 error, 1490 PackDayBatchPrintError::QueueLaunch { 1491 submitted_artifacts: vec![PackDayBatchPrintArtifact::from_print_kind( 1492 PackDayPrintKind::PrintPackSheet, 1493 )], 1494 failed_artifact: PackDayBatchPrintArtifact::from_print_kind( 1495 PackDayPrintKind::PrintPickupRoster, 1496 ), 1497 source: PackDayPrintError::CommandLaunch { 1498 kind: PackDayPrintKind::PrintPickupRoster, 1499 program: "lp".to_owned(), 1500 source: io::Error::new(io::ErrorKind::PermissionDenied, "lp launch denied"), 1501 }, 1502 } 1503 ); 1504 assert_eq!( 1505 error.failed_artifact(), 1506 Some(PackDayBatchPrintArtifact::from_print_kind( 1507 PackDayPrintKind::PrintPickupRoster, 1508 )) 1509 ); 1510 assert_eq!( 1511 error.failure_kind(), 1512 PackDayBatchPrintFailureKind::QueueLaunch 1513 ); 1514 assert!(!prepared_directory.exists()); 1515 } 1516 1517 #[test] 1518 fn batch_execution_stops_on_first_queue_exit_failure() { 1519 let temp_dir = TestDirectory::new(); 1520 write_all_artifacts(temp_dir.path()); 1521 let bundle = sample_bundle(temp_dir.path()); 1522 let prepared_directory = prepared_customer_label_asset_directory(&bundle); 1523 let request = sample_batch_request(&bundle); 1524 let plan = 1525 plan_pack_day_batch_print(&bundle, &request).expect("batch preflight should build"); 1526 let mut submitted = Vec::new(); 1527 1528 let error = execute_pack_day_batch_print_plan_with(&plan, |print_plan| { 1529 submitted.push(print_plan.kind); 1530 match print_plan.kind { 1531 PackDayPrintKind::PrintPackSheet => Ok(PackDayPrintCommandResult::succeeded()), 1532 PackDayPrintKind::PrintPickupRoster => Ok(PackDayPrintCommandResult::failed( 1533 Some(2), 1534 "lp stopped before submit", 1535 )), 1536 PackDayPrintKind::PrintCustomerLabels => { 1537 panic!("batch should stop before customer labels") 1538 } 1539 } 1540 }) 1541 .expect_err("queue exit failure should stop batch execution"); 1542 1543 assert_eq!( 1544 submitted, 1545 vec![ 1546 PackDayPrintKind::PrintPackSheet, 1547 PackDayPrintKind::PrintPickupRoster, 1548 ] 1549 ); 1550 assert_eq!( 1551 error, 1552 PackDayBatchPrintError::QueueExit { 1553 submitted_artifacts: vec![PackDayBatchPrintArtifact::from_print_kind( 1554 PackDayPrintKind::PrintPackSheet, 1555 )], 1556 failed_artifact: PackDayBatchPrintArtifact::from_print_kind( 1557 PackDayPrintKind::PrintPickupRoster, 1558 ), 1559 source: PackDayPrintError::CommandFailed { 1560 kind: PackDayPrintKind::PrintPickupRoster, 1561 program: "lp".to_owned(), 1562 exit_code: Some(2), 1563 stderr: "lp stopped before submit".to_owned(), 1564 }, 1565 } 1566 ); 1567 assert_eq!( 1568 error.failure_kind(), 1569 PackDayBatchPrintFailureKind::QueueExit 1570 ); 1571 assert!(!prepared_directory.exists()); 1572 } 1573 1574 #[test] 1575 fn cleanup_prepared_customer_label_asset_root_removes_existing_directories() { 1576 let root = prepared_customer_label_asset_root(); 1577 let stale_directory = root.join(PackDayExportInstanceId::new().to_string()); 1578 fs::create_dir_all(&stale_directory).expect("stale prepared directory should create"); 1579 fs::write(stale_directory.join("stale.ps"), "stale").expect("stale asset should write"); 1580 1581 cleanup_prepared_customer_label_asset_root() 1582 .expect("prepared customer label asset root should clean"); 1583 1584 assert!(!root.exists()); 1585 } 1586 1587 #[test] 1588 fn planning_fails_when_pack_sheet_reference_is_missing_on_disk() { 1589 let temp_dir = TestDirectory::new(); 1590 let bundle = sample_bundle(temp_dir.path()); 1591 1592 let error = plan_pack_day_print(&bundle, PackDayPrintKind::PrintPackSheet) 1593 .expect_err("missing pack sheet file should fail"); 1594 1595 assert_eq!( 1596 error, 1597 PackDayPrintError::MissingTargetPath { 1598 kind: PackDayPrintKind::PrintPackSheet, 1599 path: temp_dir.path().join("pack_sheet.txt"), 1600 } 1601 ); 1602 } 1603 1604 #[test] 1605 fn planning_fails_when_pickup_roster_relative_path_is_invalid() { 1606 let temp_dir = TestDirectory::new(); 1607 write_artifact(temp_dir.path(), "pickup_roster.txt"); 1608 let mut bundle = sample_bundle(temp_dir.path()); 1609 bundle.artifacts[1].relative_path = "../pickup_roster.txt".to_owned(); 1610 1611 let error = plan_pack_day_print(&bundle, PackDayPrintKind::PrintPickupRoster) 1612 .expect_err("invalid relative path should fail"); 1613 1614 assert_eq!( 1615 error, 1616 PackDayPrintError::InvalidArtifactRelativePath { 1617 kind: PackDayPrintKind::PrintPickupRoster, 1618 relative_path: "../pickup_roster.txt".to_owned(), 1619 } 1620 ); 1621 } 1622 1623 #[test] 1624 fn execution_accepts_successful_lp_runs() { 1625 let temp_dir = TestDirectory::new(); 1626 let pack_sheet_path = write_artifact(temp_dir.path(), "pack_sheet.txt"); 1627 let bundle = sample_bundle(temp_dir.path()); 1628 let plan = plan_pack_day_print(&bundle, PackDayPrintKind::PrintPackSheet) 1629 .expect("pack sheet print plan should build"); 1630 1631 assert_eq!(plan.target_path, pack_sheet_path); 1632 assert!( 1633 execute_pack_day_print_plan_with(&plan, |_| { 1634 Ok(PackDayPrintCommandResult::succeeded()) 1635 }) 1636 .is_ok() 1637 ); 1638 } 1639 1640 #[test] 1641 fn execution_classifies_command_launch_failures() { 1642 let temp_dir = TestDirectory::new(); 1643 write_artifact(temp_dir.path(), "pickup_roster.txt"); 1644 let bundle = sample_bundle(temp_dir.path()); 1645 let plan = plan_pack_day_print(&bundle, PackDayPrintKind::PrintPickupRoster) 1646 .expect("pickup roster print plan should build"); 1647 1648 let error = execute_pack_day_print_plan_with(&plan, |_| { 1649 Err(io::Error::new( 1650 io::ErrorKind::PermissionDenied, 1651 "lp unavailable", 1652 )) 1653 }) 1654 .expect_err("launch failure should surface"); 1655 1656 assert_eq!( 1657 error, 1658 PackDayPrintError::CommandLaunch { 1659 kind: PackDayPrintKind::PrintPickupRoster, 1660 program: "lp".to_owned(), 1661 source: io::Error::new(io::ErrorKind::PermissionDenied, "lp unavailable"), 1662 } 1663 ); 1664 } 1665 1666 #[test] 1667 fn execution_classifies_nonzero_exit_failures() { 1668 let temp_dir = TestDirectory::new(); 1669 write_artifact(temp_dir.path(), "pack_sheet.txt"); 1670 let bundle = sample_bundle(temp_dir.path()); 1671 let plan = plan_pack_day_print(&bundle, PackDayPrintKind::PrintPackSheet) 1672 .expect("pack sheet print plan should build"); 1673 1674 let error = execute_pack_day_print_plan_with(&plan, |_| { 1675 Ok(PackDayPrintCommandResult::failed( 1676 Some(1), 1677 "lp: printer not found", 1678 )) 1679 }) 1680 .expect_err("nonzero exit should surface"); 1681 1682 assert_eq!( 1683 error, 1684 PackDayPrintError::CommandFailed { 1685 kind: PackDayPrintKind::PrintPackSheet, 1686 program: "lp".to_owned(), 1687 exit_code: Some(1), 1688 stderr: "lp: printer not found".to_owned(), 1689 } 1690 ); 1691 } 1692 }