Skip to main content

irontide_format/
lib.rs

1#![allow(
2    clippy::cast_possible_truncation,
3    clippy::cast_precision_loss,
4    clippy::cast_possible_wrap,
5    clippy::cast_sign_loss,
6    reason = "M175: human-readable display formatting — values bounded by realistic torrent sizes; precision loss intentional for short readable strings"
7)]
8
9//! Shared human-readable formatting helpers for irontide crates.
10//!
11//! Provides consistent string representations for sizes, transfer rates,
12//! ETAs, share ratios, and torrent state labels used by the GUI, CLI, and
13//! Web UI frontends.
14
15pub mod file_entries;
16pub mod peer_flags;
17pub mod pseudo_trackers;
18
19pub use file_entries::{FlatFileEntry, build_flat};
20pub use peer_flags::peer_flags;
21pub use pseudo_trackers::{
22    PSEUDO_TRACKER_DHT_URL, PSEUDO_TRACKER_LSD_URL, PSEUDO_TRACKER_PEX_URL, PSEUDO_TRACKER_TIER,
23    is_pseudo_tracker, synthesize_pseudo_trackers,
24};
25
26use irontide_core::FilePriority;
27use irontide_session::TorrentState;
28
29/// Parse a priority slug (`"skip" | "low" | "normal" | "high"`) into a
30/// `FilePriority`. Returns `None` for any other input — callers can map
31/// that to a 422 (Web UI) or ignore it (GUI passes the int directly).
32#[must_use]
33pub fn parse_priority_label(s: &str) -> Option<FilePriority> {
34    match s {
35        "skip" => Some(FilePriority::Skip),
36        "low" => Some(FilePriority::Low),
37        "normal" => Some(FilePriority::Normal),
38        "high" => Some(FilePriority::High),
39        _ => None,
40    }
41}
42
43/// Format a raw byte count as a human-readable size string.
44///
45/// Uses binary (KiB/MiB/GiB) units with decimal precision matching
46/// libtorrent / rqbit conventions. Sub-KiB values are reported as raw
47/// bytes. The largest unit is GiB to keep very large torrents on a single line.
48#[must_use]
49pub fn format_size(bytes: u64) -> String {
50    const KIB: u64 = 1024;
51    const MIB: u64 = 1024 * KIB;
52    const GIB: u64 = 1024 * MIB;
53    if bytes >= GIB {
54        format!("{:.2} GiB", bytes as f64 / GIB as f64)
55    } else if bytes >= MIB {
56        format!("{:.1} MiB", bytes as f64 / MIB as f64)
57    } else if bytes >= KIB {
58        format!("{:.1} KiB", bytes as f64 / KIB as f64)
59    } else {
60        format!("{bytes} B")
61    }
62}
63
64/// Format a byte-per-second rate as a human-readable string.
65///
66/// Note: rates use the `KB/s` / `MB/s` suffix (without the `i`) to match
67/// libtorrent progress output. Sub-KB/s values are reported in raw `B/s`.
68#[must_use]
69pub fn format_rate(bytes_per_sec: u64) -> String {
70    const KIB: u64 = 1024;
71    const MIB: u64 = 1024 * KIB;
72    if bytes_per_sec >= MIB {
73        format!("{:.1} MB/s", bytes_per_sec as f64 / MIB as f64)
74    } else if bytes_per_sec >= KIB {
75        format!("{:.1} KB/s", bytes_per_sec as f64 / KIB as f64)
76    } else {
77        format!("{bytes_per_sec} B/s")
78    }
79}
80
81/// Map a `TorrentState` variant to its lowercase display label.
82///
83/// When `user_seed_mode` is true and the torrent is in the `Downloading`
84/// state, returns `"seed only"` to reflect the user-imposed constraint.
85#[must_use]
86pub fn format_state(state: &TorrentState, user_seed_mode: bool) -> &'static str {
87    if user_seed_mode && matches!(state, TorrentState::Downloading) {
88        return "seed only";
89    }
90    match state {
91        TorrentState::FetchingMetadata => "fetching metadata",
92        TorrentState::Checking => "checking",
93        TorrentState::Downloading => "downloading",
94        TorrentState::Complete => "complete",
95        TorrentState::Seeding => "seeding",
96        TorrentState::Paused => "paused",
97        TorrentState::Queued => "queued",
98        TorrentState::Stopped => "stopped",
99        TorrentState::Sharing => "sharing",
100    }
101}
102
103#[must_use]
104pub fn format_state_with_super_seeding(
105    state: &TorrentState,
106    user_seed_mode: bool,
107    super_seeding: bool,
108) -> &'static str {
109    if super_seeding && matches!(state, TorrentState::Seeding) {
110        return "super seeding";
111    }
112    format_state(state, user_seed_mode)
113}
114
115/// Format the upload/download share ratio.
116///
117/// Returns `"∞"` when bytes were uploaded but nothing was downloaded,
118/// `"0.00"` when both counters are zero, and a two-decimal ratio otherwise.
119#[must_use]
120pub fn format_ratio(uploaded: u64, downloaded: u64) -> String {
121    if downloaded == 0 && uploaded > 0 {
122        return "\u{221e}".to_string(); // ∞
123    }
124    if downloaded == 0 {
125        return "0.00".to_string();
126    }
127    format!("{:.2}", uploaded as f64 / downloaded as f64)
128}
129
130/// Estimate remaining download time given outstanding bytes and current rate.
131///
132/// Returns `"—"` (em dash) when the rate is zero (stalled). Otherwise formats
133/// the duration as `"Xd Yh"`, `"Xh Ym"`, `"Xm Ys"`, or `"Xs"` depending on
134/// the magnitude. Returns `"0s"` when `remaining_bytes` is zero.
135#[must_use]
136pub fn format_eta(remaining_bytes: u64, rate_bps: u64) -> String {
137    if rate_bps == 0 {
138        return "\u{2014}".to_string(); // em dash
139    }
140    let secs = remaining_bytes / rate_bps;
141    if secs >= 86400 {
142        let days = secs / 86400;
143        let hours = (secs % 86400) / 3600;
144        format!("{days}d {hours}h")
145    } else if secs >= 3600 {
146        let hours = secs / 3600;
147        let mins = (secs % 3600) / 60;
148        format!("{hours}h {mins}m")
149    } else if secs >= 60 {
150        let mins = secs / 60;
151        let s = secs % 60;
152        format!("{mins}m {s}s")
153    } else {
154        format!("{secs}s")
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn test_format_size_boundaries() {
164        // Zero bytes
165        assert_eq!(format_size(0), "0 B");
166        // Sub-KiB
167        assert_eq!(format_size(512), "512 B");
168        // Exactly 1 KiB
169        assert_eq!(format_size(1024), "1.0 KiB");
170        // Sub-MiB
171        assert_eq!(format_size(512 * 1024), "512.0 KiB");
172        // Exactly 1 MiB
173        assert_eq!(format_size(1_048_576), "1.0 MiB");
174        // Sub-GiB
175        assert_eq!(format_size(512 * 1_048_576), "512.0 MiB");
176        // Exactly 1 GiB
177        assert_eq!(format_size(1_073_741_824), "1.00 GiB");
178        // Large value stays in GiB (no TiB branch)
179        assert_eq!(format_size(512 * 1_073_741_824), "512.00 GiB");
180    }
181
182    #[test]
183    fn test_format_rate_boundaries() {
184        // Zero rate
185        assert_eq!(format_rate(0), "0 B/s");
186        // Sub-KB/s
187        assert_eq!(format_rate(512), "512 B/s");
188        // Exactly 1 KB/s
189        assert_eq!(format_rate(1024), "1.0 KB/s");
190        // Sub-MB/s
191        assert_eq!(format_rate(512 * 1024), "512.0 KB/s");
192        // Exactly 1 MB/s
193        assert_eq!(format_rate(1_048_576), "1.0 MB/s");
194        // Very high rate stays in MB/s (no GB/s branch)
195        assert_eq!(format_rate(1_073_741_824), "1024.0 MB/s");
196    }
197
198    #[test]
199    fn test_format_state_all_variants() {
200        // All variants without seed mode
201        assert_eq!(
202            format_state(&TorrentState::FetchingMetadata, false),
203            "fetching metadata"
204        );
205        assert_eq!(format_state(&TorrentState::Checking, false), "checking");
206        assert_eq!(
207            format_state(&TorrentState::Downloading, false),
208            "downloading"
209        );
210        assert_eq!(format_state(&TorrentState::Complete, false), "complete");
211        assert_eq!(format_state(&TorrentState::Seeding, false), "seeding");
212        assert_eq!(format_state(&TorrentState::Paused, false), "paused");
213        assert_eq!(format_state(&TorrentState::Queued, false), "queued");
214        assert_eq!(format_state(&TorrentState::Stopped, false), "stopped");
215        assert_eq!(format_state(&TorrentState::Sharing, false), "sharing");
216
217        // Seed mode overrides Downloading only
218        assert_eq!(format_state(&TorrentState::Downloading, true), "seed only");
219        // Seed mode does NOT override other states
220        assert_eq!(format_state(&TorrentState::Seeding, true), "seeding");
221        assert_eq!(format_state(&TorrentState::Paused, true), "paused");
222        assert_eq!(format_state(&TorrentState::Queued, true), "queued");
223        assert_eq!(format_state(&TorrentState::Complete, true), "complete");
224        assert_eq!(format_state(&TorrentState::Sharing, true), "sharing");
225        assert_eq!(format_state(&TorrentState::Stopped, true), "stopped");
226    }
227
228    #[test]
229    fn test_format_ratio() {
230        // Both zero → 0.00
231        assert_eq!(format_ratio(0, 0), "0.00");
232        // Upload with no download → ∞
233        assert_eq!(format_ratio(500, 0), "\u{221e}");
234        // Normal ratio: 150 / 100 = 1.50
235        assert_eq!(format_ratio(150, 100), "1.50");
236        // Exactly 1:1
237        assert_eq!(format_ratio(1000, 1000), "1.00");
238        // Large ratio: 1000x
239        assert_eq!(format_ratio(1_000_000, 1_000), "1000.00");
240        // Less than 1: 0.50
241        assert_eq!(format_ratio(50, 100), "0.50");
242    }
243
244    #[test]
245    fn test_parse_priority_label_known_slugs() {
246        assert_eq!(parse_priority_label("skip"), Some(FilePriority::Skip));
247        assert_eq!(parse_priority_label("low"), Some(FilePriority::Low));
248        assert_eq!(parse_priority_label("normal"), Some(FilePriority::Normal));
249        assert_eq!(parse_priority_label("high"), Some(FilePriority::High));
250    }
251
252    #[test]
253    fn test_parse_priority_label_unknown_returns_none() {
254        assert_eq!(parse_priority_label(""), None);
255        assert_eq!(parse_priority_label("SKIP"), None);
256        assert_eq!(parse_priority_label("medium"), None);
257        assert_eq!(parse_priority_label("4"), None);
258    }
259
260    #[test]
261    fn test_format_eta() {
262        // Zero remaining → "0s" (0 / 1 = 0 secs)
263        assert_eq!(format_eta(0, 1), "0s");
264        // Stalled (rate=0) → em dash
265        assert_eq!(format_eta(1_000_000, 0), "\u{2014}");
266        // Seconds only
267        assert_eq!(format_eta(30, 1), "30s");
268        // Minutes and seconds
269        assert_eq!(format_eta(45 * 60 + 12, 1), "45m 12s");
270        // Hours and minutes
271        assert_eq!(format_eta(2 * 3600 + 15 * 60, 1), "2h 15m");
272        // Days and hours
273        assert_eq!(format_eta(2 * 86400 + 15 * 3600, 1), "2d 15h");
274        // Normal download: 100 MB at 1 MB/s = 100s
275        assert_eq!(format_eta(100 * 1_048_576, 1_048_576), "1m 40s");
276    }
277
278    #[test]
279    fn format_state_with_super_seeding_shows_super_seeding() {
280        assert_eq!(
281            format_state_with_super_seeding(&TorrentState::Seeding, false, true),
282            "super seeding"
283        );
284    }
285
286    #[test]
287    fn format_state_with_super_seeding_normal_seeding() {
288        assert_eq!(
289            format_state_with_super_seeding(&TorrentState::Seeding, false, false),
290            "seeding"
291        );
292    }
293
294    #[test]
295    fn format_state_with_super_seeding_non_seeding_state() {
296        assert_eq!(
297            format_state_with_super_seeding(&TorrentState::Downloading, false, true),
298            "downloading"
299        );
300    }
301}