Skip to main content

smbcloud_ascapi_play/
listing.rs

1use serde::{Deserialize, Serialize};
2
3/// Maximum `title` length Google Play accepts, in characters.
4pub const TITLE_MAX_LEN: usize = 30;
5/// Maximum `shortDescription` length, in characters.
6pub const SHORT_DESCRIPTION_MAX_LEN: usize = 80;
7/// Maximum `fullDescription` length, in characters.
8pub const FULL_DESCRIPTION_MAX_LEN: usize = 4000;
9/// Maximum length of one release note, per language, in characters.
10pub 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")]
15pub 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")]
32pub 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
41impl 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)]
99mod 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}