app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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 }