app

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

commit 949007ee18b77b83b05904d63e29dfef9af6b378
parent 3ad6cf55370f16b6c0ac6f179111b5f45e7db9ec
Author: triesap <tyson@radroots.org>
Date:   Wed, 22 Apr 2026 20:46:21 +0000

pack_day: add stock-aware customer label planning

- derive avery 5160 print assets from exported customer_labels text
- bind prepared label assets to export_instance_id in an ephemeral temp path
- classify stock preparation directory and write failures before queue submission
- cover label planning, export scoping, and failure cases in pack day tests

Diffstat:
Mcrates/launchers/desktop/src/pack_day_print.rs | 462+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 443 insertions(+), 19 deletions(-)

diff --git a/crates/launchers/desktop/src/pack_day_print.rs b/crates/launchers/desktop/src/pack_day_print.rs @@ -1,11 +1,32 @@ +use std::fmt::Write as _; +use std::fs; use std::io; use std::path::{Component, Path, PathBuf}; #[cfg(target_os = "macos")] use std::process::Command; -use radroots_app_models::{PackDayExportArtifactKind, PackDayExportBundle, PackDayPrintKind}; +use radroots_app_models::{ + PackDayExportArtifactKind, PackDayExportBundle, PackDayPrintKind, PackDayPrintLabelStock, +}; use thiserror::Error; +const CUSTOMER_LABEL_PREPARED_ASSET_ROOT: &str = "radroots_app_pack_day_print"; +const AVERY_5160_LABELS_PER_ROW: usize = 3; +const AVERY_5160_LABEL_ROWS_PER_PAGE: usize = 10; +const AVERY_5160_LABELS_PER_PAGE: usize = + AVERY_5160_LABELS_PER_ROW * AVERY_5160_LABEL_ROWS_PER_PAGE; +const AVERY_5160_COLUMN_PITCH_POINTS: f32 = 198.0; +const AVERY_5160_ROW_PITCH_POINTS: f32 = 72.0; +const AVERY_5160_LEFT_MARGIN_POINTS: f32 = 13.5; +const AVERY_5160_TOP_MARGIN_POINTS: f32 = 36.0; +const AVERY_5160_PAGE_HEIGHT_POINTS: f32 = 792.0; +const AVERY_5160_TEXT_LEFT_PADDING_POINTS: f32 = 9.0; +const AVERY_5160_TEXT_TOP_PADDING_POINTS: f32 = 11.0; +const AVERY_5160_TEXT_LEADING_POINTS: f32 = 10.0; +const AVERY_5160_TEXT_FONT_SIZE_POINTS: f32 = 9.0; +const AVERY_5160_MAX_CHARS_PER_LINE: usize = 32; +const AVERY_5160_MAX_LINES_PER_LABEL: usize = 6; + #[derive(Clone, Debug, Eq, PartialEq)] pub struct PackDayPrintCommandPlan { pub kind: PackDayPrintKind, @@ -45,8 +66,6 @@ impl PackDayPrintCommandResult { pub enum PackDayPrintError { #[error("pack day export bundle directory does not exist: {path}")] MissingBundleDirectory { path: PathBuf }, - #[error("pack day print kind is not supported by this launcher path yet: {kind:?}")] - UnsupportedKind { kind: PackDayPrintKind }, #[error("pack day export bundle is missing required artifact {artifact_kind:?} for {kind:?}")] MissingArtifactReference { kind: PackDayPrintKind, @@ -67,6 +86,24 @@ pub enum PackDayPrintError { kind: PackDayPrintKind, path: PathBuf, }, + #[error("failed to read pack day print source artifact {path} for {kind:?}: {source}")] + ReadSourceArtifact { + kind: PackDayPrintKind, + path: PathBuf, + source: io::Error, + }, + #[error("failed to create prepared print asset directory {path} for {kind:?}: {source}")] + CreatePreparedAssetDirectory { + kind: PackDayPrintKind, + path: PathBuf, + source: io::Error, + }, + #[error("failed to write prepared print asset {path} for {kind:?}: {source}")] + WritePreparedAsset { + kind: PackDayPrintKind, + path: PathBuf, + source: io::Error, + }, #[error("pack day print is only supported on macos")] UnsupportedPlatform, #[error("failed to launch macos print command {program} for {kind:?}: {source}")] @@ -91,9 +128,6 @@ impl PartialEq for PackDayPrintError { Self::MissingBundleDirectory { path: left }, Self::MissingBundleDirectory { path: right }, ) => left == right, - (Self::UnsupportedKind { kind: left }, Self::UnsupportedKind { kind: right }) => { - left == right - } ( Self::MissingArtifactReference { kind: left_kind, @@ -134,6 +168,54 @@ impl PartialEq for PackDayPrintError { path: right_path, }, ) => left_kind == right_kind && left_path == right_path, + ( + Self::ReadSourceArtifact { + kind: left_kind, + path: left_path, + source: left_source, + }, + Self::ReadSourceArtifact { + kind: right_kind, + path: right_path, + source: right_source, + }, + ) => { + left_kind == right_kind + && left_path == right_path + && io_errors_match(left_source, right_source) + } + ( + Self::CreatePreparedAssetDirectory { + kind: left_kind, + path: left_path, + source: left_source, + }, + Self::CreatePreparedAssetDirectory { + kind: right_kind, + path: right_path, + source: right_source, + }, + ) => { + left_kind == right_kind + && left_path == right_path + && io_errors_match(left_source, right_source) + } + ( + Self::WritePreparedAsset { + kind: left_kind, + path: left_path, + source: left_source, + }, + Self::WritePreparedAsset { + kind: right_kind, + path: right_path, + source: right_source, + }, + ) => { + left_kind == right_kind + && left_path == right_path + && io_errors_match(left_source, right_source) + } (Self::UnsupportedPlatform, Self::UnsupportedPlatform) => true, ( Self::CommandLaunch { @@ -178,6 +260,10 @@ impl PartialEq for PackDayPrintError { impl Eq for PackDayPrintError {} +fn io_errors_match(left: &io::Error, right: &io::Error) -> bool { + left.kind() == right.kind() && left.to_string() == right.to_string() +} + pub fn plan_pack_day_print( bundle: &PackDayExportBundle, kind: PackDayPrintKind, @@ -189,14 +275,17 @@ pub fn plan_pack_day_print( }); } - let artifact_kind = match kind { - PackDayPrintKind::PrintPackSheet => PackDayExportArtifactKind::PackSheet, - PackDayPrintKind::PrintPickupRoster => PackDayExportArtifactKind::PickupRoster, + let target_path = match kind { + PackDayPrintKind::PrintPackSheet => { + resolve_bundle_artifact_path(bundle, PackDayExportArtifactKind::PackSheet, kind)? + } + PackDayPrintKind::PrintPickupRoster => { + resolve_bundle_artifact_path(bundle, PackDayExportArtifactKind::PickupRoster, kind)? + } PackDayPrintKind::PrintCustomerLabels => { - return Err(PackDayPrintError::UnsupportedKind { kind }); + prepare_customer_label_stock_asset(bundle, PackDayPrintLabelStock::Avery5160Letter30Up)? } }; - let target_path = resolve_bundle_artifact_path(bundle, artifact_kind, kind)?; Ok(PackDayPrintCommandPlan { kind, @@ -263,6 +352,224 @@ fn resolve_bundle_artifact_path( Ok(path) } +fn prepare_customer_label_stock_asset( + bundle: &PackDayExportBundle, + stock: PackDayPrintLabelStock, +) -> Result<PathBuf, PackDayPrintError> { + let kind = PackDayPrintKind::PrintCustomerLabels; + let source_path = + resolve_bundle_artifact_path(bundle, PackDayExportArtifactKind::CustomerLabels, kind)?; + let source_contents = fs::read_to_string(&source_path).map_err(|source| { + PackDayPrintError::ReadSourceArtifact { + kind, + path: source_path.clone(), + source, + } + })?; + let target_directory = prepared_customer_label_asset_directory(bundle); + fs::create_dir_all(&target_directory).map_err(|source| { + PackDayPrintError::CreatePreparedAssetDirectory { + kind, + path: target_directory.clone(), + source, + } + })?; + 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| { + PackDayPrintError::WritePreparedAsset { + kind, + path: target_path.clone(), + source, + } + })?; + + Ok(target_path) +} + +fn prepared_customer_label_asset_directory(bundle: &PackDayExportBundle) -> PathBuf { + std::env::temp_dir() + .join(CUSTOMER_LABEL_PREPARED_ASSET_ROOT) + .join(bundle.export_instance_id.to_string()) +} + +fn prepared_customer_label_asset_path( + bundle: &PackDayExportBundle, + stock: PackDayPrintLabelStock, +) -> PathBuf { + prepared_customer_label_asset_directory(bundle) + .join(format!("customer_labels_{}.ps", stock.storage_key())) +} + +fn render_customer_label_stock_asset( + source_contents: &str, + stock: PackDayPrintLabelStock, +) -> String { + match stock { + PackDayPrintLabelStock::Avery5160Letter30Up => { + render_avery_5160_customer_labels_postscript(parse_customer_label_blocks( + source_contents, + )) + } + } +} + +fn parse_customer_label_blocks(source_contents: &str) -> Vec<Vec<String>> { + let blocks = source_contents + .trim() + .split("\n\n---\n\n") + .filter_map(|block| { + let lines = block + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect::<Vec<_>>(); + (!lines.is_empty()).then_some(lines) + }) + .collect::<Vec<_>>(); + + if blocks.is_empty() { + vec![vec!["No customer labels".to_owned()]] + } else { + blocks + } +} + +fn render_avery_5160_customer_labels_postscript(blocks: Vec<Vec<String>>) -> String { + let page_count = blocks.len().div_ceil(AVERY_5160_LABELS_PER_PAGE); + let mut rendered = String::new(); + + let _ = writeln!(&mut rendered, "%!PS-Adobe-3.0"); + let _ = writeln!(&mut rendered, "%%Creator: radroots_app"); + let _ = writeln!(&mut rendered, "%%Pages: {page_count}"); + let _ = writeln!(&mut rendered, "%%BoundingBox: 0 0 612 792"); + let _ = writeln!(&mut rendered, "%%EndComments"); + + for (page_index, page_blocks) in blocks.chunks(AVERY_5160_LABELS_PER_PAGE).enumerate() { + let page_number = page_index + 1; + let _ = writeln!(&mut rendered, "%%Page: {page_number} {page_number}"); + let _ = writeln!( + &mut rendered, + "/Courier findfont {} scalefont setfont", + AVERY_5160_TEXT_FONT_SIZE_POINTS + ); + + for (slot_index, block) in page_blocks.iter().enumerate() { + let row = slot_index / AVERY_5160_LABELS_PER_ROW; + let column = slot_index % AVERY_5160_LABELS_PER_ROW; + let left = AVERY_5160_LEFT_MARGIN_POINTS + + (column as f32 * AVERY_5160_COLUMN_PITCH_POINTS) + + AVERY_5160_TEXT_LEFT_PADDING_POINTS; + let top = AVERY_5160_PAGE_HEIGHT_POINTS + - AVERY_5160_TOP_MARGIN_POINTS + - (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() { + let baseline = top - (line_index as f32 * AVERY_5160_TEXT_LEADING_POINTS); + let escaped = escape_postscript_text(&line); + let _ = writeln!( + &mut rendered, + "{left:.2} {baseline:.2} moveto ({escaped}) show" + ); + } + } + + let _ = writeln!(&mut rendered, "showpage"); + } + + rendered +} + +fn wrap_customer_label_block(lines: &[String]) -> Vec<String> { + 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; + } + wrapped.push(segment); + } + } + + wrapped +} + +fn wrap_customer_label_line(line: &str) -> Vec<String> { + let trimmed = line.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + + let mut wrapped = Vec::new(); + let mut current = String::new(); + + for word in trimmed.split_whitespace() { + let word_len = word.chars().count(); + if word_len > AVERY_5160_MAX_CHARS_PER_LINE { + if !current.is_empty() { + wrapped.push(std::mem::take(&mut current)); + } + push_chunked_word(word, &mut wrapped); + continue; + } + + if current.is_empty() { + current.push_str(word); + continue; + } + + if current.chars().count() + 1 + word_len <= AVERY_5160_MAX_CHARS_PER_LINE { + current.push(' '); + current.push_str(word); + continue; + } + + wrapped.push(std::mem::take(&mut current)); + current.push_str(word); + } + + if !current.is_empty() { + wrapped.push(current); + } + + wrapped +} + +fn push_chunked_word(word: &str, wrapped: &mut Vec<String>) { + let mut chunk = String::new(); + + for character in word.chars() { + if chunk.chars().count() == AVERY_5160_MAX_CHARS_PER_LINE { + wrapped.push(std::mem::take(&mut chunk)); + } + chunk.push(character); + } + + if !chunk.is_empty() { + wrapped.push(chunk); + } +} + +fn escape_postscript_text(line: &str) -> String { + let mut escaped = String::with_capacity(line.len()); + + for character in line.chars() { + match character { + '(' | ')' | '\\' => { + escaped.push('\\'); + escaped.push(character); + } + '\n' | '\r' | '\t' => escaped.push(' '), + _ => escaped.push(character), + } + } + + escaped +} + fn execute_pack_day_print_plan_with( plan: &PackDayPrintCommandPlan, run_command: impl FnOnce(&PackDayPrintCommandPlan) -> Result<PackDayPrintCommandResult, io::Error>, @@ -304,11 +611,12 @@ fn run_macos_print_command( mod tests { use super::{ PackDayPrintCommandResult, PackDayPrintError, execute_pack_day_print_plan_with, - plan_pack_day_print, + plan_pack_day_print, prepared_customer_label_asset_directory, + prepared_customer_label_asset_path, }; use radroots_app_models::{ PackDayExportArtifact, PackDayExportArtifactKind, PackDayExportBundle, - PackDayExportInstanceId, PackDayPrintKind, + PackDayExportInstanceId, PackDayPrintKind, PackDayPrintLabelStock, }; use std::fs; use std::io; @@ -404,19 +712,135 @@ mod tests { } #[test] - fn customer_labels_are_deferred_to_the_stock_preparation_slice() { + fn customer_labels_plan_derives_a_stock_aware_asset_outside_the_export_bundle() { let temp_dir = TestDirectory::new(); + let source_path = temp_dir.path().join("customer_labels.txt"); + fs::write( + &source_path, + "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", + ) + .expect("customer labels should write"); let bundle = sample_bundle(temp_dir.path()); + let prepared_path = prepared_customer_label_asset_path( + &bundle, + PackDayPrintLabelStock::Avery5160Letter30Up, + ); - let error = plan_pack_day_print(&bundle, PackDayPrintKind::PrintCustomerLabels) - .expect_err("customer labels should remain deferred"); + let plan = plan_pack_day_print(&bundle, PackDayPrintKind::PrintCustomerLabels) + .expect("customer labels plan should build"); + assert_eq!(plan.kind, PackDayPrintKind::PrintCustomerLabels); + assert_eq!(plan.target_path, prepared_path.clone()); + assert_eq!(plan.command_program, "lp"); assert_eq!( - error, - PackDayPrintError::UnsupportedKind { - kind: PackDayPrintKind::PrintCustomerLabels, + plan.command_args, + vec![prepared_path.to_string_lossy().into_owned()] + ); + assert!(plan.target_path.is_file()); + assert!(!plan.target_path.starts_with(temp_dir.path())); + assert!( + plan.target_path + .to_string_lossy() + .contains(bundle.export_instance_id.to_string().as_str()) + ); + assert_eq!( + fs::read_to_string(&source_path).expect("source labels should stay untouched"), + "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" + ); + + let prepared_contents = + fs::read_to_string(&prepared_path).expect("prepared labels should render"); + assert!(prepared_contents.contains("%!PS-Adobe-3.0")); + assert!(prepared_contents.contains("%%Pages: 1")); + assert!(prepared_contents.contains("(Casey) show")); + assert!(prepared_contents.contains("(Taylor) show")); + assert!( + prepared_contents.contains("(Order: R-1001) show") + || prepared_contents.contains("(Order: R-1002) show") + ); + + let _ = fs::remove_dir_all(prepared_customer_label_asset_directory(&bundle)); + } + + #[test] + fn customer_label_stock_assets_are_scoped_by_export_instance_id() { + let temp_dir = TestDirectory::new(); + fs::write( + temp_dir.path().join("customer_labels.txt"), + "Willow farm\nCasey\nOrder: R-1001\n", + ) + .expect("customer labels should write"); + let bundle = sample_bundle(temp_dir.path()); + let other_bundle = PackDayExportBundle { + export_instance_id: PackDayExportInstanceId::from(Uuid::new_v4()), + ..bundle.clone() + }; + + let plan = plan_pack_day_print(&bundle, PackDayPrintKind::PrintCustomerLabels) + .expect("first customer labels plan should build"); + let other_plan = plan_pack_day_print(&other_bundle, PackDayPrintKind::PrintCustomerLabels) + .expect("second customer labels plan should build"); + + assert_ne!(plan.target_path, other_plan.target_path); + assert!(plan.target_path.is_file()); + assert!(other_plan.target_path.is_file()); + + let _ = fs::remove_dir_all(prepared_customer_label_asset_directory(&bundle)); + let _ = fs::remove_dir_all(prepared_customer_label_asset_directory(&other_bundle)); + } + + #[test] + fn customer_label_stock_preparation_classifies_directory_creation_failures() { + let temp_dir = TestDirectory::new(); + write_artifact(temp_dir.path(), "customer_labels.txt"); + let bundle = sample_bundle(temp_dir.path()); + let prepared_directory = prepared_customer_label_asset_directory(&bundle); + if let Some(parent) = prepared_directory.parent() { + fs::create_dir_all(parent).expect("prepared asset parent should create"); + } + fs::write(&prepared_directory, "blocked").expect("blocking file should write"); + + let error = plan_pack_day_print(&bundle, PackDayPrintKind::PrintCustomerLabels) + .expect_err("prepared directory failure should surface"); + + match error { + PackDayPrintError::CreatePreparedAssetDirectory { kind, path, source } => { + assert_eq!(kind, PackDayPrintKind::PrintCustomerLabels); + assert_eq!(path, prepared_directory); + assert_eq!(source.kind(), io::ErrorKind::AlreadyExists); } + other => panic!("unexpected error: {other:?}"), + } + + let _ = fs::remove_file(prepared_directory); + } + + #[test] + fn customer_label_stock_preparation_classifies_write_failures() { + let temp_dir = TestDirectory::new(); + write_artifact(temp_dir.path(), "customer_labels.txt"); + let bundle = sample_bundle(temp_dir.path()); + let prepared_directory = prepared_customer_label_asset_directory(&bundle); + fs::create_dir_all(&prepared_directory).expect("prepared directory should create"); + let prepared_path = prepared_customer_label_asset_path( + &bundle, + PackDayPrintLabelStock::Avery5160Letter30Up, ); + fs::create_dir_all(&prepared_path).expect("prepared asset directory should block writes"); + + let error = plan_pack_day_print(&bundle, PackDayPrintKind::PrintCustomerLabels) + .expect_err("prepared asset write failure should surface"); + + match error { + PackDayPrintError::WritePreparedAsset { kind, path, source } => { + assert_eq!(kind, PackDayPrintKind::PrintCustomerLabels); + assert_eq!(path, prepared_path); + assert_eq!(source.kind(), io::ErrorKind::IsADirectory); + } + other => panic!("unexpected error: {other:?}"), + } + + let _ = fs::remove_dir_all(prepared_customer_label_asset_directory(&bundle)); } #[test]