app

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

pack_day_host_handoff.rs (21519B)


      1 use std::io;
      2 use std::path::{Component, Path, PathBuf};
      3 #[cfg(target_os = "macos")]
      4 use std::process::Command;
      5 
      6 use radroots_app_view::{PackDayExportArtifactKind, PackDayExportBundle, PackDayHostHandoffKind};
      7 use thiserror::Error;
      8 
      9 #[derive(Clone, Debug, Eq, PartialEq)]
     10 pub struct PackDayHostHandoffCommandPlan {
     11     pub kind: PackDayHostHandoffKind,
     12     pub target_path: PathBuf,
     13     pub command_program: &'static str,
     14     pub command_args: Vec<String>,
     15 }
     16 
     17 #[derive(Clone, Debug, Eq, PartialEq)]
     18 struct PackDayHostHandoffCommandResult {
     19     success: bool,
     20     exit_code: Option<i32>,
     21     stderr: String,
     22 }
     23 
     24 impl PackDayHostHandoffCommandResult {
     25     #[cfg(test)]
     26     fn succeeded() -> Self {
     27         Self {
     28             success: true,
     29             exit_code: Some(0),
     30             stderr: String::new(),
     31         }
     32     }
     33 
     34     #[cfg(test)]
     35     fn failed(exit_code: Option<i32>, stderr: impl Into<String>) -> Self {
     36         Self {
     37             success: false,
     38             exit_code,
     39             stderr: stderr.into(),
     40         }
     41     }
     42 }
     43 
     44 #[derive(Debug, Error)]
     45 pub enum PackDayHostHandoffError {
     46     #[error("pack day export bundle directory does not exist: {path}")]
     47     MissingBundleDirectory { path: PathBuf },
     48     #[error("pack day export bundle is missing required artifact {artifact_kind:?} for {kind:?}")]
     49     MissingArtifactReference {
     50         kind: PackDayHostHandoffKind,
     51         artifact_kind: PackDayExportArtifactKind,
     52     },
     53     #[error("pack day export artifact path is invalid for {kind:?}: {relative_path}")]
     54     InvalidArtifactRelativePath {
     55         kind: PackDayHostHandoffKind,
     56         relative_path: String,
     57     },
     58     #[error("pack day host handoff target does not exist for {kind:?}: {path}")]
     59     MissingTargetPath {
     60         kind: PackDayHostHandoffKind,
     61         path: PathBuf,
     62     },
     63     #[error("pack day host handoff target must be a file for {kind:?}: {path}")]
     64     InvalidTargetFile {
     65         kind: PackDayHostHandoffKind,
     66         path: PathBuf,
     67     },
     68     #[error("pack day host handoff is only supported on macos")]
     69     UnsupportedPlatform,
     70     #[error("failed to launch macos host command {program} for {kind:?}: {source}")]
     71     CommandLaunch {
     72         kind: PackDayHostHandoffKind,
     73         program: String,
     74         source: io::Error,
     75     },
     76     #[error("macos host command {program} for {kind:?} exited with code {exit_code:?}: {stderr}")]
     77     CommandFailed {
     78         kind: PackDayHostHandoffKind,
     79         program: String,
     80         exit_code: Option<i32>,
     81         stderr: String,
     82     },
     83 }
     84 
     85 impl PartialEq for PackDayHostHandoffError {
     86     fn eq(&self, other: &Self) -> bool {
     87         match (self, other) {
     88             (
     89                 Self::MissingBundleDirectory { path: left },
     90                 Self::MissingBundleDirectory { path: right },
     91             ) => left == right,
     92             (
     93                 Self::MissingArtifactReference {
     94                     kind: left_kind,
     95                     artifact_kind: left_artifact,
     96                 },
     97                 Self::MissingArtifactReference {
     98                     kind: right_kind,
     99                     artifact_kind: right_artifact,
    100                 },
    101             ) => left_kind == right_kind && left_artifact == right_artifact,
    102             (
    103                 Self::InvalidArtifactRelativePath {
    104                     kind: left_kind,
    105                     relative_path: left_path,
    106                 },
    107                 Self::InvalidArtifactRelativePath {
    108                     kind: right_kind,
    109                     relative_path: right_path,
    110                 },
    111             ) => left_kind == right_kind && left_path == right_path,
    112             (
    113                 Self::MissingTargetPath {
    114                     kind: left_kind,
    115                     path: left_path,
    116                 },
    117                 Self::MissingTargetPath {
    118                     kind: right_kind,
    119                     path: right_path,
    120                 },
    121             ) => left_kind == right_kind && left_path == right_path,
    122             (
    123                 Self::InvalidTargetFile {
    124                     kind: left_kind,
    125                     path: left_path,
    126                 },
    127                 Self::InvalidTargetFile {
    128                     kind: right_kind,
    129                     path: right_path,
    130                 },
    131             ) => left_kind == right_kind && left_path == right_path,
    132             (Self::UnsupportedPlatform, Self::UnsupportedPlatform) => true,
    133             (
    134                 Self::CommandLaunch {
    135                     kind: left_kind,
    136                     program: left_program,
    137                     source: left_source,
    138                 },
    139                 Self::CommandLaunch {
    140                     kind: right_kind,
    141                     program: right_program,
    142                     source: right_source,
    143                 },
    144             ) => {
    145                 left_kind == right_kind
    146                     && left_program == right_program
    147                     && left_source.kind() == right_source.kind()
    148                     && left_source.to_string() == right_source.to_string()
    149             }
    150             (
    151                 Self::CommandFailed {
    152                     kind: left_kind,
    153                     program: left_program,
    154                     exit_code: left_code,
    155                     stderr: left_stderr,
    156                 },
    157                 Self::CommandFailed {
    158                     kind: right_kind,
    159                     program: right_program,
    160                     exit_code: right_code,
    161                     stderr: right_stderr,
    162                 },
    163             ) => {
    164                 left_kind == right_kind
    165                     && left_program == right_program
    166                     && left_code == right_code
    167                     && left_stderr == right_stderr
    168             }
    169             _ => false,
    170         }
    171     }
    172 }
    173 
    174 impl Eq for PackDayHostHandoffError {}
    175 
    176 pub fn plan_pack_day_host_handoff(
    177     bundle: &PackDayExportBundle,
    178     kind: PackDayHostHandoffKind,
    179 ) -> Result<PackDayHostHandoffCommandPlan, PackDayHostHandoffError> {
    180     let bundle_directory = PathBuf::from(&bundle.bundle_directory);
    181     if !bundle_directory.is_dir() {
    182         return Err(PackDayHostHandoffError::MissingBundleDirectory {
    183             path: bundle_directory,
    184         });
    185     }
    186 
    187     let target_path = match kind.artifact_kind() {
    188         None => bundle_directory.clone(),
    189         Some(artifact_kind) => resolve_bundle_artifact_path(bundle, artifact_kind, kind)?,
    190     };
    191 
    192     let command_args = match kind {
    193         PackDayHostHandoffKind::RevealBundle => {
    194             vec!["-R".to_owned(), target_path.to_string_lossy().into_owned()]
    195         }
    196         PackDayHostHandoffKind::OpenPackSheet
    197         | PackDayHostHandoffKind::OpenPickupRoster
    198         | PackDayHostHandoffKind::OpenCustomerLabels => {
    199             vec![target_path.to_string_lossy().into_owned()]
    200         }
    201     };
    202 
    203     Ok(PackDayHostHandoffCommandPlan {
    204         kind,
    205         target_path,
    206         command_program: "open",
    207         command_args,
    208     })
    209 }
    210 
    211 pub fn execute_pack_day_host_handoff_plan(
    212     plan: &PackDayHostHandoffCommandPlan,
    213 ) -> Result<(), PackDayHostHandoffError> {
    214     #[cfg(target_os = "macos")]
    215     {
    216         execute_pack_day_host_handoff_plan_with(plan, run_macos_host_command)
    217     }
    218 
    219     #[cfg(not(target_os = "macos"))]
    220     {
    221         let _ = plan;
    222         Err(PackDayHostHandoffError::UnsupportedPlatform)
    223     }
    224 }
    225 
    226 fn resolve_bundle_artifact_path(
    227     bundle: &PackDayExportBundle,
    228     artifact_kind: PackDayExportArtifactKind,
    229     kind: PackDayHostHandoffKind,
    230 ) -> Result<PathBuf, PackDayHostHandoffError> {
    231     let Some(artifact) = bundle
    232         .artifacts
    233         .iter()
    234         .find(|artifact| artifact.kind == artifact_kind)
    235     else {
    236         return Err(PackDayHostHandoffError::MissingArtifactReference {
    237             kind,
    238             artifact_kind,
    239         });
    240     };
    241 
    242     let relative_path = Path::new(&artifact.relative_path);
    243     if relative_path.is_absolute()
    244         || relative_path.components().any(|component| {
    245             matches!(
    246                 component,
    247                 Component::ParentDir | Component::RootDir | Component::Prefix(_)
    248             )
    249         })
    250     {
    251         return Err(PackDayHostHandoffError::InvalidArtifactRelativePath {
    252             kind,
    253             relative_path: artifact.relative_path.clone(),
    254         });
    255     }
    256 
    257     let path = PathBuf::from(&bundle.bundle_directory).join(relative_path);
    258     if !path.exists() {
    259         return Err(PackDayHostHandoffError::MissingTargetPath { kind, path });
    260     }
    261     if !path.is_file() {
    262         return Err(PackDayHostHandoffError::InvalidTargetFile { kind, path });
    263     }
    264 
    265     Ok(path)
    266 }
    267 
    268 fn execute_pack_day_host_handoff_plan_with(
    269     plan: &PackDayHostHandoffCommandPlan,
    270     run_command: impl FnOnce(
    271         &PackDayHostHandoffCommandPlan,
    272     ) -> Result<PackDayHostHandoffCommandResult, io::Error>,
    273 ) -> Result<(), PackDayHostHandoffError> {
    274     let result = run_command(plan).map_err(|source| PackDayHostHandoffError::CommandLaunch {
    275         kind: plan.kind,
    276         program: plan.command_program.to_owned(),
    277         source,
    278     })?;
    279 
    280     if result.success {
    281         return Ok(());
    282     }
    283 
    284     Err(PackDayHostHandoffError::CommandFailed {
    285         kind: plan.kind,
    286         program: plan.command_program.to_owned(),
    287         exit_code: result.exit_code,
    288         stderr: result.stderr,
    289     })
    290 }
    291 
    292 #[cfg(target_os = "macos")]
    293 fn run_macos_host_command(
    294     plan: &PackDayHostHandoffCommandPlan,
    295 ) -> Result<PackDayHostHandoffCommandResult, io::Error> {
    296     let output = Command::new(plan.command_program)
    297         .args(&plan.command_args)
    298         .output()?;
    299 
    300     Ok(PackDayHostHandoffCommandResult {
    301         success: output.status.success(),
    302         exit_code: output.status.code(),
    303         stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
    304     })
    305 }
    306 
    307 #[cfg(test)]
    308 mod tests {
    309     use super::{
    310         PackDayHostHandoffCommandResult, PackDayHostHandoffError,
    311         execute_pack_day_host_handoff_plan_with, plan_pack_day_host_handoff,
    312     };
    313     use radroots_app_view::{
    314         PackDayExportArtifact, PackDayExportArtifactKind, PackDayExportBundle,
    315         PackDayHostHandoffKind,
    316     };
    317     use std::fs;
    318     use std::io;
    319     use std::path::PathBuf;
    320     use uuid::Uuid;
    321 
    322     struct TestDirectory {
    323         path: PathBuf,
    324     }
    325 
    326     impl TestDirectory {
    327         fn new() -> Self {
    328             let path = std::env::temp_dir().join(format!(
    329                 "radroots_app_pack_day_host_handoff_{}",
    330                 Uuid::new_v4()
    331             ));
    332             fs::create_dir_all(&path).expect("test directory should create");
    333             Self { path }
    334         }
    335 
    336         fn path(&self) -> &PathBuf {
    337             &self.path
    338         }
    339     }
    340 
    341     impl Drop for TestDirectory {
    342         fn drop(&mut self) {
    343             let _ = fs::remove_dir_all(&self.path);
    344         }
    345     }
    346 
    347     fn sample_bundle(bundle_directory: &PathBuf) -> PackDayExportBundle {
    348         PackDayExportBundle {
    349             fulfillment_window_id: radroots_app_view::FulfillmentWindowId::new(),
    350             export_instance_id: radroots_app_view::PackDayExportInstanceId::new(),
    351             generated_at_utc: "2026-04-23T15:00:00Z".to_owned(),
    352             bundle_directory: bundle_directory.to_string_lossy().into_owned(),
    353             artifacts: vec![
    354                 PackDayExportArtifact {
    355                     kind: PackDayExportArtifactKind::PackSheet,
    356                     relative_path: "pack_sheet.txt".to_owned(),
    357                 },
    358                 PackDayExportArtifact {
    359                     kind: PackDayExportArtifactKind::PickupRoster,
    360                     relative_path: "pickup_roster.txt".to_owned(),
    361                 },
    362                 PackDayExportArtifact {
    363                     kind: PackDayExportArtifactKind::CustomerLabels,
    364                     relative_path: "customer_labels.txt".to_owned(),
    365                 },
    366             ],
    367         }
    368     }
    369 
    370     fn write_artifact(bundle_directory: &PathBuf, file_name: &str) -> PathBuf {
    371         let path = bundle_directory.join(file_name);
    372         fs::write(&path, file_name).expect("artifact should write");
    373         path
    374     }
    375 
    376     #[test]
    377     fn reveal_bundle_plan_uses_open_reveal_for_the_bundle_directory() {
    378         let temp_dir = TestDirectory::new();
    379         let bundle = sample_bundle(temp_dir.path());
    380 
    381         let plan = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::RevealBundle)
    382             .expect("reveal plan should build");
    383 
    384         assert_eq!(plan.kind, PackDayHostHandoffKind::RevealBundle);
    385         assert_eq!(plan.target_path, temp_dir.path().clone());
    386         assert_eq!(plan.command_program, "open");
    387         assert_eq!(
    388             plan.command_args,
    389             vec![
    390                 "-R".to_owned(),
    391                 temp_dir.path().to_string_lossy().into_owned(),
    392             ]
    393         );
    394     }
    395 
    396     #[test]
    397     fn open_pack_sheet_plan_targets_the_exported_pack_sheet() {
    398         let temp_dir = TestDirectory::new();
    399         let pack_sheet_path = write_artifact(temp_dir.path(), "pack_sheet.txt");
    400         let bundle = sample_bundle(temp_dir.path());
    401 
    402         let plan = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::OpenPackSheet)
    403             .expect("open plan should build");
    404 
    405         assert_eq!(plan.kind, PackDayHostHandoffKind::OpenPackSheet);
    406         assert_eq!(plan.target_path, pack_sheet_path.clone());
    407         assert_eq!(plan.command_program, "open");
    408         assert_eq!(
    409             plan.command_args,
    410             vec![pack_sheet_path.to_string_lossy().into_owned()]
    411         );
    412     }
    413 
    414     #[test]
    415     fn open_pickup_roster_plan_targets_the_exported_pickup_roster() {
    416         let temp_dir = TestDirectory::new();
    417         let pickup_roster_path = write_artifact(temp_dir.path(), "pickup_roster.txt");
    418         let bundle = sample_bundle(temp_dir.path());
    419 
    420         let plan = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::OpenPickupRoster)
    421             .expect("open pickup roster plan should build");
    422 
    423         assert_eq!(plan.kind, PackDayHostHandoffKind::OpenPickupRoster);
    424         assert_eq!(plan.target_path, pickup_roster_path.clone());
    425         assert_eq!(plan.command_program, "open");
    426         assert_eq!(
    427             plan.command_args,
    428             vec![pickup_roster_path.to_string_lossy().into_owned()]
    429         );
    430     }
    431 
    432     #[test]
    433     fn open_customer_labels_plan_targets_the_exported_customer_labels() {
    434         let temp_dir = TestDirectory::new();
    435         let customer_labels_path = write_artifact(temp_dir.path(), "customer_labels.txt");
    436         let bundle = sample_bundle(temp_dir.path());
    437 
    438         let plan = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::OpenCustomerLabels)
    439             .expect("open customer labels plan should build");
    440 
    441         assert_eq!(plan.kind, PackDayHostHandoffKind::OpenCustomerLabels);
    442         assert_eq!(plan.target_path, customer_labels_path.clone());
    443         assert_eq!(plan.command_program, "open");
    444         assert_eq!(
    445             plan.command_args,
    446             vec![customer_labels_path.to_string_lossy().into_owned()]
    447         );
    448     }
    449 
    450     #[test]
    451     fn planning_fails_when_the_bundle_directory_is_missing() {
    452         let bundle_directory = std::env::temp_dir().join(format!(
    453             "radroots_app_pack_day_host_handoff_missing_{}",
    454             Uuid::new_v4()
    455         ));
    456         let bundle = sample_bundle(&bundle_directory);
    457 
    458         let error = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::RevealBundle)
    459             .expect_err("missing bundle directory should fail");
    460 
    461         assert_eq!(
    462             error,
    463             PackDayHostHandoffError::MissingBundleDirectory {
    464                 path: bundle_directory,
    465             }
    466         );
    467     }
    468 
    469     #[test]
    470     fn planning_fails_when_pack_sheet_reference_is_missing() {
    471         let temp_dir = TestDirectory::new();
    472         let mut bundle = sample_bundle(temp_dir.path());
    473         bundle
    474             .artifacts
    475             .retain(|artifact| artifact.kind != PackDayExportArtifactKind::PackSheet);
    476 
    477         let error = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::OpenPackSheet)
    478             .expect_err("missing pack sheet artifact should fail");
    479 
    480         assert_eq!(
    481             error,
    482             PackDayHostHandoffError::MissingArtifactReference {
    483                 kind: PackDayHostHandoffKind::OpenPackSheet,
    484                 artifact_kind: PackDayExportArtifactKind::PackSheet,
    485             }
    486         );
    487     }
    488 
    489     #[test]
    490     fn planning_fails_when_pickup_roster_reference_is_missing() {
    491         let temp_dir = TestDirectory::new();
    492         let mut bundle = sample_bundle(temp_dir.path());
    493         bundle
    494             .artifacts
    495             .retain(|artifact| artifact.kind != PackDayExportArtifactKind::PickupRoster);
    496 
    497         let error = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::OpenPickupRoster)
    498             .expect_err("missing pickup roster artifact should fail");
    499 
    500         assert_eq!(
    501             error,
    502             PackDayHostHandoffError::MissingArtifactReference {
    503                 kind: PackDayHostHandoffKind::OpenPickupRoster,
    504                 artifact_kind: PackDayExportArtifactKind::PickupRoster,
    505             }
    506         );
    507     }
    508 
    509     #[test]
    510     fn planning_fails_when_pack_sheet_relative_path_is_invalid() {
    511         let temp_dir = TestDirectory::new();
    512         let mut bundle = sample_bundle(temp_dir.path());
    513         bundle.artifacts[0].relative_path = "../pack_sheet.txt".to_owned();
    514 
    515         let error = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::OpenPackSheet)
    516             .expect_err("invalid relative path should fail");
    517 
    518         assert_eq!(
    519             error,
    520             PackDayHostHandoffError::InvalidArtifactRelativePath {
    521                 kind: PackDayHostHandoffKind::OpenPackSheet,
    522                 relative_path: "../pack_sheet.txt".to_owned(),
    523             }
    524         );
    525     }
    526 
    527     #[test]
    528     fn planning_fails_when_customer_labels_target_is_missing_on_disk() {
    529         let temp_dir = TestDirectory::new();
    530         let bundle = sample_bundle(temp_dir.path());
    531 
    532         let error = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::OpenCustomerLabels)
    533             .expect_err("missing customer labels file should fail");
    534 
    535         assert_eq!(
    536             error,
    537             PackDayHostHandoffError::MissingTargetPath {
    538                 kind: PackDayHostHandoffKind::OpenCustomerLabels,
    539                 path: temp_dir.path().join("customer_labels.txt"),
    540             }
    541         );
    542     }
    543 
    544     #[test]
    545     fn execution_classifies_command_launch_failures() {
    546         let temp_dir = TestDirectory::new();
    547         let bundle = sample_bundle(temp_dir.path());
    548         let plan = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::RevealBundle)
    549             .expect("reveal plan should build");
    550 
    551         let error = execute_pack_day_host_handoff_plan_with(&plan, |_| {
    552             Err(io::Error::new(io::ErrorKind::NotFound, "open missing"))
    553         })
    554         .expect_err("launch failure should classify");
    555 
    556         assert!(matches!(
    557             error,
    558             PackDayHostHandoffError::CommandLaunch {
    559                 kind: PackDayHostHandoffKind::RevealBundle,
    560                 ..
    561             }
    562         ));
    563     }
    564 
    565     #[test]
    566     fn execution_classifies_nonzero_exit_failures() {
    567         let temp_dir = TestDirectory::new();
    568         let bundle = sample_bundle(temp_dir.path());
    569         let plan = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::RevealBundle)
    570             .expect("reveal plan should build");
    571 
    572         let error = execute_pack_day_host_handoff_plan_with(&plan, |_| {
    573             Ok(PackDayHostHandoffCommandResult::failed(
    574                 Some(1),
    575                 "finder unavailable",
    576             ))
    577         })
    578         .expect_err("nonzero exit should classify");
    579 
    580         assert_eq!(
    581             error,
    582             PackDayHostHandoffError::CommandFailed {
    583                 kind: PackDayHostHandoffKind::RevealBundle,
    584                 program: "open".to_owned(),
    585                 exit_code: Some(1),
    586                 stderr: "finder unavailable".to_owned(),
    587             }
    588         );
    589     }
    590 
    591     #[test]
    592     fn execution_classifies_nonzero_exit_failures_for_customer_labels() {
    593         let temp_dir = TestDirectory::new();
    594         write_artifact(temp_dir.path(), "customer_labels.txt");
    595         let bundle = sample_bundle(temp_dir.path());
    596         let plan = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::OpenCustomerLabels)
    597             .expect("customer labels plan should build");
    598 
    599         let error = execute_pack_day_host_handoff_plan_with(&plan, |_| {
    600             Ok(PackDayHostHandoffCommandResult::failed(
    601                 Some(1),
    602                 "labels unavailable",
    603             ))
    604         })
    605         .expect_err("nonzero exit should classify");
    606 
    607         assert_eq!(
    608             error,
    609             PackDayHostHandoffError::CommandFailed {
    610                 kind: PackDayHostHandoffKind::OpenCustomerLabels,
    611                 program: "open".to_owned(),
    612                 exit_code: Some(1),
    613                 stderr: "labels unavailable".to_owned(),
    614             }
    615         );
    616     }
    617 
    618     #[test]
    619     fn execution_accepts_successful_runs() {
    620         let temp_dir = TestDirectory::new();
    621         let bundle = sample_bundle(temp_dir.path());
    622         let plan = plan_pack_day_host_handoff(&bundle, PackDayHostHandoffKind::RevealBundle)
    623             .expect("reveal plan should build");
    624 
    625         let result = execute_pack_day_host_handoff_plan_with(&plan, |_| {
    626             Ok(PackDayHostHandoffCommandResult::succeeded())
    627         });
    628 
    629         assert_eq!(result, Ok(()));
    630     }
    631 }