rspotify_model/idtypes.rs
1//! This module makes it possible to represent Spotify IDs and URIs with type
2//! safety and almost no overhead.
3//!
4//! ## Concrete IDs
5//!
6//! The trait [`Id`] is the central element of this module. It's implemented by
7//! all kinds of ID, and includes the main functionality to use them. Remember
8//! that you will need to import this trait to access its methods. The easiest
9//! way is to add `use rspotify::prelude::*`.
10//!
11//! * [`Type::Artist`] => [`ArtistId`]
12//! * [`Type::Album`] => [`AlbumId`]
13//! * [`Type::Track`] => [`TrackId`]
14//! * [`Type::Playlist`] => [`PlaylistId`]
15//! * [`Type::User`] => [`UserId`]
16//! * [`Type::Show`] => [`ShowId`]
17//! * [`Type::Episode`] => [`EpisodeId`]
18//!
19//! Every kind of ID defines its own validity function, i.e., what characters it
20//! can be made up of, such as alphanumeric or any.
21//!
22//! These types are just wrappers for [`Cow<str>`], so their usage should be
23//! quite similar overall.
24//!
25//! [`Cow<str>`]: [`std::borrow::Cow`]
26//!
27//! ## Examples
28//!
29//! If an endpoint requires a `TrackId`, you may pass it as:
30//!
31//! ```
32//! # use rspotify_model::TrackId;
33//! fn pause_track(id: TrackId<'_>) { /* ... */ }
34//!
35//! let id = TrackId::from_id("4iV5W9uYEdYUVa79Axb7Rh").unwrap();
36//! pause_track(id);
37//! ```
38//!
39//! Notice how this way it's type safe; the following example would fail at
40//! compile-time:
41//!
42//! ```compile_fail
43//! # use rspotify_model::{TrackId, EpisodeId};
44//! fn pause_track(id: TrackId<'_>) { /* ... */ }
45//!
46//! let id = EpisodeId::from_id("4iV5W9uYEdYUVa79Axb7Rh").unwrap();
47//! pause_track(id);
48//! ```
49//!
50//! And this would panic because it's a `TrackId` but its URI string specifies
51//! it's an album (`spotify:album:xxxx`).
52//!
53//! ```should_panic
54//! # use rspotify_model::TrackId;
55//! fn pause_track(id: TrackId<'_>) { /* ... */ }
56//!
57//! let id = TrackId::from_uri("spotify:album:6akEvsycLGftJxYudPjmqK").unwrap();
58//! pause_track(id);
59//! ```
60//!
61//! A more complex example where an endpoint takes a vector of IDs of different
62//! types:
63//!
64//! ```
65//! use rspotify_model::{TrackId, EpisodeId, PlayableId};
66//!
67//! fn track(id: TrackId<'_>) { /* ... */ }
68//! fn episode(id: EpisodeId<'_>) { /* ... */ }
69//! fn add_to_queue(id: &[PlayableId<'_>]) { /* ... */ }
70//!
71//! let tracks = [
72//! TrackId::from_uri("spotify:track:4iV5W9uYEdYUVa79Axb7Rh").unwrap(),
73//! TrackId::from_uri("spotify:track:5iKndSu1XI74U2OZePzP8L").unwrap(),
74//! ];
75//! let episodes = [
76//! EpisodeId::from_id("0lbiy3LKzIY2fnyjioC11p").unwrap(),
77//! EpisodeId::from_id("4zugY5eJisugQj9rj8TYuh").unwrap(),
78//! ];
79//!
80//! // First we get some info about the tracks and episodes
81//! let track_info = tracks.iter().map(|id| track(id.as_ref())).collect::<Vec<_>>();
82//! let ep_info = episodes.iter().map(|id| episode(id.as_ref())).collect::<Vec<_>>();
83//! println!("Track info: {:?}", track_info);
84//! println!("Episode info: {:?}", ep_info);
85//!
86//! // And then we add both the tracks and episodes to the queue
87//! let playable = tracks
88//! .into_iter()
89//! .map(|t| t.as_ref().into())
90//! .chain(
91//! episodes.into_iter().map(|e| e.as_ref().into())
92//! )
93//! .collect::<Vec<PlayableId>>();
94//! add_to_queue(&playable);
95//! ```
96
97use enum_dispatch::enum_dispatch;
98use serde::{Deserialize, Serialize};
99use strum::Display;
100use thiserror::Error;
101
102use std::{borrow::Cow, fmt::Debug, hash::Hash};
103
104use crate::Type;
105
106/// Spotify ID or URI parsing error
107///
108/// See also [`Id`] for details.
109#[derive(Debug, PartialEq, Eq, Clone, Copy, Display, Error)]
110pub enum IdError {
111 /// Spotify URI prefix is not `spotify:` or `spotify/`.
112 InvalidPrefix,
113 /// Spotify URI can't be split into type and id parts (e.g., it has invalid
114 /// separator).
115 InvalidFormat,
116 /// Spotify URI has invalid type name, or id has invalid type in a given
117 /// context (e.g. a method expects a track id, but artist id is provided).
118 InvalidType,
119 /// Spotify id is invalid (empty or contains invalid characters).
120 InvalidId,
121}
122
123/// The main interface for an ID.
124///
125/// See the [module level documentation] for more information.
126///
127/// [module level documentation]: [`crate::idtypes`]
128#[enum_dispatch]
129pub trait Id {
130 /// Returns the inner Spotify object ID, which is guaranteed to be valid for
131 /// its type.
132 fn id(&self) -> &str;
133
134 /// The type of the ID, as a function.
135 fn _type(&self) -> Type;
136
137 /// Returns a Spotify object URI in a well-known format: `spotify:type:id`.
138 ///
139 /// Examples: `spotify:album:6IcGNaXFRf5Y1jc7QsE9O2`,
140 /// `spotify:track:4y4VO05kYgUTo2bzbox1an`.
141 fn uri(&self) -> String {
142 format!("spotify:{}:{}", self._type(), self.id())
143 }
144
145 /// Returns a full Spotify object URL that can be opened in a browser.
146 ///
147 /// Examples: `https://open.spotify.com/track/4y4VO05kYgUTo2bzbox1an`,
148 /// `https://open.spotify.com/artist/2QI8e2Vwgg9KXOz2zjcrkI`.
149 fn url(&self) -> String {
150 format!("https://open.spotify.com/{}/{}", self._type(), self.id())
151 }
152}
153
154/// A lower level function to parse a URI into both its type and its actual ID.
155/// Note that this function doesn't check the validity of the returned ID (e.g.,
156/// whether it's alphanumeric; that should be done in `Id::from_id`).
157///
158/// This is only useful for advanced use-cases, such as implementing your own ID
159/// type.
160pub fn parse_uri(uri: &str) -> Result<(Type, &str), IdError> {
161 let mut chars = uri
162 .strip_prefix("spotify")
163 .ok_or(IdError::InvalidPrefix)?
164 .chars();
165 let sep = match chars.next() {
166 Some(ch) if ch == '/' || ch == ':' => ch,
167 _ => return Err(IdError::InvalidPrefix),
168 };
169 let rest = chars.as_str();
170
171 let (tpe, id) = rest
172 .rfind(sep)
173 .map(|mid| rest.split_at(mid))
174 .ok_or(IdError::InvalidFormat)?;
175
176 // Note that in case the type isn't known at compile time,
177 // any type will be accepted.
178 match tpe.parse::<Type>() {
179 Ok(tpe) => Ok((tpe, &id[1..])),
180 _ => Err(IdError::InvalidType),
181 }
182}
183
184/// This macro helps consistently define ID types.
185///
186/// * The `$type` parameter indicates what variant in `Type` the ID is for (say,
187/// `Artist`, or `Album`).
188/// * The `$name` parameter is the identifier of the struct.
189/// * The `$validity` parameter is the implementation of `id_is_valid`.
190macro_rules! define_idtypes {
191 ($($type:ident => {
192 name: $name:ident,
193 validity: $validity:expr
194 }),+) => {
195 $(
196 #[doc = concat!(
197 "ID of type [`Type::", stringify!($type), "`]. The validity of \
198 its characters is defined by the closure `",
199 stringify!($validity), "`.\n\nRefer to the [module-level \
200 docs][`crate::idtypes`] for more information. "
201 )]
202 #[repr(transparent)]
203 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)]
204 pub struct $name<'a>(Cow<'a, str>);
205
206 impl<'a> $name<'a> {
207 /// The type of the ID, as a constant.
208 const TYPE: Type = Type::$type;
209
210 /// Only returns `true` in case the given string is valid
211 /// according to that specific ID (e.g., some may require
212 /// alphanumeric characters only).
213 #[must_use]
214 pub fn id_is_valid(id: &str) -> bool {
215 const VALID_FN: fn(&str) -> bool = $validity;
216 VALID_FN(id)
217 }
218
219 /// Initialize the ID without checking its validity.
220 ///
221 /// # Safety
222 ///
223 /// The string passed to this method must be made out of valid
224 /// characters only; otherwise undefined behaviour may occur.
225 pub unsafe fn from_id_unchecked<S>(id: S) -> Self
226 where
227 S: Into<Cow<'a, str>>
228 {
229 Self(id.into())
230 }
231
232 /// Parse Spotify ID from string slice.
233 ///
234 /// A valid Spotify object id must be a non-empty string with
235 /// valid characters.
236 ///
237 /// # Errors
238 ///
239 /// - `IdError::InvalidId` - if `id` contains invalid characters.
240 pub fn from_id<S>(id: S) -> Result<Self, IdError>
241 where
242 S: Into<Cow<'a, str>>
243 {
244 let id = id.into();
245 if Self::id_is_valid(&id) {
246 // Safe, we've just checked that the ID is valid.
247 Ok(unsafe { Self::from_id_unchecked(id) })
248 } else {
249 Err(IdError::InvalidId)
250 }
251 }
252
253 /// Parse Spotify URI from string slice
254 ///
255 /// Spotify URI must be in one of the following formats:
256 /// `spotify:{type}:{id}` or `spotify/{type}/{id}`.
257 /// Where `{type}` is one of `artist`, `album`, `track`,
258 /// `playlist`, `user`, `show`, or `episode`, and `{id}` is a
259 /// non-empty valid string.
260 ///
261 /// Examples: `spotify:album:6IcGNaXFRf5Y1jc7QsE9O2`,
262 /// `spotify/track/4y4VO05kYgUTo2bzbox1an`.
263 ///
264 /// # Errors
265 ///
266 /// - `IdError::InvalidPrefix` - if `uri` is not started with
267 /// `spotify:` or `spotify/`,
268 /// - `IdError::InvalidType` - if type part of an `uri` is not a
269 /// valid Spotify type `T`,
270 /// - `IdError::InvalidId` - if id part of an `uri` is not a
271 /// valid id,
272 /// - `IdError::InvalidFormat` - if it can't be splitted into
273 /// type and id parts.
274 ///
275 /// # Implementation details
276 ///
277 /// Unlike [`Self::from_id`], this method takes a `&str` rather
278 /// than an `Into<Cow<str>>`. This is because the inner `Cow` in
279 /// the ID would reference a slice from the given `&str` (i.e.,
280 /// taking the ID out of the URI). The parameter wouldn't live
281 /// long enough when using `Into<Cow<str>>`, so the only
282 /// sensible choice is to just use a `&str`.
283 pub fn from_uri(uri: &'a str) -> Result<Self, IdError> {
284 let (tpe, id) = parse_uri(&uri)?;
285 if tpe == Type::$type {
286 Self::from_id(id)
287 } else {
288 Err(IdError::InvalidType)
289 }
290 }
291
292 /// Parse Spotify ID or URI from string slice
293 ///
294 /// Spotify URI must be in one of the following formats:
295 /// `spotify:{type}:{id}` or `spotify/{type}/{id}`.
296 /// Where `{type}` is one of `artist`, `album`, `track`,
297 /// `playlist`, `user`, `show`, or `episode`, and `{id}` is a
298 /// non-empty valid string. The URI must be match with the ID's
299 /// type (`Id::TYPE`), otherwise `IdError::InvalidType` error is
300 /// returned.
301 ///
302 /// Examples: `spotify:album:6IcGNaXFRf5Y1jc7QsE9O2`,
303 /// `spotify/track/4y4VO05kYgUTo2bzbox1an`.
304 ///
305 /// If input string is not a valid Spotify URI (it's not started
306 /// with `spotify:` or `spotify/`), it must be a valid Spotify
307 /// object ID, i.e. a non-empty valid string.
308 ///
309 /// # Errors
310 ///
311 /// - `IdError::InvalidType` - if `id_or_uri` is an URI, and
312 /// it's type part is not equal to `T`,
313 /// - `IdError::InvalidId` - either if `id_or_uri` is an URI
314 /// with invalid id part, or it's an invalid id (id is invalid
315 /// if it contains valid characters),
316 /// - `IdError::InvalidFormat` - if `id_or_uri` is an URI, and
317 /// it can't be split into type and id parts.
318 ///
319 /// # Implementation details
320 ///
321 /// Unlike [`Self::from_id`], this method takes a `&str` rather
322 /// than an `Into<Cow<str>>`. This is because the inner `Cow` in
323 /// the ID would reference a slice from the given `&str` (i.e.,
324 /// taking the ID out of the URI). The parameter wouldn't live
325 /// long enough when using `Into<Cow<str>>`, so the only
326 /// sensible choice is to just use a `&str`.
327 pub fn from_id_or_uri(id_or_uri: &'a str) -> Result<Self, IdError> {
328 match Self::from_uri(id_or_uri) {
329 Ok(id) => Ok(id),
330 Err(IdError::InvalidPrefix) => Self::from_id(id_or_uri),
331 Err(error) => Err(error),
332 }
333 }
334
335 /// This creates an ID with the underlying `&str` variant from a
336 /// reference. Useful to use an ID multiple times without having
337 /// to clone it.
338 #[must_use]
339 pub fn as_ref(&'a self) -> Self {
340 Self(Cow::Borrowed(self.0.as_ref()))
341 }
342
343 /// An ID is a `Cow` after all, so this will switch to the its
344 /// owned version, which has a `'static` lifetime.
345 #[must_use]
346 pub fn into_static(self) -> $name<'static> {
347 $name(Cow::Owned(self.0.into_owned()))
348 }
349
350 /// Similar to [`Self::into_static`], but without consuming the
351 /// original ID.
352 #[must_use]
353 pub fn clone_static(&self) -> $name<'static> {
354 $name(Cow::Owned(self.0.clone().into_owned()))
355 }
356 }
357
358 impl Id for $name<'_> {
359 fn id(&self) -> &str {
360 &self.0
361 }
362
363 fn _type(&self) -> Type {
364 Self::TYPE
365 }
366 }
367
368 // Deserialization may take either an ID or an URI, so its
369 // implementation has to be done manually.
370 impl<'de> Deserialize<'de> for $name<'static> {
371 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
372 where
373 D: serde::Deserializer<'de>,
374 {
375 struct IdVisitor;
376
377 impl<'de> serde::de::Visitor<'de> for IdVisitor {
378 type Value = $name<'static>;
379
380 fn expecting(
381 &self, formatter: &mut std::fmt::Formatter<'_>
382 ) -> Result<(), std::fmt::Error>
383 {
384 let msg = concat!("ID or URI for struct ", stringify!($name));
385 formatter.write_str(msg)
386 }
387
388 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
389 where
390 E: serde::de::Error,
391 {
392 $name::from_id_or_uri(value)
393 .map($name::into_static)
394 .map_err(serde::de::Error::custom)
395 }
396
397 fn visit_newtype_struct<A>(
398 self,
399 deserializer: A,
400 ) -> Result<Self::Value, A::Error>
401 where
402 A: serde::Deserializer<'de>,
403 {
404 deserializer.deserialize_str(self)
405 }
406
407 fn visit_seq<A>(
408 self,
409 mut seq: A,
410 ) -> Result<Self::Value, A::Error>
411 where
412 A: serde::de::SeqAccess<'de>,
413 {
414 let field: &str = seq.next_element()?
415 .ok_or_else(|| serde::de::Error::invalid_length(0, &self))?;
416 $name::from_id_or_uri(field)
417 .map($name::into_static)
418 .map_err(serde::de::Error::custom)
419 }
420 }
421
422 deserializer.deserialize_newtype_struct(stringify!($name), IdVisitor)
423 }
424 }
425
426 /// `Id`s may be borrowed as `str` the same way `Box<T>` may be
427 /// borrowed as `T` or `String` as `str`
428 impl std::borrow::Borrow<str> for $name<'_> {
429 fn borrow(&self) -> &str {
430 self.id()
431 }
432 }
433
434 /// Displaying the ID shows its URI
435 impl std::fmt::Display for $name<'_> {
436 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
437 write!(f, "{}", self.uri())
438 }
439 }
440 )+
441 }
442}
443
444// First declaring the regular IDs. Those with custom behaviour will have to be
445// declared manually later on.
446define_idtypes!(
447 Artist => {
448 name: ArtistId,
449 validity: |id| id.chars().all(|ch| ch.is_ascii_alphanumeric())
450 },
451 Album => {
452 name: AlbumId,
453 validity: |id| id.chars().all(|ch| ch.is_ascii_alphanumeric())
454 },
455 Track => {
456 name: TrackId,
457 validity: |id| id.chars().all(|ch| ch.is_ascii_alphanumeric())
458 },
459 Playlist => {
460 name: PlaylistId,
461 validity: |id| id.chars().all(|ch| ch.is_ascii_alphanumeric())
462 },
463 Show => {
464 name: ShowId,
465 validity: |id| id.chars().all(|ch| ch.is_ascii_alphanumeric())
466 },
467 Episode => {
468 name: EpisodeId,
469 validity: |id| id.chars().all(|ch| ch.is_ascii_alphanumeric())
470 },
471 User => {
472 name: UserId,
473 validity: |_| true
474 }
475);
476
477// We use `enum_dispatch` for dynamic dispatch, which is not only easier to use
478// than `dyn`, but also more efficient.
479/// Grouping up multiple kinds of IDs to treat them generically. This also
480/// implements [`Id`], and [`From`] to instantiate it.
481#[enum_dispatch(Id)]
482#[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)]
483pub enum PlayContextId<'a> {
484 Artist(ArtistId<'a>),
485 Album(AlbumId<'a>),
486 Playlist(PlaylistId<'a>),
487 Show(ShowId<'a>),
488}
489// These don't work with `enum_dispatch`, unfortunately.
490impl<'a> PlayContextId<'a> {
491 #[must_use]
492 pub fn as_ref(&'a self) -> Self {
493 match self {
494 PlayContextId::Artist(x) => PlayContextId::Artist(x.as_ref()),
495 PlayContextId::Album(x) => PlayContextId::Album(x.as_ref()),
496 PlayContextId::Playlist(x) => PlayContextId::Playlist(x.as_ref()),
497 PlayContextId::Show(x) => PlayContextId::Show(x.as_ref()),
498 }
499 }
500
501 #[must_use]
502 pub fn into_static(self) -> PlayContextId<'static> {
503 match self {
504 PlayContextId::Artist(x) => PlayContextId::Artist(x.into_static()),
505 PlayContextId::Album(x) => PlayContextId::Album(x.into_static()),
506 PlayContextId::Playlist(x) => PlayContextId::Playlist(x.into_static()),
507 PlayContextId::Show(x) => PlayContextId::Show(x.into_static()),
508 }
509 }
510
511 #[must_use]
512 pub fn clone_static(&'a self) -> PlayContextId<'static> {
513 match self {
514 PlayContextId::Artist(x) => PlayContextId::Artist(x.clone_static()),
515 PlayContextId::Album(x) => PlayContextId::Album(x.clone_static()),
516 PlayContextId::Playlist(x) => PlayContextId::Playlist(x.clone_static()),
517 PlayContextId::Show(x) => PlayContextId::Show(x.clone_static()),
518 }
519 }
520}
521
522/// Grouping up multiple kinds of IDs to treat them generically. This also
523/// implements [`Id`] and [`From`] to instantiate it.
524#[enum_dispatch(Id)]
525#[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)]
526pub enum PlayableId<'a> {
527 Track(TrackId<'a>),
528 Episode(EpisodeId<'a>),
529}
530// These don't work with `enum_dispatch`, unfortunately.
531impl<'a> PlayableId<'a> {
532 #[must_use]
533 pub fn as_ref(&'a self) -> Self {
534 match self {
535 PlayableId::Track(x) => PlayableId::Track(x.as_ref()),
536 PlayableId::Episode(x) => PlayableId::Episode(x.as_ref()),
537 }
538 }
539
540 #[must_use]
541 pub fn into_static(self) -> PlayableId<'static> {
542 match self {
543 PlayableId::Track(x) => PlayableId::Track(x.into_static()),
544 PlayableId::Episode(x) => PlayableId::Episode(x.into_static()),
545 }
546 }
547
548 #[must_use]
549 pub fn clone_static(&'a self) -> PlayableId<'static> {
550 match self {
551 PlayableId::Track(x) => PlayableId::Track(x.clone_static()),
552 PlayableId::Episode(x) => PlayableId::Episode(x.clone_static()),
553 }
554 }
555}
556
557#[cfg(test)]
558mod test {
559 use super::*;
560 use std::{borrow::Cow, error::Error};
561
562 // Valid values:
563 const ID: &str = "4iV5W9uYEdYUVa79Axb7Rh";
564 const URI: &str = "spotify:track:4iV5W9uYEdYUVa79Axb7Rh";
565 const URI_SLASHES: &str = "spotify/track/4iV5W9uYEdYUVa79Axb7Rh";
566 // Invalid values:
567 const URI_EMPTY: &str = "spotify::4iV5W9uYEdYUVa79Axb7Rh";
568 const URI_WRONGTYPE1: &str = "spotify:unknown:4iV5W9uYEdYUVa79Axb7Rh";
569 const URI_SHORT: &str = "track:4iV5W9uYEdYUVa79Axb7Rh";
570 const URI_MIXED1: &str = "spotify/track:4iV5W9uYEdYUVa79Axb7Rh";
571 const URI_MIXED2: &str = "spotify:track/4iV5W9uYEdYUVa79Axb7Rh";
572
573 #[test]
574 fn test_id_parse() {
575 assert!(TrackId::from_id(ID).is_ok());
576 assert_eq!(TrackId::from_id(URI), Err(IdError::InvalidId));
577 assert_eq!(TrackId::from_id(URI_SLASHES), Err(IdError::InvalidId));
578 assert_eq!(TrackId::from_id(URI_EMPTY), Err(IdError::InvalidId));
579 assert_eq!(TrackId::from_id(URI_WRONGTYPE1), Err(IdError::InvalidId));
580 assert_eq!(TrackId::from_id(URI_SHORT), Err(IdError::InvalidId));
581 assert_eq!(TrackId::from_id(URI_MIXED1), Err(IdError::InvalidId));
582 assert_eq!(TrackId::from_id(URI_MIXED2), Err(IdError::InvalidId));
583 }
584
585 #[test]
586 fn test_uri_parse() {
587 assert!(TrackId::from_uri(URI).is_ok());
588 assert!(TrackId::from_uri(URI_SLASHES).is_ok());
589 assert_eq!(TrackId::from_uri(ID), Err(IdError::InvalidPrefix));
590 assert_eq!(TrackId::from_uri(URI_SHORT), Err(IdError::InvalidPrefix));
591 assert_eq!(TrackId::from_uri(URI_EMPTY), Err(IdError::InvalidType));
592 assert_eq!(TrackId::from_uri(URI_WRONGTYPE1), Err(IdError::InvalidType));
593 assert_eq!(TrackId::from_uri(URI_MIXED1), Err(IdError::InvalidFormat));
594 assert_eq!(TrackId::from_uri(URI_MIXED2), Err(IdError::InvalidFormat));
595 }
596
597 /// Deserialization should accept both IDs and URIs as well.
598 #[test]
599 fn test_id_or_uri_and_deserialize() {
600 fn test_any<F, E>(check: F)
601 where
602 F: Fn(&str) -> Result<TrackId<'_>, E>,
603 E: Error,
604 {
605 // In this case we also check that the contents are the ID and not
606 // the URI.
607 assert!(check(ID).is_ok());
608 assert_eq!(check(ID).unwrap().id(), ID);
609 assert!(check(URI).is_ok());
610 assert_eq!(check(URI).unwrap().id(), ID);
611 assert!(check(URI_SLASHES).is_ok());
612 assert_eq!(check(URI_SLASHES).unwrap().id(), ID);
613
614 // These should not work in any case
615 assert!(check(URI_SHORT).is_err());
616 assert!(check(URI_EMPTY).is_err());
617 assert!(check(URI_WRONGTYPE1).is_err());
618 assert!(check(URI_MIXED1).is_err());
619 assert!(check(URI_MIXED2).is_err());
620 }
621
622 // Easily testing both ways to obtain an ID
623 test_any(|s| TrackId::from_id_or_uri(s));
624 test_any(|s| {
625 let json = format!("\"{s}\"");
626 serde_json::from_str::<'_, TrackId>(&json)
627 });
628 }
629
630 /// Serializing should return the Id within it, not the URI.
631 #[test]
632 fn test_serialize() {
633 let json_expected = format!("\"{ID}\"");
634 let track = TrackId::from_uri(URI).unwrap();
635 let json = serde_json::to_string(&track).unwrap();
636 assert_eq!(json, json_expected);
637 }
638
639 #[test]
640 fn test_multiple_types() {
641 fn endpoint<'a>(_ids: impl IntoIterator<Item = PlayableId<'a>>) {}
642
643 let tracks: Vec<PlayableId> = vec![
644 PlayableId::Track(TrackId::from_id(ID).unwrap()),
645 PlayableId::Track(TrackId::from_id(ID).unwrap()),
646 PlayableId::Episode(EpisodeId::from_id(ID).unwrap()),
647 PlayableId::Episode(EpisodeId::from_id(ID).unwrap()),
648 ];
649 endpoint(tracks);
650 }
651
652 #[test]
653 fn test_unknown_at_compile_time() {
654 fn endpoint1(input: &str, is_episode: bool) -> PlayableId<'_> {
655 if is_episode {
656 PlayableId::Episode(EpisodeId::from_id(input).unwrap())
657 } else {
658 PlayableId::Track(TrackId::from_id(input).unwrap())
659 }
660 }
661 fn endpoint2(_id: &[PlayableId]) {}
662
663 let id = endpoint1(ID, false);
664 endpoint2(&[id]);
665 }
666
667 #[test]
668 fn test_constructor() {
669 // With `&str`
670 let _ = EpisodeId::from_id(ID).unwrap();
671 // With `String`
672 let _ = EpisodeId::from_id(ID.to_string()).unwrap();
673 // With borrowed `Cow<str>`
674 let _ = EpisodeId::from_id(Cow::Borrowed(ID)).unwrap();
675 // With owned `Cow<str>`
676 let _ = EpisodeId::from_id(Cow::Owned(ID.to_string())).unwrap();
677 }
678
679 #[test]
680 fn test_owned() {
681 // We check it twice to make sure cloning statically also works.
682 fn check_static(_: EpisodeId<'static>) {}
683
684 // With lifetime smaller than static because it's a locally owned
685 // variable.
686 let local_id = String::from(ID);
687
688 // With `&str`: should be converted
689 let id: EpisodeId<'_> = EpisodeId::from_id(local_id.as_str()).unwrap();
690 check_static(id.clone_static());
691 check_static(id.into_static());
692
693 // With `String`: already static
694 let id = EpisodeId::from_id(local_id.clone()).unwrap();
695 check_static(id.clone());
696 check_static(id);
697 }
698}