| 1 | use serde::{Deserialize, Serialize}; |
| 2 | |
| 3 | /// Maximum `title` length Google Play accepts, in characters. |
| 4 | pub const TITLE_MAX_LEN: usize = 30; |
| 5 | /// Maximum `shortDescription` length, in characters. |
| 6 | pub const SHORT_DESCRIPTION_MAX_LEN: usize = 80; |
| 7 | /// Maximum `fullDescription` length, in characters. |
| 8 | pub const FULL_DESCRIPTION_MAX_LEN: usize = 4000; |
| 9 | /// Maximum length of one release note, per language, in characters. |
| 10 | pub const RELEASE_NOTES_MAX_LEN: usize = 500; |
| 11 | |
| 12 | /// One localized store listing, as Play returns it. |
| 13 | #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] |
| 14 | #[serde(rename_all = "camelCase")] |
| 15 | pub struct Listing { |
| 16 | #[serde(default)] |
| 17 | pub language: String, |
| 18 | #[serde(default)] |
| 19 | pub title: Option<String>, |
| 20 | #[serde(default)] |
| 21 | pub short_description: Option<String>, |
| 22 | #[serde(default)] |
| 23 | pub full_description: Option<String>, |
| 24 | #[serde(default)] |
| 25 | pub video: Option<String>, |
| 26 | } |
| 27 | |
| 28 | /// The fields of a listing patch. Unset fields are left off the body, so Play |
| 29 | /// keeps their current value. |
| 30 | #[derive(Debug, Clone, Default, Serialize)] |
| 31 | #[serde(rename_all = "camelCase")] |
| 32 | pub struct ListingFields { |
| 33 | #[serde(skip_serializing_if = "Option::is_none")] |
| 34 | pub title: Option<String>, |
| 35 | #[serde(skip_serializing_if = "Option::is_none")] |
| 36 | pub short_description: Option<String>, |
| 37 | #[serde(skip_serializing_if = "Option::is_none")] |
| 38 | pub full_description: Option<String>, |
| 39 | } |
| 40 | |
| 41 | impl ListingFields { |
| 42 | pub fn is_empty(&self) -> bool { |
| 43 | self.title.is_none() && self.short_description.is_none() && self.full_description.is_none() |
| 44 | } |
| 45 | |
| 46 | /// Check the fields against Play's length limits before anything is sent. |
| 47 | /// Returns `Err` naming the first field over its limit. |
| 48 | pub fn validate(&self) -> std::result::Result<(), String> { |
| 49 | let limits = [ |
| 50 | ("title", &self.title, TITLE_MAX_LEN), |
| 51 | ( |
| 52 | "shortDescription", |
| 53 | &self.short_description, |
| 54 | SHORT_DESCRIPTION_MAX_LEN, |
| 55 | ), |
| 56 | ( |
| 57 | "fullDescription", |
| 58 | &self.full_description, |
| 59 | FULL_DESCRIPTION_MAX_LEN, |
| 60 | ), |
| 61 | ]; |
| 62 | for (name, value, max) in limits { |
| 63 | let len = value.as_deref().map_or(0, |v| v.chars().count()); |
| 64 | if len > max { |
| 65 | return Err(format!( |
| 66 | "{name} is {len} characters; Google Play accepts at most {max}" |
| 67 | )); |
| 68 | } |
| 69 | } |
| 70 | Ok(()) |
| 71 | } |
| 72 | |
| 73 | /// Names of the fields sent whose stored value is not an exact match. |
| 74 | pub fn diff(&self, stored: &Listing) -> Vec<&'static str> { |
| 75 | [ |
| 76 | ("title", &self.title, &stored.title), |
| 77 | ( |
| 78 | "shortDescription", |
| 79 | &self.short_description, |
| 80 | &stored.short_description, |
| 81 | ), |
| 82 | ( |
| 83 | "fullDescription", |
| 84 | &self.full_description, |
| 85 | &stored.full_description, |
| 86 | ), |
| 87 | ] |
| 88 | .into_iter() |
| 89 | .filter(|(_, sent, stored)| match sent { |
| 90 | Some(sent) => stored.as_deref() != Some(sent.as_str()), |
| 91 | None => false, |
| 92 | }) |
| 93 | .map(|(name, _, _)| name) |
| 94 | .collect() |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | #[cfg(test)] |
| 99 | mod tests { |
| 100 | use super::*; |
| 101 | |
| 102 | #[test] |
| 103 | fn each_field_over_its_limit_is_rejected_by_name() { |
| 104 | let cases = [ |
| 105 | ( |
| 106 | ListingFields { |
| 107 | title: Some("x".repeat(31)), |
| 108 | ..Default::default() |
| 109 | }, |
| 110 | "title", |
| 111 | ), |
| 112 | ( |
| 113 | ListingFields { |
| 114 | short_description: Some("x".repeat(81)), |
| 115 | ..Default::default() |
| 116 | }, |
| 117 | "shortDescription", |
| 118 | ), |
| 119 | ( |
| 120 | ListingFields { |
| 121 | full_description: Some("x".repeat(4001)), |
| 122 | ..Default::default() |
| 123 | }, |
| 124 | "fullDescription", |
| 125 | ), |
| 126 | ]; |
| 127 | for (fields, name) in cases { |
| 128 | let message = fields.validate().unwrap_err(); |
| 129 | assert!(message.starts_with(name), "{message}"); |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | #[test] |
| 134 | fn fields_at_their_limit_pass_and_lengths_count_characters() { |
| 135 | let fields = ListingFields { |
| 136 | title: Some("é".repeat(30)), |
| 137 | short_description: Some("x".repeat(80)), |
| 138 | full_description: Some("x".repeat(4000)), |
| 139 | }; |
| 140 | assert!(fields.validate().is_ok()); |
| 141 | } |
| 142 | |
| 143 | #[test] |
| 144 | fn unset_fields_are_left_off_the_body() { |
| 145 | let fields = ListingFields { |
| 146 | title: Some("T".into()), |
| 147 | ..Default::default() |
| 148 | }; |
| 149 | assert_eq!(serde_json::to_string(&fields).unwrap(), r#"{"title":"T"}"#); |
| 150 | } |
| 151 | |
| 152 | #[test] |
| 153 | fn diff_names_only_the_fields_that_were_sent_and_differ() { |
| 154 | let sent = ListingFields { |
| 155 | title: Some("A".into()), |
| 156 | short_description: Some("S".into()), |
| 157 | full_description: None, |
| 158 | }; |
| 159 | let stored = Listing { |
| 160 | title: Some("A".into()), |
| 161 | short_description: Some("S ".into()), |
| 162 | full_description: Some("other".into()), |
| 163 | ..Default::default() |
| 164 | }; |
| 165 | assert_eq!(sent.diff(&stored), vec!["shortDescription"]); |
| 166 | } |
| 167 | } |