use anyhow::{Context, Result};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use tracing::debug;
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct AccountConfig {
pub id: String,
pub name: String,
pub role: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct WorkspaceConfig {
pub id: String,
pub name: String,
pub members: Option<Vec<String>>,
#[serde(default)]
pub agents: Vec<String>,
#[serde(default)]
pub workflows: Vec<String>,
#[serde(default)]
pub prompts: Vec<String>,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ServerConfig {
#[serde(default = "default_server_host")]
pub host: String,
#[serde(default = "default_server_port")]
pub port: u16,
pub endpoint_url: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct DebugConfig {
#[serde(default)]
pub show_nodes: bool,
#[serde(default)]
pub show_context: bool,
#[serde(default)]
pub show_conditions: bool,
#[serde(default)]
pub show_variables: bool,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct RuntimeLimits {
#[serde(default = "default_max_loop_iterations")]
pub max_loop_iterations: usize,
#[serde(default = "default_max_execution_depth")]
pub max_execution_depth: usize,
#[serde(default = "default_http_timeout_secs")]
pub http_timeout_secs: u64,
#[serde(default = "default_python_workers")]
pub python_workers: usize,
}
impl Default for RuntimeLimits {
fn default() -> Self {
Self {
max_loop_iterations: default_max_loop_iterations(),
max_execution_depth: default_max_execution_depth(),
http_timeout_secs: default_http_timeout_secs(),
python_workers: default_python_workers(),
}
}
}
fn default_max_loop_iterations() -> usize {
100
}
fn default_max_execution_depth() -> usize {
10
}
fn default_http_timeout_secs() -> u64 {
120
}
fn default_python_workers() -> usize {
1
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct AiConfig {
pub default_model: Option<String>,
#[serde(default)]
pub providers: std::collections::HashMap<String, ProviderConfig>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct ProviderConfig {
pub api_key: Option<String>,
pub base_url: Option<String>,
}
impl AiConfig {
pub fn has_providers(&self) -> bool {
self.providers
.values()
.any(|p| p.api_key.as_ref().map(|k| !k.is_empty()).unwrap_or(false))
}
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct PathsConfig {
pub base: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct BotConfig {
pub telegram: Option<TelegramBotConfig>,
pub feishu: Option<FeishuBotConfig>,
pub wechat: Option<WechatBotConfig>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct TelegramBotConfig {
pub token: String,
#[serde(default = "default_bot_agent")]
pub agent: String,
#[serde(default)]
pub mode: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct FeishuBotConfig {
pub app_id: Option<String>,
pub app_secret: Option<String>,
pub webhook_url: Option<String>,
#[serde(default = "default_bot_agent")]
pub agent: String,
#[serde(default = "default_feishu_port")]
pub port: u16,
#[serde(default = "default_feishu_base_url")]
pub base_url: String,
#[serde(default)]
pub approvers: Vec<String>,
#[serde(default)]
pub mode: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct WechatBotConfig {
#[serde(default = "default_bot_agent")]
pub agent: String,
}
fn default_bot_agent() -> String {
"default".to_string()
}
fn default_feishu_port() -> u16 {
9000
}
fn default_feishu_base_url() -> String {
"https://open.feishu.cn".to_string()
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct HistoryConfig {
#[serde(default = "default_history_enabled")]
pub enabled: bool,
#[serde(default = "default_history_backend")]
pub backend: String,
pub dir: Option<String>,
pub path: Option<String>,
#[serde(default = "default_history_max_messages")]
pub max_messages: usize,
#[serde(default = "default_history_max_tokens")]
pub max_tokens: u32,
#[serde(default = "default_history_retention_days")]
pub retention_days: u32,
}
impl Default for HistoryConfig {
fn default() -> Self {
Self {
enabled: default_history_enabled(),
backend: default_history_backend(),
dir: None,
path: None,
max_messages: default_history_max_messages(),
max_tokens: default_history_max_tokens(),
retention_days: default_history_retention_days(),
}
}
}
fn default_history_enabled() -> bool {
true
}
fn default_history_backend() -> String {
"jsonl".to_string()
}
fn default_history_max_messages() -> usize {
20
}
fn default_history_max_tokens() -> u32 {
8000
}
fn default_history_retention_days() -> u32 {
30
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct RegistryConfig {
#[serde(default = "default_registry_url")]
pub url: String,
pub port: Option<u16>,
pub data_dir: Option<String>,
}
fn default_registry_url() -> String {
"https://jgr.juglans.ai".to_string()
}
fn default_server_host() -> String {
"127.0.0.1".to_string()
}
fn default_server_port() -> u16 {
3000
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct JuglansConfig {
pub account: AccountConfig,
pub workspace: Option<WorkspaceConfig>,
#[serde(default)]
pub server: ServerConfig,
#[serde(default = "default_env_file")]
pub env_file: Vec<String>,
#[serde(default)]
pub env: std::collections::HashMap<String, String>,
#[serde(default)]
pub debug: DebugConfig,
#[serde(default)]
pub limits: RuntimeLimits,
pub bot: Option<BotConfig>,
#[serde(default)]
pub paths: PathsConfig,
pub registry: Option<RegistryConfig>,
#[serde(default)]
pub ai: AiConfig,
#[serde(default)]
pub history: HistoryConfig,
}
fn default_env_file() -> Vec<String> {
vec![".env".to_string()]
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: default_server_host(),
port: default_server_port(),
endpoint_url: None,
}
}
}
impl JuglansConfig {
pub fn load() -> Result<Self> {
let path = Path::new("juglans.toml");
if !path.exists() {
debug!("⚠️ juglans.toml not found, using defaults");
return Ok(JuglansConfig {
account: AccountConfig {
id: "dev_user".to_string(),
name: "Developer".to_string(),
role: Some("admin".to_string()),
},
workspace: Some(WorkspaceConfig {
id: "default_ws".to_string(),
name: "Default Workspace".to_string(),
members: Some(vec!["dev_user".to_string()]),
agents: vec![],
workflows: vec![],
prompts: vec![],
tools: vec![],
exclude: vec![],
}),
server: ServerConfig::default(),
env_file: default_env_file(),
env: Default::default(),
debug: DebugConfig::default(),
limits: RuntimeLimits::default(),
bot: None,
paths: PathsConfig::default(),
registry: None,
ai: AiConfig::default(),
history: HistoryConfig::default(),
});
}
let content = fs::read_to_string(path).context("Failed to read juglans.toml")?;
let pre: PreConfig = toml::from_str(&content).unwrap_or_default();
for env_path in &pre.env_file {
if let Ok(p) = dotenvy::from_filename(env_path) {
debug!("✓ Loaded env file: {:?}", p);
}
}
let content = interpolate_env_vars(&content);
let mut config: JuglansConfig =
toml::from_str(&content).context("Failed to parse juglans.toml")?;
config.apply_env_overrides();
debug!("✓ Config loaded for user: {}", config.account.name);
Ok(config)
}
fn apply_env_overrides(&mut self) {
if let Ok(v) = std::env::var("SERVER_HOST") {
self.server.host = v;
}
if let Ok(Ok(v)) = std::env::var("SERVER_PORT").map(|s| s.parse::<u16>()) {
self.server.port = v;
}
let feishu_app_id = std::env::var("FEISHU_APP_ID").ok();
let feishu_app_secret = std::env::var("FEISHU_APP_SECRET").ok();
if feishu_app_id.is_some() || feishu_app_secret.is_some() {
let bot = self.bot.get_or_insert(BotConfig {
telegram: None,
feishu: None,
wechat: None,
});
let feishu = bot.feishu.get_or_insert_with(|| FeishuBotConfig {
app_id: None,
app_secret: None,
webhook_url: None,
agent: default_bot_agent(),
port: default_feishu_port(),
base_url: default_feishu_base_url(),
approvers: vec![],
mode: None,
});
if let Some(v) = feishu_app_id {
feishu.app_id = Some(v);
}
if let Some(v) = feishu_app_secret {
feishu.app_secret = Some(v);
}
}
if let Ok(v) = std::env::var("JUGLANS_HISTORY_BACKEND") {
self.history.backend = v;
}
if let Ok(v) = std::env::var("JUGLANS_HISTORY_DIR") {
self.history.dir = Some(v);
}
if let Ok(v) = std::env::var("JUGLANS_HISTORY_PATH") {
self.history.path = Some(v);
}
if let Ok(Ok(v)) = std::env::var("JUGLANS_HISTORY_MAX_MESSAGES").map(|s| s.parse::<usize>())
{
self.history.max_messages = v;
}
if let Ok(Ok(v)) = std::env::var("JUGLANS_HISTORY_MAX_TOKENS").map(|s| s.parse::<u32>()) {
self.history.max_tokens = v;
}
if let Ok(Ok(v)) = std::env::var("JUGLANS_HISTORY_ENABLED").map(|s| s.parse::<bool>()) {
self.history.enabled = v;
}
if let Ok(token) = std::env::var("TELEGRAM_BOT_TOKEN") {
let bot = self.bot.get_or_insert(BotConfig {
telegram: None,
feishu: None,
wechat: None,
});
let tg = bot.telegram.get_or_insert_with(|| TelegramBotConfig {
token: String::new(),
agent: default_bot_agent(),
mode: None,
});
tg.token = token;
}
}
}
#[derive(Deserialize, Default)]
struct PreConfig {
#[serde(default = "default_env_file")]
env_file: Vec<String>,
}
fn interpolate_env_vars(content: &str) -> String {
let re = Regex::new(r"\$\{([^}]+)\}").unwrap();
re.replace_all(content, |caps: ®ex::Captures| {
let var_name = &caps[1];
std::env::var(var_name).unwrap_or_default()
})
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_interpolate_basic() {
std::env::set_var("TEST_INTERP_VAR", "hello");
let result = interpolate_env_vars("key = \"${TEST_INTERP_VAR}\"");
assert_eq!(result, "key = \"hello\"");
std::env::remove_var("TEST_INTERP_VAR");
}
#[test]
fn test_interpolate_missing_var() {
let result = interpolate_env_vars("key = \"${NONEXISTENT_VAR_XYZ}\"");
assert_eq!(result, "key = \"\"");
}
#[test]
fn test_interpolate_no_pattern() {
let input = "key = \"plain value\"";
assert_eq!(interpolate_env_vars(input), input);
}
#[test]
fn test_interpolate_multiple() {
std::env::set_var("TEST_A", "aaa");
std::env::set_var("TEST_B", "bbb");
let result = interpolate_env_vars("a = \"${TEST_A}\"\nb = \"${TEST_B}\"");
assert_eq!(result, "a = \"aaa\"\nb = \"bbb\"");
std::env::remove_var("TEST_A");
std::env::remove_var("TEST_B");
}
#[test]
fn test_pre_config_default() {
let pre: PreConfig = toml::from_str("").unwrap();
assert_eq!(pre.env_file, vec![".env".to_string()]);
}
#[test]
fn test_pre_config_custom() {
let pre: PreConfig = toml::from_str("env_file = [\".env\", \".env.deploy\"]").unwrap();
assert_eq!(pre.env_file, vec![".env", ".env.deploy"]);
}
}