Skip to main content

dove_core/
config.rs

1//! The named-backend registry: where dove looks up *which* cloud a share goes
2//! to. Today there's one kind of backend (`self-hosted` — your own S3 bucket,
3//! the fields `dove provision` writes), but the registry format supports many,
4//! named and switchable (`dove backend use <name>`, a later CLI feature).
5//!
6//! Lives at `~/.config/dove/config.toml`, honoring `$XDG_CONFIG_HOME` (and, for
7//! tests, a `DOVE_CONFIG` override that pins the exact path). A registry file
8//! from before this format existed — bare `bucket`/`region`/… fields, no
9//! `active`/`backends` — is transparently migrated into a `"default"`
10//! self-hosted backend and rewritten in the new format the first time it's
11//! loaded.
12
13use crate::error::{Error, Result};
14use serde::{Deserialize, Serialize};
15use std::path::PathBuf;
16
17/// The self-hosted backend's fields — this *is* the CLI's original bare
18/// `Config` struct, moved here verbatim so behavior is preserved exactly.
19#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
20pub struct SelfHostedConfig {
21    /// The S3 bucket dove uploads shares to.
22    pub bucket: String,
23    pub region: String,
24    /// AWS profile whose credentials sign presigned URLs. `None` → the default
25    /// credential chain (env / default profile / instance role).
26    #[serde(default)]
27    pub profile: Option<String>,
28    /// Optional S3-compatible endpoint (MinIO, R2, …); omitted → real AWS S3.
29    #[serde(default)]
30    pub endpoint: Option<String>,
31    /// DynamoDB table holding share policies — full tier only.
32    #[serde(default)]
33    pub table: Option<String>,
34    /// The access-gate base URL. Its presence marks the config as full-tier:
35    /// `share` registers a policy and points links at the gate instead of a raw
36    /// presigned URL. This is the CloudFront domain (or a custom domain), which
37    /// signs requests to the IAM-private Lambda Function URL behind it.
38    #[serde(default)]
39    pub gate_url: Option<String>,
40    /// The CloudFront distribution fronting the gate (full tier). `domain add`
41    /// updates this distribution to attach a custom domain.
42    #[serde(default)]
43    pub distribution_id: Option<String>,
44}
45
46impl SelfHostedConfig {
47    /// Whether this config is provisioned for the full (gated, encrypted) tier.
48    pub fn is_full(&self) -> bool {
49        self.gate_url.is_some()
50    }
51}
52
53/// One named backend in the registry. `config` is kind-specific — for
54/// `kind == "self-hosted"` it deserializes into [`SelfHostedConfig`].
55#[derive(Serialize, Deserialize, Clone, Debug)]
56pub struct Backend {
57    pub name: String,
58    /// `"self-hosted"` today; other kinds arrive with future backends.
59    pub kind: String,
60    #[serde(flatten)]
61    pub config: toml::Table,
62}
63
64impl Backend {
65    /// Build a `self-hosted` backend named `name` from a [`SelfHostedConfig`].
66    pub fn self_hosted(name: &str, cfg: &SelfHostedConfig) -> Result<Backend> {
67        let value = toml::Value::try_from(cfg)
68            .map_err(|e| Error::Config(format!("serializing self-hosted config: {e}")))?;
69        let config = match value {
70            toml::Value::Table(t) => t,
71            _ => {
72                return Err(Error::Config(
73                    "self-hosted config did not serialize to a table".into(),
74                ))
75            }
76        };
77        Ok(Backend {
78            name: name.to_string(),
79            kind: "self-hosted".to_string(),
80            config,
81        })
82    }
83}
84
85/// The registry: which backend is active, and the full list dove knows about.
86#[derive(Serialize, Deserialize, Default, Debug)]
87pub struct Registry {
88    pub active: String,
89    pub backends: Vec<Backend>,
90}
91
92impl Registry {
93    /// Load the registry, migrating an old bare-`Config` file in place.
94    ///
95    /// - New format (has `active` + `backends`): parsed and returned as-is.
96    /// - Legacy format (bare self-hosted fields, no `active`/`backends`): wrapped
97    ///   into a `"default"` backend, saved back in the new format, and returned.
98    /// - No file yet: an empty registry (`active` empty, no backends) — the
99    ///   "not provisioned yet" state. This does *not* itself surface the
100    ///   `dove provision`-pointing error the old `Config::load()` gave on a
101    ///   missing file; that guidance now lives in [`Registry::active_backend`],
102    ///   which every real read path goes through.
103    pub fn load() -> Result<Registry> {
104        let path = config_path()?;
105        let text = match std::fs::read_to_string(&path) {
106            Ok(t) => t,
107            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
108                return Ok(Registry {
109                    active: String::new(),
110                    backends: Vec::new(),
111                })
112            }
113            Err(e) => return Err(Error::Config(format!("reading {}: {e}", path.display()))),
114        };
115
116        // Parse once into a generic table so format detection doesn't depend on
117        // serde's `#[serde(default)]` silently filling in a Registry shape for
118        // what's actually a legacy file (every Registry field is defaultable,
119        // so a naive "does it deserialize as Registry" check would say yes to
120        // almost anything). Presence of the `active`/`backends` keys themselves
121        // is what distinguishes the two formats.
122        let table: toml::Table = toml::from_str(&text)
123            .map_err(|e| Error::Config(format!("parsing dove config: {e}")))?;
124
125        if table.contains_key("active") && table.contains_key("backends") {
126            let reg: Registry = toml::Value::Table(table)
127                .try_into()
128                .map_err(|e| Error::Config(format!("parsing dove config registry: {e}")))?;
129            return Ok(reg);
130        }
131
132        // Legacy bare self-hosted config: migrate it into a "default" backend
133        // and persist the new format immediately so this is a one-time upgrade.
134        let legacy: SelfHostedConfig = toml::Value::Table(table)
135            .try_into()
136            .map_err(|e| Error::Config(format!("parsing dove config: {e}")))?;
137        let backend = Backend::self_hosted("default", &legacy)?;
138        let reg = Registry {
139            active: "default".to_string(),
140            backends: vec![backend],
141        };
142        reg.save()?;
143        Ok(reg)
144    }
145
146    /// Write the registry to `~/.config/dove/config.toml` (or `$DOVE_CONFIG`).
147    pub fn save(&self) -> Result<()> {
148        let path = config_path()?;
149        if let Some(parent) = path.parent() {
150            std::fs::create_dir_all(parent)
151                .map_err(|e| Error::Config(format!("creating {}: {e}", parent.display())))?;
152        }
153        let text = toml::to_string_pretty(self)
154            .map_err(|e| Error::Config(format!("serializing dove config: {e}")))?;
155        std::fs::write(&path, text)
156            .map_err(|e| Error::Config(format!("writing {}: {e}", path.display())))
157    }
158
159    /// The currently-active backend. This is where "not provisioned yet" is
160    /// surfaced — same guidance the old `Config::load()` gave on a missing file.
161    pub fn active_backend(&self) -> Result<&Backend> {
162        if self.active.is_empty() {
163            return Err(Error::Config(
164                "no dove config yet — run `dove provision` first".into(),
165            ));
166        }
167        self.backends
168            .iter()
169            .find(|b| b.name == self.active)
170            .ok_or_else(|| {
171                Error::Config(format!(
172                    "active backend '{}' not found in config — run `dove provision` first",
173                    self.active
174                ))
175            })
176    }
177
178    /// Switch the active backend. Errors if no backend by that name exists.
179    pub fn set_active(&mut self, name: &str) -> Result<()> {
180        if !self.backends.iter().any(|b| b.name == name) {
181            return Err(Error::Config(format!("no backend named '{name}'")));
182        }
183        self.active = name.to_string();
184        Ok(())
185    }
186
187    /// Replace the backend with this name, or add it if none exists yet.
188    pub fn upsert(&mut self, b: Backend) {
189        if let Some(existing) = self.backends.iter_mut().find(|x| x.name == b.name) {
190            *existing = b;
191        } else {
192            self.backends.push(b);
193        }
194    }
195
196    /// The active backend's config, deserialized as a [`SelfHostedConfig`].
197    /// Convenience for the CLI's self-hosted-only read paths.
198    pub fn active_self_hosted(&self) -> Result<SelfHostedConfig> {
199        let backend = self.active_backend()?;
200        toml::Value::Table(backend.config.clone())
201            .try_into()
202            .map_err(|e| Error::Config(format!("reading '{}' backend config: {e}", backend.name)))
203    }
204}
205
206/// `~/.config/dove/config.toml`, honoring `$XDG_CONFIG_HOME`. Tests (and
207/// anything else that needs an isolated config) can pin the exact path with
208/// `$DOVE_CONFIG`.
209fn config_path() -> Result<PathBuf> {
210    if let Ok(p) = std::env::var("DOVE_CONFIG") {
211        if !p.is_empty() {
212            return Ok(PathBuf::from(p));
213        }
214    }
215    if let Ok(x) = std::env::var("XDG_CONFIG_HOME") {
216        if !x.is_empty() {
217            return Ok(PathBuf::from(x).join("dove/config.toml"));
218        }
219    }
220    let home = std::env::var("HOME").map_err(|_| Error::Config("HOME is not set".into()))?;
221    Ok(PathBuf::from(home).join(".config/dove/config.toml"))
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn self_hosted_round_trips_through_backend() {
230        let cfg = SelfHostedConfig {
231            bucket: "dove-shares-example".into(),
232            region: "us-east-1".into(),
233            profile: Some("work".into()),
234            endpoint: None,
235            table: Some("dove-shares-example".into()),
236            gate_url: Some("https://share.example.com".into()),
237            distribution_id: Some("E123ABC".into()),
238        };
239        let backend = Backend::self_hosted("default", &cfg).unwrap();
240        assert_eq!(backend.name, "default");
241        assert_eq!(backend.kind, "self-hosted");
242        assert!(cfg.is_full());
243
244        let reg = Registry {
245            active: "default".into(),
246            backends: vec![backend],
247        };
248        let recovered = reg.active_self_hosted().unwrap();
249        assert_eq!(recovered, cfg);
250    }
251
252    #[test]
253    fn active_backend_missing_is_config_error() {
254        let reg = Registry {
255            active: String::new(),
256            backends: vec![],
257        };
258        let err = reg.active_backend().unwrap_err();
259        assert!(matches!(err, Error::Config(_)));
260    }
261
262    // Carried over from the CLI's original `Config` unit tests, so this
263    // extraction doesn't lose coverage of the field-level TOML contract.
264
265    #[test]
266    fn optional_fields_default_to_none() {
267        let cfg: SelfHostedConfig =
268            toml::from_str("bucket = \"b\"\nregion = \"us-east-1\"\n").unwrap();
269        assert_eq!(cfg.profile, None);
270        assert_eq!(cfg.endpoint, None);
271    }
272
273    #[test]
274    fn missing_required_field_is_an_error() {
275        let result: std::result::Result<SelfHostedConfig, _> =
276            toml::from_str("region = \"us-east-1\"\n"); // no bucket
277        assert!(result.is_err());
278    }
279}