Skip to main content

irontide_format/
file_entries.rs

1//! Shared flat-file entry builder for the GUI Content tab and Web UI
2//! `/webui/fragments/torrent/{hash}/files` template.
3//!
4//! Both surfaces consume `(TorrentInfo, file_progress, file_priorities)`
5//! and project them into one row per file. Keeping the projection in one
6//! place ensures that bug fixes and length-mismatch handling stay in
7//! sync between Web UI and GUI (D-eng-3, M177).
8
9use std::path::PathBuf;
10
11use irontide_core::FilePriority;
12use irontide_session::TorrentInfo;
13
14/// One file row with all per-file state collapsed into a flat record.
15///
16/// The path stays as a [`PathBuf`] because the GUI tree-flattener walks
17/// the components to compute folder depth, while the Web UI only needs
18/// `to_string_lossy()` for the bare display string.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct FlatFileEntry {
21    /// Relative path of the file within the torrent (mirrors
22    /// [`irontide_session::FileInfo::path`]).
23    pub path: PathBuf,
24    /// File size in bytes.
25    pub size: u64,
26    /// Bytes already downloaded for this file.
27    pub progress: u64,
28    /// Current download priority for this file.
29    pub priority: FilePriority,
30}
31
32/// Project a `TorrentInfo` plus its parallel progress + priority slices
33/// into a `Vec<FlatFileEntry>` of length `info.files.len()`.
34///
35/// Saturating on length mismatch (D-eng-5 defensive): a missing
36/// `progress[i]` defaults to `0`, a missing `priorities[i]` defaults to
37/// [`FilePriority::Normal`]. The caller may also pass slices that are
38/// *longer* than `info.files` — extras are ignored. This pattern keeps
39/// the rest of the GUI / Web UI rendering pipeline running on partial
40/// data (e.g. mid-actor-message round-trip) instead of panicking.
41#[must_use]
42pub fn build_flat(
43    info: &TorrentInfo,
44    progress: &[u64],
45    priorities: &[FilePriority],
46) -> Vec<FlatFileEntry> {
47    info.files
48        .iter()
49        .enumerate()
50        .map(|(i, entry)| FlatFileEntry {
51            path: entry.path.clone(),
52            size: entry.length,
53            progress: progress.get(i).copied().unwrap_or(0),
54            priority: priorities.get(i).copied().unwrap_or(FilePriority::Normal),
55        })
56        .collect()
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use irontide_core::Id20;
63    use irontide_session::FileInfo;
64
65    fn info(files: Vec<(&str, u64)>) -> TorrentInfo {
66        TorrentInfo {
67            info_hash: Id20([0u8; 20]),
68            name: "test".into(),
69            total_length: files.iter().map(|(_, n)| *n).sum(),
70            piece_length: 16_384,
71            num_pieces: 1,
72            files: files
73                .into_iter()
74                .map(|(p, n)| FileInfo {
75                    path: PathBuf::from(p),
76                    length: n,
77                })
78                .collect(),
79            private: false,
80        }
81    }
82
83    #[test]
84    fn build_flat_empty_info() {
85        let i = info(vec![]);
86        let out = build_flat(&i, &[], &[]);
87        assert!(out.is_empty());
88    }
89
90    #[test]
91    fn build_flat_single_file() {
92        let i = info(vec![("a.bin", 1000)]);
93        let out = build_flat(&i, &[500], &[FilePriority::High]);
94        assert_eq!(out.len(), 1);
95        assert_eq!(out[0].path, PathBuf::from("a.bin"));
96        assert_eq!(out[0].size, 1000);
97        assert_eq!(out[0].progress, 500);
98        assert_eq!(out[0].priority, FilePriority::High);
99    }
100
101    #[test]
102    fn build_flat_nested_files_preserve_paths() {
103        // Mixed-depth paths: GUI flattener depends on the path
104        // components round-tripping through this helper exactly.
105        let i = info(vec![
106            ("readme.txt", 100),
107            ("video/intro.mp4", 50_000),
108            ("video/extras/bts.mkv", 80_000),
109        ]);
110        let out = build_flat(
111            &i,
112            &[100, 25_000, 0],
113            &[FilePriority::Normal, FilePriority::High, FilePriority::Skip],
114        );
115        assert_eq!(out.len(), 3);
116        assert_eq!(out[0].path, PathBuf::from("readme.txt"));
117        assert_eq!(out[1].path, PathBuf::from("video/intro.mp4"));
118        assert_eq!(out[2].path, PathBuf::from("video/extras/bts.mkv"));
119    }
120
121    #[test]
122    fn build_flat_priority_progress_aligned_by_index() {
123        let i = info(vec![("a", 10), ("b", 20), ("c", 30)]);
124        let out = build_flat(
125            &i,
126            &[1, 2, 3],
127            &[FilePriority::Skip, FilePriority::Low, FilePriority::High],
128        );
129        assert_eq!(out[0].progress, 1);
130        assert_eq!(out[0].priority, FilePriority::Skip);
131        assert_eq!(out[1].progress, 2);
132        assert_eq!(out[1].priority, FilePriority::Low);
133        assert_eq!(out[2].progress, 3);
134        assert_eq!(out[2].priority, FilePriority::High);
135    }
136
137    #[test]
138    fn build_flat_length_mismatch_saturates_to_defaults() {
139        // D-eng-5 defensive: 3 files, only 2 progress entries, 1 priority
140        // entry. Tail rows must default to (0, Normal) — never panic.
141        let i = info(vec![("a", 10), ("b", 20), ("c", 30)]);
142        let out = build_flat(&i, &[1, 2], &[FilePriority::High]);
143        assert_eq!(out.len(), 3);
144        // Row 0: both slices have data.
145        assert_eq!(out[0].progress, 1);
146        assert_eq!(out[0].priority, FilePriority::High);
147        // Row 1: progress has it, priority doesn't.
148        assert_eq!(out[1].progress, 2);
149        assert_eq!(out[1].priority, FilePriority::Normal);
150        // Row 2: neither has it.
151        assert_eq!(out[2].progress, 0);
152        assert_eq!(out[2].priority, FilePriority::Normal);
153    }
154}