cloud_sync_lib 0.1.0

Cloud storage provider synchronization library
//! Dropbox storage backend provider implementation.
//!
//! Handles interaction with the Dropbox v2 REST API. Supports full OAuth2-based
//! upload, download, delete, and list operations, with custom prefix path resolution.

use crate::traits::{StorageBackend, StorageError, StorageItem};
use crate::providers::OAuthCredentials;
use crate::providers::utils::translate_http_error;
use async_trait::async_trait;
use std::path::{Path, PathBuf};
use tracing::info;

/// Storage provider client for Dropbox REST API.
pub struct DropboxProvider {
    /// The HTTP client for making API requests.
    client: reqwest::Client,
    /// Credentials configuration (client id/secret, refresh token).
    credentials: OAuthCredentials,
    /// The base API URL.
    api_url: String,
    /// The base content API URL.
    content_url: String,
    /// Shared OAuth token manager.
    token_manager: std::sync::Arc<super::utils::OAuthTokenManager>,
    /// Optional upload rate limiter.
    upload_limiter: Option<crate::rate_limit::TokenBucket>,
    /// Optional download rate limiter.
    download_limiter: Option<crate::rate_limit::TokenBucket>,
}

crate::impl_provider_builder!(DropboxProvider, DropboxProviderBuilder, OAuthCredentials, absolute);
crate::impl_oauth_token_helper!(DropboxProvider);

impl DropboxProvider {

    /// Creates a new `DropboxProvider` with custom HTTP client options.
    pub fn with_client_options(
        credentials: OAuthCredentials,
        timeout: Option<std::time::Duration>,
        custom_headers: Option<reqwest::header::HeaderMap>,
    ) -> Self {
        let client = super::utils::build_http_client(timeout, custom_headers);
        let auth_url = "https://api.dropbox.com/oauth2/token".to_string();
        let token_manager = std::sync::Arc::new(super::utils::OAuthTokenManager::new(
            client.clone(),
            &auth_url,
            &credentials.client_id,
            &credentials.client_secret,
            &credentials.refresh_token,
            "Dropbox",
        ));
        Self {
            client,
            credentials,
            api_url: "https://api.dropboxapi.com/2/files".to_string(),
            content_url: "https://content.dropboxapi.com/2/files".to_string(),
            token_manager,
            upload_limiter: None,
            download_limiter: None,
        }
    }

    /// Sets the upload and download rate limiters.
    pub fn with_limiters(
        mut self,
        upload_limiter: Option<crate::rate_limit::TokenBucket>,
        download_limiter: Option<crate::rate_limit::TokenBucket>,
    ) -> Self {
        if self.upload_limiter.is_none() {
            self.upload_limiter = upload_limiter;
        }
        if self.download_limiter.is_none() {
            self.download_limiter = download_limiter;
        }
        self
    }

    /// Configures custom endpoints, useful for mocking during tests.
    ///
    /// # Arguments
    /// * `auth_url` - Custom authorization URL.
    /// * `api_url` - Custom API URL.
    /// * `content_url` - Custom content API URL.
    ///
    /// # Returns
    /// The modified `DropboxProvider` instance.
    #[cfg(test)]
    pub fn with_endpoints(mut self, auth_url: String, api_url: String, content_url: String) -> Self {
        self.api_url = api_url;
        self.content_url = content_url;
        self.token_manager = std::sync::Arc::new(super::utils::OAuthTokenManager::new(
            self.client.clone(),
            &auth_url,
            &self.credentials.client_id,
            &self.credentials.client_secret,
            &self.credentials.refresh_token,
            "Dropbox",
        ));
        self
    }



}

#[async_trait]
impl StorageBackend for DropboxProvider {
    fn name(&self) -> &str {
        "Dropbox"
    }


    async fn upload(&self, local_path: &Path, remote_path: &str) -> Result<(), StorageError> {
        super::utils::execute_with_retry(self.name(), "upload", || async {
            let token = self.get_access_token().await?;
            let dbx_path = self.format_path(remote_path);

            info!("[{}] Real upload starting for '{}'", self.name(), dbx_path);
            let (body, size) = super::utils::get_upload_body(local_path, self.upload_limiter.clone()).await?;

            let client_modified = std::fs::metadata(local_path)
                .and_then(|m| m.modified())
                .ok()
                .map(|t| {
                    let datetime = time::OffsetDateTime::from(t);
                    format!(
                        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
                        datetime.year(),
                        datetime.month() as u8,
                        datetime.day(),
                        datetime.hour(),
                        datetime.minute(),
                        datetime.second()
                    )
                });

            let mut api_arg = serde_json::json!({
                "path": dbx_path,
                "mode": "overwrite",
                "autorename": false,
                "mute": false,
                "strict_conflict": false
            });

            if let Some(ref cm) = client_modified {
                if let Some(obj) = api_arg.as_object_mut() {
                    obj.insert("client_modified".to_string(), serde_json::Value::String(cm.clone()));
                }
            }

            let upload_url = format!("{}/upload", self.content_url);
            let res = super::utils::apply_bearer_auth(self.client.post(&upload_url), &token)
                .header("Dropbox-API-Arg", serde_json::to_string(&api_arg).unwrap())
                .header("Content-Type", "application/octet-stream")
                .header("Content-Length", size.to_string())
                .body(body)
                .send()
                .await?;

            if !res.status().is_success() {
                return Err(translate_http_error(res, self.name(), "upload").await);
            }

            Ok(())
        }).await
    }

    async fn download(&self, remote_path: &str, local_path: &Path) -> Result<(), StorageError> {
        super::utils::execute_with_retry(self.name(), "download", || async {
            let token = self.get_access_token().await?;
            let dbx_path = self.format_path(remote_path);

            let api_arg = serde_json::json!({
                "path": dbx_path
            });

            let download_url = format!("{}/download", self.content_url);
            let res = super::utils::apply_bearer_auth(self.client.post(&download_url), &token)
                .header("Dropbox-API-Arg", serde_json::to_string(&api_arg).unwrap())
                .header("Content-Type", "")
                .send()
                .await?;

            if !res.status().is_success() {
                return Err(translate_http_error(res, self.name(), "download").await);
            }

            super::utils::download_rate_limited(res, local_path, self.download_limiter.clone()).await?;
            Ok(())
        }).await
    }

    async fn delete(&self, remote_path: &str) -> Result<(), StorageError> {
        super::utils::execute_with_retry(self.name(), "delete", || async {
            let token = self.get_access_token().await?;
            let dbx_path = self.format_path(remote_path);

            let body = serde_json::json!({
                "path": dbx_path
            });

            let delete_url = format!("{}/delete_v2", self.api_url);
            let res = super::utils::apply_bearer_auth(self.client.post(&delete_url), &token)
                .json(&body)
                .send()
                .await?;

            if !res.status().is_success() {
                return Err(translate_http_error(res, self.name(), "delete").await);
            }

            Ok(())
        }).await
    }

    /// Creates a directory folder on Dropbox.
    ///
    /// # Arguments
    /// * `remote_path` - The folder path relative to the sync root.
    async fn create_folder(&self, remote_path: &str) -> Result<(), StorageError> {
        super::utils::execute_with_retry(self.name(), "create_folder", || async {
            let token = self.get_access_token().await?;
            let dbx_path = self.format_path(remote_path);

            let body = serde_json::json!({
                "path": dbx_path,
                "autorename": false
            });

            let create_url = format!("{}/create_folder_v2", self.api_url);
            let res = super::utils::apply_bearer_auth(self.client.post(&create_url), &token)
                .json(&body)
                .send()
                .await?;

            let status = res.status();
            if !status.is_success() {
                let err_text = res.text().await.unwrap_or_default();
                if err_text.contains("path/conflict") {
                    return Ok(());
                }
                return Err(StorageError::Provider { message: format!("Failed to create folder: {}", err_text), status: Some(status.as_u16()) });
            }

            Ok(())
        }).await
    }

    async fn list(&self, remote_path: &str) -> Result<Vec<StorageItem>, StorageError> {
        super::utils::execute_with_retry(self.name(), "list", || async {
            let token = self.get_access_token().await?;
            let dbx_path = self.format_path(remote_path);

            let mut items = Vec::new();
            let mut cursor: Option<String> = None;

            loop {
                let res = if let Some(ref cur) = cursor {
                    let body = serde_json::json!({
                        "cursor": cur
                    });
                    let continue_url = format!("{}/list_folder/continue", self.api_url);
                    super::utils::apply_bearer_auth(self.client.post(&continue_url), &token)
                        .json(&body)
                        .send()
                        .await?
                        .json::<serde_json::Value>()
                        .await?
                } else {
                    let body = serde_json::json!({
                        "path": dbx_path,
                        "recursive": false
                    });
                    let list_url = format!("{}/list_folder", self.api_url);
                    super::utils::apply_bearer_auth(self.client.post(&list_url), &token)
                        .json(&body)
                        .send()
                        .await?
                        .json::<serde_json::Value>()
                        .await?
                };

                if let Some(entries) = res["entries"].as_array() {
                    for entry in entries {
                        let name = entry["name"].as_str().unwrap_or("").to_string();
                        let size = entry["size"].as_u64().unwrap_or(0);
                        let tag = entry[".tag"].as_str().unwrap_or("");
                        let is_dir = tag == "folder";
                        let checksum = entry["content_hash"].as_str().map(|s| s.to_string());

                        let modified = entry["client_modified"].as_str()
                            .and_then(|t| time::OffsetDateTime::parse(t, &time::format_description::well_known::Rfc3339).ok())
                            .map(std::time::SystemTime::from)
                            .unwrap_or_else(std::time::SystemTime::now);

                        let rel_path = if remote_path.is_empty() {
                            name
                        } else {
                            format!("{}/{}", remote_path, name)
                        };

                        items.push(StorageItem {
                            path: PathBuf::from(rel_path),
                            size,
                            modified,
                            is_dir,
                            checksum,
                            permissions: None,
                });
                    }
                }

                if res["has_more"].as_bool().unwrap_or(false) {
                    cursor = res["cursor"].as_str().map(|s| s.to_string());
                } else {
                    break;
                }
            }

            Ok(items)
        }).await
    }

    async fn compute_local_checksum(&self, local_path: &Path) -> Result<Option<String>, StorageError> {
        Ok(crate::checksum::compute_dropbox_hash(local_path).await.ok())
    }
}



/// Builder for [`DropboxProvider`].
pub struct DropboxProviderBuilder {
    pub credentials: OAuthCredentials,
    pub timeout: Option<std::time::Duration>,
    pub custom_headers: Option<reqwest::header::HeaderMap>,
}

impl DropboxProviderBuilder {
    /// Creates a new builder with the required credentials.
    pub fn new(credentials: OAuthCredentials) -> Self {
        Self {
            credentials,
            timeout: None,
            custom_headers: None,
        }
    }

    /// Builds the provider.
    pub fn build(self) -> DropboxProvider {
        DropboxProvider::with_client_options(self.credentials, self.timeout, self.custom_headers)
    }
}