spotify_cli/io/
output.rs

1//! Response types and output functions
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use super::registry::format_payload_with_kind as format_payload;
7use crate::http::client::HttpError;
8
9/// Payload type hint for reliable formatter matching.
10///
11/// This eliminates brittle payload inspection by explicitly declaring the payload type.
12#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(rename_all = "snake_case")]
14pub enum PayloadKind {
15    // Player-related
16    PlayerStatus,
17    Queue,
18    Devices,
19    PlayHistory,
20
21    // Search
22    SearchResults,
23    CombinedSearch,
24    Pins,
25
26    // Resources
27    Track,
28    Album,
29    Artist,
30    Playlist,
31    Show,
32    Episode,
33    Audiobook,
34    Chapter,
35    Category,
36    User,
37
38    // Lists
39    TrackList,
40    AlbumList,
41    ArtistList,
42    PlaylistList,
43    ShowList,
44    EpisodeList,
45    AudiobookList,
46    ChapterList,
47    CategoryList,
48    TopTracks,
49    TopArtists,
50    ArtistTopTracks,
51    RelatedArtists,
52    NewReleases,
53    FollowedArtists,
54
55    // Library
56    SavedTracks,
57    SavedAlbums,
58    SavedShows,
59    SavedEpisodes,
60    SavedAudiobooks,
61    LibraryCheck,
62
63    // Other
64    Markets,
65    Generic,
66}
67
68/// Type-safe error categories
69#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
70#[serde(rename_all = "snake_case")]
71pub enum ErrorKind {
72    /// Network/connectivity issues
73    Network,
74    /// Spotify API returned an error
75    Api,
76    /// Authentication required or failed
77    Auth,
78    /// Resource not found
79    NotFound,
80    /// Permission denied
81    Forbidden,
82    /// Rate limited by Spotify
83    RateLimited,
84    /// Invalid input from user
85    Validation,
86    /// Local storage error
87    Storage,
88    /// Configuration error
89    Config,
90    /// Player-specific error
91    Player,
92}
93
94impl ErrorKind {
95    pub fn as_str(&self) -> &'static str {
96        match self {
97            ErrorKind::Network => "network_error",
98            ErrorKind::Api => "api_error",
99            ErrorKind::Auth => "auth_error",
100            ErrorKind::NotFound => "not_found",
101            ErrorKind::Forbidden => "forbidden",
102            ErrorKind::RateLimited => "rate_limited",
103            ErrorKind::Validation => "validation_error",
104            ErrorKind::Storage => "storage_error",
105            ErrorKind::Config => "config_error",
106            ErrorKind::Player => "player_error",
107        }
108    }
109}
110
111impl std::fmt::Display for ErrorKind {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        write!(f, "{}", self.as_str())
114    }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum Status {
120    Success,
121    Error,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[must_use = "Response should be returned or printed, not ignored"]
126pub struct Response {
127    pub status: Status,
128    pub code: u16,
129    pub message: String,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub payload: Option<Value>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub payload_kind: Option<PayloadKind>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub error: Option<ErrorDetail>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ErrorDetail {
140    pub kind: String,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub details: Option<String>,
143}
144
145impl Response {
146    pub fn success(code: u16, message: impl Into<String>) -> Self {
147        Self {
148            status: Status::Success,
149            code,
150            message: message.into(),
151            payload: None,
152            payload_kind: None,
153            error: None,
154        }
155    }
156
157    pub fn success_with_payload(code: u16, message: impl Into<String>, payload: Value) -> Self {
158        Self {
159            status: Status::Success,
160            code,
161            message: message.into(),
162            payload: Some(payload),
163            payload_kind: None,
164            error: None,
165        }
166    }
167
168    /// Create a success response with typed payload for reliable formatter matching.
169    ///
170    /// Use this instead of `success_with_payload` when you know the exact payload type.
171    pub fn success_typed(
172        code: u16,
173        message: impl Into<String>,
174        kind: PayloadKind,
175        payload: Value,
176    ) -> Self {
177        Self {
178            status: Status::Success,
179            code,
180            message: message.into(),
181            payload: Some(payload),
182            payload_kind: Some(kind),
183            error: None,
184        }
185    }
186
187    /// Create an error response with ErrorKind
188    pub fn err(code: u16, message: impl Into<String>, kind: ErrorKind) -> Self {
189        Self {
190            status: Status::Error,
191            code,
192            message: message.into(),
193            payload: None,
194            payload_kind: None,
195            error: Some(ErrorDetail {
196                kind: kind.to_string(),
197                details: None,
198            }),
199        }
200    }
201
202    /// Create an error response with ErrorKind and details
203    pub fn err_with_details(
204        code: u16,
205        message: impl Into<String>,
206        kind: ErrorKind,
207        details: impl Into<String>,
208    ) -> Self {
209        Self {
210            status: Status::Error,
211            code,
212            message: message.into(),
213            payload: None,
214            payload_kind: None,
215            error: Some(ErrorDetail {
216                kind: kind.to_string(),
217                details: Some(details.into()),
218            }),
219        }
220    }
221
222    /// Create a Response from an HttpError, preserving the original status code
223    pub fn from_http_error(err: &HttpError, context: &str) -> Self {
224        let kind = match err {
225            HttpError::Network(_) => ErrorKind::Network,
226            HttpError::Unauthorized => ErrorKind::Auth,
227            HttpError::Forbidden => ErrorKind::Forbidden,
228            HttpError::NotFound => ErrorKind::NotFound,
229            HttpError::RateLimited { .. } => ErrorKind::RateLimited,
230            HttpError::Api { .. } => ErrorKind::Api,
231        };
232
233        let status_text = match err.status_code() {
234            400 => "Bad Request",
235            401 => "Unauthorized",
236            403 => "Forbidden",
237            404 => "Not Found",
238            429 => "Rate Limited",
239            500 => "Internal Server Error",
240            502 => "Bad Gateway",
241            503 => "Service Unavailable",
242            _ => "",
243        };
244
245        let message = if status_text.is_empty() {
246            format!("{} ({})", context, err.status_code())
247        } else {
248            format!("{}: {} {}", context, err.status_code(), status_text)
249        };
250
251        Self {
252            status: Status::Error,
253            code: err.status_code(),
254            message,
255            payload: None,
256            payload_kind: None,
257            error: Some(ErrorDetail {
258                kind: kind.to_string(),
259                details: Some(err.user_message().to_string()),
260            }),
261        }
262    }
263
264    pub fn to_json(&self) -> String {
265        serde_json::to_string(self).unwrap_or_else(|_| {
266            r#"{"status":"error","code":500,"message":"Failed to serialize response"}"#.to_string()
267        })
268    }
269}
270
271/// Create an API error response from HttpError (preserves status code)
272#[macro_export]
273macro_rules! api_error {
274    ($ctx:expr, $err:expr) => {
275        $crate::io::output::Response::from_http_error(&$err, $ctx)
276    };
277}
278
279/// Create a storage error response (500, ErrorKind::Storage)
280#[macro_export]
281macro_rules! storage_error {
282    ($msg:expr, $err:expr) => {
283        $crate::io::output::Response::err_with_details(
284            500,
285            $msg,
286            $crate::io::output::ErrorKind::Storage,
287            $err.to_string(),
288        )
289    };
290}
291
292/// Create an auth error response (401, ErrorKind::Auth)
293#[macro_export]
294macro_rules! auth_error {
295    ($msg:expr, $err:expr) => {
296        $crate::io::output::Response::err_with_details(
297            401,
298            $msg,
299            $crate::io::output::ErrorKind::Auth,
300            $err.to_string(),
301        )
302    };
303}
304
305pub fn print_json(response: &Response) {
306    println!("{}", response.to_json());
307}
308
309pub fn print_human(response: &Response) {
310    match &response.status {
311        Status::Error => {
312            eprintln!("Error: {}", response.message);
313            if let Some(err) = &response.error
314                && let Some(details) = &err.details
315            {
316                eprintln!("  {}", details);
317            }
318        }
319        Status::Success => {
320            if let Some(payload) = &response.payload {
321                format_payload(payload, &response.message, response.payload_kind);
322            } else {
323                println!("{}", response.message);
324            }
325        }
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn success_response_serializes() {
335        let resp = Response::success(200, "OK");
336        let json = resp.to_json();
337        assert!(json.contains(r#""status":"success""#));
338        assert!(json.contains(r#""code":200"#));
339    }
340
341    #[test]
342    fn error_response_includes_error_detail() {
343        let resp = Response::err(401, "Unauthorized", ErrorKind::Auth);
344        let json = resp.to_json();
345        assert!(json.contains(r#""status":"error""#));
346        assert!(json.contains(r#""kind":"auth_error""#));
347    }
348
349    #[test]
350    fn payload_skipped_when_none() {
351        let resp = Response::success(200, "OK");
352        let json = resp.to_json();
353        assert!(!json.contains("payload"));
354    }
355
356    #[test]
357    fn payload_included_when_present() {
358        let payload = serde_json::json!({"track": "test"});
359        let resp = Response::success_with_payload(200, "OK", payload);
360        let json = resp.to_json();
361        assert!(json.contains("payload"));
362        assert!(json.contains("track"));
363    }
364
365    #[test]
366    fn error_kind_serializes_to_snake_case() {
367        assert_eq!(ErrorKind::NotFound.as_str(), "not_found");
368        assert_eq!(ErrorKind::RateLimited.as_str(), "rate_limited");
369        assert_eq!(ErrorKind::Api.as_str(), "api_error");
370    }
371
372    #[test]
373    fn success_typed_includes_payload_kind() {
374        let payload = serde_json::json!({"name": "Test Track"});
375        let resp = Response::success_typed(200, "Track info", PayloadKind::Track, payload);
376        assert!(resp.payload_kind.is_some());
377        assert_eq!(resp.payload_kind.unwrap(), PayloadKind::Track);
378    }
379
380    #[test]
381    fn success_typed_serializes_payload_kind() {
382        let payload = serde_json::json!({"name": "Test"});
383        let resp = Response::success_typed(200, "OK", PayloadKind::Playlist, payload);
384        let json = resp.to_json();
385        assert!(json.contains("payload_kind"));
386        assert!(json.contains("playlist"));
387    }
388
389    #[test]
390    fn success_without_typed_has_no_payload_kind() {
391        let payload = serde_json::json!({"name": "Test"});
392        let resp = Response::success_with_payload(200, "OK", payload);
393        assert!(resp.payload_kind.is_none());
394        let json = resp.to_json();
395        assert!(!json.contains("payload_kind"));
396    }
397
398    #[test]
399    fn payload_kind_variants() {
400        // Test that PayloadKind serializes correctly
401        let kinds = vec![
402            (PayloadKind::PlayerStatus, "player_status"),
403            (PayloadKind::Queue, "queue"),
404            (PayloadKind::Track, "track"),
405            (PayloadKind::Album, "album"),
406            (PayloadKind::Artist, "artist"),
407            (PayloadKind::Playlist, "playlist"),
408            (PayloadKind::SavedTracks, "saved_tracks"),
409            (PayloadKind::LibraryCheck, "library_check"),
410        ];
411        for (kind, expected) in kinds {
412            let serialized = serde_json::to_string(&kind).unwrap();
413            assert!(
414                serialized.contains(expected),
415                "Expected {} in {}",
416                expected,
417                serialized
418            );
419        }
420    }
421
422    #[test]
423    fn error_response_has_no_payload_kind() {
424        let resp = Response::err(404, "Not found", ErrorKind::NotFound);
425        assert!(resp.payload_kind.is_none());
426    }
427
428    #[test]
429    fn err_with_details_includes_details() {
430        let resp =
431            Response::err_with_details(500, "Storage failed", ErrorKind::Storage, "Disk full");
432        assert!(resp.error.is_some());
433        let error = resp.error.unwrap();
434        assert_eq!(error.kind, "storage_error");
435        assert_eq!(error.details, Some("Disk full".to_string()));
436    }
437
438    #[test]
439    fn all_error_kinds_as_str() {
440        assert_eq!(ErrorKind::Network.as_str(), "network_error");
441        assert_eq!(ErrorKind::Api.as_str(), "api_error");
442        assert_eq!(ErrorKind::Auth.as_str(), "auth_error");
443        assert_eq!(ErrorKind::NotFound.as_str(), "not_found");
444        assert_eq!(ErrorKind::Forbidden.as_str(), "forbidden");
445        assert_eq!(ErrorKind::RateLimited.as_str(), "rate_limited");
446        assert_eq!(ErrorKind::Validation.as_str(), "validation_error");
447        assert_eq!(ErrorKind::Storage.as_str(), "storage_error");
448        assert_eq!(ErrorKind::Config.as_str(), "config_error");
449        assert_eq!(ErrorKind::Player.as_str(), "player_error");
450    }
451
452    #[test]
453    fn error_kind_display() {
454        assert_eq!(format!("{}", ErrorKind::Network), "network_error");
455        assert_eq!(format!("{}", ErrorKind::Auth), "auth_error");
456    }
457
458    #[test]
459    fn from_http_error_unauthorized() {
460        let http_err = HttpError::Unauthorized;
461        let resp = Response::from_http_error(&http_err, "Auth check");
462        assert_eq!(resp.code, 401);
463        assert!(resp.message.contains("Unauthorized"));
464        assert!(resp.error.is_some());
465    }
466
467    #[test]
468    fn from_http_error_not_found() {
469        let http_err = HttpError::NotFound;
470        let resp = Response::from_http_error(&http_err, "Get resource");
471        assert_eq!(resp.code, 404);
472        assert!(resp.message.contains("Not Found"));
473    }
474
475    #[test]
476    fn from_http_error_rate_limited() {
477        let http_err = HttpError::RateLimited {
478            retry_after_secs: 30,
479        };
480        let resp = Response::from_http_error(&http_err, "API call");
481        assert_eq!(resp.code, 429);
482        assert!(resp.message.contains("Rate Limited"));
483    }
484
485    #[test]
486    fn from_http_error_forbidden() {
487        let http_err = HttpError::Forbidden;
488        let resp = Response::from_http_error(&http_err, "Action");
489        assert_eq!(resp.code, 403);
490        assert!(resp.message.contains("Forbidden"));
491    }
492
493    #[test]
494    fn from_http_error_api_error() {
495        let http_err = HttpError::Api {
496            status: 500,
497            message: "Server error".to_string(),
498        };
499        let resp = Response::from_http_error(&http_err, "Request");
500        assert_eq!(resp.code, 500);
501        assert!(resp.message.contains("Internal Server Error"));
502    }
503
504    #[test]
505    fn status_serialization() {
506        let success = serde_json::to_string(&Status::Success).unwrap();
507        assert!(success.contains("success"));
508
509        let error = serde_json::to_string(&Status::Error).unwrap();
510        assert!(error.contains("error"));
511    }
512
513    #[test]
514    fn more_payload_kind_variants() {
515        let kinds = vec![
516            (PayloadKind::Devices, "devices"),
517            (PayloadKind::PlayHistory, "play_history"),
518            (PayloadKind::SearchResults, "search_results"),
519            (PayloadKind::Show, "show"),
520            (PayloadKind::Episode, "episode"),
521            (PayloadKind::Audiobook, "audiobook"),
522            (PayloadKind::Chapter, "chapter"),
523            (PayloadKind::Category, "category"),
524            (PayloadKind::User, "user"),
525            (PayloadKind::TrackList, "track_list"),
526            (PayloadKind::AlbumList, "album_list"),
527            (PayloadKind::Markets, "markets"),
528            (PayloadKind::Generic, "generic"),
529        ];
530        for (kind, expected) in kinds {
531            let serialized = serde_json::to_string(&kind).unwrap();
532            assert!(
533                serialized.contains(expected),
534                "Expected {} in {}",
535                expected,
536                serialized
537            );
538        }
539    }
540
541    #[test]
542    fn print_json_outputs_valid_json() {
543        let resp = Response::success(200, "Test");
544        print_json(&resp);
545    }
546
547    #[test]
548    fn print_human_success_with_payload() {
549        let payload = serde_json::json!({"name": "Test"});
550        let resp = Response::success_with_payload(200, "Message", payload);
551        print_human(&resp);
552    }
553
554    #[test]
555    fn print_human_success_without_payload() {
556        let resp = Response::success(200, "Simple message");
557        print_human(&resp);
558    }
559
560    #[test]
561    fn print_human_error_with_details() {
562        let resp = Response::err_with_details(
563            500,
564            "Operation failed",
565            ErrorKind::Api,
566            "Detailed error info",
567        );
568        print_human(&resp);
569    }
570
571    #[test]
572    fn print_human_error_without_details() {
573        let resp = Response::err(404, "Not found", ErrorKind::NotFound);
574        print_human(&resp);
575    }
576
577    #[test]
578    fn from_http_error_api_includes_details() {
579        let http_err = HttpError::Api {
580            status: 500,
581            message: "Server error".to_string(),
582        };
583        let resp = Response::from_http_error(&http_err, "Request failed");
584        assert!(resp.error.is_some());
585        let error = resp.error.unwrap();
586        assert_eq!(error.kind, "api_error");
587        assert!(error.details.is_some());
588    }
589
590    #[test]
591    fn from_http_error_unusual_status() {
592        let http_err = HttpError::Api {
593            status: 418,
594            message: "I'm a teapot".to_string(),
595        };
596        let resp = Response::from_http_error(&http_err, "Request");
597        assert_eq!(resp.code, 418);
598        // Non-standard status codes should use fallback format
599        assert!(resp.message.contains("418"));
600    }
601
602    #[test]
603    fn from_http_error_bad_gateway() {
604        let http_err = HttpError::Api {
605            status: 502,
606            message: "Bad Gateway".to_string(),
607        };
608        let resp = Response::from_http_error(&http_err, "Request");
609        assert_eq!(resp.code, 502);
610        assert!(resp.message.contains("Bad Gateway"));
611    }
612
613    #[test]
614    fn from_http_error_service_unavailable() {
615        let http_err = HttpError::Api {
616            status: 503,
617            message: "Unavailable".to_string(),
618        };
619        let resp = Response::from_http_error(&http_err, "Request");
620        assert_eq!(resp.code, 503);
621        assert!(resp.message.contains("Service Unavailable"));
622    }
623
624    #[test]
625    fn from_http_error_bad_request() {
626        let http_err = HttpError::Api {
627            status: 400,
628            message: "Invalid params".to_string(),
629        };
630        let resp = Response::from_http_error(&http_err, "Validation");
631        assert_eq!(resp.code, 400);
632        assert!(resp.message.contains("Bad Request"));
633    }
634
635    #[test]
636    fn print_human_with_typed_payload() {
637        let payload = serde_json::json!({"name": "Test Track"});
638        let resp = Response::success_typed(200, "Track", PayloadKind::Track, payload);
639        print_human(&resp);
640    }
641}