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    /// Path to .env file for secrets (supports ~ expansion).
9    /// Loaded before any command runs. Gitignored by default.
10    #[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    /// Path to local skills repo (e.g. ~/projects/skills)
21    #[serde(default)]
22    pub skills_repo: Option<String>,
23}
24
25/// Memory backend configuration.
26#[derive(Debug, Deserialize)]
27pub struct MemoryConfig {
28    /// "file" or "eruka"
29    #[serde(default = "default_backend")]
30    pub backend: String,
31    /// Path to local memory store (supports ~ expansion)
32    #[serde(default = "default_memory_path")]
33    pub path: String,
34    /// Eruka-specific settings (only used when backend = "eruka")
35    #[serde(default)]
36    pub eruka: ErukaConfig,
37}
38
39/// Eruka memory backend configuration.
40#[derive(Debug, Deserialize)]
41pub struct ErukaConfig {
42    #[serde(default = "default_eruka_url")]
43    pub url: String,
44    /// Service key for Eruka API authentication.
45    /// Can also be set via $DSTACK_ERUKA_KEY env var (takes precedence).
46    #[serde(default)]
47    pub service_key: Option<String>,
48}
49
50/// Repository discovery and tracking configuration.
51#[derive(Debug, Deserialize)]
52pub struct RepoConfig {
53    /// Root directory to scan for repos
54    #[serde(default = "default_repo_root")]
55    pub root: String,
56    /// Explicit list of tracked repo names
57    #[serde(default)]
58    pub tracked: Vec<String>,
59}
60
61/// Deployment target for a service.
62#[derive(Debug, Deserialize)]
63pub struct DeployTarget {
64    /// Build command (e.g., "cargo build --release")
65    pub build: String,
66    /// Systemd service name (e.g., "ares")
67    pub service: String,
68    /// Optional smoke test command
69    pub smoke: Option<String>,
70}
71
72/// Git authorship configuration.
73#[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
81// --- Defaults ---
82
83fn 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
153// --- Config loading ---
154
155/// Returns the path to the dstack config file: ~/.config/dstack/config.toml
156pub 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    /// Load configuration from ~/.config/dstack/config.toml.
165    /// Returns defaults if the file does not exist.
166    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    /// Returns the memory path with ~ expanded to the user's home directory.
178    pub fn memory_path(&self) -> PathBuf {
179        expand_tilde(&self.memory.path)
180    }
181
182    /// Load environment variables from the configured .env file.
183    /// Silently skips if file doesn't exist. Does NOT override existing env vars.
184    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            // Skip comments and empty lines
196            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                // Don't override existing env vars
203                if std::env::var(key).is_err() {
204                    std::env::set_var(key, value);
205                }
206            }
207        }
208    }
209
210    /// Returns the Eruka service key.
211    /// Checks $DSTACK_ERUKA_KEY env var first, falls back to config file value.
212    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
219/// Expand leading ~ to the user's home directory.
220fn 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        // Should not start with ~ after expansion (unless no home dir)
251        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        // Without env var, should return None (no config file key set)
329        // We can't reliably test env var override without setting it,
330        // but we verify the fallback path works.
331        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        // Should not panic regardless of whether config file exists
347        let cfg = Config::load().unwrap();
348        // Backend is either "file" (default) or whatever is configured
349        assert!(cfg.memory.backend == "file" || cfg.memory.backend == "eruka");
350    }
351}