irontide-format 1.0.2

Shared human-readable formatting helpers for irontide crates
Documentation
//! Shared flat-file entry builder for the GUI Content tab and Web UI
//! `/webui/fragments/torrent/{hash}/files` template.
//!
//! Both surfaces consume `(TorrentInfo, file_progress, file_priorities)`
//! and project them into one row per file. Keeping the projection in one
//! place ensures that bug fixes and length-mismatch handling stay in
//! sync between Web UI and GUI (D-eng-3, M177).

use std::path::PathBuf;

use irontide_core::FilePriority;
use irontide_session::TorrentInfo;

/// One file row with all per-file state collapsed into a flat record.
///
/// The path stays as a [`PathBuf`] because the GUI tree-flattener walks
/// the components to compute folder depth, while the Web UI only needs
/// `to_string_lossy()` for the bare display string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FlatFileEntry {
    /// Relative path of the file within the torrent (mirrors
    /// [`irontide_session::FileInfo::path`]).
    pub path: PathBuf,
    /// File size in bytes.
    pub size: u64,
    /// Bytes already downloaded for this file.
    pub progress: u64,
    /// Current download priority for this file.
    pub priority: FilePriority,
}

/// Project a `TorrentInfo` plus its parallel progress + priority slices
/// into a `Vec<FlatFileEntry>` of length `info.files.len()`.
///
/// Saturating on length mismatch (D-eng-5 defensive): a missing
/// `progress[i]` defaults to `0`, a missing `priorities[i]` defaults to
/// [`FilePriority::Normal`]. The caller may also pass slices that are
/// *longer* than `info.files` — extras are ignored. This pattern keeps
/// the rest of the GUI / Web UI rendering pipeline running on partial
/// data (e.g. mid-actor-message round-trip) instead of panicking.
#[must_use]
pub fn build_flat(
    info: &TorrentInfo,
    progress: &[u64],
    priorities: &[FilePriority],
) -> Vec<FlatFileEntry> {
    info.files
        .iter()
        .enumerate()
        .map(|(i, entry)| FlatFileEntry {
            path: entry.path.clone(),
            size: entry.length,
            progress: progress.get(i).copied().unwrap_or(0),
            priority: priorities.get(i).copied().unwrap_or(FilePriority::Normal),
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use irontide_core::Id20;
    use irontide_session::FileInfo;

    fn info(files: Vec<(&str, u64)>) -> TorrentInfo {
        TorrentInfo {
            info_hash: Id20([0u8; 20]),
            name: "test".into(),
            total_length: files.iter().map(|(_, n)| *n).sum(),
            piece_length: 16_384,
            num_pieces: 1,
            files: files
                .into_iter()
                .map(|(p, n)| FileInfo {
                    path: PathBuf::from(p),
                    length: n,
                })
                .collect(),
            private: false,
        }
    }

    #[test]
    fn build_flat_empty_info() {
        let i = info(vec![]);
        let out = build_flat(&i, &[], &[]);
        assert!(out.is_empty());
    }

    #[test]
    fn build_flat_single_file() {
        let i = info(vec![("a.bin", 1000)]);
        let out = build_flat(&i, &[500], &[FilePriority::High]);
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].path, PathBuf::from("a.bin"));
        assert_eq!(out[0].size, 1000);
        assert_eq!(out[0].progress, 500);
        assert_eq!(out[0].priority, FilePriority::High);
    }

    #[test]
    fn build_flat_nested_files_preserve_paths() {
        // Mixed-depth paths: GUI flattener depends on the path
        // components round-tripping through this helper exactly.
        let i = info(vec![
            ("readme.txt", 100),
            ("video/intro.mp4", 50_000),
            ("video/extras/bts.mkv", 80_000),
        ]);
        let out = build_flat(
            &i,
            &[100, 25_000, 0],
            &[FilePriority::Normal, FilePriority::High, FilePriority::Skip],
        );
        assert_eq!(out.len(), 3);
        assert_eq!(out[0].path, PathBuf::from("readme.txt"));
        assert_eq!(out[1].path, PathBuf::from("video/intro.mp4"));
        assert_eq!(out[2].path, PathBuf::from("video/extras/bts.mkv"));
    }

    #[test]
    fn build_flat_priority_progress_aligned_by_index() {
        let i = info(vec![("a", 10), ("b", 20), ("c", 30)]);
        let out = build_flat(
            &i,
            &[1, 2, 3],
            &[FilePriority::Skip, FilePriority::Low, FilePriority::High],
        );
        assert_eq!(out[0].progress, 1);
        assert_eq!(out[0].priority, FilePriority::Skip);
        assert_eq!(out[1].progress, 2);
        assert_eq!(out[1].priority, FilePriority::Low);
        assert_eq!(out[2].progress, 3);
        assert_eq!(out[2].priority, FilePriority::High);
    }

    #[test]
    fn build_flat_length_mismatch_saturates_to_defaults() {
        // D-eng-5 defensive: 3 files, only 2 progress entries, 1 priority
        // entry. Tail rows must default to (0, Normal) — never panic.
        let i = info(vec![("a", 10), ("b", 20), ("c", 30)]);
        let out = build_flat(&i, &[1, 2], &[FilePriority::High]);
        assert_eq!(out.len(), 3);
        // Row 0: both slices have data.
        assert_eq!(out[0].progress, 1);
        assert_eq!(out[0].priority, FilePriority::High);
        // Row 1: progress has it, priority doesn't.
        assert_eq!(out[1].progress, 2);
        assert_eq!(out[1].priority, FilePriority::Normal);
        // Row 2: neither has it.
        assert_eq!(out[2].progress, 0);
        assert_eq!(out[2].priority, FilePriority::Normal);
    }
}