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