batata-client 0.0.2

Rust client for Batata/Nacos service discovery and configuration management
Documentation
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};

use tracing::{debug, warn};

use crate::api::config::ConfigInfo;
use crate::api::naming::Service;
use crate::common::{build_config_key, build_service_key, md5_hash};

/// File-based cache for failover
pub struct FileCache {
    cache_dir: PathBuf,
}

impl FileCache {
    /// Create a new file cache
    pub fn new(cache_dir: impl AsRef<Path>) -> std::io::Result<Self> {
        let cache_dir = cache_dir.as_ref().to_path_buf();
        fs::create_dir_all(&cache_dir)?;
        fs::create_dir_all(cache_dir.join("config"))?;
        fs::create_dir_all(cache_dir.join("naming"))?;
        Ok(Self { cache_dir })
    }

    /// Get the config cache directory
    fn config_dir(&self) -> PathBuf {
        self.cache_dir.join("config")
    }

    /// Get the naming cache directory
    fn naming_dir(&self) -> PathBuf {
        self.cache_dir.join("naming")
    }

    /// Encode a key for use as a filename
    fn encode_key(key: &str) -> String {
        // Use MD5 hash to avoid issues with special characters
        md5_hash(key)
    }

    // ==================== Config Cache ====================

    /// Save configuration to file cache
    pub fn save_config(&self, config: &ConfigInfo) -> std::io::Result<()> {
        let key = build_config_key(&config.data_id, &config.group, &config.tenant);
        let filename = Self::encode_key(&key);
        let path = self.config_dir().join(&filename);

        let json = serde_json::to_string(&ConfigCacheEntry {
            data_id: config.data_id.clone(),
            group: config.group.clone(),
            tenant: config.tenant.clone(),
            content: config.content.clone(),
            md5: config.md5.clone(),
            last_modified: config.last_modified,
            content_type: config.content_type.clone(),
        })
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;

        let mut file = fs::File::create(&path)?;
        file.write_all(json.as_bytes())?;
        debug!("Saved config to cache: {}", key);
        Ok(())
    }

    /// Load configuration from file cache
    pub fn load_config(&self, data_id: &str, group: &str, tenant: &str) -> Option<ConfigInfo> {
        let key = build_config_key(data_id, group, tenant);
        let filename = Self::encode_key(&key);
        let path = self.config_dir().join(&filename);

        if !path.exists() {
            return None;
        }

        match fs::File::open(&path) {
            Ok(mut file) => {
                let mut contents = String::new();
                if file.read_to_string(&mut contents).is_err() {
                    return None;
                }

                match serde_json::from_str::<ConfigCacheEntry>(&contents) {
                    Ok(entry) => {
                        debug!("Loaded config from cache: {}", key);
                        Some(ConfigInfo {
                            data_id: entry.data_id,
                            group: entry.group,
                            tenant: entry.tenant,
                            content: entry.content,
                            md5: entry.md5,
                            last_modified: entry.last_modified,
                            content_type: entry.content_type,
                        })
                    }
                    Err(e) => {
                        warn!("Failed to parse cached config {}: {}", key, e);
                        None
                    }
                }
            }
            Err(e) => {
                warn!("Failed to read cached config {}: {}", key, e);
                None
            }
        }
    }

    /// Remove configuration from file cache
    pub fn remove_config(&self, data_id: &str, group: &str, tenant: &str) -> std::io::Result<()> {
        let key = build_config_key(data_id, group, tenant);
        let filename = Self::encode_key(&key);
        let path = self.config_dir().join(&filename);

        if path.exists() {
            fs::remove_file(&path)?;
            debug!("Removed config from cache: {}", key);
        }
        Ok(())
    }

    // ==================== Naming Cache ====================

    /// Save service info to file cache
    pub fn save_service(&self, namespace: &str, service: &Service) -> std::io::Result<()> {
        let key = build_service_key(&service.name, &service.group_name, namespace);
        let filename = Self::encode_key(&key);
        let path = self.naming_dir().join(&filename);

        let json = serde_json::to_string(service)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;

        let mut file = fs::File::create(&path)?;
        file.write_all(json.as_bytes())?;
        debug!("Saved service to cache: {}", key);
        Ok(())
    }

    /// Load service info from file cache
    pub fn load_service(
        &self,
        namespace: &str,
        group_name: &str,
        service_name: &str,
    ) -> Option<Service> {
        let key = build_service_key(service_name, group_name, namespace);
        let filename = Self::encode_key(&key);
        let path = self.naming_dir().join(&filename);

        if !path.exists() {
            return None;
        }

        match fs::File::open(&path) {
            Ok(mut file) => {
                let mut contents = String::new();
                if file.read_to_string(&mut contents).is_err() {
                    return None;
                }

                match serde_json::from_str::<Service>(&contents) {
                    Ok(service) => {
                        debug!("Loaded service from cache: {}", key);
                        Some(service)
                    }
                    Err(e) => {
                        warn!("Failed to parse cached service {}: {}", key, e);
                        None
                    }
                }
            }
            Err(e) => {
                warn!("Failed to read cached service {}: {}", key, e);
                None
            }
        }
    }

    /// Remove service info from file cache
    pub fn remove_service(
        &self,
        namespace: &str,
        group_name: &str,
        service_name: &str,
    ) -> std::io::Result<()> {
        let key = build_service_key(service_name, group_name, namespace);
        let filename = Self::encode_key(&key);
        let path = self.naming_dir().join(&filename);

        if path.exists() {
            fs::remove_file(&path)?;
            debug!("Removed service from cache: {}", key);
        }
        Ok(())
    }

    /// Clear all cached data
    pub fn clear(&self) -> std::io::Result<()> {
        // Clear config cache
        for entry in fs::read_dir(self.config_dir())?.flatten() {
            fs::remove_file(entry.path())?;
        }

        // Clear naming cache
        for entry in fs::read_dir(self.naming_dir())?.flatten() {
            fs::remove_file(entry.path())?;
        }

        debug!("Cleared file cache");
        Ok(())
    }
}

/// Cache entry for configuration
#[derive(serde::Serialize, serde::Deserialize)]
struct ConfigCacheEntry {
    data_id: String,
    group: String,
    tenant: String,
    content: String,
    md5: String,
    last_modified: i64,
    content_type: String,
}

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

    #[test]
    fn test_config_cache() {
        let dir = tempdir().unwrap();
        let cache = FileCache::new(dir.path()).unwrap();

        let mut config = ConfigInfo::new("test-data-id", "test-group", "test-tenant");
        config.content = "test content".to_string();
        config.md5 = md5_hash(&config.content);

        // Save
        cache.save_config(&config).unwrap();

        // Load
        let loaded = cache
            .load_config("test-data-id", "test-group", "test-tenant")
            .unwrap();
        assert_eq!(loaded.content, "test content");

        // Remove
        cache
            .remove_config("test-data-id", "test-group", "test-tenant")
            .unwrap();
        assert!(cache
            .load_config("test-data-id", "test-group", "test-tenant")
            .is_none());
    }

    #[test]
    fn test_service_cache() {
        let dir = tempdir().unwrap();
        let cache = FileCache::new(dir.path()).unwrap();

        let service = Service::new("test-service", "test-group");

        // Save
        cache.save_service("test-namespace", &service).unwrap();

        // Load
        let loaded = cache
            .load_service("test-namespace", "test-group", "test-service")
            .unwrap();
        assert_eq!(loaded.name, "test-service");

        // Remove
        cache
            .remove_service("test-namespace", "test-group", "test-service")
            .unwrap();
        assert!(cache
            .load_service("test-namespace", "test-group", "test-service")
            .is_none());
    }
}