spotify_cli/io/registry/
lists.rs

1//! List formatters (playlists, saved tracks, top tracks/artists, etc.)
2
3use serde_json::Value;
4
5use super::PayloadFormatter;
6use crate::io::formatters;
7use crate::io::output::PayloadKind;
8
9pub struct PlaylistsFormatter;
10
11impl PayloadFormatter for PlaylistsFormatter {
12    fn name(&self) -> &'static str {
13        "playlists"
14    }
15
16    fn supported_kinds(&self) -> &'static [PayloadKind] {
17        &[PayloadKind::PlaylistList]
18    }
19
20    fn matches(&self, payload: &Value) -> bool {
21        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
22            !items.is_empty()
23                && (items[0].get("tracks").is_some() || items[0].get("owner").is_some())
24        } else {
25            false
26        }
27    }
28
29    fn format(&self, payload: &Value, _message: &str) {
30        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
31            formatters::format_playlists(items);
32        }
33    }
34}
35
36pub struct SavedTracksFormatter;
37
38impl PayloadFormatter for SavedTracksFormatter {
39    fn name(&self) -> &'static str {
40        "saved_tracks"
41    }
42
43    fn supported_kinds(&self) -> &'static [PayloadKind] {
44        &[PayloadKind::SavedTracks]
45    }
46
47    fn matches(&self, payload: &Value) -> bool {
48        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
49            !items.is_empty()
50                && items[0].get("track").is_some()
51                && items[0].get("added_at").is_some()
52        } else {
53            false
54        }
55    }
56
57    fn format(&self, payload: &Value, _message: &str) {
58        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
59            formatters::format_saved_tracks(items);
60        }
61    }
62}
63
64pub struct TopTracksFormatter;
65
66impl PayloadFormatter for TopTracksFormatter {
67    fn name(&self) -> &'static str {
68        "top_tracks"
69    }
70
71    fn supported_kinds(&self) -> &'static [PayloadKind] {
72        &[PayloadKind::TopTracks, PayloadKind::TrackList]
73    }
74
75    fn matches(&self, payload: &Value) -> bool {
76        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
77            !items.is_empty() && items[0].get("album").is_some()
78        } else {
79            false
80        }
81    }
82
83    fn format(&self, payload: &Value, message: &str) {
84        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
85            formatters::format_top_tracks(items, message);
86        }
87    }
88}
89
90pub struct TopArtistsFormatter;
91
92impl PayloadFormatter for TopArtistsFormatter {
93    fn name(&self) -> &'static str {
94        "top_artists"
95    }
96
97    fn supported_kinds(&self) -> &'static [PayloadKind] {
98        &[
99            PayloadKind::TopArtists,
100            PayloadKind::ArtistList,
101            PayloadKind::FollowedArtists,
102        ]
103    }
104
105    fn matches(&self, payload: &Value) -> bool {
106        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
107            !items.is_empty() && items[0].get("genres").is_some()
108        } else {
109            false
110        }
111    }
112
113    fn format(&self, payload: &Value, message: &str) {
114        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
115            formatters::format_top_artists(items, message);
116        }
117    }
118}
119
120pub struct ArtistTopTracksFormatter;
121
122impl PayloadFormatter for ArtistTopTracksFormatter {
123    fn name(&self) -> &'static str {
124        "artist_top_tracks"
125    }
126
127    fn supported_kinds(&self) -> &'static [PayloadKind] {
128        &[PayloadKind::ArtistTopTracks]
129    }
130
131    fn matches(&self, payload: &Value) -> bool {
132        payload.get("tracks").map(|t| t.is_array()).unwrap_or(false)
133            && payload.get("items").is_none()
134    }
135
136    fn format(&self, payload: &Value, _message: &str) {
137        if let Some(tracks) = payload.get("tracks").and_then(|t| t.as_array()) {
138            formatters::format_artist_top_tracks(tracks);
139        }
140    }
141}
142
143pub struct LibraryCheckFormatter;
144
145impl PayloadFormatter for LibraryCheckFormatter {
146    fn name(&self) -> &'static str {
147        "library_check"
148    }
149
150    fn supported_kinds(&self) -> &'static [PayloadKind] {
151        &[PayloadKind::LibraryCheck]
152    }
153
154    fn matches(&self, payload: &Value) -> bool {
155        if let Some(arr) = payload.as_array() {
156            !arr.is_empty() && arr[0].is_boolean()
157        } else {
158            false
159        }
160    }
161
162    fn format(&self, payload: &Value, _message: &str) {
163        if let Some(arr) = payload.as_array() {
164            formatters::format_library_check(arr);
165        }
166    }
167}
168
169pub struct SavedAlbumsFormatter;
170
171impl PayloadFormatter for SavedAlbumsFormatter {
172    fn name(&self) -> &'static str {
173        "saved_albums"
174    }
175
176    fn supported_kinds(&self) -> &'static [PayloadKind] {
177        &[PayloadKind::SavedAlbums]
178    }
179
180    fn matches(&self, payload: &Value) -> bool {
181        // Saved albums have items with "album" and "added_at" fields
182        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
183            !items.is_empty()
184                && items[0].get("album").is_some()
185                && items[0].get("added_at").is_some()
186        } else {
187            false
188        }
189    }
190
191    fn format(&self, payload: &Value, _message: &str) {
192        if let Some(items) = payload.get("items").and_then(|i| i.as_array()) {
193            formatters::format_saved_albums(items);
194        }
195    }
196}
197
198pub struct MarketsFormatter;
199
200impl PayloadFormatter for MarketsFormatter {
201    fn name(&self) -> &'static str {
202        "markets"
203    }
204
205    fn supported_kinds(&self) -> &'static [PayloadKind] {
206        &[PayloadKind::Markets]
207    }
208
209    fn matches(&self, payload: &Value) -> bool {
210        payload
211            .get("markets")
212            .map(|m| m.is_array())
213            .unwrap_or(false)
214    }
215
216    fn format(&self, payload: &Value, _message: &str) {
217        if let Some(markets) = payload.get("markets").and_then(|m| m.as_array()) {
218            formatters::format_markets(markets);
219        }
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use serde_json::json;
227
228    #[test]
229    fn playlist_formatter_supports_playlist_list() {
230        let formatter = PlaylistsFormatter;
231        let kinds = formatter.supported_kinds();
232        assert!(kinds.contains(&PayloadKind::PlaylistList));
233    }
234
235    #[test]
236    fn playlists_formatter_matches() {
237        let formatter = PlaylistsFormatter;
238        let payload = json!({ "items": [{ "tracks": {} }] });
239        assert!(formatter.matches(&payload));
240        let empty = json!({ "items": [] });
241        assert!(!formatter.matches(&empty));
242    }
243
244    #[test]
245    fn saved_tracks_formatter_matches() {
246        let formatter = SavedTracksFormatter;
247        let payload = json!({ "items": [{ "track": {}, "added_at": "2024" }] });
248        assert!(formatter.matches(&payload));
249        let empty = json!({ "items": [] });
250        assert!(!formatter.matches(&empty));
251    }
252
253    #[test]
254    fn top_tracks_formatter_matches() {
255        let formatter = TopTracksFormatter;
256        let payload = json!({ "items": [{ "album": {} }] });
257        assert!(formatter.matches(&payload));
258        let empty = json!({ "items": [] });
259        assert!(!formatter.matches(&empty));
260    }
261
262    #[test]
263    fn top_artists_formatter_supports_multiple_kinds() {
264        let formatter = TopArtistsFormatter;
265        let kinds = formatter.supported_kinds();
266        assert!(kinds.contains(&PayloadKind::TopArtists));
267        assert!(kinds.contains(&PayloadKind::ArtistList));
268        assert!(kinds.contains(&PayloadKind::FollowedArtists));
269    }
270
271    #[test]
272    fn top_artists_formatter_matches() {
273        let formatter = TopArtistsFormatter;
274        let payload = json!({ "items": [{ "genres": [] }] });
275        assert!(formatter.matches(&payload));
276        let empty = json!({ "items": [] });
277        assert!(!formatter.matches(&empty));
278    }
279
280    #[test]
281    fn artist_top_tracks_formatter_matches() {
282        let formatter = ArtistTopTracksFormatter;
283        let payload = json!({ "tracks": [] });
284        assert!(formatter.matches(&payload));
285        let with_items = json!({ "tracks": [], "items": [] });
286        assert!(!formatter.matches(&with_items));
287    }
288
289    #[test]
290    fn library_check_matches_boolean_array() {
291        let formatter = LibraryCheckFormatter;
292        let payload = json!([true, false, true]);
293        assert!(formatter.matches(&payload));
294    }
295
296    #[test]
297    fn library_check_does_not_match_non_boolean_array() {
298        let formatter = LibraryCheckFormatter;
299        let payload = json!(["string", "array"]);
300        assert!(!formatter.matches(&payload));
301    }
302
303    #[test]
304    fn formatter_names() {
305        assert_eq!(PlaylistsFormatter.name(), "playlists");
306        assert_eq!(SavedTracksFormatter.name(), "saved_tracks");
307        assert_eq!(TopTracksFormatter.name(), "top_tracks");
308        assert_eq!(TopArtistsFormatter.name(), "top_artists");
309        assert_eq!(ArtistTopTracksFormatter.name(), "artist_top_tracks");
310        assert_eq!(LibraryCheckFormatter.name(), "library_check");
311    }
312
313    #[test]
314    fn playlists_format_runs() {
315        let formatter = PlaylistsFormatter;
316        let payload = json!({
317            "items": [{
318                "name": "My Playlist",
319                "tracks": {"total": 10},
320                "owner": {"display_name": "User"}
321            }]
322        });
323        formatter.format(&payload, "Playlists");
324    }
325
326    #[test]
327    fn saved_tracks_format_runs() {
328        let formatter = SavedTracksFormatter;
329        let payload = json!({
330            "items": [{
331                "track": {"name": "Track", "artists": [{"name": "Artist"}]},
332                "added_at": "2024-01-01"
333            }]
334        });
335        formatter.format(&payload, "Saved Tracks");
336    }
337
338    #[test]
339    fn top_tracks_format_runs() {
340        let formatter = TopTracksFormatter;
341        let payload = json!({
342            "items": [{
343                "name": "Track",
344                "album": {"name": "Album"},
345                "artists": [{"name": "Artist"}]
346            }]
347        });
348        formatter.format(&payload, "Top Tracks");
349    }
350
351    #[test]
352    fn top_artists_format_runs() {
353        let formatter = TopArtistsFormatter;
354        let payload = json!({
355            "items": [{
356                "name": "Artist",
357                "genres": ["rock"],
358                "popularity": 80
359            }]
360        });
361        formatter.format(&payload, "Top Artists");
362    }
363
364    #[test]
365    fn artist_top_tracks_format_runs() {
366        let formatter = ArtistTopTracksFormatter;
367        let payload = json!({
368            "tracks": [{
369                "name": "Track",
370                "album": {"name": "Album"}
371            }]
372        });
373        formatter.format(&payload, "Artist Top Tracks");
374    }
375
376    #[test]
377    fn library_check_format_runs() {
378        let formatter = LibraryCheckFormatter;
379        let payload = json!([true, false, true]);
380        formatter.format(&payload, "Library Check");
381    }
382
383    #[test]
384    fn playlists_matches_with_owner() {
385        let formatter = PlaylistsFormatter;
386        let payload = json!({ "items": [{ "owner": {} }] });
387        assert!(formatter.matches(&payload));
388    }
389
390    #[test]
391    fn saved_tracks_does_not_match_without_added_at() {
392        let formatter = SavedTracksFormatter;
393        let payload = json!({ "items": [{ "track": {} }] });
394        assert!(!formatter.matches(&payload));
395    }
396
397    #[test]
398    fn library_check_does_not_match_empty_array() {
399        let formatter = LibraryCheckFormatter;
400        let payload = json!([]);
401        assert!(!formatter.matches(&payload));
402    }
403
404    #[test]
405    fn library_check_does_not_match_non_array() {
406        let formatter = LibraryCheckFormatter;
407        let payload = json!({ "data": true });
408        assert!(!formatter.matches(&payload));
409    }
410}