1use serde::Deserialize;
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5#[derive(Debug, Deserialize)]
7pub struct Config {
8 #[serde(default)]
9 pub memory: MemoryConfig,
10 #[serde(default)]
11 pub repos: RepoConfig,
12 #[serde(default)]
13 pub deploy: HashMap<String, DeployTarget>,
14 #[serde(default)]
15 pub git: GitConfig,
16}
17
18#[derive(Debug, Deserialize)]
20pub struct MemoryConfig {
21 #[serde(default = "default_backend")]
23 pub backend: String,
24 #[serde(default = "default_memory_path")]
26 pub path: String,
27 #[serde(default)]
29 pub eruka: ErukaConfig,
30}
31
32#[derive(Debug, Deserialize)]
34pub struct ErukaConfig {
35 #[serde(default = "default_eruka_url")]
36 pub url: String,
37 #[serde(default)]
40 pub service_key: Option<String>,
41}
42
43#[derive(Debug, Deserialize)]
45pub struct RepoConfig {
46 #[serde(default = "default_repo_root")]
48 pub root: String,
49 #[serde(default)]
51 pub tracked: Vec<String>,
52}
53
54#[derive(Debug, Deserialize)]
56pub struct DeployTarget {
57 pub build: String,
59 pub service: String,
61 pub smoke: Option<String>,
63}
64
65#[derive(Debug, Deserialize)]
67pub struct GitConfig {
68 #[serde(default)]
69 pub author_name: Option<String>,
70 #[serde(default)]
71 pub author_email: Option<String>,
72}
73
74fn default_backend() -> String {
77 "file".to_string()
78}
79
80fn default_memory_path() -> String {
81 "~/.dstack/memory".to_string()
82}
83
84fn default_eruka_url() -> String {
85 "http://localhost:8081".to_string()
86}
87
88fn default_repo_root() -> String {
89 "/opt".to_string()
90}
91
92impl Default for MemoryConfig {
93 fn default() -> Self {
94 Self {
95 backend: default_backend(),
96 path: default_memory_path(),
97 eruka: ErukaConfig::default(),
98 }
99 }
100}
101
102impl Default for ErukaConfig {
103 fn default() -> Self {
104 Self {
105 url: default_eruka_url(),
106 service_key: None,
107 }
108 }
109}
110
111impl Default for RepoConfig {
112 fn default() -> Self {
113 Self {
114 root: default_repo_root(),
115 tracked: Vec::new(),
116 }
117 }
118}
119
120impl Default for GitConfig {
121 fn default() -> Self {
122 Self {
123 author_name: None,
124 author_email: None,
125 }
126 }
127}
128
129impl Default for Config {
130 fn default() -> Self {
131 Self {
132 memory: MemoryConfig::default(),
133 repos: RepoConfig::default(),
134 deploy: HashMap::new(),
135 git: GitConfig::default(),
136 }
137 }
138}
139
140pub fn config_path() -> PathBuf {
144 dirs::config_dir()
145 .unwrap_or_else(|| PathBuf::from("~/.config"))
146 .join("dstack")
147 .join("config.toml")
148}
149
150impl Config {
151 pub fn load() -> anyhow::Result<Self> {
154 let path = config_path();
155 if path.exists() {
156 let contents = std::fs::read_to_string(&path)?;
157 let config: Config = toml::from_str(&contents)?;
158 Ok(config)
159 } else {
160 Ok(Config::default())
161 }
162 }
163
164 pub fn memory_path(&self) -> PathBuf {
166 expand_tilde(&self.memory.path)
167 }
168
169 pub fn eruka_service_key(&self) -> Option<String> {
172 std::env::var("DSTACK_ERUKA_KEY")
173 .ok()
174 .or_else(|| self.memory.eruka.service_key.clone())
175 }
176}
177
178fn expand_tilde(path: &str) -> PathBuf {
180 if let Some(rest) = path.strip_prefix("~/") {
181 if let Some(home) = dirs::home_dir() {
182 return home.join(rest);
183 }
184 } else if path == "~" {
185 if let Some(home) = dirs::home_dir() {
186 return home;
187 }
188 }
189 PathBuf::from(path)
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn test_defaults() {
198 let cfg = Config::default();
199 assert_eq!(cfg.memory.backend, "file");
200 assert_eq!(cfg.memory.path, "~/.dstack/memory");
201 assert_eq!(cfg.repos.root, "/opt");
202 assert!(cfg.repos.tracked.is_empty());
203 assert!(cfg.deploy.is_empty());
204 }
205
206 #[test]
207 fn test_expand_tilde() {
208 let expanded = expand_tilde("~/.dstack/memory");
209 if dirs::home_dir().is_some() {
211 assert!(!expanded.to_string_lossy().starts_with('~'));
212 assert!(expanded.to_string_lossy().ends_with(".dstack/memory"));
213 }
214 }
215
216 #[test]
217 fn test_expand_tilde_no_prefix() {
218 let expanded = expand_tilde("/absolute/path");
219 assert_eq!(expanded, PathBuf::from("/absolute/path"));
220 }
221
222 #[test]
223 fn test_parse_minimal_toml() {
224 let toml_str = r#"
225[memory]
226backend = "eruka"
227
228[repos]
229root = "/home/user/projects"
230tracked = ["ares", "eruka"]
231"#;
232 let cfg: Config = toml::from_str(toml_str).unwrap();
233 assert_eq!(cfg.memory.backend, "eruka");
234 assert_eq!(cfg.repos.root, "/home/user/projects");
235 assert_eq!(cfg.repos.tracked, vec!["ares", "eruka"]);
236 }
237
238 #[test]
239 fn test_parse_full_toml() {
240 let toml_str = r#"
241[memory]
242backend = "eruka"
243path = "/custom/memory"
244
245[memory.eruka]
246url = "https://eruka.example.com"
247service_key = "secret123"
248
249[repos]
250root = "/opt"
251tracked = ["ares", "eruka", "doltares"]
252
253[deploy.ares]
254build = "cargo build --release"
255service = "ares"
256smoke = "curl -sf http://localhost:3000/health"
257
258[deploy.eruka]
259build = "cargo build --release"
260service = "eruka"
261
262[git]
263author_name = "bkataru"
264author_email = "baalateja.k@gmail.com"
265"#;
266 let cfg: Config = toml::from_str(toml_str).unwrap();
267 assert_eq!(cfg.memory.backend, "eruka");
268 assert_eq!(cfg.memory.eruka.url, "https://eruka.example.com");
269 assert_eq!(
270 cfg.memory.eruka.service_key,
271 Some("secret123".to_string())
272 );
273 assert_eq!(cfg.repos.tracked.len(), 3);
274 assert!(cfg.deploy.contains_key("ares"));
275 assert!(cfg.deploy.contains_key("eruka"));
276 assert_eq!(
277 cfg.deploy["ares"].smoke,
278 Some("curl -sf http://localhost:3000/health".to_string())
279 );
280 assert!(cfg.deploy["eruka"].smoke.is_none());
281 assert_eq!(cfg.git.author_name, Some("bkataru".to_string()));
282 }
283
284 #[test]
285 fn test_eruka_service_key_env_override() {
286 let cfg = Config::default();
287 let key = cfg.eruka_service_key();
291 if std::env::var("DSTACK_ERUKA_KEY").is_err() {
292 assert!(key.is_none());
293 }
294 }
295
296 #[test]
297 fn test_config_path() {
298 let path = config_path();
299 assert!(path.to_string_lossy().contains("dstack"));
300 assert!(path.to_string_lossy().ends_with("config.toml"));
301 }
302
303 #[test]
304 fn test_load_returns_defaults_when_no_file() {
305 let cfg = Config::load().unwrap();
307 assert_eq!(cfg.memory.backend, "file");
308 }
309}