Skip to main content

hermes_agent_cli_core/
config.rs

1use anyhow::{Context, Result};
2use directories::ProjectDirs;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::PathBuf;
6use tracing::info;
7
8#[derive(Debug, Clone, Default, Deserialize, Serialize)]
9pub struct Config {
10    #[serde(default)]
11    pub model: ModelConfig,
12    #[serde(default)]
13    pub terminal: TerminalConfig,
14    #[serde(default)]
15    pub display: DisplayConfig,
16    #[serde(default)]
17    pub agent: AgentConfig,
18}
19
20#[derive(Debug, Clone, Deserialize, Serialize)]
21pub struct ModelConfig {
22    #[serde(default = "default_model")]
23    pub default: String,
24    #[serde(default)]
25    pub base_url: String,
26    #[serde(default)]
27    pub provider: String,
28}
29
30impl Default for ModelConfig {
31    fn default() -> Self {
32        Self { default: default_model(), base_url: String::new(), provider: String::new() }
33    }
34}
35
36fn default_model() -> String {
37    "gpt-4o".to_string()
38}
39
40#[derive(Debug, Clone, Deserialize, Serialize)]
41pub struct TerminalConfig {
42    #[serde(default = "default_env_type")]
43    pub env_type: String,
44    #[serde(default)]
45    pub cwd: String,
46    #[serde(default = "default_timeout")]
47    pub timeout: u64,
48}
49
50fn default_env_type() -> String {
51    "local".to_string()
52}
53
54fn default_timeout() -> u64 {
55    120
56}
57
58impl Default for TerminalConfig {
59    fn default() -> Self {
60        Self { env_type: default_env_type(), cwd: String::new(), timeout: default_timeout() }
61    }
62}
63
64#[derive(Debug, Clone, Deserialize, Serialize)]
65pub struct DisplayConfig {
66    #[serde(default)]
67    pub compact: bool,
68    #[serde(default)]
69    pub resume_display: String,
70    #[serde(default)]
71    pub show_reasoning: bool,
72    #[serde(default = "default_streaming")]
73    pub streaming: bool,
74    #[serde(default)]
75    pub skin: String,
76}
77
78fn default_streaming() -> bool {
79    true
80}
81
82impl Default for DisplayConfig {
83    fn default() -> Self {
84        Self {
85            compact: false,
86            resume_display: String::new(),
87            show_reasoning: false,
88            streaming: default_streaming(),
89            skin: String::new(),
90        }
91    }
92}
93
94#[derive(Debug, Clone, Deserialize, Serialize)]
95pub struct AgentConfig {
96    #[serde(default = "default_max_turns")]
97    pub max_turns: u32,
98    #[serde(default)]
99    pub verbose: bool,
100    #[serde(default)]
101    pub system_prompt: String,
102    #[serde(default = "default_reasoning_effort")]
103    pub reasoning_effort: String,
104}
105
106fn default_max_turns() -> u32 {
107    30
108}
109
110fn default_reasoning_effort() -> String {
111    "medium".to_string()
112}
113
114impl Default for AgentConfig {
115    fn default() -> Self {
116        Self {
117            max_turns: default_max_turns(),
118            verbose: false,
119            system_prompt: String::new(),
120            reasoning_effort: default_reasoning_effort(),
121        }
122    }
123}
124
125impl Config {
126    pub fn load() -> Result<Self> {
127        let config_path = Self::config_path();
128        if !config_path.exists() {
129            info!("config not found at {:?}, using defaults", config_path);
130            return Ok(Config::default());
131        }
132        let content = fs::read_to_string(&config_path)
133            .with_context(|| format!("failed to read config from {:?}", config_path))?;
134        let config: Config = serde_yaml::from_str(&content)
135            .with_context(|| format!("failed to parse config from {:?}", config_path))?;
136        info!("loaded config from {:?}", config_path);
137        Ok(config)
138    }
139
140    pub fn config_path() -> PathBuf {
141        Self::hermes_home().join("config.yaml")
142    }
143
144    pub fn hermes_home() -> PathBuf {
145        if let Ok(home) = std::env::var("HERMES_HOME") {
146            return PathBuf::from(home);
147        }
148        if let Ok(profile) = std::env::var("HERMES_PROFILE") {
149            if let Some(proj_dirs) =
150                ProjectDirs::from("ai", "hermes", &format!("hermes-{}", profile))
151            {
152                return proj_dirs.config_dir().to_path_buf();
153            }
154        }
155        if let Some(proj_dirs) = ProjectDirs::from("ai", "hermes", "hermes-cli") {
156            return proj_dirs.config_dir().to_path_buf();
157        }
158        if let Ok(home) = std::env::var("USERPROFILE") {
159            return PathBuf::from(home).join(".hermes");
160        }
161        PathBuf::from(".hermes")
162    }
163
164    pub fn save(&self) -> Result<()> {
165        let config_path = Self::config_path();
166        if let Some(parent) = config_path.parent() {
167            fs::create_dir_all(parent)
168                .with_context(|| format!("failed to create config directory {:?}", parent))?;
169        }
170        let content = serde_yaml::to_string(self).context("failed to serialize config")?;
171        fs::write(&config_path, content)
172            .with_context(|| format!("failed to write config to {:?}", config_path))?;
173        info!("saved config to {:?}", config_path);
174        Ok(())
175    }
176}
177
178pub fn load_dotenv() -> Result<()> {
179    let hermes_home = Config::hermes_home();
180    let dotenv_path = hermes_home.join(".env");
181    if dotenv_path.exists() {
182        info!("loading .env from {:?}", dotenv_path);
183        let content = fs::read_to_string(&dotenv_path)?;
184        for line in content.lines() {
185            let line = line.trim();
186            if line.is_empty() || line.starts_with('#') {
187                continue;
188            }
189            if let Some(pos) = line.find('=') {
190                let key = line[..pos].trim();
191                let value = line[pos + 1..].trim();
192                if !key.is_empty() {
193                    std::env::set_var(key, value);
194                }
195            }
196        }
197    }
198    Ok(())
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_config_default() {
207        let config = Config::default();
208        assert_eq!(config.model.default, "gpt-4o");
209        assert_eq!(config.model.provider, "");
210        assert_eq!(config.terminal.env_type, "local");
211        assert_eq!(config.terminal.timeout, 120);
212        assert!(!config.display.compact);
213        assert!(config.display.streaming);
214        assert_eq!(config.agent.max_turns, 30);
215        assert_eq!(config.agent.reasoning_effort, "medium");
216    }
217
218    #[test]
219    fn test_hermes_home() {
220        let home = Config::hermes_home();
221        assert!(!home.to_string_lossy().is_empty());
222    }
223
224    #[test]
225    fn test_config_serialization() {
226        let config = Config::default();
227        let yaml = serde_yaml::to_string(&config).unwrap();
228        // Default values should be present after round-trip
229        let parsed: Config = serde_yaml::from_str(&yaml).unwrap();
230        assert_eq!(parsed.model.default, "gpt-4o");
231        assert_eq!(parsed.terminal.timeout, 120);
232        assert_eq!(parsed.agent.max_turns, 30);
233    }
234
235    #[test]
236    fn test_config_roundtrip() {
237        let config = Config::default();
238        let yaml = serde_yaml::to_string(&config).unwrap();
239        let parsed: Config = serde_yaml::from_str(&yaml).unwrap();
240        assert_eq!(parsed.model.default, config.model.default);
241        assert_eq!(parsed.terminal.timeout, config.terminal.timeout);
242        assert_eq!(parsed.agent.max_turns, config.agent.max_turns);
243    }
244
245    #[test]
246    fn test_load_dotenv_no_file() {
247        // Should succeed even if .env doesn't exist
248        assert!(load_dotenv_from_path("/nonexistent/.env").is_ok());
249    }
250
251    fn load_dotenv_from_path(path: &str) -> Result<()> {
252        let path = std::path::PathBuf::from(path);
253        if !path.exists() {
254            return Ok(());
255        }
256        load_dotenv()
257    }
258}