Skip to main content

dstack/
config.rs

1use serde::Deserialize;
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5/// Top-level dstack configuration.
6#[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/// Memory backend configuration.
19#[derive(Debug, Deserialize)]
20pub struct MemoryConfig {
21    /// "file" or "eruka"
22    #[serde(default = "default_backend")]
23    pub backend: String,
24    /// Path to local memory store (supports ~ expansion)
25    #[serde(default = "default_memory_path")]
26    pub path: String,
27    /// Eruka-specific settings (only used when backend = "eruka")
28    #[serde(default)]
29    pub eruka: ErukaConfig,
30}
31
32/// Eruka memory backend configuration.
33#[derive(Debug, Deserialize)]
34pub struct ErukaConfig {
35    #[serde(default = "default_eruka_url")]
36    pub url: String,
37    /// Service key for Eruka API authentication.
38    /// Can also be set via $DSTACK_ERUKA_KEY env var (takes precedence).
39    #[serde(default)]
40    pub service_key: Option<String>,
41}
42
43/// Repository discovery and tracking configuration.
44#[derive(Debug, Deserialize)]
45pub struct RepoConfig {
46    /// Root directory to scan for repos
47    #[serde(default = "default_repo_root")]
48    pub root: String,
49    /// Explicit list of tracked repo names
50    #[serde(default)]
51    pub tracked: Vec<String>,
52}
53
54/// Deployment target for a service.
55#[derive(Debug, Deserialize)]
56pub struct DeployTarget {
57    /// Build command (e.g., "cargo build --release")
58    pub build: String,
59    /// Systemd service name (e.g., "ares")
60    pub service: String,
61    /// Optional smoke test command
62    pub smoke: Option<String>,
63}
64
65/// Git authorship configuration.
66#[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
74// --- Defaults ---
75
76fn 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
140// --- Config loading ---
141
142/// Returns the path to the dstack config file: ~/.config/dstack/config.toml
143pub 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    /// Load configuration from ~/.config/dstack/config.toml.
152    /// Returns defaults if the file does not exist.
153    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    /// Returns the memory path with ~ expanded to the user's home directory.
165    pub fn memory_path(&self) -> PathBuf {
166        expand_tilde(&self.memory.path)
167    }
168
169    /// Returns the Eruka service key.
170    /// Checks $DSTACK_ERUKA_KEY env var first, falls back to config file value.
171    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
178/// Expand leading ~ to the user's home directory.
179fn 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        // Should not start with ~ after expansion (unless no home dir)
210        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        // Without env var, should return None (no config file key set)
288        // We can't reliably test env var override without setting it,
289        // but we verify the fallback path works.
290        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        // config_path() likely doesn't exist in test env
306        let cfg = Config::load().unwrap();
307        assert_eq!(cfg.memory.backend, "file");
308    }
309}