1use crate::error::{HermesError, Result};
2use serde::Deserialize;
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6pub const DEFAULT_MODEL: &str = "claude-opus-4-6";
7
8#[derive(Deserialize)]
9pub struct Config {
10 pub slack: SlackConfig,
11 pub defaults: DefaultsConfig,
12 #[serde(default)]
13 pub tuning: TuningConfig,
14 #[serde(default)]
15 pub repos: HashMap<String, RepoConfig>,
16 #[serde(default = "default_sessions_file")]
18 pub sessions_file: PathBuf,
19}
20
21fn default_sessions_file() -> PathBuf {
22 PathBuf::from("sessions.db")
23}
24
25impl std::fmt::Debug for Config {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 f.debug_struct("Config")
28 .field("slack", &self.slack)
29 .field("defaults", &self.defaults)
30 .field("tuning", &self.tuning)
31 .field("repos", &self.repos)
32 .field("sessions_file", &self.sessions_file)
33 .finish()
34 }
35}
36
37#[derive(Deserialize)]
38pub struct SlackConfig {
39 #[serde(default)]
40 pub app_token: String,
41 #[serde(default)]
42 pub bot_token: String,
43 #[serde(default)]
44 pub allowed_users: Vec<String>,
45}
46
47impl std::fmt::Debug for SlackConfig {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 f.debug_struct("SlackConfig")
50 .field("app_token", &"[REDACTED]")
51 .field("bot_token", &"[REDACTED]")
52 .field("allowed_users", &self.allowed_users)
53 .finish()
54 }
55}
56
57#[derive(Debug, Deserialize)]
81pub struct DefaultsConfig {
82 #[serde(default)]
83 pub append_system_prompt: Option<String>,
84 #[serde(default)]
85 pub allowed_tools: Vec<String>,
86 #[serde(default)]
87 pub streaming_mode: StreamingMode,
88 #[serde(default)]
89 pub model: Option<String>,
90 #[serde(default = "default_true")]
92 pub sync_local_sessions: bool,
93}
94
95fn default_true() -> bool {
96 true
97}
98
99#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
100#[serde(rename_all = "lowercase")]
101pub enum StreamingMode {
102 #[default]
103 Batch,
104 Live,
105}
106
107impl std::fmt::Display for StreamingMode {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 match self {
110 StreamingMode::Batch => write!(f, "batch"),
111 StreamingMode::Live => write!(f, "live"),
112 }
113 }
114}
115
116#[derive(Debug, Clone, Deserialize)]
117pub struct RepoConfig {
118 pub path: PathBuf,
119 #[serde(default = "default_agent")]
120 pub agent: AgentKind,
121 pub channel: Option<String>,
123 #[serde(default)]
124 pub allowed_tools: Vec<String>,
125 #[serde(default)]
126 pub model: Option<String>,
127 #[serde(default)]
129 pub sync_local_sessions: Option<bool>,
130}
131
132#[derive(Debug, Clone, serde::Serialize, Deserialize, PartialEq, Eq, Hash)]
133#[serde(rename_all = "lowercase")]
134pub enum AgentKind {
135 Claude,
136}
137
138fn default_agent() -> AgentKind {
139 AgentKind::Claude
140}
141
142#[derive(Debug, Clone, Deserialize)]
145#[serde(default)]
146pub struct TuningConfig {
147 pub slack_max_message_chars: usize,
149 pub session_ttl_days: i64,
151 pub live_update_interval_secs: u64,
153 pub rate_limit_interval_ms: u64,
155 pub max_accumulated_text_bytes: usize,
157 pub first_chunk_max_retries: u32,
159 pub log_preview_max_len: usize,
161}
162
163impl Default for TuningConfig {
164 fn default() -> Self {
165 Self {
166 slack_max_message_chars: 39_000,
167 session_ttl_days: 7,
168 live_update_interval_secs: 2,
169 rate_limit_interval_ms: 1100,
170 max_accumulated_text_bytes: 1_000_000,
171 first_chunk_max_retries: 3,
172 log_preview_max_len: 100,
173 }
174 }
175}
176
177impl RepoConfig {
178 pub fn merged_tools(&self, defaults: &DefaultsConfig) -> Vec<String> {
180 let mut tools = defaults.allowed_tools.clone();
181 for tool in &self.allowed_tools {
182 if !tools.contains(tool) {
183 tools.push(tool.clone());
184 }
185 }
186 tools
187 }
188
189 pub fn sync_enabled(&self, defaults: &DefaultsConfig) -> bool {
191 self.sync_local_sessions
192 .unwrap_or(defaults.sync_local_sessions)
193 }
194
195 pub fn resolved_model(&self, defaults: &DefaultsConfig) -> String {
197 self.model
198 .clone()
199 .or_else(|| defaults.model.clone())
200 .unwrap_or_else(|| DEFAULT_MODEL.to_string())
201 }
202}
203
204impl Config {
205 pub fn load() -> Result<Self> {
206 let path = std::env::var("HERMES_CONFIG").unwrap_or_else(|_| "config.toml".into());
207 let contents = std::fs::read_to_string(&path).map_err(|e| {
208 HermesError::Config(format!("Failed to read config file '{}': {}", path, e))
209 })?;
210 let mut config: Config = toml::from_str(&contents)?;
211
212 if let Ok(val) = std::env::var("SLACK_APP_TOKEN") {
214 config.slack.app_token = val;
215 }
216 if let Ok(val) = std::env::var("SLACK_BOT_TOKEN") {
217 config.slack.bot_token = val;
218 }
219
220 if config.slack.app_token.is_empty() {
221 return Err(HermesError::Config(
222 "Slack app token not set. Use SLACK_APP_TOKEN env var or slack.app_token in config."
223 .into(),
224 ));
225 }
226 if config.slack.bot_token.is_empty() {
227 return Err(HermesError::Config(
228 "Slack bot token not set. Use SLACK_BOT_TOKEN env var or slack.bot_token in config."
229 .into(),
230 ));
231 }
232
233 config.validate()?;
234 Ok(config)
235 }
236
237 fn validate(&self) -> Result<()> {
238 if !self.slack.app_token.starts_with("xapp-") {
239 return Err(HermesError::Config(
240 "Slack app_token should start with 'xapp-'. Did you swap app_token and bot_token?"
241 .into(),
242 ));
243 }
244 if !self.slack.bot_token.starts_with("xoxb-") {
245 return Err(HermesError::Config(
246 "Slack bot_token should start with 'xoxb-'. Did you swap app_token and bot_token?"
247 .into(),
248 ));
249 }
250
251 if self.repos.is_empty() {
252 return Err(HermesError::Config(
253 "No repos configured. Add at least one [repos.<name>] section.".into(),
254 ));
255 }
256
257 if let Some(path_str) = self.sessions_file.to_str() {
259 if path_str.contains("..") {
260 tracing::warn!(
261 "sessions_file contains '..': {}. This may be a path traversal risk.",
262 path_str
263 );
264 }
265 if self.sessions_file.is_absolute() {
267 tracing::info!(
268 "sessions_file uses absolute path: {}. Ensure proper permissions.",
269 self.sessions_file.display()
270 );
271 }
272 }
273
274 for (name, repo) in &self.repos {
275 if !repo.path.exists() {
276 return Err(HermesError::Config(format!(
277 "Repo '{}' path does not exist: {}",
278 name,
279 repo.path.display()
280 )));
281 }
282
283 if let Some(path_str) = repo.path.to_str()
285 && path_str.contains("..")
286 {
287 tracing::warn!(
288 "Repo '{}' path contains '..': {}. Verify this is intentional.",
289 name,
290 path_str
291 );
292 }
293 }
294
295 Ok(())
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use rstest::rstest;
303
304 fn minimal_config(repos_path: &str) -> String {
305 format!(
306 r#"
307[slack]
308app_token = "xapp-test"
309bot_token = "xoxb-test"
310allowed_users = ["U123"]
311
312[defaults]
313streaming_mode = "batch"
314
315[repos.test]
316path = "{}"
317"#,
318 repos_path
319 )
320 }
321
322 #[test]
323 fn test_parse_minimal_config() {
324 let toml = minimal_config("/tmp");
325 let config: Config = toml::from_str(&toml).unwrap();
326 assert_eq!(config.slack.app_token, "xapp-test");
327 assert_eq!(config.slack.bot_token, "xoxb-test");
328 assert_eq!(config.slack.allowed_users, vec!["U123"]);
329 assert_eq!(config.defaults.streaming_mode, StreamingMode::Batch);
330 assert!(config.repos.contains_key("test"));
331 }
332
333 #[test]
334 fn test_streaming_mode_live() {
335 let toml = r#"
336[slack]
337[defaults]
338streaming_mode = "live"
339"#;
340 let config: Config = toml::from_str(toml).unwrap();
341 assert_eq!(config.defaults.streaming_mode, StreamingMode::Live);
342 }
343
344 #[test]
345 fn test_streaming_mode_defaults_to_batch() {
346 let toml = r#"
347[slack]
348[defaults]
349"#;
350 let config: Config = toml::from_str(toml).unwrap();
351 assert_eq!(config.defaults.streaming_mode, StreamingMode::Batch);
352 }
353
354 #[test]
355 fn test_sessions_file_defaults() {
356 let toml = r#"
357[slack]
358[defaults]
359"#;
360 let config: Config = toml::from_str(toml).unwrap();
361 assert_eq!(config.sessions_file, PathBuf::from("sessions.db"));
362 }
363
364 #[test]
365 fn test_sessions_file_custom() {
366 let toml = r#"
367sessions_file = "/var/lib/hermes/sessions.db"
368[slack]
369[defaults]
370"#;
371 let config: Config = toml::from_str(toml).unwrap();
372 assert_eq!(
373 config.sessions_file,
374 PathBuf::from("/var/lib/hermes/sessions.db")
375 );
376 }
377
378 fn make_defaults(tools: Vec<&str>) -> DefaultsConfig {
380 DefaultsConfig {
381 append_system_prompt: None,
382 allowed_tools: tools.iter().map(|s| s.to_string()).collect(),
383 streaming_mode: StreamingMode::Batch,
384 model: None,
385 sync_local_sessions: true,
386 }
387 }
388
389 fn make_repo(tools: Vec<&str>) -> RepoConfig {
391 RepoConfig {
392 path: PathBuf::from("/tmp"),
393 agent: AgentKind::Claude,
394 channel: None,
395 allowed_tools: tools.iter().map(|s| s.to_string()).collect(),
396 model: None,
397 sync_local_sessions: None,
398 }
399 }
400
401 #[rstest]
402 #[case(
403 vec!["Read", "Grep"],
404 vec!["Edit", "Write"],
405 vec!["Read", "Grep", "Edit", "Write"],
406 "combines defaults and repo tools"
407 )]
408 #[case(
409 vec!["Read", "Grep"],
410 vec!["Read", "Edit"],
411 vec!["Read", "Grep", "Edit"],
412 "deduplicates tools"
413 )]
414 #[case(
415 vec!["Read"],
416 vec![],
417 vec!["Read"],
418 "empty repo tools uses only defaults"
419 )]
420 fn test_merged_tools(
421 #[case] defaults_tools: Vec<&str>,
422 #[case] repo_tools: Vec<&str>,
423 #[case] expected: Vec<&str>,
424 #[case] description: &str,
425 ) {
426 let defaults = make_defaults(defaults_tools);
427 let repo = make_repo(repo_tools);
428 let merged = repo.merged_tools(&defaults);
429 assert_eq!(merged, expected, "{}", description);
430 }
431
432 #[test]
433 fn test_debug_redacts_tokens() {
434 let config: Config = toml::from_str(
435 r#"
436[slack]
437app_token = "xapp-secret-123"
438bot_token = "xoxb-secret-456"
439[defaults]
440"#,
441 )
442 .unwrap();
443 let debug_output = format!("{:?}", config);
444 assert!(!debug_output.contains("xapp-secret-123"));
445 assert!(!debug_output.contains("xoxb-secret-456"));
446 assert!(debug_output.contains("[REDACTED]"));
447 }
448
449 #[test]
450 fn test_agent_kind_defaults_to_claude() {
451 let toml = r#"
452[slack]
453[defaults]
454[repos.test]
455path = "/tmp"
456"#;
457 let config: Config = toml::from_str(toml).unwrap();
458 assert_eq!(config.repos["test"].agent, AgentKind::Claude);
459 }
460
461 #[test]
462 fn test_validate_rejects_no_repos() {
463 let toml = r#"
464[slack]
465app_token = "xapp-test"
466bot_token = "xoxb-test"
467[defaults]
468"#;
469 let config: Config = toml::from_str(toml).unwrap();
470 let result = config.validate();
471 assert!(result.is_err());
472 assert!(
473 result
474 .unwrap_err()
475 .to_string()
476 .contains("No repos configured")
477 );
478 }
479
480 #[test]
481 fn test_validate_rejects_nonexistent_path() {
482 let toml = r#"
483[slack]
484app_token = "xapp-test"
485bot_token = "xoxb-test"
486[defaults]
487[repos.test]
488path = "/nonexistent/path/that/should/not/exist"
489"#;
490 let config: Config = toml::from_str(toml).unwrap();
491 let result = config.validate();
492 assert!(result.is_err());
493 assert!(result.unwrap_err().to_string().contains("does not exist"));
494 }
495
496 #[test]
497 fn test_streaming_mode_display() {
498 assert_eq!(StreamingMode::Batch.to_string(), "batch");
499 assert_eq!(StreamingMode::Live.to_string(), "live");
500 }
501
502 #[test]
503 fn test_validate_rejects_bad_app_token_prefix() {
504 let toml = r#"
505[slack]
506app_token = "xoxb-wrong-prefix"
507bot_token = "xoxb-test"
508[defaults]
509[repos.test]
510path = "/tmp"
511"#;
512 let config: Config = toml::from_str(toml).unwrap();
513 let result = config.validate();
514 assert!(result.is_err());
515 assert!(result.unwrap_err().to_string().contains("xapp-"));
516 }
517
518 #[test]
519 fn test_validate_rejects_bad_bot_token_prefix() {
520 let toml = r#"
521[slack]
522app_token = "xapp-test"
523bot_token = "xapp-wrong-prefix"
524[defaults]
525[repos.test]
526path = "/tmp"
527"#;
528 let config: Config = toml::from_str(toml).unwrap();
529 let result = config.validate();
530 assert!(result.is_err());
531 assert!(result.unwrap_err().to_string().contains("xoxb-"));
532 }
533
534 #[test]
535 fn test_resolved_model_defaults() {
536 let defaults = DefaultsConfig {
537 append_system_prompt: None,
538 allowed_tools: vec![],
539 streaming_mode: StreamingMode::Batch,
540 model: None,
541 sync_local_sessions: true,
542 };
543 let repo = RepoConfig {
544 path: PathBuf::from("/tmp"),
545 agent: AgentKind::Claude,
546 channel: None,
547 allowed_tools: vec![],
548 model: None,
549 sync_local_sessions: None,
550 };
551 assert_eq!(repo.resolved_model(&defaults), DEFAULT_MODEL);
552 }
553
554 #[test]
555 fn test_resolved_model_global_override() {
556 let defaults = DefaultsConfig {
557 append_system_prompt: None,
558 allowed_tools: vec![],
559 streaming_mode: StreamingMode::Batch,
560 model: Some("claude-sonnet-4-5-20250929".to_string()),
561 sync_local_sessions: true,
562 };
563 let repo = RepoConfig {
564 path: PathBuf::from("/tmp"),
565 agent: AgentKind::Claude,
566 channel: None,
567 allowed_tools: vec![],
568 model: None,
569 sync_local_sessions: None,
570 };
571 assert_eq!(repo.resolved_model(&defaults), "claude-sonnet-4-5-20250929");
572 }
573
574 #[test]
575 fn test_resolved_model_repo_override() {
576 let defaults = DefaultsConfig {
577 append_system_prompt: None,
578 allowed_tools: vec![],
579 streaming_mode: StreamingMode::Batch,
580 model: Some("claude-sonnet-4-5-20250929".to_string()),
581 sync_local_sessions: true,
582 };
583 let repo = RepoConfig {
584 path: PathBuf::from("/tmp"),
585 agent: AgentKind::Claude,
586 channel: None,
587 allowed_tools: vec![],
588 model: Some("claude-haiku-4-5-20251001".to_string()),
589 sync_local_sessions: None,
590 };
591 assert_eq!(repo.resolved_model(&defaults), "claude-haiku-4-5-20251001");
592 }
593
594 #[test]
595 fn test_sync_enabled_defaults_to_true() {
596 let defaults = make_defaults(vec![]);
597 let repo = make_repo(vec![]);
598 assert!(repo.sync_enabled(&defaults));
599 }
600
601 #[test]
602 fn test_sync_enabled_global_disable() {
603 let mut defaults = make_defaults(vec![]);
604 defaults.sync_local_sessions = false;
605 let repo = make_repo(vec![]);
606 assert!(!repo.sync_enabled(&defaults));
607 }
608
609 #[test]
610 fn test_sync_enabled_repo_override_enable() {
611 let mut defaults = make_defaults(vec![]);
612 defaults.sync_local_sessions = false;
613 let mut repo = make_repo(vec![]);
614 repo.sync_local_sessions = Some(true);
615 assert!(repo.sync_enabled(&defaults));
616 }
617
618 #[test]
619 fn test_sync_enabled_repo_override_disable() {
620 let defaults = make_defaults(vec![]);
621 let mut repo = make_repo(vec![]);
622 repo.sync_local_sessions = Some(false);
623 assert!(!repo.sync_enabled(&defaults));
624 }
625}