1use serde::{Deserialize, Serialize};
10use std::num::NonZeroU64;
11
12use crate::store_dir::StoreDir;
13
14#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
16pub enum CloudProvider {
17 S3,
18 GoogleDrive,
19 Dropbox,
20 OneDrive,
21 CloudKit,
22}
23
24#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "snake_case")]
29pub enum ExactUploadVerification {
30 UploadChecksum,
33 MetadataHash,
35 Readback,
37 Unchecked,
40}
41
42fn default_exact_upload_verification() -> ExactUploadVerification {
43 ExactUploadVerification::MetadataHash
44}
45
46impl CloudProvider {
47 pub fn needs_oauth(&self) -> bool {
51 matches!(self, Self::GoogleDrive | Self::Dropbox | Self::OneDrive)
52 }
53}
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "lowercase")]
76pub enum HomeStorage {
77 Opaque,
78 Browsable,
79}
80
81impl HomeStorage {
82 pub fn is_opaque(self) -> bool {
85 matches!(self, HomeStorage::Opaque)
86 }
87
88 pub fn is_browsable(self) -> bool {
91 matches!(self, HomeStorage::Browsable)
92 }
93}
94
95#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
99pub struct CloudHomeConfig {
100 pub provider: Option<CloudProvider>,
102 pub s3_bucket: Option<String>,
103 pub s3_region: Option<String>,
104 pub s3_endpoint: Option<String>,
105 pub s3_key_prefix: Option<String>,
106 pub exact_upload_verification: ExactUploadVerification,
107 pub google_drive_folder_id: Option<String>,
108 pub dropbox_folder_path: Option<String>,
109 pub onedrive_drive_id: Option<String>,
110 pub onedrive_folder_id: Option<String>,
111 pub cloudkit_owner_name: Option<String>,
112 pub cloudkit_zone_name: Option<String>,
113 pub storage: HomeStorage,
117}
118
119impl Default for CloudHomeConfig {
120 fn default() -> Self {
121 Self {
122 provider: None,
123 s3_bucket: None,
124 s3_region: None,
125 s3_endpoint: None,
126 s3_key_prefix: None,
127 exact_upload_verification: default_exact_upload_verification(),
128 google_drive_folder_id: None,
129 dropbox_folder_path: None,
130 onedrive_drive_id: None,
131 onedrive_folder_id: None,
132 cloudkit_owner_name: None,
133 cloudkit_zone_name: None,
134 storage: HomeStorage::Opaque,
135 }
136 }
137}
138
139#[derive(thiserror::Error, Debug)]
141pub enum ConfigError {
142 #[error("serialize configuration: {0}")]
143 Serialize(#[source] serde_yaml::Error),
144 #[error("parse configuration {}: {source}", path.display())]
145 Parse {
146 path: std::path::PathBuf,
147 #[source]
148 source: serde_yaml::Error,
149 },
150 #[error("configuration file: {0}")]
151 File(#[from] crate::atomic_file::FileError),
152}
153
154#[derive(Clone, Debug, PartialEq)]
156pub struct Config {
157 pub store_id: String,
158 pub device_id: String,
160 pub store_name: String,
161 pub snapshot_commit_threshold: NonZeroU64,
164 pub cloud_home: CloudHomeConfig,
166}
167
168impl Config {
169 pub const DEFAULT_SNAPSHOT_COMMIT_THRESHOLD: NonZeroU64 = NonZeroU64::new(100).unwrap();
170
171 pub fn with_defaults(store_id: String, device_id: String, store_name: String) -> Self {
173 Self {
174 store_id,
175 device_id,
176 store_name,
177 snapshot_commit_threshold: Self::DEFAULT_SNAPSHOT_COMMIT_THRESHOLD,
178 cloud_home: CloudHomeConfig::default(),
179 }
180 }
181
182 pub fn save_to_config_yaml(&self, store_dir: &StoreDir) -> Result<(), ConfigError> {
184 let yaml: ConfigYaml = self.into();
185 let text = serde_yaml::to_string(&yaml).map_err(ConfigError::Serialize)?;
186 crate::atomic_file::AtomicFile::new(store_dir.config_path())
187 .replace(text.as_bytes())
188 .map_err(ConfigError::File)
189 }
190
191 pub fn load_from_config_yaml(store_dir: &StoreDir) -> Result<Config, ConfigError> {
194 let path = store_dir.config_path();
195 let text = std::fs::read_to_string(&path).map_err(|source| {
196 ConfigError::File(crate::atomic_file::FileError::at(
197 "read configuration",
198 &path,
199 source,
200 ))
201 })?;
202 let yaml: ConfigYaml =
203 serde_yaml::from_str(&text).map_err(|source| ConfigError::Parse {
204 path: path.clone(),
205 source,
206 })?;
207 Ok(yaml.into_config())
208 }
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
217pub(crate) struct ConfigYaml {
218 pub(crate) store_id: String,
219 pub(crate) store_name: String,
220 pub(crate) device_id: String,
221 pub(crate) snapshot_commit_threshold: NonZeroU64,
222 #[serde(flatten)]
223 pub(crate) cloud_home: CloudHomeConfig,
224}
225
226impl From<&Config> for ConfigYaml {
227 fn from(config: &Config) -> Self {
228 Self {
229 store_id: config.store_id.clone(),
230 store_name: config.store_name.clone(),
231 device_id: config.device_id.clone(),
232 snapshot_commit_threshold: config.snapshot_commit_threshold,
233 cloud_home: config.cloud_home.clone(),
234 }
235 }
236}
237
238impl ConfigYaml {
239 fn into_config(self) -> Config {
241 Config {
242 store_id: self.store_id,
243 device_id: self.device_id,
244 store_name: self.store_name,
245 snapshot_commit_threshold: self.snapshot_commit_threshold,
246 cloud_home: self.cloud_home,
247 }
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn oauth_requirement_follows_the_provider() {
257 assert!(!CloudProvider::S3.needs_oauth());
258 assert!(!CloudProvider::CloudKit.needs_oauth());
259 assert!(CloudProvider::GoogleDrive.needs_oauth());
260 assert!(CloudProvider::Dropbox.needs_oauth());
261 assert!(CloudProvider::OneDrive.needs_oauth());
262 }
263
264 #[test]
267 fn round_trips_through_save_and_load() {
268 let dir = tempfile::tempdir().expect("temp dir");
269 let store_dir = StoreDir::new_ephemeral(dir.path());
270 let mut config = Config::with_defaults(
271 "store-1".to_string(),
272 "device-1".to_string(),
273 "My Store".to_string(),
274 );
275 config.cloud_home = CloudHomeConfig {
276 provider: Some(CloudProvider::S3),
277 s3_bucket: Some("bucket".to_string()),
278 s3_region: Some("us-east-1".to_string()),
279 exact_upload_verification: ExactUploadVerification::Readback,
280 storage: HomeStorage::Opaque,
281 ..CloudHomeConfig::default()
282 };
283 config.snapshot_commit_threshold = NonZeroU64::new(7).unwrap();
284
285 config.save_to_config_yaml(&store_dir).expect("save");
286 let config_yaml =
287 std::fs::read_to_string(store_dir.config_path()).expect("read saved local config");
288 assert!(config_yaml.contains("exact_upload_verification: readback"));
289 assert!(config_yaml.contains("snapshot_commit_threshold: 7"));
290 let loaded = Config::load_from_config_yaml(&store_dir).expect("load");
291
292 assert_eq!(loaded, config);
293 }
294
295 #[test]
299 fn round_trips_cloudkit_share_owner_and_zone() {
300 let dir = tempfile::tempdir().expect("temp dir");
301 let store_dir = StoreDir::new_ephemeral(dir.path());
302 let mut config = Config::with_defaults(
303 "store-1".to_string(),
304 "device-1".to_string(),
305 "Shared CloudKit Store".to_string(),
306 );
307 config.cloud_home = CloudHomeConfig {
308 provider: Some(CloudProvider::CloudKit),
309 cloudkit_owner_name: Some("owner-name".to_string()),
310 cloudkit_zone_name: Some("zone-name".to_string()),
311 storage: HomeStorage::Opaque,
312 ..CloudHomeConfig::default()
313 };
314
315 config.save_to_config_yaml(&store_dir).expect("save");
316 let loaded = Config::load_from_config_yaml(&store_dir).expect("load");
317 assert_eq!(loaded, config);
318 }
319
320 #[test]
323 fn load_with_absent_optional_provider_fields() {
324 let dir = tempfile::tempdir().expect("temp dir");
325 let store_dir = StoreDir::new_ephemeral(dir.path());
326 std::fs::write(
327 store_dir.config_path(),
328 "store_id: store-1\nstore_name: My Store\ndevice_id: device-1\nsnapshot_commit_threshold: 100\nexact_upload_verification: metadata_hash\nstorage: opaque\n",
329 )
330 .expect("write config.yaml");
331
332 let loaded = Config::load_from_config_yaml(&store_dir).expect("load");
333
334 assert_eq!(loaded.store_id, "store-1");
335 assert_eq!(loaded.store_name, "My Store");
336 assert_eq!(loaded.device_id, "device-1");
337 assert_eq!(loaded.cloud_home, CloudHomeConfig::default());
338 }
339
340 #[test]
341 fn load_requires_a_positive_snapshot_commit_threshold() {
342 for threshold in ["", "snapshot_commit_threshold: 0\n"] {
343 let dir = tempfile::tempdir().expect("temp dir");
344 let store_dir = StoreDir::new_ephemeral(dir.path());
345 std::fs::write(
346 store_dir.config_path(),
347 format!("store_id: store-1\nstore_name: My Store\ndevice_id: device-1\n{threshold}exact_upload_verification: metadata_hash\nstorage: opaque\n"),
348 )
349 .expect("write config.yaml");
350 let error = Config::load_from_config_yaml(&store_dir)
351 .expect_err("snapshot threshold must be present and positive");
352 assert!(matches!(error, ConfigError::Parse { .. }));
353 assert!(error.to_string().contains("snapshot_commit_threshold"));
354 }
355 }
356
357 #[test]
358 fn load_with_missing_upload_verification_errors() {
359 let dir = tempfile::tempdir().expect("temp dir");
360 let store_dir = StoreDir::new_ephemeral(dir.path());
361 std::fs::write(
362 store_dir.config_path(),
363 "store_id: store-1\nstore_name: My Store\ndevice_id: device-1\nsnapshot_commit_threshold: 100\nstorage: opaque\n",
364 )
365 .expect("write config.yaml");
366
367 let error = Config::load_from_config_yaml(&store_dir)
368 .expect_err("missing exact upload verification");
369 assert!(matches!(error, ConfigError::Parse { .. }));
370 }
371
372 #[test]
375 fn load_with_missing_device_id_errors() {
376 let dir = tempfile::tempdir().expect("temp dir");
377 let store_dir = StoreDir::new_ephemeral(dir.path());
378 std::fs::write(
379 store_dir.config_path(),
380 "store_id: store-1\nstore_name: My Store\n",
381 )
382 .expect("write config.yaml");
383
384 let err = Config::load_from_config_yaml(&store_dir).expect_err("missing device_id");
385 assert!(matches!(err, ConfigError::Parse { .. }));
386 }
387
388 #[test]
391 fn load_with_no_file_errors_naming_the_path() {
392 let dir = tempfile::tempdir().expect("temp dir");
393 let store_dir = StoreDir::new_ephemeral(dir.path());
394
395 let err = Config::load_from_config_yaml(&store_dir).expect_err("no file");
396 let message = err.to_string();
397 assert!(
398 message.contains(&store_dir.config_path().display().to_string()),
399 "error should name the missing path, got: {message}",
400 );
401 }
402}