libfw-client 0.1.2

WASM engine + JS SDK for libfw browser clients
Documentation
//! Task state machine and user control flags.
//!
//! The transfer state machine is `Idle → Downloading/Uploading → Paused →
//! Resumed → Completed/Failed`. Pause/resume/cancel are implemented as
//! `Cell`-backed flags checked cooperatively between chunks, which is safe
//! because WASM is single-threaded and each flag read is a cheap copy (no
//! borrows span `.await` points).

use std::cell::Cell;

use wasm_bindgen::JsValue;

use crate::error::LibfwError;

/// Lifecycle state of the current transfer task.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TaskState {
    /// No transfer is running.
    Idle,
    /// Downloading files from the server.
    Downloading,
    /// Uploading files to the server.
    Uploading,
    /// The active transfer is paused (user requested).
    Paused,
    /// The transfer finished successfully.
    Completed,
    /// The transfer failed (or was cancelled).
    Failed,
}

impl TaskState {
    /// Stable lowercase name exposed to JS.
    pub fn as_str(self) -> &'static str {
        match self {
            TaskState::Idle => "idle",
            TaskState::Downloading => "downloading",
            TaskState::Uploading => "uploading",
            TaskState::Paused => "paused",
            TaskState::Completed => "completed",
            TaskState::Failed => "failed",
        }
    }
}

/// Shared, mutable task control state.
#[derive(Debug, Clone)]
pub struct TaskControl {
    state: Cell<TaskState>,
    /// The state to restore on `resume()` (downloading/uploading).
    active: Cell<TaskState>,
    cancelled: Cell<bool>,
    done_bytes: Cell<u64>,
    total_bytes: Cell<u64>,
}

impl Default for TaskControl {
    fn default() -> Self {
        TaskControl::new()
    }
}

impl TaskControl {
    /// Create a fresh, idle control block.
    pub fn new() -> Self {
        TaskControl {
            state: Cell::new(TaskState::Idle),
            active: Cell::new(TaskState::Idle),
            cancelled: Cell::new(false),
            done_bytes: Cell::new(0),
            total_bytes: Cell::new(0),
        }
    }

    /// Reset everything for a new transfer.
    pub fn reset(&self) {
        self.state.set(TaskState::Idle);
        self.active.set(TaskState::Idle);
        self.cancelled.set(false);
        self.done_bytes.set(0);
        self.total_bytes.set(0);
    }

    /// Current state.
    pub fn state(&self) -> TaskState {
        self.state.get()
    }

    /// Transition into an active state (`Downloading`/`Uploading`).
    pub fn begin(&self, s: TaskState) {
        self.active.set(s);
        self.state.set(s);
    }

    /// Mark the transfer completed.
    pub fn complete(&self) {
        self.state.set(TaskState::Completed);
    }

    /// Mark the transfer failed.
    pub fn fail(&self) {
        self.state.set(TaskState::Failed);
    }

    /// Pause: remember the active state, then go `Paused`.
    pub fn pause(&self) {
        if matches!(
            self.state.get(),
            TaskState::Downloading | TaskState::Uploading
        ) {
            self.active.set(self.state.get());
            self.state.set(TaskState::Paused);
        }
    }

    /// Resume: restore the remembered active state.
    pub fn resume(&self) {
        if self.state.get() == TaskState::Paused {
            self.state.set(self.active.get());
        }
    }

    /// Request cancellation. Cooperating loops observe it via
    /// [`TaskControl::check`] / [`TaskControl::wait_ready`].
    pub fn cancel(&self) {
        self.cancelled.set(true);
        if !matches!(self.state.get(), TaskState::Completed | TaskState::Failed) {
            self.state.set(TaskState::Failed);
        }
    }

    /// Whether cancellation was requested.
    #[allow(dead_code)] // public control API; used by tests and the SDK
    pub fn is_cancelled(&self) -> bool {
        self.cancelled.get()
    }

    /// Throw [`LibfwError::Cancelled`] when the user cancelled.
    pub fn check(&self) -> Result<(), LibfwError> {
        if self.cancelled.get() {
            Err(LibfwError::Cancelled)
        } else {
            Ok(())
        }
    }

    /// Block until the task is neither paused nor cancelled, yielding to
    /// the JS event loop so `pause`/`resume`/`cancel` can be delivered.
    pub async fn wait_ready(&self) -> Result<(), LibfwError> {
        loop {
            self.check()?;
            if self.state.get() != TaskState::Paused {
                return Ok(());
            }
            yield_to_event_loop().await;
        }
    }

    /// Record progress made.
    pub fn add_progress(&self, bytes: u64) {
        self.done_bytes.set(self.done_bytes.get().saturating_add(bytes));
    }

    /// Bytes transferred so far.
    pub fn done_bytes(&self) -> u64 {
        self.done_bytes.get()
    }

    /// Total bytes to transfer (may be 0 until known).
    pub fn total_bytes(&self) -> u64 {
        self.total_bytes.get()
    }

    /// Set the expected total (e.g. sum of file sizes).
    pub fn set_total(&self, bytes: u64) {
        self.total_bytes.set(bytes);
    }

    /// Progress in `[0.0, 1.0]` (0 when the total is unknown).
    pub fn progress(&self) -> f64 {
        let total = self.total_bytes.get();
        if total == 0 {
            0.0
        } else {
            (self.done_bytes.get() as f64 / total as f64).clamp(0.0, 1.0)
        }
    }
}

/// Yield control back to the JS event loop by awaiting a resolved promise.
///
/// Only ever awaited from the WASM transfer loops; native unit tests never
/// reach this path.
async fn yield_to_event_loop() {
    let promise = js_sys::Promise::resolve(&JsValue::UNDEFINED);
    let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
}

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

    #[test]
    fn idle_state_machine() {
        let c = TaskControl::new();
        assert_eq!(c.state(), TaskState::Idle);
        c.begin(TaskState::Downloading);
        assert_eq!(c.state(), TaskState::Downloading);
        c.pause();
        assert_eq!(c.state(), TaskState::Paused);
        c.resume();
        assert_eq!(c.state(), TaskState::Downloading);
        c.complete();
        assert_eq!(c.state(), TaskState::Completed);
    }

    #[test]
    fn pause_restores_active_state() {
        let c = TaskControl::new();
        c.begin(TaskState::Uploading);
        c.pause();
        assert_eq!(c.state(), TaskState::Paused);
        c.resume();
        assert_eq!(c.state(), TaskState::Uploading);
    }

    #[test]
    fn cancel_marks_failed_and_checks() {
        let c = TaskControl::new();
        c.begin(TaskState::Downloading);
        c.cancel();
        assert!(c.is_cancelled());
        assert!(matches!(c.check(), Err(LibfwError::Cancelled)));
        assert_eq!(c.state(), TaskState::Failed);
    }

    #[test]
    fn progress_is_bounded() {
        let c = TaskControl::new();
        c.set_total(100);
        c.add_progress(25);
        assert_eq!(c.progress(), 0.25);
        c.add_progress(200);
        assert_eq!(c.progress(), 1.0);
    }

    #[test]
    fn state_names_are_stable() {
        assert_eq!(TaskState::Idle.as_str(), "idle");
        assert_eq!(TaskState::Paused.as_str(), "paused");
        assert_eq!(TaskState::Completed.as_str(), "completed");
        assert_eq!(TaskState::Failed.as_str(), "failed");
    }
}