viewpoint-core 0.4.3

High-level browser automation API for Viewpoint
Documentation
//! Download handling for browser downloads.
//!
//! This module provides functionality for handling file downloads.

// Allow dead code for scaffolding that will be wired up in future

use std::path::{Path, PathBuf};

use tokio::sync::watch;
use tracing::{debug, instrument};

use crate::error::NetworkError;

/// Download progress state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DownloadState {
    /// Download is in progress.
    InProgress,
    /// Download completed successfully.
    Completed,
    /// Download was canceled.
    Canceled,
}

/// A file download.
///
/// Downloads are emitted via the `page.on_download()` callback or can be
/// obtained using `page.wait_for_download()`.
///
/// # Example
///
/// ```
/// # #[cfg(feature = "integration")]
/// # tokio_test::block_on(async {
/// # use viewpoint_core::Browser;
/// # let browser = Browser::launch().headless(true).launch().await.unwrap();
/// # let context = browser.new_context().await.unwrap();
/// # let page = context.new_page().await.unwrap();
/// # page.goto("about:blank").goto().await.unwrap();
///
/// // Downloads are obtained like this:
/// // let download = page.wait_for_download(async {
/// //     page.locator("a.download").click().await?;
/// //     Ok(())
/// // }).await?;
/// //
/// // // Get the downloaded file path
/// // let path = download.path().await?;
/// // println!("Downloaded to: {}", path.display());
/// //
/// // // Or save to a custom location
/// // download.save_as("./downloads/my-file.pdf").await?;
/// # });
/// ```
#[derive(Debug)]
pub struct Download {
    /// Global unique identifier.
    guid: String,
    /// Download URL.
    url: String,
    /// Suggested filename from the browser.
    suggested_filename: String,
    /// Temporary file path where the download is saved.
    temp_path: Option<PathBuf>,
    /// State of the download.
    state: DownloadState,
    /// Failure reason if any.
    failure: Option<String>,
    /// Receiver for state updates.
    state_rx: watch::Receiver<DownloadState>,
    /// Receiver for path updates.
    path_rx: watch::Receiver<Option<PathBuf>>,
}

impl Download {
    /// Create a new Download.
    pub(crate) fn new(
        guid: String,
        url: String,
        suggested_filename: String,
        state_rx: watch::Receiver<DownloadState>,
        path_rx: watch::Receiver<Option<PathBuf>>,
    ) -> Self {
        Self {
            guid,
            url,
            suggested_filename,
            temp_path: None,
            state: DownloadState::InProgress,
            failure: None,
            state_rx,
            path_rx,
        }
    }

    /// Get the download URL.
    pub fn url(&self) -> &str {
        &self.url
    }

    /// Get the suggested filename from the browser.
    ///
    /// This is the filename that the browser suggested based on the
    /// Content-Disposition header or URL.
    pub fn suggested_filename(&self) -> &str {
        &self.suggested_filename
    }

    /// Get the global unique identifier of this download.
    pub fn guid(&self) -> &str {
        &self.guid
    }

    /// Get the path to the downloaded file.
    ///
    /// This method waits for the download to complete if it's still in progress.
    /// The file is saved to a temporary location by default.
    ///
    /// # Errors
    ///
    /// Returns an error if the download fails or is canceled.
    #[instrument(level = "debug", skip(self), fields(guid = %self.guid))]
    pub async fn path(&mut self) -> Result<PathBuf, NetworkError> {
        // Wait for the path to be available
        let mut path_rx = self.path_rx.clone();

        loop {
            {
                let path = path_rx.borrow();
                if let Some(ref p) = *path {
                    self.temp_path = Some(p.clone());
                    return Ok(p.clone());
                }
            }

            // Check if we've failed
            if self.failure.is_some() {
                return Err(NetworkError::IoError(
                    self.failure
                        .clone()
                        .unwrap_or_else(|| "Unknown download error".to_string()),
                ));
            }

            // Wait for changes
            if path_rx.changed().await.is_err() {
                return Err(NetworkError::Aborted);
            }
        }
    }

    /// Save the downloaded file to a custom location.
    ///
    /// This method waits for the download to complete if it's still in progress,
    /// then copies the file to the specified path.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - The download fails or is canceled
    /// - The file cannot be copied to the destination
    #[instrument(level = "debug", skip(self), fields(guid = %self.guid, dest = %dest.as_ref().display()))]
    pub async fn save_as(&mut self, dest: impl AsRef<Path>) -> Result<(), NetworkError> {
        let source = self.path().await?;

        debug!("Copying download to destination");
        tokio::fs::copy(&source, dest.as_ref())
            .await
            .map_err(|e| NetworkError::IoError(e.to_string()))?;

        Ok(())
    }

    /// Cancel the download.
    ///
    /// This method cancels an in-progress download. If the download has already
    /// completed, this has no effect.
    #[instrument(level = "debug", skip(self), fields(guid = %self.guid))]
    pub async fn cancel(&mut self) -> Result<(), NetworkError> {
        // For now, just mark it as canceled
        // In a full implementation, we'd send a CDP command to cancel
        self.state = DownloadState::Canceled;
        self.failure = Some("canceled".to_string());
        Ok(())
    }

    /// Get the failure reason if the download failed.
    ///
    /// Returns `None` if the download completed successfully or is still in progress.
    pub fn failure(&self) -> Option<&str> {
        self.failure.as_deref()
    }

    /// Update the download state.
    pub(crate) fn update_state(&mut self, state: DownloadState, failure: Option<String>) {
        self.state = state;
        if let Some(f) = failure {
            self.failure = Some(f);
        }
    }

    /// Set the temporary path.
    pub(crate) fn set_path(&mut self, path: PathBuf) {
        self.temp_path = Some(path);
    }
}

/// Manager for tracking downloads.
#[derive(Debug)]
pub(crate) struct DownloadManager {
    /// Base download directory.
    download_dir: PathBuf,
}

impl DownloadManager {
    /// Create a new download manager.
    pub fn new() -> Self {
        // Use temp directory by default
        let download_dir = std::env::temp_dir().join("viewpoint-downloads");
        Self { download_dir }
    }

    /// Get the download directory.
    pub fn download_dir(&self) -> &Path {
        &self.download_dir
    }
}

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