rsclaw 0.0.1-alpha.1

rsclaw: High-performance AI agent (BETA). Optimized for M4 Max and 2GB VPS. 100% compatible with openclaw
Documentation
use super::runtime::RuntimeConfig;
use super::schema_openclaw::OpenClawConfig;
use super::schema_rsclaw::RsclawConfig;
use anyhow::{Context, Result};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;

/// Configuration loader supporting dual formats (TOML/JSON5).
pub struct ConfigLoader;

impl ConfigLoader {
    /// Load configuration with priority: rsclaw.toml > openclaw.json > defaults.
    pub fn load() -> Result<RuntimeConfig> {
        let rsclaw_path = Self::get_rsclaw_path()?;
        let openclaw_path = Self::get_openclaw_path()?;

        if rsclaw_path.exists() {
            tracing::info!("Loading config from {:?}", rsclaw_path);
            return Self::load_toml(&rsclaw_path);
        }

        if openclaw_path.exists() {
            tracing::info!("Loading config from {:?}", openclaw_path);
            return Self::load_openclaw(&openclaw_path);
        }

        tracing::info!("No config file found, using defaults");
        Ok(RuntimeConfig::default())
    }

    /// Get rsclaw config file path.
    fn get_rsclaw_path() -> Result<PathBuf> {
        if let Ok(custom) = env::var("RSCLAW_CONFIG_PATH") {
            return Ok(PathBuf::from(custom));
        }

        let home = dirs::home_dir().context("Cannot determine home directory")?;
        Ok(home.join(".rsclaw").join("rsclaw.toml"))
    }

    /// Get openclaw config file path.
    fn get_openclaw_path() -> Result<PathBuf> {
        if let Ok(custom) = env::var("OPENCLAW_CONFIG_PATH") {
            return Ok(PathBuf::from(custom));
        }

        let home = dirs::home_dir().context("Cannot determine home directory")?;
        Ok(home.join(".openclaw").join("openclaw.json"))
    }

    /// Load TOML configuration.
    pub fn load_toml(path: &Path) -> Result<RuntimeConfig> {
        let content = fs::read_to_string(path)
            .with_context(|| format!("Failed to read config file: {:?}", path))?;

        let processed = Self::process_env_vars(&content);
        let config: RsclawConfig = toml::from_str(&processed)
            .with_context(|| format!("Failed to parse TOML config: {:?}", path))?;

        Ok(Self::rsclaw_to_runtime(config))
    }

    /// Load OpenClaw JSON5 configuration.
    pub fn load_openclaw(path: &Path) -> Result<RuntimeConfig> {
        let content = fs::read_to_string(path)
            .with_context(|| format!("Failed to read config file: {:?}", path))?;

        let processed = Self::process_includes(&content, path.parent().unwrap_or(Path::new(".")))?;
        let processed = Self::process_env_vars(&processed);

        let config: OpenClawConfig = serde_json::from_str(&processed)
            .with_context(|| format!("Failed to parse OpenClaw config: {:?}", path))?;

        Ok(Self::openclaw_to_runtime(config))
    }

    /// Load JSON5 configuration (alias for load_openclaw).
    pub fn load_json5(path: &Path) -> Result<RuntimeConfig> {
        Self::load_openclaw(path)
    }

    /// Process ${VAR} environment variable substitution.
    fn process_env_vars(content: &str) -> String {
        let re = regex::Regex::new(r"\$\{([^}]+)\}").unwrap();
        let mut result = content.to_string();

        for cap in re.captures_iter(content) {
            let var_name = &cap[1];
            let replacement = env::var(var_name).unwrap_or_else(|_| {
                tracing::warn!("Environment variable {} not defined", var_name);
                cap[0].to_string()
            });
            result = result.replace(&cap[0], &replacement);
        }

        result
    }

    /// Process $include directives for config splitting.
    fn process_includes(content: &str, base_dir: &Path) -> Result<String> {
        let re = regex::Regex::new(r#""\$include"\s*:\s*"([^"]+)""#).unwrap();
        let mut result = content.to_string();

        for cap in re.captures_iter(content) {
            let include_path = &cap[1];
            let full_path = base_dir.join(include_path);

            let include_content = fs::read_to_string(&full_path)
                .with_context(|| format!("Failed to include config file: {:?}", full_path))?;

            result = result.replace(&cap[0], &include_content);
        }

        Ok(result)
    }

    /// Convert string to Arc<str>.
    fn to_arc(s: Option<String>) -> Option<Arc<str>> {
        s.map(|s| Arc::from(s.as_str()))
    }

    /// Convert OpenClaw config to runtime config.
    fn openclaw_to_runtime(config: OpenClawConfig) -> RuntimeConfig {
        let providers = config.get_providers();
        let agents = config.get_agents();

        RuntimeConfig {
            meta: RuntimeConfig::default().meta,
            gateway: super::runtime::GatewayConfig {
                host: Arc::from(config.gateway_host().as_str()),
                port: config.gateway_port(),
            },
            agents: super::runtime::AgentsConfig {
                list: agents
                    .into_iter()
                    .map(|a| super::runtime::AgentEntry {
                        name: Arc::from(a.name.as_str()),
                        model: Self::to_arc(a.model),
                        system_prompt: Self::to_arc(a.system_prompt),
                        tools: a
                            .tools
                            .map(|t| t.into_iter().map(|s| Arc::from(s.as_str())).collect()),
                        channels: a
                            .channels
                            .map(|c| c.into_iter().map(|s| Arc::from(s.as_str())).collect()),
                        default: a.default,
                        max_tokens: a.max_tokens,
                        memory_limit_mb: a.memory_limit_mb,
                    })
                    .collect(),
            },
            models: super::runtime::ModelsConfig {
                providers: providers
                    .into_iter()
                    .map(|p| super::runtime::ProviderConfig {
                        name: Arc::from(p.name.as_str()),
                        provider_type: Arc::from(p.provider_type.as_str()),
                        api_key: Self::to_arc(p.api_key),
                        base_url: Self::to_arc(p.base_url),
                        models: p
                            .models
                            .into_iter()
                            .map(|m| super::runtime::ModelEntry {
                                name: Arc::from(m.name.as_str()),
                                max_tokens: m.max_tokens,
                                supports_functions: m.supports_functions,
                                supports_vision: m.supports_vision,
                            })
                            .collect(),
                    })
                    .collect(),
                primary_model: None,
            },
            ..RuntimeConfig::default()
        }
    }

    /// Convert rsclaw TOML config to runtime config.
    fn rsclaw_to_runtime(config: RsclawConfig) -> RuntimeConfig {
        RuntimeConfig {
            meta: config
                .meta
                .map(|m| super::runtime::MetaConfig {
                    name: Self::to_arc(m.name).unwrap_or_else(|| Arc::from("rsclaw")),
                    version: Self::to_arc(m.version)
                        .unwrap_or_else(|| Arc::from(env!("CARGO_PKG_VERSION"))),
                    description: Self::to_arc(m.description).unwrap_or_else(|| Arc::from("")),
                })
                .unwrap_or_default(),
            gateway: config
                .gateway
                .map(|g| super::runtime::GatewayConfig {
                    host: Self::to_arc(g.host).unwrap_or_else(|| Arc::from("127.0.0.1")),
                    port: g.port.unwrap_or(8080),
                })
                .unwrap_or_default(),
            agents: config
                .agents
                .map(|a| super::runtime::AgentsConfig {
                    list: a
                        .list
                        .into_iter()
                        .map(|agent| super::runtime::AgentEntry {
                            name: Arc::from(agent.name.as_str()),
                            model: Self::to_arc(agent.model),
                            system_prompt: Self::to_arc(agent.system_prompt),
                            tools: agent
                                .tools
                                .map(|t| t.into_iter().map(|s| Arc::from(s.as_str())).collect()),
                            channels: agent
                                .channels
                                .map(|c| c.into_iter().map(|s| Arc::from(s.as_str())).collect()),
                            default: agent.default,
                            max_tokens: agent.max_tokens,
                            memory_limit_mb: agent.memory_limit_mb,
                        })
                        .collect(),
                })
                .unwrap_or_default(),
            models: config
                .models
                .map(|m| super::runtime::ModelsConfig {
                    providers: m
                        .providers
                        .into_iter()
                        .map(|p| super::runtime::ProviderConfig {
                            name: Arc::from(p.name.as_str()),
                            provider_type: Arc::from(p.provider_type.as_str()),
                            api_key: Self::to_arc(p.api_key),
                            base_url: Self::to_arc(p.base_url),
                            models: p
                                .models
                                .into_iter()
                                .map(|model| super::runtime::ModelEntry {
                                    name: Arc::from(model.name.as_str()),
                                    max_tokens: model.max_tokens,
                                    supports_functions: model.supports_functions,
                                    supports_vision: model.supports_vision,
                                })
                                .collect(),
                        })
                        .collect(),
                    primary_model: None,
                })
                .unwrap_or_default(),
            auth: config
                .auth
                .map(|a| super::runtime::AuthConfig {
                    api_keys: a
                        .api_keys
                        .unwrap_or_default()
                        .into_iter()
                        .map(|(k, v)| (Arc::from(k.as_str()), Arc::from(v.as_str())))
                        .collect(),
                })
                .unwrap_or_default(),
            channels: config
                .channels
                .map(|c| super::runtime::ChannelsConfig {
                    telegram: c.telegram.map(|ch| super::runtime::ChannelConfig {
                        enabled: ch.enabled.unwrap_or(false),
                        token: Self::to_arc(ch.token),
                        webhook_url: Self::to_arc(ch.webhook_url),
                    }),
                    discord: c.discord.map(|ch| super::runtime::ChannelConfig {
                        enabled: ch.enabled.unwrap_or(false),
                        token: Self::to_arc(ch.token),
                        webhook_url: Self::to_arc(ch.webhook_url),
                    }),
                    slack: c.slack.map(|ch| super::runtime::ChannelConfig {
                        enabled: ch.enabled.unwrap_or(false),
                        token: Self::to_arc(ch.token),
                        webhook_url: Self::to_arc(ch.webhook_url),
                    }),
                    whatsapp: c.whatsapp.map(|ch| super::runtime::ChannelConfig {
                        enabled: ch.enabled.unwrap_or(false),
                        token: Self::to_arc(ch.token),
                        webhook_url: Self::to_arc(ch.webhook_url),
                    }),
                })
                .unwrap_or_default(),
            session: config
                .session
                .map(|s| super::runtime::SessionConfig {
                    timeout_minutes: s.timeout_minutes.unwrap_or(30),
                    max_history: s.max_history.unwrap_or(100),
                })
                .unwrap_or_default(),
            bindings: config
                .bindings
                .unwrap_or_default()
                .into_iter()
                .map(|b| super::runtime::BindingRule {
                    channel: Self::to_arc(b.channel),
                    agent: Arc::from(b.agent.as_str()),
                    peer_id: Self::to_arc(b.peer_id),
                    group_id: Self::to_arc(b.group_id),
                    path: Self::to_arc(b.path),
                    priority: b.priority.unwrap_or(0),
                })
                .collect(),
            cron: config
                .cron
                .map(|c| super::runtime::CronConfig {
                    enabled: c.enabled.unwrap_or(false),
                })
                .unwrap_or_default(),
            tools: config
                .tools
                .map(|t| super::runtime::ToolsConfig {
                    enabled: t
                        .enabled
                        .unwrap_or_default()
                        .into_iter()
                        .map(|s| Arc::from(s.as_str()))
                        .collect(),
                    web_search: t.web_search.map(|ws| super::runtime::WebSearchConfig {
                        engine: Self::to_arc(ws.engine).unwrap_or_else(|| Arc::from("google")),
                        api_key: Self::to_arc(ws.api_key),
                    }),
                })
                .unwrap_or_default(),
            sandbox: config
                .sandbox
                .map(|s| super::runtime::SandboxConfig {
                    enabled: s.enabled.unwrap_or(false),
                    timeout_seconds: s.timeout_seconds.unwrap_or(30),
                })
                .unwrap_or_default(),
            skills: config
                .skills
                .map(|s| super::runtime::SkillsConfig {
                    paths: s
                        .paths
                        .unwrap_or_default()
                        .into_iter()
                        .map(PathBuf::from)
                        .collect(),
                })
                .unwrap_or_default(),
            plugins: config
                .plugins
                .map(|p| super::runtime::PluginsConfig {
                    paths: p
                        .paths
                        .unwrap_or_default()
                        .into_iter()
                        .map(PathBuf::from)
                        .collect(),
                })
                .unwrap_or_default(),
            hooks: config
                .hooks
                .map(|h| super::runtime::HooksConfig {
                    enabled: h.enabled.unwrap_or(false),
                })
                .unwrap_or_default(),
            memory: config
                .memory
                .map(|m| super::runtime::MemoryConfig {
                    max_agent_memory_mb: m.max_agent_memory_mb.unwrap_or(512),
                    conversation_cache_size: m.conversation_cache_size.unwrap_or(100),
                    token_threshold: m.token_threshold.unwrap_or(1500),
                    max_concurrent_agents: m.max_concurrent_agents.unwrap_or(3),
                })
                .unwrap_or_default(),
        }
    }

    /// Initialize default configuration file.
    pub fn init_default_config(path: &Path) -> Result<()> {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("Failed to create config directory: {:?}", parent))?;
        }

        let default_config = RuntimeConfig::default();
        let content = toml::to_string_pretty(&default_config)
            .context("Failed to serialize default config")?;

        fs::write(path, content)
            .with_context(|| format!("Failed to write config file: {:?}", path))?;

        Ok(())
    }

    /// Save configuration to file.
    pub fn save(config: &RuntimeConfig, path: &Path) -> Result<()> {
        let content = toml::to_string_pretty(config).context("Failed to serialize config")?;

        fs::write(path, content)
            .with_context(|| format!("Failed to write config file: {:?}", path))?;

        Ok(())
    }
}