1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use thiserror::Error;
10
11#[derive(Error, Debug)]
13pub enum ConfigError {
14 #[error("IO error: {0}")]
16 Io(#[from] std::io::Error),
17
18 #[error("Parse error: {0}")]
20 Parse(#[from] json5::Error),
21
22 #[error("Validation error: {0}")]
24 Validation(String),
25
26 #[error("Missing required field: {0}")]
28 MissingField(String),
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36#[derive(Default)]
37pub struct Config {
38 #[serde(default)]
40 pub gateway: GatewayConfig,
41
42 #[serde(default)]
44 pub agents: HashMap<String, AgentConfig>,
45
46 #[serde(default)]
48 pub channels: ChannelsConfig,
49
50 #[serde(default)]
52 pub providers: ProvidersConfig,
53
54 #[serde(default)]
56 pub settings: GlobalSettings,
57}
58
59impl Config {
60 pub fn load_default() -> Result<Self, ConfigError> {
66 let path = Self::default_path();
67 if path.exists() {
68 Self::load(&path)
69 } else {
70 Ok(Self::default())
71 }
72 }
73
74 pub fn load(path: &Path) -> Result<Self, ConfigError> {
80 let content = std::fs::read_to_string(path)?;
81 let config: Self = json5::from_str(&content)?;
82 config.validate()?;
83 Ok(config)
84 }
85
86 pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
92 if let Some(parent) = path.parent() {
94 std::fs::create_dir_all(parent)?;
95 }
96
97 let content = serde_json::to_string_pretty(self)
98 .map_err(|e| ConfigError::Validation(e.to_string()))?;
99 std::fs::write(path, content)?;
100 Ok(())
101 }
102
103 #[must_use]
105 pub fn default_path() -> PathBuf {
106 Self::state_dir().join("openclaw.json")
107 }
108
109 #[must_use]
113 pub fn state_dir() -> PathBuf {
114 if let Ok(dir) = std::env::var("OPENCLAW_STATE_DIR") {
115 PathBuf::from(dir)
116 } else if let Some(home) = dirs::home_dir() {
117 home.join(".openclaw")
118 } else {
119 PathBuf::from(".openclaw")
120 }
121 }
122
123 #[must_use]
125 pub fn credentials_dir() -> PathBuf {
126 Self::state_dir().join("credentials")
127 }
128
129 #[must_use]
131 pub fn sessions_dir() -> PathBuf {
132 Self::state_dir().join("sessions")
133 }
134
135 #[must_use]
137 pub fn agents_dir() -> PathBuf {
138 Self::state_dir().join("agents")
139 }
140
141 fn validate(&self) -> Result<(), ConfigError> {
143 if self.gateway.port == 0 {
145 return Err(ConfigError::Validation(
146 "Gateway port cannot be 0".to_string(),
147 ));
148 }
149
150 for (id, agent) in &self.agents {
152 if agent.model.is_empty() {
153 return Err(ConfigError::Validation(format!(
154 "Agent '{id}' has empty model"
155 )));
156 }
157 }
158
159 Ok(())
160 }
161
162 #[must_use]
164 pub fn get_agent(&self, id: &str) -> AgentConfig {
165 self.agents
166 .get(id)
167 .cloned()
168 .unwrap_or_else(AgentConfig::default)
169 }
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase")]
175pub struct GatewayConfig {
176 #[serde(default = "default_port")]
178 pub port: u16,
179
180 #[serde(default)]
182 pub mode: BindMode,
183
184 #[serde(default = "default_true")]
186 pub cors: bool,
187
188 #[serde(default = "default_timeout")]
190 pub timeout_secs: u64,
191}
192
193impl Default for GatewayConfig {
194 fn default() -> Self {
195 Self {
196 port: default_port(),
197 mode: BindMode::default(),
198 cors: true,
199 timeout_secs: default_timeout(),
200 }
201 }
202}
203
204const fn default_port() -> u16 {
205 18789
206}
207
208const fn default_timeout() -> u64 {
209 300
210}
211
212const fn default_true() -> bool {
213 true
214}
215
216#[derive(Debug, Clone, Default, Serialize, Deserialize)]
218#[serde(rename_all = "lowercase")]
219pub enum BindMode {
220 #[default]
222 Local,
223 Public,
225 Custom(String),
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
231#[serde(rename_all = "camelCase")]
232pub struct AgentConfig {
233 #[serde(default = "default_model")]
235 pub model: String,
236
237 #[serde(default = "default_provider")]
239 pub provider: String,
240
241 #[serde(default)]
243 pub system_prompt: Option<String>,
244
245 #[serde(default = "default_max_tokens")]
247 pub max_tokens: u32,
248
249 #[serde(default = "default_temperature")]
251 pub temperature: f32,
252
253 #[serde(default)]
255 pub tools: Vec<String>,
256
257 #[serde(default)]
259 pub allowlist: Vec<AllowlistEntry>,
260}
261
262impl Default for AgentConfig {
263 fn default() -> Self {
264 Self {
265 model: default_model(),
266 provider: default_provider(),
267 system_prompt: None,
268 max_tokens: default_max_tokens(),
269 temperature: default_temperature(),
270 tools: vec![],
271 allowlist: vec![],
272 }
273 }
274}
275
276fn default_model() -> String {
277 "claude-3-5-sonnet-20241022".to_string()
278}
279
280fn default_provider() -> String {
281 "anthropic".to_string()
282}
283
284const fn default_max_tokens() -> u32 {
285 4096
286}
287
288const fn default_temperature() -> f32 {
289 0.7
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
294#[serde(rename_all = "camelCase")]
295pub struct AllowlistEntry {
296 pub channel: String,
298
299 pub peer_id: String,
301
302 #[serde(default)]
304 pub label: Option<String>,
305}
306
307#[derive(Debug, Clone, Default, Serialize, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct ChannelsConfig {
311 #[serde(default)]
313 pub telegram: Option<TelegramConfig>,
314
315 #[serde(default)]
317 pub discord: Option<DiscordConfig>,
318
319 #[serde(default)]
321 pub slack: Option<SlackConfig>,
322
323 #[serde(default)]
325 pub signal: Option<SignalConfig>,
326
327 #[serde(default)]
329 pub matrix: Option<MatrixConfig>,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
334#[serde(rename_all = "camelCase")]
335pub struct TelegramConfig {
336 pub bot_token: Option<String>,
338
339 #[serde(default)]
341 pub webhook: bool,
342
343 #[serde(default)]
345 pub webhook_url: Option<String>,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
350#[serde(rename_all = "camelCase")]
351pub struct DiscordConfig {
352 pub bot_token: Option<String>,
354
355 pub application_id: Option<String>,
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize)]
361#[serde(rename_all = "camelCase")]
362pub struct SlackConfig {
363 pub bot_token: Option<String>,
365
366 pub app_token: Option<String>,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
372#[serde(rename_all = "camelCase")]
373pub struct SignalConfig {
374 pub phone_number: Option<String>,
376}
377
378#[derive(Debug, Clone, Serialize, Deserialize)]
380#[serde(rename_all = "camelCase")]
381pub struct MatrixConfig {
382 pub homeserver: Option<String>,
384
385 pub user_id: Option<String>,
387
388 pub access_token: Option<String>,
390}
391
392#[derive(Debug, Clone, Default, Serialize, Deserialize)]
394#[serde(rename_all = "camelCase")]
395pub struct ProvidersConfig {
396 #[serde(default)]
398 pub anthropic: Option<AnthropicConfig>,
399
400 #[serde(default)]
402 pub openai: Option<OpenAIConfig>,
403
404 #[serde(default)]
406 pub ollama: Option<OllamaConfig>,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize)]
411#[serde(rename_all = "camelCase")]
412pub struct AnthropicConfig {
413 pub api_key: Option<String>,
415
416 #[serde(default)]
418 pub base_url: Option<String>,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
423#[serde(rename_all = "camelCase")]
424pub struct OpenAIConfig {
425 pub api_key: Option<String>,
427
428 #[serde(default)]
430 pub base_url: Option<String>,
431
432 #[serde(default)]
434 pub org_id: Option<String>,
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize)]
439#[serde(rename_all = "camelCase")]
440pub struct OllamaConfig {
441 #[serde(default = "default_ollama_url")]
443 pub base_url: String,
444}
445
446fn default_ollama_url() -> String {
447 "http://localhost:11434".to_string()
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize)]
452#[serde(rename_all = "camelCase")]
453#[derive(Default)]
454pub struct GlobalSettings {
455 #[serde(default)]
457 pub debug: bool,
458
459 #[serde(default)]
461 pub log_format: LogFormat,
462
463 #[serde(default)]
465 pub telemetry: bool,
466}
467
468#[derive(Debug, Clone, Default, Serialize, Deserialize)]
470#[serde(rename_all = "lowercase")]
471pub enum LogFormat {
472 #[default]
474 Pretty,
475 Json,
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use tempfile::tempdir;
483
484 #[test]
485 fn test_default_config() {
486 let config = Config::default();
487 assert_eq!(config.gateway.port, 18789);
488 }
489
490 #[test]
491 fn test_config_roundtrip() {
492 let temp = tempdir().unwrap();
493 let path = temp.path().join("config.json");
494
495 let mut config = Config::default();
496 config.agents.insert(
497 "test".to_string(),
498 AgentConfig {
499 model: "gpt-4".to_string(),
500 ..Default::default()
501 },
502 );
503
504 config.save(&path).unwrap();
505
506 let loaded = Config::load(&path).unwrap();
507 assert_eq!(loaded.agents.get("test").unwrap().model, "gpt-4");
508 }
509
510 #[test]
511 fn test_json5_parsing() {
512 let json5_content = r#"{
513 // This is a comment
514 gateway: {
515 port: 8080,
516 },
517 agents: {
518 default: {
519 model: "claude-3-5-sonnet-20241022",
520 // trailing comma
521 },
522 },
523 }"#;
524
525 let config: Config = json5::from_str(json5_content).unwrap();
526 assert_eq!(config.gateway.port, 8080);
527 }
528
529 #[test]
530 fn test_config_validation() {
531 let mut config = Config::default();
532 config.gateway.port = 0;
533
534 let result = config.validate();
535 assert!(result.is_err());
536 }
537
538 #[test]
539 fn test_state_dir() {
540 let dir = Config::state_dir();
541 assert!(dir.to_str().unwrap().contains("openclaw"));
542 }
543}