lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

classified_listing.rs (8001B)


      1 #[cfg(not(feature = "std"))]
      2 use alloc::string::String;
      3 #[cfg(feature = "std")]
      4 use std::string::String;
      5 
      6 use crate::envelope::{EventTag, EventTags};
      7 
      8 pub const TAG_RADROOTS_PRICE_UNIT: &str = "radroots:price_unit";
      9 pub const TAG_RADROOTS_QUANTITY: &str = "radroots:quantity";
     10 pub const TAG_RADROOTS_PRIMARY_BIN: &str = "radroots:primary_bin";
     11 pub const TAG_RADROOTS_BIN: &str = "radroots:bin";
     12 pub const TAG_RADROOTS_PRICE: &str = "radroots:price";
     13 
     14 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     15 /// The marker-selected profile partition for a NIP-99 classified listing.
     16 pub enum ClassifiedListingPartition {
     17     FocusedFoodAvailability,
     18     OperationalListing,
     19     GenericNip99,
     20     Ambiguous,
     21 }
     22 
     23 #[inline]
     24 /// Partitions a classified listing by exact raw marker-name presence.
     25 ///
     26 /// The caller owns kind checks and profile validation. Tag values and arity do
     27 /// not affect this partition, so malformed one-element marker tags still count.
     28 pub fn classify_classified_listing_tags(tags: &EventTags) -> ClassifiedListingPartition {
     29     classify_classified_listing_tag_slice(tags.as_slice())
     30 }
     31 
     32 /// Partitions a borrowed classified-listing tag slice without allocating.
     33 ///
     34 /// The caller owns kind checks and profile validation. Tag values and arity do
     35 /// not affect this partition, so malformed one-element marker tags still count.
     36 pub fn classify_classified_listing_tag_slice(tags: &[EventTag]) -> ClassifiedListingPartition {
     37     classify_classified_listing_marker_names(
     38         tags.iter()
     39             .map(|tag| tag.as_slice().first().map(String::as_str)),
     40     )
     41 }
     42 
     43 /// Partitions borrowed raw tag names without allocating or validating tag arity.
     44 pub fn classify_classified_listing_marker_names<'a>(
     45     names: impl IntoIterator<Item = Option<&'a str>>,
     46 ) -> ClassifiedListingPartition {
     47     let mut has_focused_marker = false;
     48     let mut has_operational_marker = false;
     49 
     50     for name in names {
     51         let Some(name) = name else {
     52             continue;
     53         };
     54 
     55         match name {
     56             TAG_RADROOTS_PRICE_UNIT | TAG_RADROOTS_QUANTITY => has_focused_marker = true,
     57             TAG_RADROOTS_PRIMARY_BIN | TAG_RADROOTS_BIN | TAG_RADROOTS_PRICE => {
     58                 has_operational_marker = true;
     59             }
     60             _ => {}
     61         }
     62 
     63         if has_focused_marker && has_operational_marker {
     64             return ClassifiedListingPartition::Ambiguous;
     65         }
     66     }
     67 
     68     match (has_focused_marker, has_operational_marker) {
     69         (true, false) => ClassifiedListingPartition::FocusedFoodAvailability,
     70         (false, true) => ClassifiedListingPartition::OperationalListing,
     71         (false, false) => ClassifiedListingPartition::GenericNip99,
     72         (true, true) => ClassifiedListingPartition::Ambiguous,
     73     }
     74 }
     75 
     76 #[cfg(test)]
     77 #[cfg_attr(coverage_nightly, coverage(off))]
     78 mod tests {
     79     use super::*;
     80 
     81     const FOCUSED_MARKERS: [&str; 2] = [TAG_RADROOTS_PRICE_UNIT, TAG_RADROOTS_QUANTITY];
     82     const OPERATIONAL_MARKERS: [&str; 3] = [
     83         TAG_RADROOTS_PRIMARY_BIN,
     84         TAG_RADROOTS_BIN,
     85         TAG_RADROOTS_PRICE,
     86     ];
     87 
     88     fn tags(values: &[&[&str]]) -> EventTags {
     89         EventTags::new(
     90             values
     91                 .iter()
     92                 .map(|tag| tag.iter().map(|value| (*value).to_owned()).collect())
     93                 .collect(),
     94         )
     95         .expect("valid test tags")
     96     }
     97 
     98     fn classify(values: &[&[&str]]) -> ClassifiedListingPartition {
     99         classify_classified_listing_tags(&tags(values))
    100     }
    101 
    102     #[test]
    103     fn exposes_exact_marker_names() {
    104         assert_eq!(TAG_RADROOTS_PRICE_UNIT, "radroots:price_unit");
    105         assert_eq!(TAG_RADROOTS_QUANTITY, "radroots:quantity");
    106         assert_eq!(TAG_RADROOTS_PRIMARY_BIN, "radroots:primary_bin");
    107         assert_eq!(TAG_RADROOTS_BIN, "radroots:bin");
    108         assert_eq!(TAG_RADROOTS_PRICE, "radroots:price");
    109     }
    110 
    111     #[test]
    112     fn each_focused_marker_selects_focused_food_availability() {
    113         for marker in FOCUSED_MARKERS {
    114             assert_eq!(
    115                 classify(&[&[marker, "value"]]),
    116                 ClassifiedListingPartition::FocusedFoodAvailability,
    117                 "focused marker {marker}"
    118             );
    119         }
    120     }
    121 
    122     #[test]
    123     fn each_operational_marker_selects_operational_listing() {
    124         for marker in OPERATIONAL_MARKERS {
    125             assert_eq!(
    126                 classify(&[&[marker, "value"]]),
    127                 ClassifiedListingPartition::OperationalListing,
    128                 "operational marker {marker}"
    129             );
    130         }
    131     }
    132 
    133     #[test]
    134     fn one_element_malformed_markers_still_partition() {
    135         for marker in FOCUSED_MARKERS {
    136             assert_eq!(
    137                 classify(&[&[marker]]),
    138                 ClassifiedListingPartition::FocusedFoodAvailability,
    139                 "malformed focused marker {marker}"
    140             );
    141         }
    142 
    143         for marker in OPERATIONAL_MARKERS {
    144             assert_eq!(
    145                 classify(&[&[marker]]),
    146                 ClassifiedListingPartition::OperationalListing,
    147                 "malformed operational marker {marker}"
    148             );
    149         }
    150     }
    151 
    152     #[test]
    153     fn every_focused_and_operational_marker_pair_is_ambiguous_in_either_order() {
    154         for focused in FOCUSED_MARKERS {
    155             for operational in OPERATIONAL_MARKERS {
    156                 assert_eq!(
    157                     classify(&[&[focused], &[operational]]),
    158                     ClassifiedListingPartition::Ambiguous,
    159                     "focused {focused} before operational {operational}"
    160                 );
    161                 assert_eq!(
    162                     classify(&[&[operational], &[focused]]),
    163                     ClassifiedListingPartition::Ambiguous,
    164                     "operational {operational} before focused {focused}"
    165                 );
    166             }
    167         }
    168     }
    169 
    170     #[test]
    171     fn duplicate_markers_do_not_change_the_partition() {
    172         assert_eq!(
    173             classify(&[
    174                 &[TAG_RADROOTS_PRICE_UNIT],
    175                 &[TAG_RADROOTS_PRICE_UNIT, "lb"],
    176                 &[TAG_RADROOTS_QUANTITY, "20", "lb"],
    177             ]),
    178             ClassifiedListingPartition::FocusedFoodAvailability
    179         );
    180         assert_eq!(
    181             classify(&[
    182                 &[TAG_RADROOTS_BIN],
    183                 &[TAG_RADROOTS_BIN, "bin-1"],
    184                 &[TAG_RADROOTS_PRICE, "bin-1", "3", "CAD", "1", "lb"],
    185             ]),
    186             ClassifiedListingPartition::OperationalListing
    187         );
    188     }
    189 
    190     #[test]
    191     fn marker_matching_is_exact_and_case_sensitive() {
    192         for near_match in [
    193             "RADROOTS:PRICE_UNIT",
    194             "Radroots:price_unit",
    195             "radroots:Price_unit",
    196             "radroots:price-unit",
    197             "radroots:price_unit ",
    198             " radroots:price_unit",
    199             "radroots:price_units",
    200             "xradroots:price_unit",
    201             "radroots:primary_bins",
    202             "radroots:binning",
    203             "radroots:prices",
    204         ] {
    205             assert_eq!(
    206                 classify(&[&[near_match, "value"]]),
    207                 ClassifiedListingPartition::GenericNip99,
    208                 "near match {near_match}"
    209             );
    210         }
    211     }
    212 
    213     #[test]
    214     fn marker_names_in_values_do_not_partition() {
    215         let values = [
    216             &["summary", TAG_RADROOTS_PRICE_UNIT][..],
    217             &["description", TAG_RADROOTS_QUANTITY][..],
    218             &["title", TAG_RADROOTS_PRIMARY_BIN][..],
    219             &["t", TAG_RADROOTS_BIN][..],
    220             &["alt", TAG_RADROOTS_PRICE][..],
    221         ];
    222 
    223         assert_eq!(classify(&values), ClassifiedListingPartition::GenericNip99);
    224     }
    225 
    226     #[test]
    227     fn empty_tags_are_generic_nip99() {
    228         let tags = EventTags::new(Vec::new()).expect("empty tag list is valid");
    229 
    230         assert_eq!(
    231             classify_classified_listing_tags(&tags),
    232             ClassifiedListingPartition::GenericNip99
    233         );
    234         assert_eq!(
    235             classify_classified_listing_tag_slice(tags.as_slice()),
    236             ClassifiedListingPartition::GenericNip99
    237         );
    238     }
    239 }