enact-config 0.0.2

Unified configuration management for Enact - secure storage with keychain and encrypted files
Documentation
//! Cloud Sync - Automatic configuration synchronization
//!
//! Syncs configuration between local and cloud for authenticated users.
//! Respects air-gapped mode (no sync when runtime.mode === "airgapped").

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};

use crate::config::{Config, RuntimeMode};

/// Sync status
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncStatus {
    /// Sync is enabled and active
    Enabled,
    /// Sync is disabled (air-gapped mode or not authenticated)
    Disabled,
    /// Sync is in progress
    Syncing,
    /// Sync failed
    Failed,
}

/// Cloud sync manager
pub struct SyncManager {
    api_url: Option<String>,
    tenant_id: Option<String>,
    auto_sync: bool,
    runtime_mode: RuntimeMode,
}

#[derive(Debug, Serialize, Deserialize)]
struct SyncRequest {
    tenant_id: String,
    config: Config,
    timestamp: i64,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SyncResponse {
    pub config: Config,
    pub timestamp: i64,
    pub conflicts: Vec<String>,
}

impl SyncManager {
    /// Create a new sync manager
    pub fn new(
        api_url: Option<String>,
        tenant_id: Option<String>,
        auto_sync: bool,
        runtime_mode: RuntimeMode,
    ) -> Self {
        Self {
            api_url,
            tenant_id,
            auto_sync,
            runtime_mode,
        }
    }

    /// Check if sync is enabled
    ///
    /// Sync is enabled only if:
    /// - auto_sync is true
    /// - runtime_mode is not "airgapped"
    /// - api_url and tenant_id are set
    pub fn is_enabled(&self) -> bool {
        if !self.auto_sync {
            return false;
        }

        if matches!(self.runtime_mode, RuntimeMode::AirGapped) {
            debug!("Sync disabled: air-gapped mode");
            return false;
        }

        if self.api_url.is_none() || self.tenant_id.is_none() {
            debug!("Sync disabled: missing API URL or tenant ID");
            return false;
        }

        true
    }

    /// Get sync status
    pub fn status(&self) -> SyncStatus {
        if self.is_enabled() {
            SyncStatus::Enabled
        } else {
            SyncStatus::Disabled
        }
    }

    /// Sync configuration to cloud
    ///
    /// # Arguments
    /// * `config` - The configuration to sync
    ///
    /// # Returns
    /// * `Ok(Some(response))` if sync was successful
    /// * `Ok(None)` if sync is disabled
    /// * `Err` if there was an error syncing
    pub async fn sync_to_cloud(&self, config: &Config) -> Result<Option<SyncResponse>> {
        if !self.is_enabled() {
            return Ok(None);
        }

        let api_url = self.api_url.as_ref().unwrap();
        let tenant_id = self.tenant_id.as_ref().unwrap();

        let sync_request = SyncRequest {
            tenant_id: tenant_id.clone(),
            config: config.clone(),
            timestamp: chrono::Utc::now().timestamp(),
        };

        let client = reqwest::Client::new();
        let url = format!("{}/api/v1/config/sync", api_url);

        debug!("Syncing configuration to cloud: {}", url);

        let response = client
            .post(&url)
            .json(&sync_request)
            .send()
            .await
            .context("Failed to send sync request")?;

        if !response.status().is_success() {
            let status = response.status();
            let error_text = response.text().await.unwrap_or_default();
            return Err(anyhow::anyhow!(
                "Sync failed with status {}: {}",
                status,
                error_text
            ));
        }

        let sync_response: SyncResponse = response
            .json()
            .await
            .context("Failed to parse sync response")?;

        if !sync_response.conflicts.is_empty() {
            warn!(
                "Configuration conflicts detected: {:?}",
                sync_response.conflicts
            );
        }

        debug!("Configuration synced successfully");
        Ok(Some(sync_response))
    }

    /// Sync configuration from cloud
    ///
    /// # Returns
    /// * `Ok(Some(config))` if sync was successful
    /// * `Ok(None)` if sync is disabled
    /// * `Err` if there was an error syncing
    pub async fn sync_from_cloud(&self) -> Result<Option<Config>> {
        if !self.is_enabled() {
            return Ok(None);
        }

        let api_url = self.api_url.as_ref().unwrap();
        let tenant_id = self.tenant_id.as_ref().unwrap();

        let client = reqwest::Client::new();
        let url = format!("{}/api/v1/config/sync?tenant_id={}", api_url, tenant_id);

        debug!("Syncing configuration from cloud: {}", url);

        let response = client
            .get(&url)
            .send()
            .await
            .context("Failed to send sync request")?;

        if !response.status().is_success() {
            let status = response.status();
            let error_text = response.text().await.unwrap_or_default();
            return Err(anyhow::anyhow!(
                "Sync failed with status {}: {}",
                status,
                error_text
            ));
        }

        let sync_response: SyncResponse = response
            .json()
            .await
            .context("Failed to parse sync response")?;

        debug!("Configuration synced from cloud successfully");
        Ok(Some(sync_response.config))
    }

    /// Resolve conflicts using last-write-wins strategy
    ///
    /// # Arguments
    /// * `local_config` - Local configuration
    /// * `cloud_config` - Cloud configuration
    /// * `conflicts` - List of conflicting keys
    ///
    /// # Returns
    /// * Merged configuration with conflicts resolved
    pub fn resolve_conflicts(
        &self,
        _local_config: &Config,
        cloud_config: &Config,
        conflicts: &[String],
    ) -> Config {
        // For now, use last-write-wins (cloud wins)
        // In the future, this could be more sophisticated
        warn!(
            "Resolving {} conflicts using last-write-wins (cloud wins)",
            conflicts.len()
        );
        cloud_config.clone()
    }
}

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

    #[test]
    fn test_sync_enabled() {
        let manager = SyncManager::new(
            Some("https://api.example.com".to_string()),
            Some("tenant-123".to_string()),
            true,
            RuntimeMode::Local,
        );
        assert!(manager.is_enabled());
        assert_eq!(manager.status(), SyncStatus::Enabled);
    }

    #[test]
    fn test_sync_disabled_airgapped() {
        let manager = SyncManager::new(
            Some("https://api.example.com".to_string()),
            Some("tenant-123".to_string()),
            true,
            RuntimeMode::AirGapped,
        );
        assert!(!manager.is_enabled());
        assert_eq!(manager.status(), SyncStatus::Disabled);
    }

    #[test]
    fn test_sync_disabled_no_auth() {
        let manager = SyncManager::new(None, None, true, RuntimeMode::Local);
        assert!(!manager.is_enabled());
        assert_eq!(manager.status(), SyncStatus::Disabled);
    }
}