coven/
config.rs

1//! Sync + storage configuration.
2//!
3//! `Config` is a plain runtime struct the host populates from its own config.
4//! coven never persists it — the host owns its config file and maps the
5//! sync-relevant fields into `Config` when constructing the sync manager.
6
7use serde::{Deserialize, Serialize};
8
9use crate::library_dir::LibraryDir;
10
11/// Cloud home provider selection.
12#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
13pub enum CloudProvider {
14    S3,
15    GoogleDrive,
16    Dropbox,
17    OneDrive,
18    HttpProxy,
19    CloudKit,
20}
21
22impl CloudProvider {
23    /// Whether this provider requires an OAuth email/account for setup.
24    pub fn needs_email(&self) -> bool {
25        matches!(self, Self::GoogleDrive | Self::Dropbox | Self::OneDrive)
26    }
27}
28
29/// The cloud home: which provider backs sync and its per-provider settings.
30/// One cohesive unit — connecting picks a provider and fills its fields;
31/// disconnecting resets the whole thing to default.
32#[derive(Clone, Debug, Default, Serialize, Deserialize)]
33pub struct CloudHomeConfig {
34    /// Selected provider. None = not configured.
35    #[serde(default)]
36    pub provider: Option<CloudProvider>,
37    #[serde(default)]
38    pub s3_bucket: Option<String>,
39    #[serde(default)]
40    pub s3_region: Option<String>,
41    #[serde(default)]
42    pub s3_endpoint: Option<String>,
43    #[serde(default)]
44    pub s3_key_prefix: Option<String>,
45    #[serde(default)]
46    pub google_drive_folder_id: Option<String>,
47    #[serde(default)]
48    pub dropbox_folder_path: Option<String>,
49    #[serde(default)]
50    pub onedrive_drive_id: Option<String>,
51    #[serde(default)]
52    pub onedrive_folder_id: Option<String>,
53    /// Whether this library's CloudKit zone is shared (joiner) vs owned (creator).
54    #[serde(default)]
55    pub cloudkit_is_shared: bool,
56    #[serde(default)]
57    pub http_url: Option<String>,
58}
59
60/// Configuration errors.
61#[derive(thiserror::Error, Debug)]
62pub enum ConfigError {
63    #[error("Serialization error: {0}")]
64    Serialization(String),
65    #[error("Configuration error: {0}")]
66    Config(String),
67    #[error("IO error: {0}")]
68    Io(#[from] std::io::Error),
69}
70
71/// Sync + storage configuration for one library.
72#[derive(Clone, Debug)]
73pub struct Config {
74    pub library_id: String,
75    /// Unique device identifier for sync changeset namespacing.
76    pub device_id: String,
77    pub library_dir: LibraryDir,
78    pub library_name: String,
79    /// Whether an encryption key is stored in the keyring (hint flag).
80    pub encryption_key_stored: bool,
81    /// SHA-256 fingerprint of the encryption key (detects wrong key without decryption).
82    pub encryption_key_fingerprint: Option<String>,
83    /// Cloud home provider + its settings.
84    pub cloud_home: CloudHomeConfig,
85}
86
87impl Config {
88    /// Whether sync is configured: a provider is selected and the matching
89    /// settings + credentials are present.
90    pub fn sync_enabled(&self, key_service: &crate::keys::KeyService) -> bool {
91        use crate::keys::CloudHomeCredentials;
92
93        let creds = key_service
94            .get_cloud_home_credentials()
95            .unwrap_or_else(|e| {
96                tracing::warn!("reading cloud home credentials for sync_enabled: {e}");
97                None
98            });
99        let has_s3 = matches!(creds, Some(CloudHomeCredentials::S3 { .. }));
100        let has_oauth = matches!(creds, Some(CloudHomeCredentials::OAuth { .. }));
101
102        let ch = &self.cloud_home;
103        match ch.provider {
104            Some(CloudProvider::S3) => ch.s3_bucket.is_some() && ch.s3_region.is_some() && has_s3,
105            Some(CloudProvider::GoogleDrive) => ch.google_drive_folder_id.is_some() && has_oauth,
106            Some(CloudProvider::Dropbox) => ch.dropbox_folder_path.is_some() && has_oauth,
107            Some(CloudProvider::OneDrive) => {
108                ch.onedrive_drive_id.is_some() && ch.onedrive_folder_id.is_some() && has_oauth
109            }
110            Some(CloudProvider::HttpProxy) => ch.http_url.is_some(),
111            Some(CloudProvider::CloudKit) => true,
112            None => false,
113        }
114    }
115
116    /// Whether the app is running in dev mode (loads secrets from env / `.env`
117    /// instead of the OS keyring). Set `COVEN_DEV_MODE` or place a `.env` file.
118    pub fn is_dev_mode() -> bool {
119        std::env::var("COVEN_DEV_MODE").is_ok() || std::path::Path::new(".env").exists()
120    }
121
122    /// Construct a config with defaults for a new or joined library.
123    pub fn with_defaults(
124        library_id: String,
125        device_id: String,
126        library_dir: LibraryDir,
127        library_name: String,
128    ) -> Self {
129        Self {
130            library_id,
131            device_id,
132            library_dir,
133            library_name,
134            encryption_key_stored: false,
135            encryption_key_fingerprint: None,
136            cloud_home: CloudHomeConfig::default(),
137        }
138    }
139
140    /// Persist the sync config to `library_dir/config.yaml`.
141    pub fn save(&self) -> Result<(), ConfigError> {
142        self.save_to_config_yaml()
143    }
144
145    /// Persist the sync config to `library_dir/config.yaml`.
146    pub fn save_to_config_yaml(&self) -> Result<(), ConfigError> {
147        std::fs::create_dir_all(&*self.library_dir)?;
148        let yaml: ConfigYaml = self.into();
149        let text =
150            serde_yaml::to_string(&yaml).map_err(|e| ConfigError::Serialization(e.to_string()))?;
151        std::fs::write(self.library_dir.config_path(), text)?;
152        Ok(())
153    }
154}
155
156/// On-disk form of [`Config`] (the runtime `library_dir` is supplied separately).
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct ConfigYaml {
159    pub library_id: String,
160    pub library_name: String,
161    #[serde(default)]
162    pub device_id: Option<String>,
163    #[serde(default)]
164    pub encryption_key_stored: bool,
165    #[serde(default)]
166    pub encryption_key_fingerprint: Option<String>,
167    #[serde(default, flatten)]
168    pub cloud_home: CloudHomeConfig,
169}
170
171impl ConfigYaml {
172    /// Build a runtime [`Config`] from the on-disk form, supplying the resolved
173    /// device id and library directory.
174    pub fn into_config(self, device_id: String, library_dir: LibraryDir) -> Config {
175        Config {
176            library_id: self.library_id,
177            device_id,
178            library_dir,
179            library_name: self.library_name,
180            encryption_key_stored: self.encryption_key_stored,
181            encryption_key_fingerprint: self.encryption_key_fingerprint,
182            cloud_home: self.cloud_home,
183        }
184    }
185}
186
187impl From<&Config> for ConfigYaml {
188    fn from(config: &Config) -> Self {
189        Self {
190            library_id: config.library_id.clone(),
191            library_name: config.library_name.clone(),
192            device_id: Some(config.device_id.clone()),
193            encryption_key_stored: config.encryption_key_stored,
194            encryption_key_fingerprint: config.encryption_key_fingerprint.clone(),
195            cloud_home: config.cloud_home.clone(),
196        }
197    }
198}