//! What a config's keys are, and which of them may sync. //! //! A [`ConfigSpec`] is the source of truth for postures: a static list of the //! keys an app knows, each tagged [`Synced`] or [`Local`]. It is pure data with //! no connection and no I/O, so it can be a `const` an app declares once. The //! sync side ([`synckit-client`]) reads [`policy_rows`](ConfigSpec::policy_rows) //! to seed the filter that keeps `Local` keys off the wire. //! //! [`Synced`]: Posture::Synced //! [`Local`]: Posture::Local /// Whether a config key may be replicated to the user's other devices. /// /// The default is [`Local`](Posture::Local), and so is the answer for any key a /// spec does not mention: see [`ConfigSpec::posture`]. That is the whole safety /// property — a key crosses the sync boundary only when someone wrote down that /// it should. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Posture { /// Stays on this device. Filesystem paths, local safety gates, and anything /// not explicitly cleared to sync. The default. #[default] Local, /// A user preference worth carrying across the user's devices. Synced, } impl Posture { /// Whether a key of this posture is replicated. What the policy table stores. pub const fn replicated(self) -> bool { matches!(self, Posture::Synced) } } /// One row of the policy the sync filter reads: a key and whether it replicates. /// /// [`ConfigSpec::policy_rows`] yields these for the sync side to seed. A plain /// pair rather than a richer type because that is all the SQL filter needs, and /// keeping it flat means the seeding query is an obvious upsert. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PolicyRow { pub key: &'static str, pub replicated: bool, } /// The keys a config knows, and their postures. /// /// Declared as a `const` per app: the table it lives in, and every key with its /// posture. Only [`Synced`](Posture::Synced) keys need listing for correctness — /// an unlisted key reads as [`Local`](Posture::Local) — but listing the local /// ones too documents the surface and lets a test assert the set is complete. #[derive(Debug, Clone, Copy)] pub struct ConfigSpec { table: &'static str, keys: &'static [(&'static str, Posture)], } impl ConfigSpec { /// A spec over `table`, with `keys` declaring each known key's posture. /// /// Duplicate keys are a programming error the caller controls (the list is a /// `const`); the last one wins in [`posture`](Self::posture), matching how a /// reader would expect a later override to read, but a spec should not carry /// duplicates and [`policy_rows`](Self::policy_rows) emits them as written. pub const fn new(table: &'static str, keys: &'static [(&'static str, Posture)]) -> Self { Self { table, keys } } /// The SQLite table the config lives in. pub const fn table(&self) -> &'static str { self.table } /// The declared keys and their postures, in declaration order. pub fn keys(&self) -> impl Iterator + '_ { self.keys.iter().copied() } /// The posture of `key`. /// /// **Fail-closed: an undeclared key is [`Local`](Posture::Local).** This is /// the rule the whole sync boundary rests on. A key nobody classified never /// replicates, so forgetting to name a new local path does not leak it — the /// failure of omission keeps data home rather than sending it. pub fn posture(&self, key: &str) -> Posture { self.keys .iter() .rev() .find(|(name, _)| *name == key) .map_or(Posture::Local, |(_, posture)| *posture) } /// Whether `key` may cross the sync boundary. Shorthand for a `Synced` /// posture, and the question the sync filter actually asks. pub fn is_synced(&self, key: &str) -> bool { self.posture(key).replicated() } /// The policy rows the sync side seeds, one per declared key. /// /// Only these rows exist in the policy table, and the sync filter admits a /// key only if its row says `replicated`. A key absent here is absent there, /// which is the same fail-closed rule as [`posture`](Self::posture) enforced /// in SQL rather than in Rust. pub fn policy_rows(&self) -> impl Iterator + '_ { self.keys.iter().map(|(key, posture)| PolicyRow { key, replicated: posture.replicated(), }) } } #[cfg(test)] mod tests { use super::*; const SPEC: ConfigSpec = ConfigSpec::new( "app_config", &[ ("theme", Posture::Synced), ("sidebar_visible", Posture::Synced), ("mirror_path", Posture::Local), ], ); #[test] fn the_default_posture_is_local() { assert_eq!(Posture::default(), Posture::Local); assert!(!Posture::Local.replicated()); assert!(Posture::Synced.replicated()); } // The rule the sync boundary rests on: a key nobody classified stays home. // A forgotten local path must not leak because it was never named. #[test] fn an_undeclared_key_is_local_not_synced() { assert_eq!(SPEC.posture("never_heard_of_it"), Posture::Local); assert!(!SPEC.is_synced("never_heard_of_it")); } #[test] fn declared_keys_report_their_posture() { assert!(SPEC.is_synced("theme")); assert!(SPEC.is_synced("sidebar_visible")); assert!(!SPEC.is_synced("mirror_path"), "a local path never syncs"); } // policy_rows is what the sync filter seeds; a key absent here is absent in // the filter, which is the same fail-closed rule enforced in SQL. #[test] fn policy_rows_carry_every_declared_key_and_its_replication() { let rows: Vec = SPEC.policy_rows().collect(); assert_eq!(rows.len(), 3); assert_eq!( rows[0], PolicyRow { key: "theme", replicated: true }, ); assert_eq!( rows[2], PolicyRow { key: "mirror_path", replicated: false, }, ); // No row for an unclassified key, so the SQL filter cannot admit one. assert!(!rows.iter().any(|r| r.key == "never_heard_of_it")); } #[test] fn the_table_and_key_set_are_readable() { assert_eq!(SPEC.table(), "app_config"); let keys: Vec<&str> = SPEC.keys().map(|(k, _)| k).collect(); assert_eq!(keys, ["theme", "sidebar_visible", "mirror_path"]); } }