use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Deserialize)]
pub struct Config {
#[serde(default = "default_env_file")]
pub env_file: String,
#[serde(default)]
pub memory: MemoryConfig,
#[serde(default)]
pub repos: RepoConfig,
#[serde(default)]
pub deploy: HashMap<String, DeployTarget>,
#[serde(default)]
pub git: GitConfig,
#[serde(default)]
pub skills_repo: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct MemoryConfig {
#[serde(default = "default_backend")]
pub backend: String,
#[serde(default = "default_memory_path")]
pub path: String,
#[serde(default)]
pub eruka: ErukaConfig,
}
#[derive(Debug, Deserialize)]
pub struct ErukaConfig {
#[serde(default = "default_eruka_url")]
pub url: String,
#[serde(default)]
pub service_key: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RepoConfig {
#[serde(default = "default_repo_root")]
pub root: String,
#[serde(default)]
pub tracked: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct DeployTarget {
#[serde(default = "default_deploy_type")]
pub deploy_type: String,
#[serde(default)]
pub build: String,
pub service: String,
pub compose_file: Option<String>,
pub smoke: Option<String>,
}
fn default_deploy_type() -> String {
"systemd".to_string()
}
#[derive(Debug, Deserialize)]
pub struct GitConfig {
#[serde(default)]
pub author_name: Option<String>,
#[serde(default)]
pub author_email: Option<String>,
}
fn default_env_file() -> String {
"~/.config/dstack/.env".to_string()
}
fn default_backend() -> String {
"file".to_string()
}
fn default_memory_path() -> String {
"~/.dstack/memory".to_string()
}
fn default_eruka_url() -> String {
"http://localhost:8081".to_string()
}
fn default_repo_root() -> String {
"/opt".to_string()
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
backend: default_backend(),
path: default_memory_path(),
eruka: ErukaConfig::default(),
}
}
}
impl Default for ErukaConfig {
fn default() -> Self {
Self {
url: default_eruka_url(),
service_key: None,
}
}
}
impl Default for RepoConfig {
fn default() -> Self {
Self {
root: default_repo_root(),
tracked: Vec::new(),
}
}
}
impl Default for GitConfig {
fn default() -> Self {
Self {
author_name: None,
author_email: None,
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
env_file: default_env_file(),
memory: MemoryConfig::default(),
repos: RepoConfig::default(),
deploy: HashMap::new(),
git: GitConfig::default(),
skills_repo: None,
}
}
}
pub fn config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("dstack")
.join("config.toml")
}
impl Config {
pub fn load() -> anyhow::Result<Self> {
let path = config_path();
if path.exists() {
let contents = std::fs::read_to_string(&path)?;
let config: Config = toml::from_str(&contents)?;
Ok(config)
} else {
Ok(Config::default())
}
}
pub fn memory_path(&self) -> PathBuf {
expand_tilde(&self.memory.path)
}
pub fn load_env(&self) {
let path = expand_tilde(&self.env_file);
if !path.exists() {
return;
}
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return,
};
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim().trim_matches('"').trim_matches('\'');
if std::env::var(key).is_err() {
std::env::set_var(key, value);
}
}
}
}
pub fn eruka_service_key(&self) -> Option<String> {
std::env::var("DSTACK_ERUKA_KEY")
.ok()
.or_else(|| self.memory.eruka.service_key.clone())
}
}
fn expand_tilde(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
} else if path == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
}
PathBuf::from(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_defaults() {
let cfg = Config::default();
assert_eq!(cfg.memory.backend, "file");
assert_eq!(cfg.memory.path, "~/.dstack/memory");
assert_eq!(cfg.repos.root, "/opt");
assert!(cfg.repos.tracked.is_empty());
assert!(cfg.deploy.is_empty());
}
#[test]
fn test_expand_tilde() {
let expanded = expand_tilde("~/.dstack/memory");
if dirs::home_dir().is_some() {
assert!(!expanded.to_string_lossy().starts_with('~'));
assert!(expanded.to_string_lossy().ends_with(".dstack/memory"));
}
}
#[test]
fn test_expand_tilde_no_prefix() {
let expanded = expand_tilde("/absolute/path");
assert_eq!(expanded, PathBuf::from("/absolute/path"));
}
#[test]
fn test_parse_minimal_toml() {
let toml_str = r#"
[memory]
backend = "eruka"
[repos]
root = "/home/user/projects"
tracked = ["ares", "eruka"]
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.memory.backend, "eruka");
assert_eq!(cfg.repos.root, "/home/user/projects");
assert_eq!(cfg.repos.tracked, vec!["ares", "eruka"]);
}
#[test]
fn test_parse_full_toml() {
let toml_str = r#"
[memory]
backend = "eruka"
path = "/custom/memory"
[memory.eruka]
url = "https://eruka.example.com"
service_key = "secret123"
[repos]
root = "/opt"
tracked = ["ares", "eruka", "doltares"]
[deploy.ares]
build = "cargo build --release"
service = "ares"
smoke = "curl -sf http://localhost:3000/health"
[deploy.eruka]
build = "cargo build --release"
service = "eruka"
[git]
author_name = "bkataru"
author_email = "baalateja.k@gmail.com"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.memory.backend, "eruka");
assert_eq!(cfg.memory.eruka.url, "https://eruka.example.com");
assert_eq!(
cfg.memory.eruka.service_key,
Some("secret123".to_string())
);
assert_eq!(cfg.repos.tracked.len(), 3);
assert!(cfg.deploy.contains_key("ares"));
assert!(cfg.deploy.contains_key("eruka"));
assert_eq!(
cfg.deploy["ares"].smoke,
Some("curl -sf http://localhost:3000/health".to_string())
);
assert!(cfg.deploy["eruka"].smoke.is_none());
assert_eq!(cfg.git.author_name, Some("bkataru".to_string()));
}
#[test]
fn test_eruka_service_key_env_override() {
let cfg = Config::default();
let key = cfg.eruka_service_key();
if std::env::var("DSTACK_ERUKA_KEY").is_err() {
assert!(key.is_none());
}
}
#[test]
fn test_config_path() {
let path = config_path();
assert!(path.to_string_lossy().contains("dstack"));
assert!(path.to_string_lossy().ends_with("config.toml"));
}
#[test]
fn test_load_succeeds() {
let cfg = Config::load().unwrap();
assert!(cfg.memory.backend == "file" || cfg.memory.backend == "eruka");
}
}