app

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

commit e8fd182ad92846b56ff8229bfa624e22333f1173
parent e10c07f838c1b586fc0b0c22d0f489a7fb214be6
Author: triesap <tyson@radroots.org>
Date:   Tue, 21 Apr 2026 18:04:28 +0000

pack_day: add export writer

Diffstat:
MCargo.lock | 1+
Mcrates/shared/core/Cargo.toml | 1+
Mcrates/shared/core/src/lib.rs | 6++++++
Acrates/shared/core/src/pack_day_export.rs | 442+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 450 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -5063,6 +5063,7 @@ name = "radroots_app_core" version = "0.1.0" dependencies = [ "chrono", + "radroots_app_models", "serde", "serde_json", "thiserror 2.0.18", diff --git a/crates/shared/core/Cargo.toml b/crates/shared/core/Cargo.toml @@ -9,6 +9,7 @@ publish = false [dependencies] chrono.workspace = true +radroots_app_models.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/shared/core/src/lib.rs b/crates/shared/core/src/lib.rs @@ -1,6 +1,7 @@ #![forbid(unsafe_code)] mod logging; +mod pack_day_export; mod paths; mod runtime; mod startup; @@ -9,6 +10,11 @@ pub use logging::{ APP_LOG_PRODUCT, APP_LOG_SCHEMA_VERSION, AppLoggingError, AppLoggingOptions, app_runtime_log_dir, bootstrap_logging, init_logging, install_panic_hook, }; +pub use pack_day_export::{ + APP_EXPORTS_DIR_NAME, PACK_DAY_EXPORTS_DIR_NAME, PackDayExportDocument, + PackDayExportWriteError, PreparedPackDayExportBundle, app_exports_root, + prepare_pack_day_export_bundle, write_prepared_pack_day_export_bundle, +}; pub use paths::{ APP_RUNTIME_NAMESPACE, APP_RUNTIME_NAMESPACE_KIND, APP_RUNTIME_NAMESPACE_VALUE, AppDesktopRuntimePaths, AppRuntimeHostEnvironment, AppRuntimePathsError, AppRuntimePlatform, diff --git a/crates/shared/core/src/pack_day_export.rs b/crates/shared/core/src/pack_day_export.rs @@ -0,0 +1,442 @@ +use std::{ + fs, io, + path::{Path, PathBuf}, +}; + +use chrono::{DateTime, Utc}; +use radroots_app_models::{ + PackDayExportArtifact, PackDayExportArtifactKind, PackDayExportBundle, PackDayOutputSource, +}; +use thiserror::Error; + +use crate::AppRuntimeRoots; + +pub const APP_EXPORTS_DIR_NAME: &str = "exports"; +pub const PACK_DAY_EXPORTS_DIR_NAME: &str = "pack_day"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PackDayExportDocument { + pub kind: PackDayExportArtifactKind, + pub absolute_path: PathBuf, + pub contents: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreparedPackDayExportBundle { + pub bundle: PackDayExportBundle, + pub documents: Vec<PackDayExportDocument>, +} + +impl PreparedPackDayExportBundle { + pub fn artifact_path(&self, kind: PackDayExportArtifactKind) -> Option<&Path> { + self.documents + .iter() + .find(|document| document.kind == kind) + .map(|document| document.absolute_path.as_path()) + } + + pub fn artifact_contents(&self, kind: PackDayExportArtifactKind) -> Option<&str> { + self.documents + .iter() + .find(|document| document.kind == kind) + .map(|document| document.contents.as_str()) + } +} + +#[derive(Debug, Error)] +pub enum PackDayExportWriteError { + #[error("failed to create export directory {path}: {source}")] + CreateDirectory { path: PathBuf, source: io::Error }, + #[error("failed to write export file {path}: {source}")] + WriteFile { path: PathBuf, source: io::Error }, +} + +pub fn app_exports_root(roots: &AppRuntimeRoots) -> PathBuf { + roots.data.join(APP_EXPORTS_DIR_NAME) +} + +pub fn prepare_pack_day_export_bundle( + roots: &AppRuntimeRoots, + source: &PackDayOutputSource, + generated_at: DateTime<Utc>, +) -> PreparedPackDayExportBundle { + let timestamp = format_bundle_timestamp(generated_at); + let bundle_directory = app_exports_root(roots) + .join(PACK_DAY_EXPORTS_DIR_NAME) + .join(source.fulfillment_window.fulfillment_window_id.to_string()) + .join(timestamp); + let artifacts = Vec::from(PackDayExportArtifactKind::all_v1()) + .into_iter() + .map(|kind| PackDayExportArtifact { + kind, + relative_path: kind.file_name().to_owned(), + }) + .collect::<Vec<_>>(); + let bundle = PackDayExportBundle { + fulfillment_window_id: source.fulfillment_window.fulfillment_window_id, + generated_at_utc: generated_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + bundle_directory: bundle_directory.to_string_lossy().into_owned(), + artifacts, + }; + let documents = bundle + .artifacts + .iter() + .map(|artifact| PackDayExportDocument { + kind: artifact.kind, + absolute_path: bundle_directory.join(&artifact.relative_path), + contents: match artifact.kind { + PackDayExportArtifactKind::PackSheet => render_pack_sheet(source), + PackDayExportArtifactKind::PickupRoster => render_pickup_roster(source), + PackDayExportArtifactKind::CustomerLabels => render_customer_labels(source), + }, + }) + .collect(); + + PreparedPackDayExportBundle { bundle, documents } +} + +pub fn write_prepared_pack_day_export_bundle( + prepared: &PreparedPackDayExportBundle, +) -> Result<(), PackDayExportWriteError> { + let bundle_directory = PathBuf::from(&prepared.bundle.bundle_directory); + fs::create_dir_all(&bundle_directory).map_err(|source| { + PackDayExportWriteError::CreateDirectory { + path: bundle_directory, + source, + } + })?; + + for document in &prepared.documents { + fs::write(&document.absolute_path, &document.contents).map_err(|source| { + PackDayExportWriteError::WriteFile { + path: document.absolute_path.clone(), + source, + } + })?; + } + + Ok(()) +} + +fn format_bundle_timestamp(generated_at: DateTime<Utc>) -> String { + generated_at.format("%Y%m%dT%H%M%SZ").to_string() +} + +fn render_pack_sheet(source: &PackDayOutputSource) -> String { + let mut lines = render_export_header("Pack day", source); + lines.push(String::new()); + lines.push("Totals by product".to_owned()); + if source.totals_by_product.is_empty() { + lines.push("- none".to_owned()); + } else { + lines.extend( + source + .totals_by_product + .iter() + .map(|row| format!("- {} | {}", row.title, format_quantity(&row.quantity))), + ); + } + lines.push(String::new()); + lines.push("Pack list".to_owned()); + if source.pack_list.is_empty() { + lines.push("- none".to_owned()); + } else { + lines.extend(source.pack_list.iter().map(|row| { + format!( + "- {} | {} | {} | {} | {}", + row.customer_display_name, + row.order_number, + row.order_state.storage_key(), + row.title, + format_quantity(&row.quantity) + ) + })); + } + + finalize_export_lines(lines) +} + +fn render_pickup_roster(source: &PackDayOutputSource) -> String { + let mut lines = render_export_header("Pickup roster", source); + lines.push(String::new()); + lines.push("Orders".to_owned()); + if source.pickup_roster.is_empty() { + lines.push("- none".to_owned()); + } else { + lines.extend(source.pickup_roster.iter().map(|row| { + format!( + "- {} | {} | {}", + row.customer_display_name, + row.order_number, + row.order_state.storage_key() + ) + })); + } + + finalize_export_lines(lines) +} + +fn render_customer_labels(source: &PackDayOutputSource) -> String { + let mut blocks = Vec::new(); + + for row in &source.pickup_roster { + let mut lines = vec![ + source.fulfillment_window.farm_display_name.clone(), + row.customer_display_name.clone(), + format!("Order: {}", row.order_number), + ]; + if let Some(pickup_location_label) = + source.fulfillment_window.pickup_location_label.as_ref() + { + lines.push(format!("Pickup: {pickup_location_label}")); + } + lines.push(format!( + "Window: {} to {}", + source.fulfillment_window.starts_at, source.fulfillment_window.ends_at + )); + blocks.push(lines.join("\n")); + } + + if blocks.is_empty() { + blocks.push( + vec![ + source.fulfillment_window.farm_display_name.clone(), + "No customer labels".to_owned(), + format!( + "Window: {} to {}", + source.fulfillment_window.starts_at, source.fulfillment_window.ends_at + ), + ] + .join("\n"), + ); + } + + format!("{}\n", blocks.join("\n\n---\n\n")) +} + +fn render_export_header(title: &str, source: &PackDayOutputSource) -> Vec<String> { + let mut lines = vec![ + format!("Radroots {title}"), + format!("Farm: {}", source.fulfillment_window.farm_display_name), + format!( + "Window: {} to {}", + source.fulfillment_window.starts_at, source.fulfillment_window.ends_at + ), + ]; + if let Some(pickup_location_label) = source.fulfillment_window.pickup_location_label.as_ref() { + lines.push(format!("Pickup location: {pickup_location_label}")); + } + lines +} + +fn finalize_export_lines(lines: Vec<String>) -> String { + format!("{}\n", lines.join("\n")) +} + +fn format_quantity(quantity: &radroots_app_models::PackDayOutputQuantity) -> String { + let unit_label = quantity.unit_label.trim(); + if unit_label.is_empty() { + quantity.value.to_string() + } else { + format!("{} {}", quantity.value, unit_label) + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, + }; + + use chrono::{TimeZone, Utc}; + use radroots_app_models::{ + FarmId, FulfillmentWindowId, OrderId, PackDayExportArtifactKind, + PackDayOutputCustomerOrder, PackDayOutputOrderState, PackDayOutputPackListEntry, + PackDayOutputProductTotal, PackDayOutputQuantity, PackDayOutputSource, PackDayOutputWindow, + }; + + use super::{ + APP_EXPORTS_DIR_NAME, PACK_DAY_EXPORTS_DIR_NAME, app_exports_root, + prepare_pack_day_export_bundle, write_prepared_pack_day_export_bundle, + }; + use crate::AppRuntimeRoots; + + #[test] + fn export_root_uses_existing_app_data_namespace() { + let roots = AppRuntimeRoots::from_base_root("/Users/treesap/.radroots").namespaced_app(); + + assert_eq!( + app_exports_root(&roots), + PathBuf::from("/Users/treesap/.radroots/data/apps/app").join(APP_EXPORTS_DIR_NAME) + ); + } + + #[test] + fn prepared_bundle_freezes_path_shape_and_file_names() { + let roots = AppRuntimeRoots::from_base_root("/Users/treesap/.radroots").namespaced_app(); + let source = sample_source(); + let generated_at = Utc + .with_ymd_and_hms(2026, 4, 23, 15, 0, 0) + .single() + .expect("timestamp should build"); + + let prepared = prepare_pack_day_export_bundle(&roots, &source, generated_at); + + assert_eq!( + prepared.bundle.bundle_directory, + roots + .data + .join(APP_EXPORTS_DIR_NAME) + .join(PACK_DAY_EXPORTS_DIR_NAME) + .join(source.fulfillment_window.fulfillment_window_id.to_string()) + .join("20260423T150000Z") + .to_string_lossy() + .into_owned() + ); + assert_eq!(prepared.bundle.artifact_count(), 3); + assert_eq!( + prepared.bundle.artifacts[0].relative_path, + PackDayExportArtifactKind::PackSheet.file_name() + ); + assert_eq!( + prepared.bundle.artifacts[1].relative_path, + PackDayExportArtifactKind::PickupRoster.file_name() + ); + assert_eq!( + prepared.bundle.artifacts[2].relative_path, + PackDayExportArtifactKind::CustomerLabels.file_name() + ); + assert_eq!( + prepared + .artifact_path(PackDayExportArtifactKind::CustomerLabels) + .expect("customer labels path should exist"), + Path::new(&prepared.bundle.bundle_directory).join("customer_labels.txt") + ); + } + + #[test] + fn prepared_bundle_renders_text_first_artifacts_from_output_source() { + let roots = AppRuntimeRoots::from_base_root("/Users/treesap/.radroots").namespaced_app(); + let source = sample_source(); + let generated_at = Utc + .with_ymd_and_hms(2026, 4, 23, 15, 0, 0) + .single() + .expect("timestamp should build"); + + let prepared = prepare_pack_day_export_bundle(&roots, &source, generated_at); + + assert_eq!( + prepared + .artifact_contents(PackDayExportArtifactKind::PackSheet) + .expect("pack sheet should render"), + "Radroots Pack day\nFarm: Willow farm\nWindow: 2026-04-23T16:00:00Z to 2026-04-23T19:00:00Z\nPickup location: North barn\n\nTotals by product\n- Carrots | 3 bunches\n- Salad mix | 2 bags\n\nPack list\n- Casey | R-1001 | scheduled | Salad mix | 2 bags\n- Taylor | R-1002 | packed | Carrots | 3 bunches\n" + ); + assert_eq!( + prepared + .artifact_contents(PackDayExportArtifactKind::PickupRoster) + .expect("pickup roster should render"), + "Radroots Pickup roster\nFarm: Willow farm\nWindow: 2026-04-23T16:00:00Z to 2026-04-23T19:00:00Z\nPickup location: North barn\n\nOrders\n- Casey | R-1001 | scheduled\n- Taylor | R-1002 | packed\n" + ); + assert_eq!( + prepared + .artifact_contents(PackDayExportArtifactKind::CustomerLabels) + .expect("customer labels should render"), + "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" + ); + } + + #[test] + fn prepared_bundle_writes_files_to_disk() { + let roots = AppRuntimeRoots::from_base_root(temp_root("write_bundle")).namespaced_app(); + let source = sample_source(); + let generated_at = Utc + .with_ymd_and_hms(2026, 4, 23, 15, 0, 0) + .single() + .expect("timestamp should build"); + let prepared = prepare_pack_day_export_bundle(&roots, &source, generated_at); + + write_prepared_pack_day_export_bundle(&prepared).expect("bundle should write"); + + for document in &prepared.documents { + assert_eq!( + fs::read_to_string(&document.absolute_path).expect("artifact should write"), + document.contents + ); + } + + cleanup_temp_root(&roots); + } + + fn sample_source() -> PackDayOutputSource { + let farm_id = FarmId::new(); + let fulfillment_window_id = FulfillmentWindowId::new(); + PackDayOutputSource { + fulfillment_window: PackDayOutputWindow { + fulfillment_window_id, + farm_id, + farm_display_name: "Willow farm".to_owned(), + pickup_location_label: Some("North barn".to_owned()), + starts_at: "2026-04-23T16:00:00Z".to_owned(), + ends_at: "2026-04-23T19:00:00Z".to_owned(), + }, + totals_by_product: vec![ + PackDayOutputProductTotal { + title: "Carrots".to_owned(), + quantity: PackDayOutputQuantity::new(3, "bunches"), + }, + PackDayOutputProductTotal { + title: "Salad mix".to_owned(), + quantity: PackDayOutputQuantity::new(2, "bags"), + }, + ], + pack_list: vec![ + PackDayOutputPackListEntry { + order_id: OrderId::new(), + order_number: "R-1001".to_owned(), + customer_display_name: "Casey".to_owned(), + order_state: PackDayOutputOrderState::Scheduled, + title: "Salad mix".to_owned(), + quantity: PackDayOutputQuantity::new(2, "bags"), + }, + PackDayOutputPackListEntry { + order_id: OrderId::new(), + order_number: "R-1002".to_owned(), + customer_display_name: "Taylor".to_owned(), + order_state: PackDayOutputOrderState::Packed, + title: "Carrots".to_owned(), + quantity: PackDayOutputQuantity::new(3, "bunches"), + }, + ], + pickup_roster: vec![ + PackDayOutputCustomerOrder { + order_id: OrderId::new(), + order_number: "R-1001".to_owned(), + customer_display_name: "Casey".to_owned(), + order_state: PackDayOutputOrderState::Scheduled, + }, + PackDayOutputCustomerOrder { + order_id: OrderId::new(), + order_number: "R-1002".to_owned(), + customer_display_name: "Taylor".to_owned(), + order_state: PackDayOutputOrderState::Packed, + }, + ], + } + } + + fn temp_root(label: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("radroots_app_pack_day_export_{label}_{unique}")) + } + + fn cleanup_temp_root(roots: &AppRuntimeRoots) { + if let Some(base) = roots.data.ancestors().nth(3) { + let _ = fs::remove_dir_all(base); + } + } +}