Skip to main content

libfw_client/
plan.rs

1//! Transfer planning: flattening server listings and JS file lists into
2//! ordered [`FileEntry`]s, and slicing them into chunks.
3
4use js_sys::{Array, Reflect};
5use wasm_bindgen::JsValue;
6
7use crate::error::LibfwError;
8use libfw_core::metadata::{etag_from_size_mtime, FileMeta, TransferPlan};
9
10/// A file to transfer, identified by its virtual path.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct FileEntry {
13    /// Virtual path relative to the mounted root (POSIX separators).
14    pub path: String,
15    /// Size in bytes.
16    pub size: u64,
17    /// Last-modified unix time.
18    pub mtime: u64,
19}
20
21impl FileEntry {
22    /// Build a [`FileMeta`] (computing the ETag from size + mtime).
23    pub fn to_meta(&self) -> FileMeta {
24        FileMeta {
25            path: self.path.clone(),
26            size: self.size,
27            mtime: self.mtime,
28            etag: etag_from_size_mtime(self.size, self.mtime),
29        }
30    }
31
32    /// The transfer plan for this file at `chunk_size`.
33    pub fn plan(&self, chunk_size: u64) -> TransferPlan {
34        TransferPlan::with_chunk_size(self.to_meta(), chunk_size)
35    }
36}
37
38/// Parse a JS array of `{ path, size, mtime }` objects.
39pub fn parse_file_entries(value: &JsValue) -> Result<Vec<FileEntry>, LibfwError> {
40    let arr = Array::from(value);
41    let mut out = Vec::with_capacity(arr.length() as usize);
42    for item in arr.iter() {
43        let path = Reflect::get(&item, &JsValue::from_str("path"))
44            .map_err(|e| LibfwError::Js(format!("missing `path`: {e:?}")))?
45            .as_string()
46            .ok_or_else(|| LibfwError::Js("`path` must be a string".into()))?;
47        let size = Reflect::get(&item, &JsValue::from_str("size"))
48            .ok()
49            .and_then(|v| v.as_f64())
50            .unwrap_or(0.0) as u64;
51        let mtime = Reflect::get(&item, &JsValue::from_str("mtime"))
52            .ok()
53            .and_then(|v| v.as_f64())
54            .unwrap_or(0.0) as u64;
55        out.push(FileEntry { path, size, mtime });
56    }
57    Ok(out)
58}
59
60/// Total bytes of a file list (for progress reporting).
61pub fn total_bytes(files: &[FileEntry]) -> u64 {
62    files.iter().map(|f| f.size).sum()
63}
64
65/// The next chunk boundaries `[offset, end)` for `file` at `chunk_size`,
66/// starting at `from` (a resume offset).
67pub fn chunk_bounds(file: &FileEntry, chunk_size: u64, from: u64) -> Vec<(u64, u64)> {
68    let mut bounds = Vec::new();
69    let mut offset = from.min(file.size);
70    while offset < file.size {
71        let end = (offset + chunk_size).min(file.size);
72        bounds.push((offset, end));
73        offset = end;
74    }
75    bounds
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn chunk_bounds_cover_file_from_resume() {
84        let f = FileEntry {
85            path: "a.bin".into(),
86            size: 10,
87            mtime: 1,
88        };
89        let bounds = chunk_bounds(&f, 4, 0);
90        assert_eq!(bounds, vec![(0, 4), (4, 8), (8, 10)]);
91
92        let resumed = chunk_bounds(&f, 4, 4);
93        assert_eq!(resumed, vec![(4, 8), (8, 10)]);
94
95        let done = chunk_bounds(&f, 4, 10);
96        assert!(done.is_empty());
97    }
98
99    #[test]
100    fn plan_meta_has_etag() {
101        let f = FileEntry {
102            path: "x".into(),
103            size: 5,
104            mtime: 42,
105        };
106        let plan = f.plan(4);
107        assert_eq!(plan.chunks.len(), 2);
108        assert!(!plan.file.etag.is_empty());
109        assert_eq!(plan.total_bytes(), 5);
110    }
111
112    #[test]
113    #[cfg(target_arch = "wasm32")]
114    fn parses_js_file_entries() {
115        let arr = Array::new();
116        let a = js_sys::Object::new();
117        js_sys::Reflect::set(&a, &JsValue::from_str("path"), &JsValue::from_str("d/f.txt"))
118            .unwrap();
119        js_sys::Reflect::set(&a, &JsValue::from_str("size"), &JsValue::from_f64(12.0)).unwrap();
120        arr.push(&a);
121        let entries = parse_file_entries(&arr.into()).unwrap();
122        assert_eq!(entries.len(), 1);
123        assert_eq!(entries[0].path, "d/f.txt");
124        assert_eq!(entries[0].size, 12);
125    }
126}