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:
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);
}