1use serde::{Deserialize, Serialize};
10
11use crate::store_dir::StoreDir;
12
13#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
15pub enum CloudProvider {
16 S3,
17 GoogleDrive,
18 Dropbox,
19 OneDrive,
20 CloudKit,
21}
22
23#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "snake_case")]
28pub enum ExactUploadVerification {
29 UploadChecksum,
32 MetadataHash,
34 Readback,
36 Unchecked,
39}
40
41fn default_exact_upload_verification() -> ExactUploadVerification {
42 ExactUploadVerification::MetadataHash
43}
44
45impl CloudProvider {
46 pub fn needs_oauth(&self) -> bool {
50 matches!(self, Self::GoogleDrive | Self::Dropbox | Self::OneDrive)
51 }
52}
53
54#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "lowercase")]
75pub enum HomeStorage {
76 Opaque,
77 Browsable,
78}
79
80impl HomeStorage {
81 pub fn is_opaque(self) -> bool {
84 matches!(self, HomeStorage::Opaque)
85 }
86
87 pub fn is_browsable(self) -> bool {
90 matches!(self, HomeStorage::Browsable)
91 }
92}
93
94#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
98pub struct CloudHomeConfig {
99 pub provider: Option<CloudProvider>,
101 pub s3_bucket: Option<String>,
102 pub s3_region: Option<String>,
103 pub s3_endpoint: Option<String>,
104 pub s3_key_prefix: Option<String>,
105 pub exact_upload_verification: ExactUploadVerification,
106 pub google_drive_folder_id: Option<String>,
107 pub dropbox_folder_path: Option<String>,
108 pub onedrive_drive_id: Option<String>,
109 pub onedrive_folder_id: Option<String>,
110 pub cloudkit_owner_name: Option<String>,
111 pub cloudkit_zone_name: Option<String>,
112 pub storage: HomeStorage,
116}
117
118impl Default for CloudHomeConfig {
119 fn default() -> Self {
120 Self {
121 provider: None,
122 s3_bucket: None,
123 s3_region: None,
124 s3_endpoint: None,
125 s3_key_prefix: None,
126 exact_upload_verification: default_exact_upload_verification(),
127 google_drive_folder_id: None,
128 dropbox_folder_path: None,
129 onedrive_drive_id: None,
130 onedrive_folder_id: None,
131 cloudkit_owner_name: None,
132 cloudkit_zone_name: None,
133 storage: HomeStorage::Opaque,
134 }
135 }
136}
137
138#[derive(thiserror::Error, Debug)]
140pub enum ConfigError {
141 #[error("serialize configuration: {0}")]
142 Serialize(#[source] serde_yaml::Error),
143 #[error("parse configuration {}: {source}", path.display())]
144 Parse {
145 path: std::path::PathBuf,
146 #[source]
147 source: serde_yaml::Error,
148 },
149 #[error("configuration file: {0}")]
150 File(#[from] crate::atomic_file::FileError),
151}
152
153#[derive(Clone, Debug, PartialEq)]
155pub struct Config {
156 pub store_id: String,
157 pub device_id: String,
159 pub store_name: String,
160 pub cloud_home: CloudHomeConfig,
162}
163
164impl Config {
165 pub fn with_defaults(store_id: String, device_id: String, store_name: String) -> Self {
167 Self {
168 store_id,
169 device_id,
170 store_name,
171 cloud_home: CloudHomeConfig::default(),
172 }
173 }
174
175 pub fn save_to_config_yaml(&self, store_dir: &StoreDir) -> Result<(), ConfigError> {
177 let yaml: ConfigYaml = self.into();
178 let text = serde_yaml::to_string(&yaml).map_err(ConfigError::Serialize)?;
179 crate::atomic_file::AtomicFile::new(store_dir.config_path())
180 .replace(text.as_bytes())
181 .map_err(ConfigError::File)
182 }
183
184 pub fn load_from_config_yaml(store_dir: &StoreDir) -> Result<Config, ConfigError> {
187 let path = store_dir.config_path();
188 let text = std::fs::read_to_string(&path).map_err(|source| {
189 ConfigError::File(crate::atomic_file::FileError::at(
190 "read configuration",
191 &path,
192 source,
193 ))
194 })?;
195 let yaml: ConfigYaml =
196 serde_yaml::from_str(&text).map_err(|source| ConfigError::Parse {
197 path: path.clone(),
198 source,
199 })?;
200 Ok(yaml.into_config())
201 }
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
210pub(crate) struct ConfigYaml {
211 pub(crate) store_id: String,
212 pub(crate) store_name: String,
213 pub(crate) device_id: String,
214 #[serde(flatten)]
215 pub(crate) cloud_home: CloudHomeConfig,
216}
217
218impl From<&Config> for ConfigYaml {
219 fn from(config: &Config) -> Self {
220 Self {
221 store_id: config.store_id.clone(),
222 store_name: config.store_name.clone(),
223 device_id: config.device_id.clone(),
224 cloud_home: config.cloud_home.clone(),
225 }
226 }
227}
228
229impl ConfigYaml {
230 fn into_config(self) -> Config {
232 Config {
233 store_id: self.store_id,
234 device_id: self.device_id,
235 store_name: self.store_name,
236 cloud_home: self.cloud_home,
237 }
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn oauth_requirement_follows_the_provider() {
247 assert!(!CloudProvider::S3.needs_oauth());
248 assert!(!CloudProvider::CloudKit.needs_oauth());
249 assert!(CloudProvider::GoogleDrive.needs_oauth());
250 assert!(CloudProvider::Dropbox.needs_oauth());
251 assert!(CloudProvider::OneDrive.needs_oauth());
252 }
253
254 #[test]
257 fn round_trips_through_save_and_load() {
258 let dir = tempfile::tempdir().expect("temp dir");
259 let store_dir = StoreDir::new_ephemeral(dir.path());
260 let mut config = Config::with_defaults(
261 "store-1".to_string(),
262 "device-1".to_string(),
263 "My Store".to_string(),
264 );
265 config.cloud_home = CloudHomeConfig {
266 provider: Some(CloudProvider::S3),
267 s3_bucket: Some("bucket".to_string()),
268 s3_region: Some("us-east-1".to_string()),
269 exact_upload_verification: ExactUploadVerification::Readback,
270 storage: HomeStorage::Opaque,
271 ..CloudHomeConfig::default()
272 };
273
274 config.save_to_config_yaml(&store_dir).expect("save");
275 let config_yaml =
276 std::fs::read_to_string(store_dir.config_path()).expect("read saved local config");
277 assert!(config_yaml.contains("exact_upload_verification: readback"));
278 let loaded = Config::load_from_config_yaml(&store_dir).expect("load");
279
280 assert_eq!(loaded, config);
281 }
282
283 #[test]
287 fn round_trips_cloudkit_share_owner_and_zone() {
288 let dir = tempfile::tempdir().expect("temp dir");
289 let store_dir = StoreDir::new_ephemeral(dir.path());
290 let mut config = Config::with_defaults(
291 "store-1".to_string(),
292 "device-1".to_string(),
293 "Shared CloudKit Store".to_string(),
294 );
295 config.cloud_home = CloudHomeConfig {
296 provider: Some(CloudProvider::CloudKit),
297 cloudkit_owner_name: Some("owner-name".to_string()),
298 cloudkit_zone_name: Some("zone-name".to_string()),
299 storage: HomeStorage::Opaque,
300 ..CloudHomeConfig::default()
301 };
302
303 config.save_to_config_yaml(&store_dir).expect("save");
304 let loaded = Config::load_from_config_yaml(&store_dir).expect("load");
305 assert_eq!(loaded, config);
306 }
307
308 #[test]
311 fn load_with_absent_optional_provider_fields() {
312 let dir = tempfile::tempdir().expect("temp dir");
313 let store_dir = StoreDir::new_ephemeral(dir.path());
314 std::fs::write(
315 store_dir.config_path(),
316 "store_id: store-1\nstore_name: My Store\ndevice_id: device-1\nexact_upload_verification: metadata_hash\nstorage: opaque\n",
317 )
318 .expect("write config.yaml");
319
320 let loaded = Config::load_from_config_yaml(&store_dir).expect("load");
321
322 assert_eq!(loaded.store_id, "store-1");
323 assert_eq!(loaded.store_name, "My Store");
324 assert_eq!(loaded.device_id, "device-1");
325 assert_eq!(loaded.cloud_home, CloudHomeConfig::default());
326 }
327
328 #[test]
329 fn load_with_missing_upload_verification_errors() {
330 let dir = tempfile::tempdir().expect("temp dir");
331 let store_dir = StoreDir::new_ephemeral(dir.path());
332 std::fs::write(
333 store_dir.config_path(),
334 "store_id: store-1\nstore_name: My Store\ndevice_id: device-1\nstorage: opaque\n",
335 )
336 .expect("write config.yaml");
337
338 let error = Config::load_from_config_yaml(&store_dir)
339 .expect_err("missing exact upload verification");
340 assert!(matches!(error, ConfigError::Parse { .. }));
341 }
342
343 #[test]
346 fn load_with_missing_device_id_errors() {
347 let dir = tempfile::tempdir().expect("temp dir");
348 let store_dir = StoreDir::new_ephemeral(dir.path());
349 std::fs::write(
350 store_dir.config_path(),
351 "store_id: store-1\nstore_name: My Store\n",
352 )
353 .expect("write config.yaml");
354
355 let err = Config::load_from_config_yaml(&store_dir).expect_err("missing device_id");
356 assert!(matches!(err, ConfigError::Parse { .. }));
357 }
358
359 #[test]
362 fn load_with_no_file_errors_naming_the_path() {
363 let dir = tempfile::tempdir().expect("temp dir");
364 let store_dir = StoreDir::new_ephemeral(dir.path());
365
366 let err = Config::load_from_config_yaml(&store_dir).expect_err("no file");
367 let message = err.to_string();
368 assert!(
369 message.contains(&store_dir.config_path().display().to_string()),
370 "error should name the missing path, got: {message}",
371 );
372 }
373}