Skip to main content

hygg_shared/sync/
mode.rs

1//! How a document is synchronized. A single knob that exists at two levels: the
2//! server keeps an account-wide *ceiling* per document, and each client keeps a
3//! local *clamp*. The effective mode is the more restrictive of the two
4//! ([`SyncMode::most_restrictive`]), so a client may lower its own copy but
5//! never raise it above the server's policy.
6//!
7//! Ordered most- to least-permissive: `Full > Metadata > Off`.
8
9use std::fmt;
10use std::str::FromStr;
11
12use serde::{Deserialize, Serialize};
13
14/// Per-document sync policy. The wire/config token is the lowercase variant
15/// name (`full` | `metadata` | `off`).
16#[derive(
17  Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default,
18)]
19#[serde(rename_all = "lowercase")]
20pub enum SyncMode {
21  /// Metadata record + document bytes (blob) + reading state. The default, and
22  /// the historical all-in behavior.
23  #[default]
24  Full,
25  /// Metadata record + reading state (progress, bookmarks, highlights, notes),
26  /// but never the document bytes — the file stays on the device.
27  Metadata,
28  /// Nothing about this document leaves (or reaches) the device.
29  Off,
30}
31
32impl SyncMode {
33  /// Rank from most permissive (2) to least (0); used to compare and combine.
34  const fn rank(self) -> u8 {
35    match self {
36      SyncMode::Full => 2,
37      SyncMode::Metadata => 1,
38      SyncMode::Off => 0,
39    }
40  }
41
42  /// The more restrictive of two modes — the effective mode when a server
43  /// ceiling and a client clamp both apply. Combining is a `min` on rank, so a
44  /// client can lower its mode but never raise it above the server's.
45  pub fn most_restrictive(self, other: SyncMode) -> SyncMode {
46    if self.rank() <= other.rank() { self } else { other }
47  }
48
49  /// Whether the document's bytes (blob) may sync in this mode.
50  pub fn syncs_blob(self) -> bool {
51    matches!(self, SyncMode::Full)
52  }
53
54  /// Whether reading state (progress + annotations) and the metadata record may
55  /// sync in this mode.
56  pub fn syncs_state(self) -> bool {
57    matches!(self, SyncMode::Full | SyncMode::Metadata)
58  }
59
60  /// Whether anything at all syncs. False only for [`SyncMode::Off`].
61  pub fn syncs_anything(self) -> bool {
62    !matches!(self, SyncMode::Off)
63  }
64
65  /// The wire / config token: `full` | `metadata` | `off`.
66  pub fn as_str(self) -> &'static str {
67    match self {
68      SyncMode::Full => "full",
69      SyncMode::Metadata => "metadata",
70      SyncMode::Off => "off",
71    }
72  }
73
74  /// Parse a stored/wire token, falling back to [`SyncMode::Full`] for anything
75  /// unrecognized (an older row, a hand-edited value) so a bad token never
76  /// silently disables sync.
77  pub fn from_token_or_default(s: &str) -> SyncMode {
78    s.parse().unwrap_or_default()
79  }
80}
81
82impl fmt::Display for SyncMode {
83  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84    f.write_str(self.as_str())
85  }
86}
87
88impl FromStr for SyncMode {
89  type Err = ();
90
91  fn from_str(s: &str) -> Result<Self, Self::Err> {
92    match s.trim().to_ascii_lowercase().as_str() {
93      "full" => Ok(SyncMode::Full),
94      "metadata" | "meta" => Ok(SyncMode::Metadata),
95      "off" | "disabled" | "none" => Ok(SyncMode::Off),
96      _ => Err(()),
97    }
98  }
99}
100
101#[cfg(test)]
102mod tests {
103  use super::*;
104
105  #[test]
106  fn default_is_full() {
107    assert_eq!(SyncMode::default(), SyncMode::Full);
108  }
109
110  #[test]
111  fn most_restrictive_takes_the_lower_rank() {
112    use SyncMode::*;
113    assert_eq!(Full.most_restrictive(Metadata), Metadata);
114    assert_eq!(Metadata.most_restrictive(Full), Metadata);
115    assert_eq!(Full.most_restrictive(Off), Off);
116    assert_eq!(Off.most_restrictive(Metadata), Off);
117    assert_eq!(Full.most_restrictive(Full), Full);
118    // Commutative.
119    assert_eq!(Metadata.most_restrictive(Off), Off.most_restrictive(Metadata));
120  }
121
122  #[test]
123  fn capability_predicates() {
124    assert!(SyncMode::Full.syncs_blob());
125    assert!(!SyncMode::Metadata.syncs_blob());
126    assert!(!SyncMode::Off.syncs_blob());
127
128    assert!(SyncMode::Full.syncs_state());
129    assert!(SyncMode::Metadata.syncs_state());
130    assert!(!SyncMode::Off.syncs_state());
131
132    assert!(SyncMode::Full.syncs_anything());
133    assert!(SyncMode::Metadata.syncs_anything());
134    assert!(!SyncMode::Off.syncs_anything());
135  }
136
137  #[test]
138  fn parse_round_trips_and_accepts_aliases() {
139    for m in [SyncMode::Full, SyncMode::Metadata, SyncMode::Off] {
140      assert_eq!(m.as_str().parse::<SyncMode>(), Ok(m));
141    }
142    assert_eq!("meta".parse(), Ok(SyncMode::Metadata));
143    assert_eq!("  OFF ".parse(), Ok(SyncMode::Off));
144    assert_eq!("disabled".parse(), Ok(SyncMode::Off));
145    assert_eq!(SyncMode::from_token_or_default("nonsense"), SyncMode::Full);
146  }
147
148  #[test]
149  fn serde_uses_lowercase_tokens() {
150    let json = serde_json::to_string(&SyncMode::Metadata).unwrap();
151    assert_eq!(json, "\"metadata\"");
152    let back: SyncMode = serde_json::from_str("\"off\"").unwrap();
153    assert_eq!(back, SyncMode::Off);
154  }
155}