hermes_agent_cli_core/
config.rs1use 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 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 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}