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    /// Deploy type: "systemd" (default) or "docker-compose"
65    #[serde(default = "default_deploy_type")]
66    pub deploy_type: String,
67    /// Build command (e.g., "cargo build --release"). Optional for docker-compose.
68    #[serde(default)]
69    pub build: String,
70    /// Systemd service name (e.g., "ares") or docker-compose service name
71    pub service: String,
72    /// Path to docker-compose.yml (only for docker-compose type)
73    pub compose_file: Option<String>,
74    /// Optional smoke test command
75    pub smoke: Option<String>,
76}
77
78fn default_deploy_type() -> String {
79    "systemd".to_string()
80}
81
82/// Git authorship configuration.
83#[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
91// --- Defaults ---
92
93fn 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
163// --- Config loading ---
164
165/// Returns the path to the dstack config file: ~/.config/dstack/config.toml
166pub 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    /// Load configuration from ~/.config/dstack/config.toml.
175    /// Returns defaults if the file does not exist.
176    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    /// Returns the memory path with ~ expanded to the user's home directory.
188    pub fn memory_path(&self) -> PathBuf {
189        expand_tilde(&self.memory.path)
190    }
191
192    /// Load environment variables from the configured .env file.
193    /// Silently skips if file doesn't exist. Does NOT override existing env vars.
194    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            // Skip comments and empty lines
206            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                // Don't override existing env vars
213                if std::env::var(key).is_err() {
214                    std::env::set_var(key, value);
215                }
216            }
217        }
218    }
219
220    /// Returns the Eruka service key.
221    /// Checks $DSTACK_ERUKA_KEY env var first, falls back to config file value.
222    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
229/// Expand leading ~ to the user's home directory.
230fn 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        // Should not start with ~ after expansion (unless no home dir)
261        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        // Without env var, should return None (no config file key set)
339        // We can't reliably test env var override without setting it,
340        // but we verify the fallback path works.
341        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        // Should not panic regardless of whether config file exists
357        let cfg = Config::load().unwrap();
358        // Backend is either "file" (default) or whatever is configured
359        assert!(cfg.memory.backend == "file" || cfg.memory.backend == "eruka");
360    }
361}