app

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

commit 9caaeccf8b55efc171db4703b387a7b60724a0cc
parent d0a72f6cb9a90bf0b1abe7ce2e2314a4d4aef2fa
Author: triesap <tyson@radroots.org>
Date:   Tue, 28 Apr 2026 17:41:52 +0000

app: add pack day batch print preflight

- build a frozen v1 batch plan from the existing per-artifact planner
- fail closed before queue submission on missing or unsafe artifacts
- prepare Avery 5160 customer labels during batch preflight
- cover complete, missing-artifact, invalid-path, and overflow cases

Diffstat:
Mcrates/launchers/desktop/src/pack_day_print.rs | 232++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
1 file changed, 219 insertions(+), 13 deletions(-)

diff --git a/crates/launchers/desktop/src/pack_day_print.rs b/crates/launchers/desktop/src/pack_day_print.rs @@ -6,8 +6,9 @@ use std::path::{Component, Path, PathBuf}; use std::process::Command; use radroots_app_models::{ - PackDayExportArtifactKind, PackDayExportBundle, PackDayExportInstanceId, - PackDayPrintFailureKind, PackDayPrintKind, PackDayPrintLabelStock, + PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, PackDayExportArtifactKind, + PackDayExportBundle, PackDayExportInstanceId, PackDayPrintFailureKind, PackDayPrintKind, + PackDayPrintLabelStock, }; use thiserror::Error; @@ -40,6 +41,11 @@ pub struct PackDayPrintCommandPlan { } #[derive(Clone, Debug, Eq, PartialEq)] +pub struct PackDayBatchPrintCommandPlan { + pub plans: Vec<PackDayPrintCommandPlan>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] struct PackDayPrintCommandResult { success: bool, exit_code: Option<i32>, @@ -222,10 +228,7 @@ impl PartialEq for PackDayPrintError { && left_path == right_path && io_errors_match(left_source, right_source) } - ( - Self::CustomerLabelsAvery5160Overflow, - Self::CustomerLabelsAvery5160Overflow, - ) => true, + (Self::CustomerLabelsAvery5160Overflow, Self::CustomerLabelsAvery5160Overflow) => true, (Self::UnsupportedPlatform, Self::UnsupportedPlatform) => true, ( Self::CommandLaunch { @@ -281,6 +284,35 @@ impl PackDayPrintError { } } +#[derive(Debug, Error, Eq, PartialEq)] +pub enum PackDayBatchPrintError { + #[error("pack day batch print preflight failed for {failed_artifact:?}: {source}")] + Preflight { + failed_artifact: Option<PackDayBatchPrintArtifact>, + source: PackDayPrintError, + }, +} + +impl PackDayBatchPrintError { + pub(crate) const fn failed_artifact(&self) -> Option<PackDayBatchPrintArtifact> { + match self { + Self::Preflight { + failed_artifact, .. + } => *failed_artifact, + } + } + + pub(crate) const fn failure_kind(&self) -> PackDayBatchPrintFailureKind { + match self { + Self::Preflight { + source: PackDayPrintError::CustomerLabelsAvery5160Overflow, + .. + } => PackDayBatchPrintFailureKind::CustomerLabelsAvery5160Overflow, + Self::Preflight { .. } => PackDayBatchPrintFailureKind::Preflight, + } + } +} + fn io_errors_match(left: &io::Error, right: &io::Error) -> bool { left.kind() == right.kind() && left.to_string() == right.to_string() } @@ -346,6 +378,34 @@ pub fn plan_pack_day_print( }) } +pub fn plan_pack_day_batch_print( + bundle: &PackDayExportBundle, +) -> Result<PackDayBatchPrintCommandPlan, PackDayBatchPrintError> { + let mut plans = Vec::new(); + + for artifact in PackDayBatchPrintArtifact::all_v1() { + let plan = plan_pack_day_print(bundle, artifact.print_kind).map_err(|source| { + PackDayBatchPrintError::Preflight { + failed_artifact: batch_preflight_failed_artifact(&source, artifact), + source, + } + })?; + plans.push(plan); + } + + Ok(PackDayBatchPrintCommandPlan { plans }) +} + +const fn batch_preflight_failed_artifact( + error: &PackDayPrintError, + artifact: PackDayBatchPrintArtifact, +) -> Option<PackDayBatchPrintArtifact> { + match error { + PackDayPrintError::MissingBundleDirectory { .. } => None, + _ => Some(artifact), + } +} + pub fn execute_pack_day_print_plan( plan: &PackDayPrintCommandPlan, ) -> Result<(), PackDayPrintError> { @@ -485,9 +545,11 @@ fn render_customer_label_stock_asset( stock: PackDayPrintLabelStock, ) -> Result<String, PackDayPrintError> { match stock { - PackDayPrintLabelStock::Avery5160Letter30Up => render_avery_5160_customer_labels_postscript( - parse_customer_label_blocks(source_contents), - ), + PackDayPrintLabelStock::Avery5160Letter30Up => { + render_avery_5160_customer_labels_postscript(parse_customer_label_blocks( + source_contents, + )) + } } } @@ -701,13 +763,14 @@ fn run_macos_print_command( mod tests { use super::{ cleanup_prepared_customer_label_asset_root, execute_pack_day_print_plan_with, - plan_pack_day_print, prepared_customer_label_asset_directory, + plan_pack_day_batch_print, plan_pack_day_print, prepared_customer_label_asset_directory, prepared_customer_label_asset_path, prepared_customer_label_asset_root, - PackDayPrintCommandResult, PackDayPrintError, LETTER_MEDIA_OPTION, + PackDayBatchPrintError, PackDayPrintCommandResult, PackDayPrintError, LETTER_MEDIA_OPTION, }; use radroots_app_models::{ - PackDayExportArtifact, PackDayExportArtifactKind, PackDayExportBundle, - PackDayExportInstanceId, PackDayPrintKind, PackDayPrintLabelStock, + PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, PackDayExportArtifact, + PackDayExportArtifactKind, PackDayExportBundle, PackDayExportInstanceId, PackDayPrintKind, + PackDayPrintLabelStock, }; use std::fs; use std::io; @@ -766,6 +829,12 @@ mod tests { path } + fn write_all_artifacts(bundle_directory: &PathBuf) { + write_artifact(bundle_directory, "pack_sheet.txt"); + write_artifact(bundle_directory, "pickup_roster.txt"); + write_artifact(bundle_directory, "customer_labels.txt"); + } + #[test] fn print_pack_sheet_plan_targets_the_exported_file_with_lp() { let temp_dir = TestDirectory::new(); @@ -958,6 +1027,143 @@ mod tests { } #[test] + fn batch_preflight_plans_all_v1_artifacts_in_order() { + let temp_dir = TestDirectory::new(); + write_all_artifacts(temp_dir.path()); + let bundle = sample_bundle(temp_dir.path()); + let prepared_path = prepared_customer_label_asset_path( + &bundle, + PackDayPrintLabelStock::Avery5160Letter30Up, + ); + + let plan = plan_pack_day_batch_print(&bundle).expect("batch preflight should build"); + + assert_eq!( + plan.plans.iter().map(|plan| plan.kind).collect::<Vec<_>>(), + vec![ + PackDayPrintKind::PrintPackSheet, + PackDayPrintKind::PrintPickupRoster, + PackDayPrintKind::PrintCustomerLabels, + ] + ); + assert_eq!( + plan.plans + .iter() + .map(|plan| plan.command_program) + .collect::<Vec<_>>(), + vec!["lp", "lp", "lp"] + ); + assert_eq!( + plan.plans[2].command_args, + vec![ + "-o".to_owned(), + LETTER_MEDIA_OPTION.to_owned(), + prepared_path.to_string_lossy().into_owned(), + ] + ); + assert!(prepared_path.is_file()); + + let _ = fs::remove_dir_all(prepared_customer_label_asset_directory(&bundle)); + } + + #[test] + fn batch_preflight_fails_closed_when_a_required_artifact_reference_is_missing() { + let temp_dir = TestDirectory::new(); + write_all_artifacts(temp_dir.path()); + let mut bundle = sample_bundle(temp_dir.path()); + bundle + .artifacts + .retain(|artifact| artifact.kind != PackDayExportArtifactKind::PickupRoster); + + let error = + plan_pack_day_batch_print(&bundle).expect_err("missing artifact should fail preflight"); + + assert_eq!( + error, + PackDayBatchPrintError::Preflight { + failed_artifact: Some(PackDayBatchPrintArtifact::from_print_kind( + PackDayPrintKind::PrintPickupRoster, + )), + source: PackDayPrintError::MissingArtifactReference { + kind: PackDayPrintKind::PrintPickupRoster, + artifact_kind: PackDayExportArtifactKind::PickupRoster, + }, + } + ); + assert_eq!( + error.failure_kind(), + PackDayBatchPrintFailureKind::Preflight + ); + assert!(!prepared_customer_label_asset_directory(&bundle).exists()); + } + + #[test] + fn batch_preflight_fails_closed_when_an_artifact_relative_path_is_invalid() { + let temp_dir = TestDirectory::new(); + write_all_artifacts(temp_dir.path()); + let mut bundle = sample_bundle(temp_dir.path()); + bundle.artifacts[0].relative_path = "../pack_sheet.txt".to_owned(); + + let error = + plan_pack_day_batch_print(&bundle).expect_err("invalid artifact path should fail"); + + assert_eq!( + error, + PackDayBatchPrintError::Preflight { + failed_artifact: Some(PackDayBatchPrintArtifact::from_print_kind( + PackDayPrintKind::PrintPackSheet, + )), + source: PackDayPrintError::InvalidArtifactRelativePath { + kind: PackDayPrintKind::PrintPackSheet, + relative_path: "../pack_sheet.txt".to_owned(), + }, + } + ); + assert_eq!( + error.failed_artifact(), + Some(PackDayBatchPrintArtifact::from_print_kind( + PackDayPrintKind::PrintPackSheet, + )) + ); + assert_eq!( + error.failure_kind(), + PackDayBatchPrintFailureKind::Preflight + ); + assert!(!prepared_customer_label_asset_directory(&bundle).exists()); + } + + #[test] + fn batch_preflight_surfaces_avery_5160_overflow_without_creating_prepared_assets() { + let temp_dir = TestDirectory::new(); + write_artifact(temp_dir.path(), "pack_sheet.txt"); + write_artifact(temp_dir.path(), "pickup_roster.txt"); + fs::write( + temp_dir.path().join("customer_labels.txt"), + "Willow farm\nCasey\nOrder R-1001\nPickup barn\nThursday\nKeep cold\nOverflow note\n", + ) + .expect("overflowing customer labels should write"); + let bundle = sample_bundle(temp_dir.path()); + + let error = plan_pack_day_batch_print(&bundle) + .expect_err("overflowing customer labels should fail batch preflight"); + + assert_eq!( + error, + PackDayBatchPrintError::Preflight { + failed_artifact: Some(PackDayBatchPrintArtifact::from_print_kind( + PackDayPrintKind::PrintCustomerLabels, + )), + source: PackDayPrintError::CustomerLabelsAvery5160Overflow, + } + ); + assert_eq!( + error.failure_kind(), + PackDayBatchPrintFailureKind::CustomerLabelsAvery5160Overflow + ); + assert!(!prepared_customer_label_asset_directory(&bundle).exists()); + } + + #[test] fn cleanup_prepared_customer_label_asset_root_removes_existing_directories() { let root = prepared_customer_label_asset_root(); let stale_directory = root.join(PackDayExportInstanceId::new().to_string());