use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
pub struct SelfHostedConfig {
pub bucket: String,
pub region: String,
#[serde(default)]
pub profile: Option<String>,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub table: Option<String>,
#[serde(default)]
pub gate_url: Option<String>,
#[serde(default)]
pub distribution_id: Option<String>,
}
impl SelfHostedConfig {
pub fn is_full(&self) -> bool {
self.gate_url.is_some()
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Backend {
pub name: String,
pub kind: String,
#[serde(flatten)]
pub config: toml::Table,
}
impl Backend {
pub fn self_hosted(name: &str, cfg: &SelfHostedConfig) -> Result<Backend> {
let value = toml::Value::try_from(cfg)
.map_err(|e| Error::Config(format!("serializing self-hosted config: {e}")))?;
let config = match value {
toml::Value::Table(t) => t,
_ => {
return Err(Error::Config(
"self-hosted config did not serialize to a table".into(),
))
}
};
Ok(Backend {
name: name.to_string(),
kind: "self-hosted".to_string(),
config,
})
}
}
#[derive(Serialize, Deserialize, Default, Debug)]
pub struct Registry {
pub active: String,
pub backends: Vec<Backend>,
}
impl Registry {
pub fn load() -> Result<Registry> {
let path = config_path()?;
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(Registry {
active: String::new(),
backends: Vec::new(),
})
}
Err(e) => return Err(Error::Config(format!("reading {}: {e}", path.display()))),
};
let table: toml::Table = toml::from_str(&text)
.map_err(|e| Error::Config(format!("parsing dove config: {e}")))?;
if table.contains_key("active") && table.contains_key("backends") {
let reg: Registry = toml::Value::Table(table)
.try_into()
.map_err(|e| Error::Config(format!("parsing dove config registry: {e}")))?;
return Ok(reg);
}
let legacy: SelfHostedConfig = toml::Value::Table(table)
.try_into()
.map_err(|e| Error::Config(format!("parsing dove config: {e}")))?;
let backend = Backend::self_hosted("default", &legacy)?;
let reg = Registry {
active: "default".to_string(),
backends: vec![backend],
};
reg.save()?;
Ok(reg)
}
pub fn save(&self) -> Result<()> {
let path = config_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| Error::Config(format!("creating {}: {e}", parent.display())))?;
}
let text = toml::to_string_pretty(self)
.map_err(|e| Error::Config(format!("serializing dove config: {e}")))?;
std::fs::write(&path, text)
.map_err(|e| Error::Config(format!("writing {}: {e}", path.display())))
}
pub fn active_backend(&self) -> Result<&Backend> {
if self.active.is_empty() {
return Err(Error::Config(
"no dove config yet — run `dove provision` first".into(),
));
}
self.backends
.iter()
.find(|b| b.name == self.active)
.ok_or_else(|| {
Error::Config(format!(
"active backend '{}' not found in config — run `dove provision` first",
self.active
))
})
}
pub fn set_active(&mut self, name: &str) -> Result<()> {
if !self.backends.iter().any(|b| b.name == name) {
return Err(Error::Config(format!("no backend named '{name}'")));
}
self.active = name.to_string();
Ok(())
}
pub fn upsert(&mut self, b: Backend) {
if let Some(existing) = self.backends.iter_mut().find(|x| x.name == b.name) {
*existing = b;
} else {
self.backends.push(b);
}
}
pub fn active_self_hosted(&self) -> Result<SelfHostedConfig> {
let backend = self.active_backend()?;
toml::Value::Table(backend.config.clone())
.try_into()
.map_err(|e| Error::Config(format!("reading '{}' backend config: {e}", backend.name)))
}
}
fn config_path() -> Result<PathBuf> {
if let Ok(p) = std::env::var("DOVE_CONFIG") {
if !p.is_empty() {
return Ok(PathBuf::from(p));
}
}
if let Ok(x) = std::env::var("XDG_CONFIG_HOME") {
if !x.is_empty() {
return Ok(PathBuf::from(x).join("dove/config.toml"));
}
}
let home = std::env::var("HOME").map_err(|_| Error::Config("HOME is not set".into()))?;
Ok(PathBuf::from(home).join(".config/dove/config.toml"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn self_hosted_round_trips_through_backend() {
let cfg = SelfHostedConfig {
bucket: "dove-shares-example".into(),
region: "us-east-1".into(),
profile: Some("work".into()),
endpoint: None,
table: Some("dove-shares-example".into()),
gate_url: Some("https://share.example.com".into()),
distribution_id: Some("E123ABC".into()),
};
let backend = Backend::self_hosted("default", &cfg).unwrap();
assert_eq!(backend.name, "default");
assert_eq!(backend.kind, "self-hosted");
assert!(cfg.is_full());
let reg = Registry {
active: "default".into(),
backends: vec![backend],
};
let recovered = reg.active_self_hosted().unwrap();
assert_eq!(recovered, cfg);
}
#[test]
fn active_backend_missing_is_config_error() {
let reg = Registry {
active: String::new(),
backends: vec![],
};
let err = reg.active_backend().unwrap_err();
assert!(matches!(err, Error::Config(_)));
}
#[test]
fn optional_fields_default_to_none() {
let cfg: SelfHostedConfig =
toml::from_str("bucket = \"b\"\nregion = \"us-east-1\"\n").unwrap();
assert_eq!(cfg.profile, None);
assert_eq!(cfg.endpoint, None);
}
#[test]
fn missing_required_field_is_an_error() {
let result: std::result::Result<SelfHostedConfig, _> =
toml::from_str("region = \"us-east-1\"\n"); assert!(result.is_err());
}
}