Skip to main content

bpi_rs/
ids.rs

1use std::fmt;
2use std::str::FromStr;
3
4use serde::de::{self, Visitor};
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6
7use crate::{BpiError, BpiResult};
8
9macro_rules! numeric_id {
10    ($name:ident, $field:literal, $doc:literal) => {
11        #[doc = $doc]
12        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
13        pub struct $name(u64);
14
15        impl $name {
16            /// Creates a non-zero numeric ID.
17            pub fn new(value: u64) -> BpiResult<Self> {
18                if value == 0 {
19                    return Err(BpiError::invalid_parameter($field, "id must be non-zero"));
20                }
21
22                Ok(Self(value))
23            }
24
25            /// Returns the raw numeric value.
26            pub fn get(self) -> u64 {
27                self.0
28            }
29        }
30
31        impl fmt::Display for $name {
32            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33                self.0.fmt(f)
34            }
35        }
36
37        impl FromStr for $name {
38            type Err = BpiError;
39
40            fn from_str(value: &str) -> Result<Self, Self::Err> {
41                let parsed = value
42                    .parse::<u64>()
43                    .map_err(|_| BpiError::invalid_parameter($field, "id must be numeric"))?;
44                Self::new(parsed)
45            }
46        }
47
48        impl Serialize for $name {
49            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
50            where
51                S: Serializer,
52            {
53                serializer.serialize_u64(self.0)
54            }
55        }
56
57        impl<'de> Deserialize<'de> for $name {
58            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
59            where
60                D: Deserializer<'de>,
61            {
62                struct IdVisitor;
63
64                impl Visitor<'_> for IdVisitor {
65                    type Value = u64;
66
67                    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68                        formatter.write_str("a non-zero numeric id")
69                    }
70
71                    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
72                    where
73                        E: de::Error,
74                    {
75                        Ok(value)
76                    }
77
78                    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
79                    where
80                        E: de::Error,
81                    {
82                        u64::try_from(value).map_err(|_| E::custom("id must be non-negative"))
83                    }
84                }
85
86                let value = deserializer.deserialize_any(IdVisitor)?;
87                Self::new(value).map_err(de::Error::custom)
88            }
89        }
90    };
91}
92
93numeric_id!(Aid, "aid", "Bilibili AV numeric video ID.");
94numeric_id!(AudioId, "sid", "Bilibili audio song ID.");
95numeric_id!(Cid, "cid", "Bilibili video page/content ID.");
96numeric_id!(Mid, "mid", "Bilibili member/user ID.");
97numeric_id!(RoomId, "room_id", "Bilibili live room ID.");
98numeric_id!(MediaId, "media_id", "Bilibili media ID.");
99numeric_id!(SeasonId, "season_id", "Bilibili season ID.");
100numeric_id!(EpisodeId, "ep_id", "Bilibili episode ID.");
101numeric_id!(NoteId, "note_id", "Bilibili note ID.");
102numeric_id!(Cvid, "cvid", "Bilibili note/article CV ID.");
103
104macro_rules! string_id {
105    ($name:ident, $field:literal, $doc:literal, $validate:ident) => {
106        #[doc = $doc]
107        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
108        pub struct $name(String);
109
110        impl $name {
111            /// Creates a validated string ID.
112            pub fn new(value: impl Into<String>) -> BpiResult<Self> {
113                let value = value.into();
114                $validate(&value)?;
115                Ok(Self(value))
116            }
117
118            /// Returns the raw string value.
119            pub fn as_str(&self) -> &str {
120                &self.0
121            }
122        }
123
124        impl fmt::Display for $name {
125            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126                self.0.fmt(f)
127            }
128        }
129
130        impl FromStr for $name {
131            type Err = BpiError;
132
133            fn from_str(value: &str) -> Result<Self, Self::Err> {
134                Self::new(value)
135            }
136        }
137
138        impl Serialize for $name {
139            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
140            where
141                S: Serializer,
142            {
143                serializer.serialize_str(&self.0)
144            }
145        }
146
147        impl<'de> Deserialize<'de> for $name {
148            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
149            where
150                D: Deserializer<'de>,
151            {
152                let value = String::deserialize(deserializer)?;
153                Self::new(value).map_err(de::Error::custom)
154            }
155        }
156    };
157}
158
159string_id!(Bvid, "bvid", "Bilibili BV string video ID.", validate_bvid);
160string_id!(
161    DynamicId,
162    "dynamic_id",
163    "Bilibili dynamic feed item ID.",
164    validate_dynamic_id
165);
166
167fn validate_bvid(value: &str) -> BpiResult<()> {
168    if !value.starts_with("BV") {
169        return Err(BpiError::invalid_parameter(
170            "bvid",
171            "bvid must start with 'BV'",
172        ));
173    }
174
175    if value.len() < 12 || !value.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
176        return Err(BpiError::invalid_parameter(
177            "bvid",
178            "bvid must be at least 12 ASCII alphanumeric characters",
179        ));
180    }
181
182    Ok(())
183}
184
185fn validate_dynamic_id(value: &str) -> BpiResult<()> {
186    if value.trim().is_empty() {
187        return Err(BpiError::invalid_parameter(
188            "dynamic_id",
189            "dynamic id cannot be blank",
190        ));
191    }
192
193    Ok(())
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn aid_rejects_zero() {
202        let err = Aid::new(0).unwrap_err();
203
204        assert!(matches!(
205            err,
206            BpiError::InvalidParameter { field: "aid", .. }
207        ));
208    }
209
210    #[test]
211    fn aid_displays_numeric_value() -> Result<(), BpiError> {
212        let aid = Aid::new(170001)?;
213
214        assert_eq!(aid.to_string(), "170001");
215        Ok(())
216    }
217
218    #[test]
219    fn audio_id_rejects_zero() {
220        let err = AudioId::new(0).unwrap_err();
221
222        assert!(matches!(
223            err,
224            BpiError::InvalidParameter { field: "sid", .. }
225        ));
226    }
227
228    #[test]
229    fn audio_id_displays_numeric_value() -> Result<(), BpiError> {
230        let sid = AudioId::new(13603)?;
231
232        assert_eq!(sid.to_string(), "13603");
233        Ok(())
234    }
235
236    #[test]
237    fn season_id_rejects_zero() {
238        let err = SeasonId::new(0).unwrap_err();
239
240        assert!(matches!(
241            err,
242            BpiError::InvalidParameter {
243                field: "season_id",
244                ..
245            }
246        ));
247    }
248
249    #[test]
250    fn episode_id_displays_numeric_value() -> Result<(), BpiError> {
251        let episode_id = EpisodeId::new(21265)?;
252
253        assert_eq!(episode_id.to_string(), "21265");
254        Ok(())
255    }
256
257    #[test]
258    fn bvid_accepts_valid_value() -> Result<(), BpiError> {
259        let bvid: Bvid = "BV1bx411c7ux".parse()?;
260
261        assert_eq!(bvid.as_str(), "BV1bx411c7ux");
262        Ok(())
263    }
264
265    #[test]
266    fn bvid_rejects_invalid_prefix() {
267        let err = "av170001".parse::<Bvid>().unwrap_err();
268
269        assert!(matches!(
270            err,
271            BpiError::InvalidParameter { field: "bvid", .. }
272        ));
273    }
274
275    #[test]
276    fn dynamic_id_rejects_blank_value() {
277        let err = "   ".parse::<DynamicId>().unwrap_err();
278
279        assert!(matches!(
280            err,
281            BpiError::InvalidParameter {
282                field: "dynamic_id",
283                ..
284            }
285        ));
286    }
287
288    #[test]
289    fn serde_round_trips_numeric_id() -> Result<(), Box<dyn std::error::Error>> {
290        let mid = Mid::new(12345)?;
291
292        let json = serde_json::to_string(&mid)?;
293        assert_eq!(json, "12345");
294        let decoded: Mid = serde_json::from_str(&json)?;
295        assert_eq!(decoded, mid);
296        Ok(())
297    }
298
299    #[test]
300    fn serde_round_trips_string_id() -> Result<(), Box<dyn std::error::Error>> {
301        let bvid: Bvid = "BV1bx411c7ux".parse()?;
302
303        let json = serde_json::to_string(&bvid)?;
304        assert_eq!(json, "\"BV1bx411c7ux\"");
305        let decoded: Bvid = serde_json::from_str(&json)?;
306        assert_eq!(decoded, bvid);
307        Ok(())
308    }
309}