shard-core 2.2.1

Core library for shard distributed VCS: chunking, compression, commits, branching, merging, WAL
Documentation
use anyhow::Result;
use std::collections::BTreeMap;
use std::path::Path;

const ENV_PREFIX: &str = "SHARD_";

pub fn env_to_config_key(env_name: &str) -> Option<String> {
    let rest = env_name.strip_prefix(ENV_PREFIX)?;
    Some(rest.to_lowercase().replace('_', "."))
}

pub fn load_env_overrides() -> BTreeMap<String, String> {
    let mut overrides = BTreeMap::new();
    for (key, value) in std::env::vars() {
        if let Some(config_key) = env_to_config_key(&key) {
            overrides.insert(config_key, value);
        }
    }
    overrides
}

pub fn load_config_env(shard_dir: &Path) -> Result<()> {
    let config_path = shard_dir.join("config.json");
    let mut config: BTreeMap<String, String> = if config_path.exists() {
        let data = std::fs::read(&config_path)?;
        serde_json::from_slice(&data)?
    } else {
        BTreeMap::new()
    };
    let overrides = load_env_overrides();
    let mut changed = false;
    for (k, v) in overrides {
        if config.get(&k).map(|s| s.as_str()) != Some(v.as_str()) {
            config.insert(k, v);
            changed = true;
        }
    }
    if changed {
        let data = serde_json::to_vec_pretty(&config)?;
        std::fs::write(&config_path, data)?;
    }
    Ok(())
}

pub fn validate_config(config: &BTreeMap<String, String>) -> Vec<String> {
    let mut errors = Vec::new();
    if let Some(backend) = config.get("storage_backend") {
        match backend.as_str() {
            "flat" | "sled" | "sqlite" => {}
            _ => errors.push(format!("Unknown storage_backend: {}", backend)),
        }
    }
    if let Some(compression) = config.get("compression") {
        match compression.as_str() {
            "none" | "zstd" | "gzip" => {}
            s if s.starts_with("zstd(") => {}
            _ => errors.push(format!("Unknown compression: {}", compression)),
        }
    }
    if let Some(chunker) = config.get("chunker_mode") {
        match chunker.as_str() {
            "fixed" | "rabin" => {}
            _ => errors.push(format!("Unknown chunker_mode: {}", chunker)),
        }
    }
    if let Some(cs) = config.get("chunk_size") {
        if cs.parse::<u64>().is_err() || cs.parse::<u64>().unwrap_or(0) == 0 {
            errors.push(format!("Invalid chunk_size: {}", cs));
        }
    }
    if let Some(rate) = config.get("rate_limit_max_requests") {
        if rate.parse::<u32>().is_err() {
            errors.push(format!("Invalid rate_limit_max_requests: {}", rate));
        }
    }
    if let Some(interval) = config.get("rate_limit_window_secs") {
        if interval.parse::<u64>().is_err() {
            errors.push(format!("Invalid rate_limit_window_secs: {}", interval));
        }
    }
    if let Some(gc) = config.get("gc_interval_secs") {
        if gc.parse::<u64>().is_err() {
            errors.push(format!("Invalid gc_interval_secs: {}", gc));
        }
    }
    errors
}

pub fn config_get_rate_limit_max(config: &BTreeMap<String, String>) -> u32 {
    config
        .get("rate_limit_max_requests")
        .and_then(|s| s.parse().ok())
        .unwrap_or(50)
}

pub fn config_get_rate_limit_window(config: &BTreeMap<String, String>) -> u64 {
    config
        .get("rate_limit_window_secs")
        .and_then(|s| s.parse().ok())
        .unwrap_or(60)
}

pub fn config_get_gc_interval(config: &BTreeMap<String, String>) -> u64 {
    config
        .get("gc_interval_secs")
        .and_then(|s| s.parse().ok())
        .unwrap_or(3600)
}

pub fn config_get_gc_enabled(config: &BTreeMap<String, String>) -> bool {
    config
        .get("gc_enabled")
        .map(|s| s == "true")
        .unwrap_or(false)
}

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

    #[test]
    fn test_env_to_config_key() {
        assert_eq!(
            env_to_config_key("SHARD_STORAGE_BACKEND"),
            Some("storage.backend".to_string())
        );
        assert_eq!(
            env_to_config_key("SHARD_COMPRESSION"),
            Some("compression".to_string())
        );
        assert_eq!(env_to_config_key("NOT_SHARD"), None);
    }

    #[test]
    fn test_validate_config_valid() {
        let mut config = BTreeMap::new();
        config.insert("storage_backend".to_string(), "flat".to_string());
        config.insert("compression".to_string(), "zstd".to_string());
        config.insert("chunker_mode".to_string(), "fixed".to_string());
        config.insert("chunk_size".to_string(), "4194304".to_string());
        assert!(validate_config(&config).is_empty());
    }

    #[test]
    fn test_validate_config_invalid() {
        let mut config = BTreeMap::new();
        config.insert("storage_backend".to_string(), "unknown".to_string());
        config.insert("compression".to_string(), "bad".to_string());
        config.insert("chunk_size".to_string(), "not_a_number".to_string());
        let errors = validate_config(&config);
        assert!(!errors.is_empty());
        assert!(errors.iter().any(|e| e.contains("unknown")));
    }

    #[test]
    fn test_config_defaults() {
        let config = BTreeMap::new();
        assert_eq!(config_get_rate_limit_max(&config), 50);
        assert_eq!(config_get_rate_limit_window(&config), 60);
        assert_eq!(config_get_gc_interval(&config), 3600);
        assert!(!config_get_gc_enabled(&config));
    }

    #[test]
    fn test_config_custom() {
        let mut config = BTreeMap::new();
        config.insert("rate_limit_max_requests".to_string(), "100".to_string());
        config.insert("rate_limit_window_secs".to_string(), "30".to_string());
        config.insert("gc_enabled".to_string(), "true".to_string());
        assert_eq!(config_get_rate_limit_max(&config), 100);
        assert_eq!(config_get_rate_limit_window(&config), 30);
        assert!(config_get_gc_enabled(&config));
    }
}