spotify_cli/output/
human.rs

1//! Human-readable output formatting.
2use crate::domain::album::Album;
3use crate::domain::artist::Artist;
4use crate::domain::auth::{AuthScopes, AuthStatus};
5use crate::domain::device::Device;
6use crate::domain::player::PlayerStatus;
7use crate::domain::pin::PinnedPlaylist;
8use crate::domain::playlist::{Playlist, PlaylistDetail};
9use crate::domain::search::SearchResults;
10use crate::error::Result;
11use crate::output::{TableConfig, DEFAULT_MAX_WIDTH};
12
13pub fn auth_status(status: AuthStatus) -> Result<()> {
14    if status.logged_in {
15        println!("logged_in");
16        return Ok(());
17    }
18
19    println!("logged_out");
20    Ok(())
21}
22
23pub fn auth_scopes(scopes: AuthScopes) -> Result<()> {
24    println!("Scopes:");
25    for scope in scopes.required {
26        let status = if let Some(granted) = scopes.granted.as_ref() {
27            if granted.iter().any(|item| item == &scope) {
28                "ok"
29            } else {
30                "missing"
31            }
32        } else {
33            "unknown"
34        };
35        println!("{:<32} {}", scope, status);
36    }
37    Ok(())
38}
39
40pub fn player_status(status: PlayerStatus) -> Result<()> {
41    let state = if status.is_playing { "playing" } else { "paused" };
42    let context = playback_context_line(&status);
43
44    if let Some(track) = status.track {
45        let artists = if track.artists.is_empty() {
46            String::new()
47        } else {
48            format!(" - {}", track.artists.join(", "))
49        };
50        let album = track
51            .album
52            .as_ref()
53            .map(|album| format!(" ({})", album))
54            .unwrap_or_default();
55        let progress = format_progress(status.progress_ms, track.duration_ms);
56        println!("{}: {}{}{}{}", state, track.name, album, artists, progress);
57        if let Some(line) = context {
58            println!("{}", line);
59        }
60        return Ok(());
61    }
62
63    println!("{}", state);
64    if let Some(line) = context {
65        println!("{}", line);
66    }
67    Ok(())
68}
69
70pub fn now_playing(status: PlayerStatus) -> Result<()> {
71    if let Some(track) = status.track {
72        let artists = if track.artists.is_empty() {
73            String::new()
74        } else {
75            format!(" - {}", track.artists.join(", "))
76        };
77        let album = track
78            .album
79            .as_ref()
80            .map(|album| format!(" ({})", album))
81            .unwrap_or_default();
82        let progress = format_progress(status.progress_ms, track.duration_ms);
83        println!("Now Playing: {}{}{}{}", track.name, album, artists, progress);
84        return Ok(());
85    }
86
87    println!("Now Playing: (no active track)");
88    Ok(())
89}
90
91fn playback_context_line(status: &PlayerStatus) -> Option<String> {
92    let repeat = status.repeat_state.as_deref();
93    let shuffle = status.shuffle_state;
94
95    if repeat.is_none() && shuffle.is_none() {
96        return None;
97    }
98
99    let repeat_text = repeat.unwrap_or("unknown");
100    let shuffle_text = match shuffle {
101        Some(true) => "on",
102        Some(false) => "off",
103        None => "unknown",
104    };
105
106    Some(format!("repeat: {}, shuffle: {}", repeat_text, shuffle_text))
107}
108
109pub fn action(message: &str) -> Result<()> {
110    println!("{}", message);
111    Ok(())
112}
113
114pub fn album_info(album: Album, table: TableConfig) -> Result<()> {
115    let artists = if album.artists.is_empty() {
116        String::new()
117    } else {
118        format!(" - {}", album.artists.join(", "))
119    };
120    let details = format_optional_details(&[
121        album.release_date,
122        album.total_tracks.map(|t| t.to_string()),
123        album.duration_ms.map(|ms| format_duration(ms)),
124    ]);
125    if details.is_empty() {
126        println!("{}{}", album.name, artists);
127    } else {
128        println!("{}{} ({})", album.name, artists, details);
129    }
130    let mut rows = Vec::new();
131    for track in album.tracks {
132        rows.push(vec![
133            format!("{:02}.", track.track_number),
134            track.name,
135            format_duration(track.duration_ms as u64),
136        ]);
137    }
138    print_table_with_header(&rows, &["NO", "TRACK", "DURATION"], table);
139    Ok(())
140}
141
142pub fn artist_info(artist: Artist) -> Result<()> {
143    let mut parts = Vec::new();
144    if !artist.genres.is_empty() {
145        parts.push(artist.genres.join(", "));
146    }
147    if let Some(followers) = artist.followers {
148        parts.push(format!("followers {}", followers));
149    }
150    if parts.is_empty() {
151        println!("{}", artist.name);
152    } else {
153        println!("{} ({})", artist.name, parts.join(" | "));
154    }
155    Ok(())
156}
157
158pub fn playlist_list(
159    playlists: Vec<Playlist>,
160    user_name: Option<&str>,
161    table: TableConfig,
162) -> Result<()> {
163    let mut rows = Vec::new();
164    for playlist in playlists {
165        let mut tags = Vec::new();
166        if playlist.collaborative {
167            tags.push("collaborative");
168        }
169        if let Some(public) = playlist.public {
170            tags.push(if public { "public" } else { "private" });
171        }
172
173        let tag_text = tags.join(", ");
174        if let Some(owner) = playlist.owner.as_ref() {
175            rows.push(vec![
176                playlist.name,
177                display_owner(owner, user_name),
178                tag_text,
179            ]);
180        } else {
181            rows.push(vec![playlist.name, String::new(), tag_text]);
182        }
183    }
184    print_table_with_header(&rows, &["NAME", "OWNER", "TAGS"], table);
185    Ok(())
186}
187
188pub fn playlist_list_with_pins(
189    playlists: Vec<Playlist>,
190    pins: Vec<PinnedPlaylist>,
191    user_name: Option<&str>,
192    table: TableConfig,
193) -> Result<()> {
194    let mut rows = Vec::new();
195    for playlist in playlists {
196        let mut tags = Vec::new();
197        if playlist.collaborative {
198            tags.push("collaborative");
199        }
200        if let Some(public) = playlist.public {
201            tags.push(if public { "public" } else { "private" });
202        }
203        let tag_text = tags.join(", ");
204        if let Some(owner) = playlist.owner.as_ref() {
205            rows.push(vec![
206                playlist.name,
207                display_owner(owner, user_name),
208                tag_text,
209            ]);
210        } else {
211            rows.push(vec![playlist.name, String::new(), tag_text]);
212        }
213    }
214    for pin in pins {
215        rows.push(vec![pin.name, "pinned".to_string(), String::new()]);
216    }
217    print_table_with_header(&rows, &["NAME", "OWNER", "TAGS"], table);
218    Ok(())
219}
220
221pub fn help() -> Result<()> {
222    println!("spotify-cli <object> <verb> [target] [flags]");
223    println!("objects: auth, player, track, search, album, artist, playlist, device, system");
224    println!("flags: --json, -v, --width N, --no-trunc");
225    println!("examples:");
226    println!("  spotify-cli player play");
227    println!("  spotify-cli player shuffle on");
228    println!("  spotify-cli player repeat context");
229    println!("  spotify-cli track like");
230    println!("  spotify-cli search \"boards of canada\" --type track --play");
231    println!("  spotify-cli playlist list");
232    println!("  spotify-cli playlist add \"MyRadar\"");
233    println!("  spotify-cli auth scopes");
234    println!("  spotify-cli playlist pin add \"Release Radar\" \"<url>\"");
235    Ok(())
236}
237
238pub fn playlist_info(playlist: PlaylistDetail, user_name: Option<&str>) -> Result<()> {
239    let owner = playlist
240        .owner
241        .as_ref()
242        .map(|owner| display_owner(owner, user_name))
243        .unwrap_or_else(|| "unknown".to_string());
244    let mut tags = Vec::new();
245    if playlist.collaborative {
246        tags.push("collaborative");
247    }
248    if let Some(public) = playlist.public {
249        tags.push(if public { "public" } else { "private" });
250    }
251    let suffix = if tags.is_empty() {
252        String::new()
253    } else {
254        format!(" [{}]", tags.join(", "))
255    };
256    if let Some(total) = playlist.tracks_total {
257        println!("{} ({}) - {} tracks{}", playlist.name, owner, total, suffix);
258    } else {
259        println!("{} ({}){}", playlist.name, owner, suffix);
260    }
261    Ok(())
262}
263
264pub fn device_list(devices: Vec<Device>, table: TableConfig) -> Result<()> {
265    let mut rows = Vec::new();
266    for device in devices {
267        let volume = device
268            .volume_percent
269            .map(|v| v.to_string())
270            .unwrap_or_default();
271        rows.push(vec![device.name, volume]);
272    }
273    print_table_with_header(&rows, &["NAME", "VOLUME"], table);
274    Ok(())
275}
276
277
278fn format_optional_details(parts: &[Option<String>]) -> String {
279    let filtered: Vec<String> = parts.iter().filter_map(|part| part.clone()).collect();
280    filtered.join(" | ")
281}
282
283fn display_owner(owner: &str, user_name: Option<&str>) -> String {
284    if let Some(user_name) = user_name {
285        if user_name.eq_ignore_ascii_case(owner) {
286            return "You".to_string();
287        }
288    }
289    owner.to_string()
290}
291
292fn format_progress(progress_ms: Option<u32>, duration_ms: Option<u32>) -> String {
293    let Some(progress_ms) = progress_ms else {
294        return String::new();
295    };
296    let duration_ms = duration_ms.unwrap_or(0);
297    if duration_ms == 0 {
298        return format!(" [{}]", format_time(progress_ms));
299    }
300    format!(
301        " [{} / {}]",
302        format_time(progress_ms),
303        format_time(duration_ms)
304    )
305}
306
307fn format_time(ms: u32) -> String {
308    let total_seconds = ms / 1000;
309    let minutes = total_seconds / 60;
310    let seconds = total_seconds % 60;
311    format!("{minutes}:{seconds:02}")
312}
313
314fn format_duration(ms: u64) -> String {
315    let total_seconds = ms / 1000;
316    let minutes = total_seconds / 60;
317    let seconds = total_seconds % 60;
318    format!("{minutes}:{seconds:02}")
319}
320
321pub fn search_results(results: SearchResults, table: TableConfig) -> Result<()> {
322    let mut rows = Vec::new();
323    let show_kind = results.kind == crate::domain::search::SearchType::All;
324    for (index, item) in results.items.into_iter().enumerate() {
325        let name = item.name;
326        let by = if !item.artists.is_empty() {
327            item.artists.join(", ")
328        } else if let Some(owner) = item.owner {
329            owner
330        } else {
331            String::new()
332        };
333        let score = item.score.map(|score| format!("{:.2}", score)).unwrap_or_default();
334        if show_kind {
335            rows.push(vec![
336                (index + 1).to_string(),
337                format_search_kind(item.kind),
338                name,
339                by,
340                score,
341            ]);
342        } else {
343            rows.push(vec![(index + 1).to_string(), name, by, score]);
344        }
345    }
346    if show_kind {
347        print_table_with_header(&rows, &["#", "TYPE", "NAME", "BY", "SCORE"], table);
348    } else {
349        print_table_with_header(&rows, &["#", "NAME", "BY", "SCORE"], table);
350    }
351    Ok(())
352}
353
354fn format_search_kind(kind: crate::domain::search::SearchType) -> String {
355    match kind {
356        crate::domain::search::SearchType::Track => "track",
357        crate::domain::search::SearchType::Album => "album",
358        crate::domain::search::SearchType::Artist => "artist",
359        crate::domain::search::SearchType::Playlist => "playlist",
360        crate::domain::search::SearchType::All => "all",
361    }
362    .to_string()
363}
364
365fn print_table_with_header(rows: &[Vec<String>], headers: &[&str], table: TableConfig) {
366    let mut all_rows = Vec::new();
367    if !headers.is_empty() {
368        all_rows.push(headers.iter().map(|text| text.to_string()).collect());
369    }
370    all_rows.extend_from_slice(rows);
371    print_table(&all_rows, table);
372}
373
374fn print_table(rows: &[Vec<String>], table: TableConfig) {
375    if rows.is_empty() {
376        return;
377    }
378    let columns = rows.iter().map(|row| row.len()).max().unwrap_or(0);
379    let mut widths = vec![0usize; columns];
380    let mut processed = Vec::with_capacity(rows.len());
381    let max_width = table.max_width.unwrap_or(DEFAULT_MAX_WIDTH);
382
383    for row in rows {
384        let mut new_row = Vec::with_capacity(row.len());
385        for (index, cell) in row.iter().enumerate() {
386            let truncated = if table.truncate {
387                truncate_cell(cell, max_width)
388            } else {
389                cell.to_string()
390            };
391            widths[index] = widths[index].max(truncated.len());
392            new_row.push(truncated);
393        }
394        processed.push(new_row);
395    }
396
397    for row in processed {
398        let mut line = String::new();
399        for (index, cell) in row.iter().enumerate() {
400            if index > 0 {
401                line.push_str("  ");
402            }
403            let width = widths[index];
404            line.push_str(&format!("{:<width$}", cell, width = width));
405        }
406        println!("{}", line.trim_end());
407    }
408}
409
410pub(crate) fn truncate_cell(text: &str, max: usize) -> String {
411    if text.chars().count() <= max {
412        return text.to_string();
413    }
414    if max <= 3 {
415        return "...".to_string();
416    }
417    let mut truncated: String = text.chars().take(max - 3).collect();
418    truncated.push_str("...");
419    truncated
420}
421
422#[cfg(test)]
423mod tests {
424    use super::{format_duration, format_optional_details, format_progress, format_time, truncate_cell};
425
426    #[test]
427    fn truncate_cell_keeps_short_values() {
428        assert_eq!(truncate_cell("short", 10), "short");
429    }
430
431    #[test]
432    fn truncate_cell_adds_ellipsis() {
433        assert_eq!(truncate_cell("0123456789", 8), "01234...");
434    }
435
436    #[test]
437    fn format_progress_with_duration() {
438        assert_eq!(format_progress(Some(61000), Some(120000)), " [1:01 / 2:00]");
439    }
440
441    #[test]
442    fn format_progress_without_duration() {
443        assert_eq!(format_progress(Some(61000), None), " [1:01]");
444    }
445
446    #[test]
447    fn format_time_minutes_seconds() {
448        assert_eq!(format_time(61000), "1:01");
449    }
450
451    #[test]
452    fn format_duration_minutes_seconds() {
453        assert_eq!(format_duration(125000), "2:05");
454    }
455
456    #[test]
457    fn format_optional_details_joins() {
458        let value = format_optional_details(&[
459            Some("2024".to_string()),
460            None,
461            Some("10".to_string()),
462        ]);
463        assert_eq!(value, "2024 | 10");
464    }
465}