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