irontide-format 1.0.2

Shared human-readable formatting helpers for irontide crates
Documentation
#![allow(
    clippy::cast_possible_truncation,
    clippy::cast_precision_loss,
    clippy::cast_possible_wrap,
    clippy::cast_sign_loss,
    reason = "M175: human-readable display formatting — values bounded by realistic torrent sizes; precision loss intentional for short readable strings"
)]

//! Shared human-readable formatting helpers for irontide crates.
//!
//! Provides consistent string representations for sizes, transfer rates,
//! ETAs, share ratios, and torrent state labels used by the GUI, CLI, and
//! Web UI frontends.

pub mod file_entries;
pub mod peer_flags;
pub mod pseudo_trackers;

pub use file_entries::{FlatFileEntry, build_flat};
pub use peer_flags::peer_flags;
pub use pseudo_trackers::{
    PSEUDO_TRACKER_DHT_URL, PSEUDO_TRACKER_LSD_URL, PSEUDO_TRACKER_PEX_URL, PSEUDO_TRACKER_TIER,
    is_pseudo_tracker, synthesize_pseudo_trackers,
};

use irontide_core::FilePriority;
use irontide_session::TorrentState;

/// Parse a priority slug (`"skip" | "low" | "normal" | "high"`) into a
/// `FilePriority`. Returns `None` for any other input — callers can map
/// that to a 422 (Web UI) or ignore it (GUI passes the int directly).
#[must_use]
pub fn parse_priority_label(s: &str) -> Option<FilePriority> {
    match s {
        "skip" => Some(FilePriority::Skip),
        "low" => Some(FilePriority::Low),
        "normal" => Some(FilePriority::Normal),
        "high" => Some(FilePriority::High),
        _ => None,
    }
}

/// Format a raw byte count as a human-readable size string.
///
/// Uses binary (KiB/MiB/GiB) units with decimal precision matching
/// libtorrent / rqbit conventions. Sub-KiB values are reported as raw
/// bytes. The largest unit is GiB to keep very large torrents on a single line.
#[must_use]
pub fn format_size(bytes: u64) -> String {
    const KIB: u64 = 1024;
    const MIB: u64 = 1024 * KIB;
    const GIB: u64 = 1024 * MIB;
    if bytes >= GIB {
        format!("{:.2} GiB", bytes as f64 / GIB as f64)
    } else if bytes >= MIB {
        format!("{:.1} MiB", bytes as f64 / MIB as f64)
    } else if bytes >= KIB {
        format!("{:.1} KiB", bytes as f64 / KIB as f64)
    } else {
        format!("{bytes} B")
    }
}

/// Format a byte-per-second rate as a human-readable string.
///
/// Note: rates use the `KB/s` / `MB/s` suffix (without the `i`) to match
/// libtorrent progress output. Sub-KB/s values are reported in raw `B/s`.
#[must_use]
pub fn format_rate(bytes_per_sec: u64) -> String {
    const KIB: u64 = 1024;
    const MIB: u64 = 1024 * KIB;
    if bytes_per_sec >= MIB {
        format!("{:.1} MB/s", bytes_per_sec as f64 / MIB as f64)
    } else if bytes_per_sec >= KIB {
        format!("{:.1} KB/s", bytes_per_sec as f64 / KIB as f64)
    } else {
        format!("{bytes_per_sec} B/s")
    }
}

/// Map a `TorrentState` variant to its lowercase display label.
///
/// When `user_seed_mode` is true and the torrent is in the `Downloading`
/// state, returns `"seed only"` to reflect the user-imposed constraint.
#[must_use]
pub fn format_state(state: &TorrentState, user_seed_mode: bool) -> &'static str {
    if user_seed_mode && matches!(state, TorrentState::Downloading) {
        return "seed only";
    }
    match state {
        TorrentState::FetchingMetadata => "fetching metadata",
        TorrentState::Checking => "checking",
        TorrentState::Downloading => "downloading",
        TorrentState::Complete => "complete",
        TorrentState::Seeding => "seeding",
        TorrentState::Paused => "paused",
        TorrentState::Queued => "queued",
        TorrentState::Stopped => "stopped",
        TorrentState::Sharing => "sharing",
    }
}

#[must_use]
pub fn format_state_with_super_seeding(
    state: &TorrentState,
    user_seed_mode: bool,
    super_seeding: bool,
) -> &'static str {
    if super_seeding && matches!(state, TorrentState::Seeding) {
        return "super seeding";
    }
    format_state(state, user_seed_mode)
}

/// Format the upload/download share ratio.
///
/// Returns `"∞"` when bytes were uploaded but nothing was downloaded,
/// `"0.00"` when both counters are zero, and a two-decimal ratio otherwise.
#[must_use]
pub fn format_ratio(uploaded: u64, downloaded: u64) -> String {
    if downloaded == 0 && uploaded > 0 {
        return "\u{221e}".to_string(); //    }
    if downloaded == 0 {
        return "0.00".to_string();
    }
    format!("{:.2}", uploaded as f64 / downloaded as f64)
}

/// Estimate remaining download time given outstanding bytes and current rate.
///
/// Returns `"—"` (em dash) when the rate is zero (stalled). Otherwise formats
/// the duration as `"Xd Yh"`, `"Xh Ym"`, `"Xm Ys"`, or `"Xs"` depending on
/// the magnitude. Returns `"0s"` when `remaining_bytes` is zero.
#[must_use]
pub fn format_eta(remaining_bytes: u64, rate_bps: u64) -> String {
    if rate_bps == 0 {
        return "\u{2014}".to_string(); // em dash
    }
    let secs = remaining_bytes / rate_bps;
    if secs >= 86400 {
        let days = secs / 86400;
        let hours = (secs % 86400) / 3600;
        format!("{days}d {hours}h")
    } else if secs >= 3600 {
        let hours = secs / 3600;
        let mins = (secs % 3600) / 60;
        format!("{hours}h {mins}m")
    } else if secs >= 60 {
        let mins = secs / 60;
        let s = secs % 60;
        format!("{mins}m {s}s")
    } else {
        format!("{secs}s")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_format_size_boundaries() {
        // Zero bytes
        assert_eq!(format_size(0), "0 B");
        // Sub-KiB
        assert_eq!(format_size(512), "512 B");
        // Exactly 1 KiB
        assert_eq!(format_size(1024), "1.0 KiB");
        // Sub-MiB
        assert_eq!(format_size(512 * 1024), "512.0 KiB");
        // Exactly 1 MiB
        assert_eq!(format_size(1_048_576), "1.0 MiB");
        // Sub-GiB
        assert_eq!(format_size(512 * 1_048_576), "512.0 MiB");
        // Exactly 1 GiB
        assert_eq!(format_size(1_073_741_824), "1.00 GiB");
        // Large value stays in GiB (no TiB branch)
        assert_eq!(format_size(512 * 1_073_741_824), "512.00 GiB");
    }

    #[test]
    fn test_format_rate_boundaries() {
        // Zero rate
        assert_eq!(format_rate(0), "0 B/s");
        // Sub-KB/s
        assert_eq!(format_rate(512), "512 B/s");
        // Exactly 1 KB/s
        assert_eq!(format_rate(1024), "1.0 KB/s");
        // Sub-MB/s
        assert_eq!(format_rate(512 * 1024), "512.0 KB/s");
        // Exactly 1 MB/s
        assert_eq!(format_rate(1_048_576), "1.0 MB/s");
        // Very high rate stays in MB/s (no GB/s branch)
        assert_eq!(format_rate(1_073_741_824), "1024.0 MB/s");
    }

    #[test]
    fn test_format_state_all_variants() {
        // All variants without seed mode
        assert_eq!(
            format_state(&TorrentState::FetchingMetadata, false),
            "fetching metadata"
        );
        assert_eq!(format_state(&TorrentState::Checking, false), "checking");
        assert_eq!(
            format_state(&TorrentState::Downloading, false),
            "downloading"
        );
        assert_eq!(format_state(&TorrentState::Complete, false), "complete");
        assert_eq!(format_state(&TorrentState::Seeding, false), "seeding");
        assert_eq!(format_state(&TorrentState::Paused, false), "paused");
        assert_eq!(format_state(&TorrentState::Queued, false), "queued");
        assert_eq!(format_state(&TorrentState::Stopped, false), "stopped");
        assert_eq!(format_state(&TorrentState::Sharing, false), "sharing");

        // Seed mode overrides Downloading only
        assert_eq!(format_state(&TorrentState::Downloading, true), "seed only");
        // Seed mode does NOT override other states
        assert_eq!(format_state(&TorrentState::Seeding, true), "seeding");
        assert_eq!(format_state(&TorrentState::Paused, true), "paused");
        assert_eq!(format_state(&TorrentState::Queued, true), "queued");
        assert_eq!(format_state(&TorrentState::Complete, true), "complete");
        assert_eq!(format_state(&TorrentState::Sharing, true), "sharing");
        assert_eq!(format_state(&TorrentState::Stopped, true), "stopped");
    }

    #[test]
    fn test_format_ratio() {
        // Both zero → 0.00
        assert_eq!(format_ratio(0, 0), "0.00");
        // Upload with no download → ∞
        assert_eq!(format_ratio(500, 0), "\u{221e}");
        // Normal ratio: 150 / 100 = 1.50
        assert_eq!(format_ratio(150, 100), "1.50");
        // Exactly 1:1
        assert_eq!(format_ratio(1000, 1000), "1.00");
        // Large ratio: 1000x
        assert_eq!(format_ratio(1_000_000, 1_000), "1000.00");
        // Less than 1: 0.50
        assert_eq!(format_ratio(50, 100), "0.50");
    }

    #[test]
    fn test_parse_priority_label_known_slugs() {
        assert_eq!(parse_priority_label("skip"), Some(FilePriority::Skip));
        assert_eq!(parse_priority_label("low"), Some(FilePriority::Low));
        assert_eq!(parse_priority_label("normal"), Some(FilePriority::Normal));
        assert_eq!(parse_priority_label("high"), Some(FilePriority::High));
    }

    #[test]
    fn test_parse_priority_label_unknown_returns_none() {
        assert_eq!(parse_priority_label(""), None);
        assert_eq!(parse_priority_label("SKIP"), None);
        assert_eq!(parse_priority_label("medium"), None);
        assert_eq!(parse_priority_label("4"), None);
    }

    #[test]
    fn test_format_eta() {
        // Zero remaining → "0s" (0 / 1 = 0 secs)
        assert_eq!(format_eta(0, 1), "0s");
        // Stalled (rate=0) → em dash
        assert_eq!(format_eta(1_000_000, 0), "\u{2014}");
        // Seconds only
        assert_eq!(format_eta(30, 1), "30s");
        // Minutes and seconds
        assert_eq!(format_eta(45 * 60 + 12, 1), "45m 12s");
        // Hours and minutes
        assert_eq!(format_eta(2 * 3600 + 15 * 60, 1), "2h 15m");
        // Days and hours
        assert_eq!(format_eta(2 * 86400 + 15 * 3600, 1), "2d 15h");
        // Normal download: 100 MB at 1 MB/s = 100s
        assert_eq!(format_eta(100 * 1_048_576, 1_048_576), "1m 40s");
    }

    #[test]
    fn format_state_with_super_seeding_shows_super_seeding() {
        assert_eq!(
            format_state_with_super_seeding(&TorrentState::Seeding, false, true),
            "super seeding"
        );
    }

    #[test]
    fn format_state_with_super_seeding_normal_seeding() {
        assert_eq!(
            format_state_with_super_seeding(&TorrentState::Seeding, false, false),
            "seeding"
        );
    }

    #[test]
    fn format_state_with_super_seeding_non_seeding_state() {
        assert_eq!(
            format_state_with_super_seeding(&TorrentState::Downloading, false, true),
            "downloading"
        );
    }
}