app

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

commit 7abe7b8b35c2799cf25381f0f4c0523075d0ba1a
parent 7faace3aab6b2b4a6accb856dca5c44d129d4438
Author: triesap <tyson@radroots.org>
Date:   Wed, 22 Apr 2026 22:15:56 +0000

app: fail closed on customer-label overflow

- reject Avery 5160 customer-label blocks that wrap past six lines
- surface overflow through typed pack-day print failure state
- avoid creating prepared label assets when planning overflows
- cover planner and runtime overflow behavior with pack-day tests

Diffstat:
Mcrates/launchers/desktop/src/pack_day_print.rs | 65++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcrates/launchers/desktop/src/runtime.rs | 96++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
2 files changed, 133 insertions(+), 28 deletions(-)

diff --git a/crates/launchers/desktop/src/pack_day_print.rs b/crates/launchers/desktop/src/pack_day_print.rs @@ -6,8 +6,8 @@ use std::path::{Component, Path, PathBuf}; use std::process::Command; use radroots_app_models::{ - PackDayExportArtifactKind, PackDayExportBundle, PackDayExportInstanceId, PackDayPrintKind, - PackDayPrintLabelStock, + PackDayExportArtifactKind, PackDayExportBundle, PackDayExportInstanceId, + PackDayPrintFailureKind, PackDayPrintKind, PackDayPrintLabelStock, }; use thiserror::Error; @@ -108,6 +108,8 @@ pub enum PackDayPrintError { path: PathBuf, source: io::Error, }, + #[error("customer label content exceeds the six-line Avery 5160 layout")] + CustomerLabelsAvery5160Overflow, #[error("pack day print is only supported on macos")] UnsupportedPlatform, #[error("failed to launch macos print command {program} for {kind:?}: {source}")] @@ -220,6 +222,10 @@ impl PartialEq for PackDayPrintError { && left_path == right_path && io_errors_match(left_source, right_source) } + ( + Self::CustomerLabelsAvery5160Overflow, + Self::CustomerLabelsAvery5160Overflow, + ) => true, (Self::UnsupportedPlatform, Self::UnsupportedPlatform) => true, ( Self::CommandLaunch { @@ -264,6 +270,17 @@ impl PartialEq for PackDayPrintError { impl Eq for PackDayPrintError {} +impl PackDayPrintError { + pub(crate) const fn failure_kind(&self) -> Option<PackDayPrintFailureKind> { + match self { + Self::CustomerLabelsAvery5160Overflow => { + Some(PackDayPrintFailureKind::CustomerLabelsAvery5160Overflow) + } + _ => None, + } + } +} + fn io_errors_match(left: &io::Error, right: &io::Error) -> bool { left.kind() == right.kind() && left.to_string() == right.to_string() } @@ -400,6 +417,7 @@ fn prepare_customer_label_stock_asset( source, } })?; + let prepared_asset = render_customer_label_stock_asset(&source_contents, stock)?; let target_directory = prepared_customer_label_asset_directory(bundle); fs::create_dir_all(&target_directory).map_err(|source| { PackDayPrintError::CreatePreparedAssetDirectory { @@ -409,7 +427,6 @@ fn prepare_customer_label_stock_asset( } })?; let target_path = prepared_customer_label_asset_path(bundle, stock); - let prepared_asset = render_customer_label_stock_asset(&source_contents, stock); fs::write(&target_path, prepared_asset).map_err(|source| { let _ = cleanup_prepared_customer_label_assets_for_export_instance(bundle.export_instance_id); @@ -466,13 +483,11 @@ fn prepared_customer_label_asset_path( fn render_customer_label_stock_asset( source_contents: &str, stock: PackDayPrintLabelStock, -) -> String { +) -> 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), + ), } } @@ -498,7 +513,9 @@ fn parse_customer_label_blocks(source_contents: &str) -> Vec<Vec<String>> { } } -fn render_avery_5160_customer_labels_postscript(blocks: Vec<Vec<String>>) -> String { +fn render_avery_5160_customer_labels_postscript( + blocks: Vec<Vec<String>>, +) -> Result<String, PackDayPrintError> { let page_count = blocks.len().div_ceil(AVERY_5160_LABELS_PER_PAGE); let mut rendered = String::new(); @@ -539,7 +556,7 @@ fn render_avery_5160_customer_labels_postscript(blocks: Vec<Vec<String>>) -> Str - (row as f32 * AVERY_5160_ROW_PITCH_POINTS) - AVERY_5160_TEXT_TOP_PADDING_POINTS; - for (line_index, line) in wrap_customer_label_block(block).into_iter().enumerate() { + for (line_index, line) in wrap_customer_label_block(block)?.into_iter().enumerate() { let baseline = top - (line_index as f32 * AVERY_5160_TEXT_LEADING_POINTS); let escaped = escape_postscript_text(&line); let _ = writeln!( @@ -552,22 +569,22 @@ fn render_avery_5160_customer_labels_postscript(blocks: Vec<Vec<String>>) -> Str let _ = writeln!(&mut rendered, "showpage"); } - rendered + Ok(rendered) } -fn wrap_customer_label_block(lines: &[String]) -> Vec<String> { +fn wrap_customer_label_block(lines: &[String]) -> Result<Vec<String>, PackDayPrintError> { let mut wrapped = Vec::new(); for line in lines { for segment in wrap_customer_label_line(line) { if wrapped.len() == AVERY_5160_MAX_LINES_PER_LABEL { - return wrapped; + return Err(PackDayPrintError::CustomerLabelsAvery5160Overflow); } wrapped.push(segment); } } - wrapped + Ok(wrapped) } fn wrap_customer_label_line(line: &str) -> Vec<String> { @@ -923,6 +940,24 @@ mod tests { } #[test] + fn customer_label_planning_rejects_avery_5160_overflow_without_creating_prepared_assets() { + let temp_dir = TestDirectory::new(); + 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 prepared_directory = prepared_customer_label_asset_directory(&bundle); + + let error = plan_pack_day_print(&bundle, PackDayPrintKind::PrintCustomerLabels) + .expect_err("overflowing customer labels should fail"); + + assert_eq!(error, PackDayPrintError::CustomerLabelsAvery5160Overflow); + assert!(!prepared_directory.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()); diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -20,11 +20,11 @@ use radroots_app_models::{ PackDayExportInstanceId, PackDayExportStatus, PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayPrintKind, PackDayPrintStatus, PackDayProjection, PackDayScreenQueryState, PersonalSection, PickupLocationRecord, ProductEditorDraft, ProductId, ProductsFilter, - ProductsListProjection, ProductsSort, RecoveryKind, RecoveryQueueProjection, RecoveryRecordId, - RecoveryState, ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, - ReminderId, ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, - ReminderUrgency, SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, - TodayAgendaProjection, + ProductsListProjection, ProductsSort, RecoveryKind, RecoveryQueueProjection, + RecoveryRecordId, RecoveryState, ReminderDeadlineProjection, ReminderDeliveryState, + ReminderFeedProjection, ReminderId, ReminderKind, ReminderLogEntryProjection, + ReminderLogProjection, ReminderSurface, ReminderUrgency, SettingsAccountProjection, + SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, @@ -1911,9 +1911,16 @@ impl DesktopAppRuntimeState { match plan_pack_day_print(&bundle, kind) { Ok(plan) => Ok(Some((request, plan))), Err(error) => { + let failure_command = match error.failure_kind() { + Some(failure) => AppStateCommand::fail_pack_day_print_with_kind( + request, + failure, + ), + None => AppStateCommand::fail_pack_day_print(request), + }; let _ = self .state_store - .apply_in_memory(AppStateCommand::fail_pack_day_print(request)); + .apply_in_memory(failure_command); Err(error.into()) } } @@ -1945,9 +1952,16 @@ impl DesktopAppRuntimeState { Ok(changed) } Err(error) => { + let failure_command = match error.failure_kind() { + Some(failure) => AppStateCommand::fail_pack_day_print_with_kind( + request, + failure, + ), + None => AppStateCommand::fail_pack_day_print(request), + }; let _ = self .state_store - .apply_in_memory(AppStateCommand::fail_pack_day_print(request)); + .apply_in_memory(failure_command); if let Some(export_instance_id) = cleanup_export_instance_id { self.cleanup_prepared_pack_day_print_assets_for_export_instance( export_instance_id, @@ -4711,12 +4725,12 @@ mod tests { FarmerActivationProjection, FarmerSection, FulfillmentWindowId, FulfillmentWindowRecord, LoggedOutStartupProjection, OrderId, OrderStatus, OrdersFilter, PackDayExportInstanceId, PackDayExportStatus, PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayPackListRow, - PackDayPrintKind, PackDayPrintStatus, PackDayProductTotalRow, PackDayProjection, - PackDayRosterRow, PersonalSection, PickupLocationId, PickupLocationRecord, - ProductEditorDraft, ProductStatus, ProductsFilter, ProductsSort, RecoveryKind, - RecoveryRecordId, ReminderDeliveryState, ReminderFeedProjection, ReminderKind, - SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection, - TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, + PackDayPrintFailureKind, PackDayPrintKind, PackDayPrintStatus, PackDayProductTotalRow, + PackDayProjection, PackDayRosterRow, PersonalSection, PickupLocationId, + PickupLocationRecord, ProductEditorDraft, ProductStatus, ProductsFilter, ProductsSort, + RecoveryKind, RecoveryRecordId, ReminderDeliveryState, ReminderFeedProjection, + ReminderKind, SelectedSurfaceProjection, SettingsPreference, SettingsSection, + ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, @@ -7540,6 +7554,62 @@ mod tests { PackDayPrintStatus::Failed ); assert_eq!(summary.pack_day_projection.print.request, Some(request)); + assert_eq!(summary.pack_day_projection.print.failure, None); + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] + fn runtime_prepare_pack_day_print_surfaces_customer_label_overflow_as_a_typed_failure() { + let (runtime, paths) = bootstrapped_runtime("pack_day_print_overflow_failure"); + let (_, farm_id) = provision_ready_farmer_account(&runtime); + + seed_order_workspace(&runtime, farm_id); + assert!(runtime.open_pack_day(None).expect("pack day should open")); + assert!(runtime + .export_pack_day() + .expect("pack day export should succeed")); + + let bundle = runtime + .summary() + .pack_day_projection + .export + .bundle + .clone() + .expect("pack day export bundle"); + let customer_labels_path = + PathBuf::from(&bundle.bundle_directory).join("customer_labels.txt"); + fs::write( + &customer_labels_path, + "Willow farm\nCasey\nOrder R-1001\nPickup barn\nThursday\nKeep cold\nOverflow note\n", + ) + .expect("overflowing customer labels should write"); + + let error = runtime + .prepare_pack_day_print(PackDayPrintKind::PrintCustomerLabels) + .expect_err("overflowing customer labels should fail"); + assert!(matches!( + error, + DesktopAppRuntimeCommandError::PackDayPrint( + PackDayPrintError::CustomerLabelsAvery5160Overflow + ) + )); + + let summary = runtime.summary(); + let print = &summary.pack_day_projection.print; + assert_eq!(print.status, PackDayPrintStatus::Failed); + assert_eq!( + print.request.as_ref().map(|request| request.kind), + Some(PackDayPrintKind::PrintCustomerLabels) + ); + assert_eq!( + print.request.as_ref().map(|request| request.export_instance_id), + Some(bundle.export_instance_id) + ); + assert_eq!( + print.failure, + Some(PackDayPrintFailureKind::CustomerLabelsAvery5160Overflow) + ); cleanup_bootstrapped_runtime_paths(&paths); }