Skip to main content

irontide_format/
lib.rs

1//! Shared human-readable formatting helpers for irontide crates.
2//!
3//! Provides consistent string representations for sizes, transfer rates,
4//! ETAs, share ratios, and torrent state labels used by the GUI, CLI, and
5//! Web UI frontends.
6
7use irontide_session::TorrentState;
8
9/// Format a raw byte count as a human-readable size string.
10///
11/// Uses binary (KiB/MiB/GiB) units with decimal precision matching
12/// libtorrent / rqbit conventions. Sub-KiB values are reported as raw
13/// bytes. The largest unit is GiB to keep very large torrents on a single line.
14pub fn format_size(bytes: u64) -> String {
15    const KIB: u64 = 1024;
16    const MIB: u64 = 1024 * KIB;
17    const GIB: u64 = 1024 * MIB;
18    if bytes >= GIB {
19        format!("{:.2} GiB", bytes as f64 / GIB as f64)
20    } else if bytes >= MIB {
21        format!("{:.1} MiB", bytes as f64 / MIB as f64)
22    } else if bytes >= KIB {
23        format!("{:.1} KiB", bytes as f64 / KIB as f64)
24    } else {
25        format!("{bytes} B")
26    }
27}
28
29/// Format a byte-per-second rate as a human-readable string.
30///
31/// Note: rates use the `KB/s` / `MB/s` suffix (without the `i`) to match
32/// libtorrent progress output. Sub-KB/s values are reported in raw `B/s`.
33pub fn format_rate(bytes_per_sec: u64) -> String {
34    const KIB: u64 = 1024;
35    const MIB: u64 = 1024 * KIB;
36    if bytes_per_sec >= MIB {
37        format!("{:.1} MB/s", bytes_per_sec as f64 / MIB as f64)
38    } else if bytes_per_sec >= KIB {
39        format!("{:.1} KB/s", bytes_per_sec as f64 / KIB as f64)
40    } else {
41        format!("{bytes_per_sec} B/s")
42    }
43}
44
45/// Map a `TorrentState` variant to its lowercase display label.
46///
47/// When `user_seed_mode` is true and the torrent is in the `Downloading`
48/// state, returns `"seed only"` to reflect the user-imposed constraint.
49pub fn format_state(state: &TorrentState, user_seed_mode: bool) -> &'static str {
50    if user_seed_mode && matches!(state, TorrentState::Downloading) {
51        return "seed only";
52    }
53    match state {
54        TorrentState::FetchingMetadata => "fetching metadata",
55        TorrentState::Checking => "checking",
56        TorrentState::Downloading => "downloading",
57        TorrentState::Complete => "complete",
58        TorrentState::Seeding => "seeding",
59        TorrentState::Paused => "paused",
60        TorrentState::Stopped => "stopped",
61        TorrentState::Sharing => "sharing",
62    }
63}
64
65/// Format the upload/download share ratio.
66///
67/// Returns `"∞"` when bytes were uploaded but nothing was downloaded,
68/// `"0.00"` when both counters are zero, and a two-decimal ratio otherwise.
69pub fn format_ratio(uploaded: u64, downloaded: u64) -> String {
70    if downloaded == 0 && uploaded > 0 {
71        return "\u{221e}".to_string(); // ∞
72    }
73    if downloaded == 0 {
74        return "0.00".to_string();
75    }
76    format!("{:.2}", uploaded as f64 / downloaded as f64)
77}
78
79/// Estimate remaining download time given outstanding bytes and current rate.
80///
81/// Returns `"—"` (em dash) when the rate is zero (stalled). Otherwise formats
82/// the duration as `"Xd Yh"`, `"Xh Ym"`, `"Xm Ys"`, or `"Xs"` depending on
83/// the magnitude. Returns `"0s"` when `remaining_bytes` is zero.
84pub fn format_eta(remaining_bytes: u64, rate_bps: u64) -> String {
85    if rate_bps == 0 {
86        return "\u{2014}".to_string(); // em dash
87    }
88    let secs = remaining_bytes / rate_bps;
89    if secs >= 86400 {
90        let days = secs / 86400;
91        let hours = (secs % 86400) / 3600;
92        format!("{days}d {hours}h")
93    } else if secs >= 3600 {
94        let hours = secs / 3600;
95        let mins = (secs % 3600) / 60;
96        format!("{hours}h {mins}m")
97    } else if secs >= 60 {
98        let mins = secs / 60;
99        let s = secs % 60;
100        format!("{mins}m {s}s")
101    } else {
102        format!("{secs}s")
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn test_format_size_boundaries() {
112        // Zero bytes
113        assert_eq!(format_size(0), "0 B");
114        // Sub-KiB
115        assert_eq!(format_size(512), "512 B");
116        // Exactly 1 KiB
117        assert_eq!(format_size(1024), "1.0 KiB");
118        // Sub-MiB
119        assert_eq!(format_size(512 * 1024), "512.0 KiB");
120        // Exactly 1 MiB
121        assert_eq!(format_size(1_048_576), "1.0 MiB");
122        // Sub-GiB
123        assert_eq!(format_size(512 * 1_048_576), "512.0 MiB");
124        // Exactly 1 GiB
125        assert_eq!(format_size(1_073_741_824), "1.00 GiB");
126        // Large value stays in GiB (no TiB branch)
127        assert_eq!(format_size(512 * 1_073_741_824), "512.00 GiB");
128    }
129
130    #[test]
131    fn test_format_rate_boundaries() {
132        // Zero rate
133        assert_eq!(format_rate(0), "0 B/s");
134        // Sub-KB/s
135        assert_eq!(format_rate(512), "512 B/s");
136        // Exactly 1 KB/s
137        assert_eq!(format_rate(1024), "1.0 KB/s");
138        // Sub-MB/s
139        assert_eq!(format_rate(512 * 1024), "512.0 KB/s");
140        // Exactly 1 MB/s
141        assert_eq!(format_rate(1_048_576), "1.0 MB/s");
142        // Very high rate stays in MB/s (no GB/s branch)
143        assert_eq!(format_rate(1_073_741_824), "1024.0 MB/s");
144    }
145
146    #[test]
147    fn test_format_state_all_variants() {
148        // All variants without seed mode
149        assert_eq!(
150            format_state(&TorrentState::FetchingMetadata, false),
151            "fetching metadata"
152        );
153        assert_eq!(format_state(&TorrentState::Checking, false), "checking");
154        assert_eq!(
155            format_state(&TorrentState::Downloading, false),
156            "downloading"
157        );
158        assert_eq!(format_state(&TorrentState::Complete, false), "complete");
159        assert_eq!(format_state(&TorrentState::Seeding, false), "seeding");
160        assert_eq!(format_state(&TorrentState::Paused, false), "paused");
161        assert_eq!(format_state(&TorrentState::Stopped, false), "stopped");
162        assert_eq!(format_state(&TorrentState::Sharing, false), "sharing");
163
164        // Seed mode overrides Downloading only
165        assert_eq!(
166            format_state(&TorrentState::Downloading, true),
167            "seed only"
168        );
169        // Seed mode does NOT override other states
170        assert_eq!(format_state(&TorrentState::Seeding, true), "seeding");
171        assert_eq!(format_state(&TorrentState::Paused, true), "paused");
172        assert_eq!(format_state(&TorrentState::Complete, true), "complete");
173        assert_eq!(format_state(&TorrentState::Sharing, true), "sharing");
174        assert_eq!(format_state(&TorrentState::Stopped, true), "stopped");
175    }
176
177    #[test]
178    fn test_format_ratio() {
179        // Both zero → 0.00
180        assert_eq!(format_ratio(0, 0), "0.00");
181        // Upload with no download → ∞
182        assert_eq!(format_ratio(500, 0), "\u{221e}");
183        // Normal ratio: 150 / 100 = 1.50
184        assert_eq!(format_ratio(150, 100), "1.50");
185        // Exactly 1:1
186        assert_eq!(format_ratio(1000, 1000), "1.00");
187        // Large ratio: 1000x
188        assert_eq!(format_ratio(1_000_000, 1_000), "1000.00");
189        // Less than 1: 0.50
190        assert_eq!(format_ratio(50, 100), "0.50");
191    }
192
193    #[test]
194    fn test_format_eta() {
195        // Zero remaining → "0s" (0 / 1 = 0 secs)
196        assert_eq!(format_eta(0, 1), "0s");
197        // Stalled (rate=0) → em dash
198        assert_eq!(format_eta(1_000_000, 0), "\u{2014}");
199        // Seconds only
200        assert_eq!(format_eta(30, 1), "30s");
201        // Minutes and seconds
202        assert_eq!(format_eta(45 * 60 + 12, 1), "45m 12s");
203        // Hours and minutes
204        assert_eq!(format_eta(2 * 3600 + 15 * 60, 1), "2h 15m");
205        // Days and hours
206        assert_eq!(format_eta(2 * 86400 + 15 * 3600, 1), "2d 15h");
207        // Normal download: 100 MB at 1 MB/s = 100s
208        assert_eq!(format_eta(100 * 1_048_576, 1_048_576), "1m 40s");
209    }
210}