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