1use serde::Deserialize;
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5#[derive(Debug, Deserialize)]
7pub struct Config {
8 #[serde(default = "default_env_file")]
11 pub env_file: String,
12 #[serde(default)]
13 pub memory: MemoryConfig,
14 #[serde(default)]
15 pub repos: RepoConfig,
16 #[serde(default)]
17 pub deploy: HashMap<String, DeployTarget>,
18 #[serde(default)]
19 pub git: GitConfig,
20 #[serde(default)]
22 pub skills_repo: Option<String>,
23}
24
25#[derive(Debug, Deserialize)]
27pub struct MemoryConfig {
28 #[serde(default = "default_backend")]
30 pub backend: String,
31 #[serde(default = "default_memory_path")]
33 pub path: String,
34 #[serde(default)]
36 pub eruka: ErukaConfig,
37}
38
39#[derive(Debug, Deserialize)]
41pub struct ErukaConfig {
42 #[serde(default = "default_eruka_url")]
43 pub url: String,
44 #[serde(default)]
47 pub service_key: Option<String>,
48}
49
50#[derive(Debug, Deserialize)]
52pub struct RepoConfig {
53 #[serde(default = "default_repo_root")]
55 pub root: String,
56 #[serde(default)]
58 pub tracked: Vec<String>,
59}
60
61#[derive(Debug, Deserialize)]
63pub struct DeployTarget {
64 pub build: String,
66 pub service: String,
68 pub smoke: Option<String>,
70}
71
72#[derive(Debug, Deserialize)]
74pub struct GitConfig {
75 #[serde(default)]
76 pub author_name: Option<String>,
77 #[serde(default)]
78 pub author_email: Option<String>,
79}
80
81fn default_env_file() -> String {
84 "~/.config/dstack/.env".to_string()
85}
86
87fn default_backend() -> String {
88 "file".to_string()
89}
90
91fn default_memory_path() -> String {
92 "~/.dstack/memory".to_string()
93}
94
95fn default_eruka_url() -> String {
96 "http://localhost:8081".to_string()
97}
98
99fn default_repo_root() -> String {
100 "/opt".to_string()
101}
102
103impl Default for MemoryConfig {
104 fn default() -> Self {
105 Self {
106 backend: default_backend(),
107 path: default_memory_path(),
108 eruka: ErukaConfig::default(),
109 }
110 }
111}
112
113impl Default for ErukaConfig {
114 fn default() -> Self {
115 Self {
116 url: default_eruka_url(),
117 service_key: None,
118 }
119 }
120}
121
122impl Default for RepoConfig {
123 fn default() -> Self {
124 Self {
125 root: default_repo_root(),
126 tracked: Vec::new(),
127 }
128 }
129}
130
131impl Default for GitConfig {
132 fn default() -> Self {
133 Self {
134 author_name: None,
135 author_email: None,
136 }
137 }
138}
139
140impl Default for Config {
141 fn default() -> Self {
142 Self {
143 env_file: default_env_file(),
144 memory: MemoryConfig::default(),
145 repos: RepoConfig::default(),
146 deploy: HashMap::new(),
147 git: GitConfig::default(),
148 skills_repo: None,
149 }
150 }
151}
152
153pub fn config_path() -> PathBuf {
157 dirs::config_dir()
158 .unwrap_or_else(|| PathBuf::from("~/.config"))
159 .join("dstack")
160 .join("config.toml")
161}
162
163impl Config {
164 pub fn load() -> anyhow::Result<Self> {
167 let path = config_path();
168 if path.exists() {
169 let contents = std::fs::read_to_string(&path)?;
170 let config: Config = toml::from_str(&contents)?;
171 Ok(config)
172 } else {
173 Ok(Config::default())
174 }
175 }
176
177 pub fn memory_path(&self) -> PathBuf {
179 expand_tilde(&self.memory.path)
180 }
181
182 pub fn load_env(&self) {
185 let path = expand_tilde(&self.env_file);
186 if !path.exists() {
187 return;
188 }
189 let contents = match std::fs::read_to_string(&path) {
190 Ok(c) => c,
191 Err(_) => return,
192 };
193 for line in contents.lines() {
194 let line = line.trim();
195 if line.is_empty() || line.starts_with('#') {
197 continue;
198 }
199 if let Some((key, value)) = line.split_once('=') {
200 let key = key.trim();
201 let value = value.trim().trim_matches('"').trim_matches('\'');
202 if std::env::var(key).is_err() {
204 std::env::set_var(key, value);
205 }
206 }
207 }
208 }
209
210 pub fn eruka_service_key(&self) -> Option<String> {
213 std::env::var("DSTACK_ERUKA_KEY")
214 .ok()
215 .or_else(|| self.memory.eruka.service_key.clone())
216 }
217}
218
219fn expand_tilde(path: &str) -> PathBuf {
221 if let Some(rest) = path.strip_prefix("~/") {
222 if let Some(home) = dirs::home_dir() {
223 return home.join(rest);
224 }
225 } else if path == "~" {
226 if let Some(home) = dirs::home_dir() {
227 return home;
228 }
229 }
230 PathBuf::from(path)
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn test_defaults() {
239 let cfg = Config::default();
240 assert_eq!(cfg.memory.backend, "file");
241 assert_eq!(cfg.memory.path, "~/.dstack/memory");
242 assert_eq!(cfg.repos.root, "/opt");
243 assert!(cfg.repos.tracked.is_empty());
244 assert!(cfg.deploy.is_empty());
245 }
246
247 #[test]
248 fn test_expand_tilde() {
249 let expanded = expand_tilde("~/.dstack/memory");
250 if dirs::home_dir().is_some() {
252 assert!(!expanded.to_string_lossy().starts_with('~'));
253 assert!(expanded.to_string_lossy().ends_with(".dstack/memory"));
254 }
255 }
256
257 #[test]
258 fn test_expand_tilde_no_prefix() {
259 let expanded = expand_tilde("/absolute/path");
260 assert_eq!(expanded, PathBuf::from("/absolute/path"));
261 }
262
263 #[test]
264 fn test_parse_minimal_toml() {
265 let toml_str = r#"
266[memory]
267backend = "eruka"
268
269[repos]
270root = "/home/user/projects"
271tracked = ["ares", "eruka"]
272"#;
273 let cfg: Config = toml::from_str(toml_str).unwrap();
274 assert_eq!(cfg.memory.backend, "eruka");
275 assert_eq!(cfg.repos.root, "/home/user/projects");
276 assert_eq!(cfg.repos.tracked, vec!["ares", "eruka"]);
277 }
278
279 #[test]
280 fn test_parse_full_toml() {
281 let toml_str = r#"
282[memory]
283backend = "eruka"
284path = "/custom/memory"
285
286[memory.eruka]
287url = "https://eruka.example.com"
288service_key = "secret123"
289
290[repos]
291root = "/opt"
292tracked = ["ares", "eruka", "doltares"]
293
294[deploy.ares]
295build = "cargo build --release"
296service = "ares"
297smoke = "curl -sf http://localhost:3000/health"
298
299[deploy.eruka]
300build = "cargo build --release"
301service = "eruka"
302
303[git]
304author_name = "bkataru"
305author_email = "baalateja.k@gmail.com"
306"#;
307 let cfg: Config = toml::from_str(toml_str).unwrap();
308 assert_eq!(cfg.memory.backend, "eruka");
309 assert_eq!(cfg.memory.eruka.url, "https://eruka.example.com");
310 assert_eq!(
311 cfg.memory.eruka.service_key,
312 Some("secret123".to_string())
313 );
314 assert_eq!(cfg.repos.tracked.len(), 3);
315 assert!(cfg.deploy.contains_key("ares"));
316 assert!(cfg.deploy.contains_key("eruka"));
317 assert_eq!(
318 cfg.deploy["ares"].smoke,
319 Some("curl -sf http://localhost:3000/health".to_string())
320 );
321 assert!(cfg.deploy["eruka"].smoke.is_none());
322 assert_eq!(cfg.git.author_name, Some("bkataru".to_string()));
323 }
324
325 #[test]
326 fn test_eruka_service_key_env_override() {
327 let cfg = Config::default();
328 let key = cfg.eruka_service_key();
332 if std::env::var("DSTACK_ERUKA_KEY").is_err() {
333 assert!(key.is_none());
334 }
335 }
336
337 #[test]
338 fn test_config_path() {
339 let path = config_path();
340 assert!(path.to_string_lossy().contains("dstack"));
341 assert!(path.to_string_lossy().ends_with("config.toml"));
342 }
343
344 #[test]
345 fn test_load_succeeds() {
346 let cfg = Config::load().unwrap();
348 assert!(cfg.memory.backend == "file" || cfg.memory.backend == "eruka");
350 }
351}