rust_filen 0.3.0

Rust interface for Filen.io API
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
//! Contains structures common for Filen file&folder API.
use crate::{
    crypto, utils,
    v1::{files, fs, optional_uuid_from_empty_string, response_payload, FileLocation, FileProperties},
};
use secstr::{SecUtf8, SecVec};
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use serde_json::json;
use snafu::{Backtrace, ResultExt, Snafu};
use std::{fmt, num::ParseIntError, str::FromStr};
use strum::{Display, EnumString};
use uuid::Uuid;

type Result<T, E = Error> = std::result::Result<T, E>;

#[derive(Snafu, Debug)]
pub enum Error {
    #[snafu(display("Caller provided invalid argument: {}", message))]
    BadArgument { message: String, backtrace: Backtrace },

    #[snafu(display("Expected metadata to be base64-encoded, but cannot decode it as such"))]
    CannotDecodeBase64Metadata {
        metadata: String,
        source: base64::DecodeError,
    },

    #[snafu(display(
        "Expected \"base\" or hyphenated lowercased UUID, got unknown string of length: {}",
        string_length
    ))]
    CannotParseParentOrBaseFromString { string_length: usize, backtrace: Backtrace },

    #[snafu(display(
        "Expected \"none\" or hyphenated lowercased UUID, got unknown string of length: {}",
        string_length
    ))]
    CannotParseParentOrNoneFromString { string_length: usize, backtrace: Backtrace },

    #[snafu(display("Failed to decrypt link key '{}': {}", metadata, source))]
    DecryptLinkKeyFailed { metadata: String, source: crypto::Error },

    #[snafu(display("Failed to decrypt location name {}: {}", metadata, source))]
    DecryptLocationNameFailed { metadata: String, source: crypto::Error },

    #[snafu(display("Failed to deserialize location name: {}", source))]
    DeserializeLocationNameFailed { source: serde_json::Error },

    #[snafu(display("Expire duration value '{}' is too short to be valid", value))]
    DurationIsTooShort { value: String, backtrace: Backtrace },

    #[snafu(display("Expire duration unit '{}' is unsupported", unit))]
    DurationUnitUnsupported { unit: String, backtrace: Backtrace },

    #[snafu(display("Expire duration value '{}' is not a number: {}", value, source))]
    DurationValueIsNotNum { value: String, source: ParseIntError },
}

/// Public link or file chunk expiration time.
///
/// For defined expiration period, Filen currently uses values "1h", "6h", "1d", "3d", "7d", "14d" and "30d".
/// Otherwise, it's "never".
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum Expire {
    Never,

    Hours(u32),

    Days(u32),
}
utils::display_from_json!(Expire);

impl FromStr for Expire {
    type Err = Error;

    /// Tries to parse `Expire` from given string, which must be either "never" or amount of hours/days,
    /// e.g. "6h" or "30d".
    fn from_str(never_or_duration: &str) -> Result<Self, Self::Err> {
        if never_or_duration.eq_ignore_ascii_case("never") {
            Ok(Self::Never)
        } else if never_or_duration.len() < 2 {
            DurationIsTooShortSnafu {
                value: never_or_duration.to_owned(),
            }
            .fail()
        } else {
            let (raw_value, unit) = never_or_duration.split_at(never_or_duration.len() - 1);
            let value = str::parse::<u32>(raw_value).context(DurationValueIsNotNumSnafu {
                value: never_or_duration,
            })?;
            match unit {
                "d" => Ok(Self::Days(value)),
                "h" => Ok(Self::Hours(value)),
                other => DurationUnitUnsupportedSnafu { unit: other.to_owned() }.fail(),
            }
        }
    }
}

impl<'de> Deserialize<'de> for Expire {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let never_or_duration_repr = String::deserialize(deserializer)?;
        str::parse::<Self>(&never_or_duration_repr).map_err(|_err| {
            de::Error::invalid_value(
                de::Unexpected::Str(&never_or_duration_repr),
                &"\"never\" or duration with time units, e.g. \"6h\" or \"1d\"",
            )
        })
    }
}

impl Serialize for Expire {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        match *self {
            Expire::Never => serializer.serialize_str("never"),
            Expire::Hours(hours) => serializer.serialize_str(&format!("{}h", hours)),
            Expire::Days(days) => serializer.serialize_str(&format!("{}d", days)),
        }
    }
}

/// Identifies whether an item is a file or folder.
#[derive(Clone, Copy, Debug, Deserialize, Display, EnumString, Eq, Hash, PartialEq, Serialize, PartialOrd, Ord)]
#[serde(rename_all = "lowercase")]
#[strum(ascii_case_insensitive, serialize_all = "lowercase")]
pub enum ItemKind {
    /// Item is a file.
    File,
    /// Item is a folder.
    Folder,
}

/// Determines where file is stored by Filen.
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct FileStorageInfo {
    /// Server's bucket where file is stored.
    pub bucket: String,

    /// Server region where file is stored.
    pub region: String,

    /// Amount of chunks file is split into.
    pub chunks: u32,
}

impl fmt::Display for FileStorageInfo {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}/{} [{} chunks]", self.region, self.bucket, self.chunks)
    }
}

/// Represents one of the user folders or some folder under Filen sync folder.
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct FolderData {
    /// Folder ID, UUID V4 in hyphenated lowercase format.
    pub uuid: Uuid,

    /// Metadata containing folder name.
    #[serde(rename = "name")]
    pub name_metadata: String,

    /// Either parent folder ID (hyphenated lowercased UUID V4) or "base" when folder is located in the base folder,
    /// also known as 'cloud drive'.
    pub parent: ParentOrBase,
}
utils::display_from_json!(FolderData);

impl HasLocationName for FolderData {
    /// Decrypts name metadata into a folder name.
    fn name_metadata_ref(&self) -> &str {
        &self.name_metadata
    }
}

impl HasUuid for FolderData {
    fn uuid_ref(&self) -> &Uuid {
        &self.uuid
    }
}

/// Identifies location color set by user. Default yellow color is often represented by the absence of specifically set
/// `LocationColor`.
#[derive(Clone, Copy, Debug, Deserialize, Display, EnumString, Eq, Hash, PartialEq, Serialize, PartialOrd, Ord)]
#[serde(rename_all = "lowercase")]
#[strum(ascii_case_insensitive, serialize_all = "lowercase")]
pub enum LocationColor {
    /// Default yellow color. Often represented by the absence of specifically set `LocationColor`.
    Default,
    Blue,
    Gray,
    Green,
    Purple,
    Red,
}

/// Identifies location type.
#[derive(Clone, Copy, Debug, Deserialize, Display, EnumString, Eq, Hash, PartialEq, Serialize, Ord, PartialOrd)]
#[serde(rename_all = "lowercase")]
#[strum(ascii_case_insensitive, serialize_all = "lowercase")]
pub enum LocationKind {
    /// Location is a folder.
    Folder,
    /// Location is a special Filen Sync folder.
    Sync,
}

/// Typed folder or file name metadata.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct LocationNameMetadata {
    pub name: String,
}

impl LocationNameMetadata {
    /// Puts the given name into Filen-expected JSON { name: "some name" } and encrypts it into metadata.
    #[allow(clippy::missing_panics_doc)]
    pub fn encrypt_name_to_metadata<S: Into<String>>(name: S, key: &SecUtf8) -> String {
        let name_json = json!(Self { name: name.into() }).to_string();
        // Cannot panic due to the way encrypt_metadata_str is implemented.
        crypto::encrypt_metadata_str(&name_json, key, super::METADATA_VERSION).unwrap()
    }

    /// Decrypt name metadata into actual name.
    pub fn decrypt_name_from_metadata(name_metadata: &str, keys: &[SecUtf8]) -> Result<String> {
        if name_metadata.eq_ignore_ascii_case("default") {
            return Ok("Default".to_owned());
        }

        let decrypted_name_result =
            crypto::decrypt_metadata_str_any_key(name_metadata, keys).context(DecryptLocationNameFailedSnafu {
                metadata: name_metadata.to_owned(),
            });

        decrypted_name_result
            .and_then(|name_metadata| Self::extract_name_from_folder_properties_json(name_metadata.as_bytes()))
    }

    /// Decrypts location name from a metadata string using RSA private key.
    /// Assumes given metadata string is base64-encoded.
    pub fn decrypt_name_from_metadata_rsa(name_metadata: &str, rsa_private_key_bytes: &SecVec<u8>) -> Result<String> {
        if name_metadata.eq_ignore_ascii_case("default") {
            return Ok("Default".to_owned());
        }

        let decoded = base64::decode(name_metadata).context(CannotDecodeBase64MetadataSnafu {
            metadata: name_metadata.to_owned(),
        })?;
        let decrypted_folder_properties_json = crypto::decrypt_rsa(&decoded, rsa_private_key_bytes.unsecure())
            .context(DecryptLocationNameFailedSnafu {
                metadata: name_metadata.to_owned(),
            })?;

        Self::extract_name_from_folder_properties_json(&decrypted_folder_properties_json)
    }

    /// Encrypts location name to a metadata string using RSA public key of the user with whom item is shared aka receiver.
    /// Returns base64-encoded bytes.
    pub fn encrypt_name_to_metadata_rsa<S: Into<String>>(
        name: S,
        rsa_public_key_bytes: &[u8],
    ) -> Result<String, crypto::Error> {
        let name_json = json!(Self { name: name.into() }).to_string();
        crypto::encrypt_rsa(name_json.as_bytes(), rsa_public_key_bytes).map(base64::encode)
    }

    /// Returns hashed given location name.
    #[must_use]
    pub fn name_hashed(name: &str) -> String {
        crypto::hash_fn(&name.to_lowercase())
    }

    pub(crate) fn extract_name_from_folder_properties_json(folder_properties_json_bytes: &[u8]) -> Result<String> {
        serde_json::from_slice::<Self>(folder_properties_json_bytes)
            .context(DeserializeLocationNameFailedSnafu {})
            .map(|typed| typed.name)
    }
}

/// Implemented to add decryption of a metadata containing Filen's file properties JSON.
pub trait HasFileMetadata {
    /// Gets a reference to file metadata, if present.
    fn file_metadata_ref(&self) -> &str;

    /// Decrypts file metadata string using user's master keys.
    fn decrypt_file_metadata(&self, master_keys: &[SecUtf8]) -> Result<FileProperties, files::Error> {
        FileProperties::decrypt_file_metadata(self.file_metadata_ref(), master_keys)
    }
}

/// Implemented to add decryption of a metadata containing Filen's file properties JSON,
/// encrypted using user's public key.
pub trait HasSharedFileMetadata {
    /// Gets a reference to file metadata, if present.
    fn file_metadata_ref(&self) -> &str;

    /// Decrypts file metadata string using user's RSA private key.
    fn decrypt_file_metadata(&self, rsa_private_key_bytes: &SecVec<u8>) -> Result<FileProperties, files::Error> {
        FileProperties::decrypt_file_metadata_rsa(self.file_metadata_ref(), rsa_private_key_bytes)
    }
}

/// Implemented to add decryption of a metadata containing Filen's file properties JSON,
/// encrypted using link key.
pub trait HasLinkedFileMetadata {
    /// Gets a reference to file metadata, if present.
    fn file_metadata_ref(&self) -> &str;

    /// Decrypts file metadata string using link key.
    fn decrypt_file_metadata(&self, link_key: SecUtf8) -> Result<FileProperties, files::Error> {
        FileProperties::decrypt_file_metadata(self.file_metadata_ref(), &[link_key])
    }
}

/// Implemented for something that has Filen file location.
pub trait HasFileLocation: HasUuid {
    /// Gets a reference to data defining where file is stored by Filen.
    fn file_storage_ref(&self) -> &FileStorageInfo;

    /// Gets data required to build a URL for a file plus file chunk count.
    fn get_file_location(&self) -> FileLocation {
        let storage = self.file_storage_ref();
        FileLocation::new(&storage.region, &storage.bucket, *self.uuid_ref(), storage.chunks)
    }
}

/// Implemented to add file properties decryption and other helper methods.
pub trait HasFiles<T: HasUuid + HasFileMetadata> {
    /// Returns files slice.
    fn files_ref(&self) -> &[T];

    /// Searches for a file with the specified ID in the files slice.
    ///
    /// If you do a lot of searches, build a `BTreeMap<Uuid, file data>` and use it instead.
    fn file_with_uuid(&self, uuid: &Uuid) -> Option<&T> {
        self.files_ref().iter().find(|file_ref| file_ref.uuid_ref() == uuid)
    }

    /// Decrypts all encrypted file properties and associates them with file data.
    fn decrypt_all_file_properties(&self, keys: &[SecUtf8]) -> Result<Vec<(&T, FileProperties)>, files::Error> {
        self.files_ref()
            .iter()
            .map(|data| data.decrypt_file_metadata(keys).map(|properties| (data, properties)))
            .collect::<Result<Vec<_>, files::Error>>()
    }
}

/// Implemented to add folder name decryption and other helper methods.
pub trait HasFolders<T: HasUuid + HasLocationName> {
    /// Returns folders slice.
    fn folders_ref(&self) -> &[T];

    /// Searches for a folder with the specified ID in the folders slice.
    ///
    /// If you do a lot of searches, build a `BTreeMap<Uuid, folder data>` and use it instead.
    fn folder_with_uuid(&self, uuid: &Uuid) -> Option<&T> {
        self.folders_ref()
            .iter()
            .find(|folder_ref| folder_ref.uuid_ref() == uuid)
    }

    /// Decrypts all encrypted folder names and associates them with folder data.
    fn decrypt_all_folder_names(&self, keys: &[SecUtf8]) -> Result<Vec<(&T, String)>, fs::Error> {
        self.folders_ref()
            .iter()
            .map(|data| data.decrypt_name_metadata(keys).map(|name| (data, name)))
            .collect::<Result<Vec<_>, fs::Error>>()
    }
}

/// Implemented to add decryption of a metadata containing Filen's name JSON: { "name": "some name value" }
pub trait HasLocationName {
    /// Returns reference to a string containing metadata with Filen's name JSON.
    fn name_metadata_ref(&self) -> &str;

    /// Decrypts name metadata into a location name using user's master keys.
    fn decrypt_name_metadata(&self, master_keys: &[SecUtf8]) -> Result<String> {
        LocationNameMetadata::decrypt_name_from_metadata(self.name_metadata_ref(), master_keys)
    }
}

/// Implemented to add decryption of a metadata containing Filen's name JSON: { "name": "some name value" },
/// encrypted using user's public key.
pub trait HasSharedLocationName {
    /// Returns reference to a string containing metadata with Filen's name JSON.
    fn name_metadata_ref(&self) -> &str;

    /// Decrypts name metadata into a location name using user's RSA private key.
    fn decrypt_name_metadata(&self, rsa_private_key_bytes: &SecVec<u8>) -> Result<String> {
        LocationNameMetadata::decrypt_name_from_metadata_rsa(self.name_metadata_ref(), rsa_private_key_bytes)
    }
}

/// Implemented to add decryption of a metadata containing Filen's name JSON: { "name": "some name value" },
/// encrypted using link key.
pub trait HasLinkedLocationName {
    /// Returns reference to a string containing metadata with Filen's name JSON.
    fn name_metadata_ref(&self) -> &str;

    /// Decrypts name metadata into a location name using link key.
    fn decrypt_name_metadata(&self, link_key: SecUtf8) -> Result<String> {
        LocationNameMetadata::decrypt_name_from_metadata(self.name_metadata_ref(), &[link_key])
    }
}

/// Implemented for something that has link key metadata.
pub trait HasLinkKey {
    /// Returns reference to a string containing link key metadata.
    fn link_key_metadata_ref(&self) -> Option<&str>;

    /// Decrypts link key using user's master keys.
    fn decrypt_link_key(&self, master_keys: &[SecUtf8]) -> Result<SecUtf8> {
        match self.link_key_metadata_ref() {
            Some(link_key_metadata) => crypto::decrypt_metadata_str_any_key(link_key_metadata, master_keys)
                .context(DecryptLinkKeyFailedSnafu {
                    metadata: link_key_metadata.to_owned(),
                })
                .map(SecUtf8::from),
            None => BadArgumentSnafu {
                message: "link key metadata is absent, cannot decrypt None",
            }
            .fail(),
        }
    }
}

/// Implemented for items that always have UUID.
pub trait HasUuid {
    /// Returns reference to an item's ID.
    fn uuid_ref(&self) -> &Uuid;
}

/// Used for requests to `DIR_TRASH_PATH` or `FILE_TRASH_PATH` endpoint.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct LocationTrashRequestPayload<'location_trash> {
    /// User-associated Filen API key.
    #[serde(rename = "apiKey")]
    pub api_key: &'location_trash SecUtf8,

    /// ID of the folder or file to move to trash, hyphenated lowercased UUID V4.
    pub uuid: Uuid,
}
utils::display_from_json_with_lifetime!('location_trash, LocationTrashRequestPayload);

/// Used for requests to `DIR_EXISTS_PATH` or `FILE_TRASH_PATH` endpoint.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct LocationExistsRequestPayload<'location_exists> {
    /// User-associated Filen API key.
    #[serde(rename = "apiKey")]
    pub api_key: &'location_exists SecUtf8,

    /// Either parent folder ID (hyphenated lowercased UUID V4) or "base" when folder is located in the base folder,
    /// also known as 'cloud drive'.
    pub parent: ParentOrBase,

    /// Currently hash_fn of lowercased target folder or file name.
    #[serde(rename = "nameHashed")]
    pub name_hashed: String,
}
utils::display_from_json_with_lifetime!('location_exists, LocationExistsRequestPayload);

impl<'location_exists> LocationExistsRequestPayload<'location_exists> {
    #[must_use]
    pub fn new(api_key: &'location_exists SecUtf8, target_parent: ParentOrBase, target_name: &str) -> Self {
        let name_hashed = LocationNameMetadata::name_hashed(target_name);
        Self {
            api_key,
            parent: target_parent,
            name_hashed,
        }
    }
}

/// Response data for `DIR_EXISTS_PATH` or `FILE_TRASH_PATH` endpoint.
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct LocationExistsResponseData {
    /// True if folder or file with given name already exists in the parent folder; false otherwise.
    pub exists: bool,

    /// Existing folder or file ID, hyphenated lowercased UUID V4. Empty string if folder or file does not exist.
    #[serde(default)]
    #[serde(deserialize_with = "optional_uuid_from_empty_string")]
    pub uuid: Option<Uuid>,
}
utils::display_from_json!(LocationExistsResponseData);

response_payload!(
    /// Response for `DIR_EXISTS_PATH` or `FILE_TRASH_PATH` endpoint.
    LocationExistsResponsePayload<LocationExistsResponseData>
);

/// Identifies parent eitner by ID or by indirect reference.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum ParentOrBase {
    /// Parent is a base folder.
    Base,
    /// Parent is a folder with the specified UUID.
    Folder(Uuid),
}
utils::display_from_json!(ParentOrBase);

impl ParentOrBase {
    /// Creates `ParentOrNone` corresponding to this value.
    #[inline]
    #[must_use]
    pub const fn as_parent_or_none(&self) -> ParentOrNone {
        match *self {
            Self::Base => ParentOrNone::None,
            Self::Folder(id) => ParentOrNone::Folder(id),
        }
    }
}

impl FromStr for ParentOrBase {
    type Err = Error;

    /// Tries to parse `ParentOrBase` from given string, which must be either "base" or hyphenated lowercased UUID.
    fn from_str(base_or_id: &str) -> Result<Self, Self::Err> {
        if base_or_id.eq_ignore_ascii_case("base") {
            Ok(Self::Base)
        } else {
            match Uuid::parse_str(base_or_id) {
                Ok(uuid) => Ok(Self::Folder(uuid)),
                Err(_) => CannotParseParentOrBaseFromStringSnafu {
                    string_length: base_or_id.len(),
                }
                .fail(),
            }
        }
    }
}

impl<'de> Deserialize<'de> for ParentOrBase {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let base_or_id = String::deserialize(deserializer)?;

        if base_or_id.eq_ignore_ascii_case("base") {
            Ok(Self::Base)
        } else {
            match Uuid::parse_str(&base_or_id) {
                Ok(uuid) => Ok(Self::Folder(uuid)),
                Err(_) => Err(de::Error::invalid_value(
                    de::Unexpected::Str(&base_or_id),
                    &"\"base\" or hyphenated lowercased UUID",
                )),
            }
        }
    }
}

impl Serialize for ParentOrBase {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        match *self {
            Self::Base => serializer.serialize_str("base"),
            Self::Folder(uuid) => serializer.serialize_str(&uuid.as_hyphenated().to_string()),
        }
    }
}

/// Eitner a parent ID or none.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum ParentOrNone {
    /// No parent, which means parent is a base folder.
    None,
    /// Parent is a folder with the specified UUID.
    Folder(Uuid),
}
utils::display_from_json!(ParentOrNone);

impl ParentOrNone {
    /// Creates `ParentOrBase` corresponding to this value.
    #[inline]
    #[must_use]
    pub const fn as_parent_or_base(&self) -> ParentOrBase {
        match *self {
            Self::None => ParentOrBase::Base,
            Self::Folder(id) => ParentOrBase::Folder(id),
        }
    }
}

impl FromStr for ParentOrNone {
    type Err = Error;

    /// Tries to parse `ParentOrNone` from given string, which must be either "none" or hyphenated lowercased UUID.
    fn from_str(none_or_id: &str) -> Result<Self, Self::Err> {
        if none_or_id.eq_ignore_ascii_case("none") {
            Ok(Self::None)
        } else {
            match Uuid::parse_str(none_or_id) {
                Ok(uuid) => Ok(Self::Folder(uuid)),
                Err(_) => CannotParseParentOrNoneFromStringSnafu {
                    string_length: none_or_id.len(),
                }
                .fail(),
            }
        }
    }
}

impl<'de> Deserialize<'de> for ParentOrNone {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let none_or_id = String::deserialize(deserializer)?;

        if none_or_id.eq_ignore_ascii_case("none") {
            Ok(Self::None)
        } else {
            match Uuid::parse_str(&none_or_id) {
                Ok(uuid) => Ok(Self::Folder(uuid)),
                Err(_) => Err(de::Error::invalid_value(
                    de::Unexpected::Str(&none_or_id),
                    &"\"none\" or hyphenated lowercased UUID",
                )),
            }
        }
    }
}

impl Serialize for ParentOrNone {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        match *self {
            ParentOrNone::None => serializer.serialize_str("none"),
            ParentOrNone::Folder(uuid) => serializer.serialize_str(&uuid.as_hyphenated().to_string()),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;

    #[test]
    fn location_should_be_deserialized_from_empty_string_uuid() {
        let json = r#"{"exists":false, "uuid":""}"#;
        let result = serde_json::from_str::<LocationExistsResponseData>(json);

        assert!(result.unwrap().uuid.is_none());
    }

    #[test]
    fn expire_time_should_be_deserialized_from_hours() {
        let json = r#""6h""#;
        let expected = Expire::Hours(6);

        let result = serde_json::from_str::<Expire>(json);

        assert_eq!(result.unwrap(), expected);
    }

    #[test]
    fn expire_time_should_be_deserialized_from_days() {
        let json = r#""30d""#;
        let expected = Expire::Days(30);

        let result = serde_json::from_str::<Expire>(json);

        assert_eq!(result.unwrap(), expected);
    }

    #[test]
    fn expire_time_should_be_deserialized_from_never() {
        let json = r#""never""#;
        let expected = Expire::Never;

        let result = serde_json::from_str::<Expire>(json);

        assert_eq!(result.unwrap(), expected);
    }

    #[test]
    fn parent_kind_should_be_deserialized_from_base() {
        let json = r#""base""#;
        let expected = ParentOrBase::Base;

        let result = serde_json::from_str::<ParentOrBase>(json);

        assert_eq!(result.unwrap(), expected);
    }

    #[test]
    fn parent_kind_should_be_deserialized_from_id() {
        let json = r#""00000000-0000-0000-0000-000000000000""#;
        let expected = ParentOrBase::Folder(Uuid::nil());

        let result = serde_json::from_str::<ParentOrBase>(json);

        assert_eq!(result.unwrap(), expected);
    }
}