spotify_cli/io/
common.rs

1//! Common utility functions for formatting output
2
3use serde_json::Value;
4
5/// Duration format variants
6pub enum DurationFormat {
7    /// Short format: "3:45" or "1:02:30" for tracks
8    Short,
9    /// Long format: "1h 23m" or "45m" for episodes
10    Long,
11    /// Long format with seconds: "1h 23m" or "5m 30s" or "45s" for chapters
12    LongWithSeconds,
13}
14
15/// Format milliseconds as duration string with specified format
16pub fn format_duration_as(ms: u64, format: DurationFormat) -> String {
17    let total_secs = ms / 1000;
18    let hours = total_secs / 3600;
19    let mins = (total_secs % 3600) / 60;
20    let secs = total_secs % 60;
21
22    match format {
23        DurationFormat::Short => {
24            if hours > 0 {
25                format!("{}:{:02}:{:02}", hours, mins, secs)
26            } else {
27                format!("{}:{:02}", mins, secs)
28            }
29        }
30        DurationFormat::Long => {
31            if hours > 0 {
32                format!("{}h {}m", hours, mins)
33            } else {
34                format!("{}m", mins)
35            }
36        }
37        DurationFormat::LongWithSeconds => {
38            if hours > 0 {
39                format!("{}h {}m", hours, mins)
40            } else if mins > 0 {
41                format!("{}m {}s", mins, secs)
42            } else {
43                format!("{}s", secs)
44            }
45        }
46    }
47}
48
49/// Format milliseconds as mm:ss duration string (short format)
50/// Convenience wrapper for backward compatibility
51pub fn format_duration(ms: u64) -> String {
52    format_duration_as(ms, DurationFormat::Short)
53}
54
55/// Truncate string to max length with ellipsis
56pub fn truncate(s: &str, max: usize) -> String {
57    if s.chars().count() <= max {
58        s.to_string()
59    } else {
60        format!("{}...", s.chars().take(max - 3).collect::<String>())
61    }
62}
63
64/// Format large numbers with K/M suffix
65pub fn format_number(n: u64) -> String {
66    if n >= 1_000_000 {
67        format!("{:.1}M", n as f64 / 1_000_000.0)
68    } else if n >= 1_000 {
69        format!("{:.1}K", n as f64 / 1_000.0)
70    } else {
71        n.to_string()
72    }
73}
74
75/// Get fuzzy score from an item
76pub fn get_score(item: &Value) -> i64 {
77    item.get("fuzzy_score")
78        .and_then(|v| v.as_f64())
79        .map(|s| s as i64)
80        .unwrap_or(0)
81}
82
83/// Extract artist names from an item's "artists" array
84pub fn extract_artist_names(item: &Value) -> String {
85    item.get("artists")
86        .and_then(|v| v.as_array())
87        .map(|arr| {
88            arr.iter()
89                .filter_map(|a| a.get("name").and_then(|n| n.as_str()))
90                .collect::<Vec<_>>()
91                .join(", ")
92        })
93        .unwrap_or_else(|| "Unknown".to_string())
94}
95
96/// Print a formatted table with header, columns, and rows
97pub fn print_table(header: &str, cols: &[&str], rows: &[Vec<String>], col_widths: &[usize]) {
98    println!("\n{}:", header);
99
100    print!("  ");
101    for (i, col) in cols.iter().enumerate() {
102        if i == cols.len() - 1 {
103            print!("{:>width$}", col, width = col_widths[i]);
104        } else {
105            print!("{:<width$}  ", col, width = col_widths[i]);
106        }
107    }
108    println!();
109
110    print!("  ");
111    for (i, &w) in col_widths.iter().enumerate() {
112        if i == col_widths.len() - 1 {
113            print!("{}", "-".repeat(w));
114        } else {
115            print!("{}  ", "-".repeat(w));
116        }
117    }
118    println!();
119
120    for row in rows {
121        print!("  ");
122        for (i, cell) in row.iter().enumerate() {
123            if i == row.len() - 1 {
124                print!("{:>width$}", cell, width = col_widths[i]);
125            } else {
126                print!("{:<width$}  ", cell, width = col_widths[i]);
127            }
128        }
129        println!();
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use serde_json::json;
137
138    #[test]
139    fn format_duration_short_minutes_only() {
140        assert_eq!(format_duration(0), "0:00");
141        assert_eq!(format_duration(1000), "0:01");
142        assert_eq!(format_duration(60000), "1:00");
143        assert_eq!(format_duration(210000), "3:30");
144    }
145
146    #[test]
147    fn format_duration_short_with_hours() {
148        assert_eq!(format_duration(3600000), "1:00:00");
149        assert_eq!(format_duration(3661000), "1:01:01");
150        assert_eq!(format_duration(7200000), "2:00:00");
151    }
152
153    #[test]
154    fn format_duration_as_short() {
155        assert_eq!(format_duration_as(180000, DurationFormat::Short), "3:00");
156        assert_eq!(
157            format_duration_as(3661000, DurationFormat::Short),
158            "1:01:01"
159        );
160    }
161
162    #[test]
163    fn format_duration_as_long() {
164        assert_eq!(format_duration_as(60000, DurationFormat::Long), "1m");
165        assert_eq!(format_duration_as(3660000, DurationFormat::Long), "1h 1m");
166        assert_eq!(format_duration_as(7200000, DurationFormat::Long), "2h 0m");
167    }
168
169    #[test]
170    fn format_duration_as_long_with_seconds() {
171        assert_eq!(
172            format_duration_as(30000, DurationFormat::LongWithSeconds),
173            "30s"
174        );
175        assert_eq!(
176            format_duration_as(90000, DurationFormat::LongWithSeconds),
177            "1m 30s"
178        );
179        assert_eq!(
180            format_duration_as(3661000, DurationFormat::LongWithSeconds),
181            "1h 1m"
182        );
183    }
184
185    #[test]
186    fn truncate_short_string() {
187        assert_eq!(truncate("hello", 10), "hello");
188        assert_eq!(truncate("short", 10), "short");
189    }
190
191    #[test]
192    fn truncate_long_string() {
193        assert_eq!(truncate("hello world", 8), "hello...");
194        assert_eq!(truncate("a very long string", 10), "a very ...");
195    }
196
197    #[test]
198    fn truncate_exact_length() {
199        assert_eq!(truncate("hello", 5), "hello");
200    }
201
202    #[test]
203    fn format_number_small() {
204        assert_eq!(format_number(0), "0");
205        assert_eq!(format_number(999), "999");
206    }
207
208    #[test]
209    fn format_number_thousands() {
210        assert_eq!(format_number(1000), "1.0K");
211        assert_eq!(format_number(1500), "1.5K");
212        assert_eq!(format_number(10000), "10.0K");
213        assert_eq!(format_number(999999), "1000.0K");
214    }
215
216    #[test]
217    fn format_number_millions() {
218        assert_eq!(format_number(1000000), "1.0M");
219        assert_eq!(format_number(1500000), "1.5M");
220        assert_eq!(format_number(10000000), "10.0M");
221    }
222
223    #[test]
224    fn get_score_present() {
225        let item = json!({ "fuzzy_score": 75.5 });
226        assert_eq!(get_score(&item), 75);
227    }
228
229    #[test]
230    fn get_score_missing() {
231        let item = json!({ "name": "test" });
232        assert_eq!(get_score(&item), 0);
233    }
234
235    #[test]
236    fn get_score_non_numeric() {
237        let item = json!({ "fuzzy_score": "not a number" });
238        assert_eq!(get_score(&item), 0);
239    }
240
241    #[test]
242    fn extract_artist_names_single() {
243        let item = json!({
244            "artists": [{ "name": "Artist One" }]
245        });
246        assert_eq!(extract_artist_names(&item), "Artist One");
247    }
248
249    #[test]
250    fn extract_artist_names_multiple() {
251        let item = json!({
252            "artists": [
253                { "name": "Artist One" },
254                { "name": "Artist Two" }
255            ]
256        });
257        assert_eq!(extract_artist_names(&item), "Artist One, Artist Two");
258    }
259
260    #[test]
261    fn extract_artist_names_empty_array() {
262        let item = json!({ "artists": [] });
263        assert_eq!(extract_artist_names(&item), "");
264    }
265
266    #[test]
267    fn extract_artist_names_missing() {
268        let item = json!({ "name": "Track" });
269        assert_eq!(extract_artist_names(&item), "Unknown");
270    }
271
272    #[test]
273    fn extract_artist_names_null() {
274        let item = json!({ "artists": null });
275        assert_eq!(extract_artist_names(&item), "Unknown");
276    }
277}