Skip to main content

forge_primitives/
settings.rs

1//! The effective-config document a server pushes to every client.
2//!
3//! One document is built per load and shared by every connection: the areas
4//! `forge.toml` is read into, every key's effective value, and the class that
5//! says when an edit to that key takes effect. Values cross as the file loads
6//! them, unredacted - the socket is reached over loopback or the private
7//! network, which is the boundary.
8
9use serde::{Deserialize, Serialize};
10
11/// When an edit to a key takes effect.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum EffectClass {
15    /// Read on the key's next use.
16    Live,
17    /// Read when a session or a connection opens.
18    NextSession,
19    /// Built once at boot; an edit needs a restart.
20    Restart,
21    /// The loader accepts the key and nothing reads it yet.
22    NotReadYet,
23}
24
25impl EffectClass {
26    /// The word the page draws and the class catalogue's own name.
27    pub const fn as_str(self) -> &'static str {
28        match self {
29            Self::Live => "live",
30            Self::NextSession => "next session",
31            Self::Restart => "restart",
32            Self::NotReadYet => "not read yet",
33        }
34    }
35}
36
37/// The shape of a key's value, so a client picks a drawing without guessing
38/// from the text.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum SettingKind {
42    String,
43    Number,
44    Bool,
45    List,
46    Table,
47    Set,
48}
49
50/// The control a key's value is edited with.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum EditKind {
54    String,
55    Number,
56    Bool,
57    Enum,
58    List,
59}
60
61/// One change in a batched save.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum SettingsEditOp {
65    Set,
66    Add,
67    Remove,
68}
69
70/// One edit a client asks the server to apply to `forge.toml`.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct SettingsEditRequest {
73    /// The area the page was drawing when the edit was made.
74    pub area: String,
75    /// The key's concrete path for a set or a leaf's removal, and the
76    /// entry's path for an entry's add or removal.
77    pub key_path: String,
78    /// The catalogue pattern the key fills. Empty for an entry edit.
79    #[serde(default)]
80    pub pattern: String,
81    pub op: SettingsEditOp,
82    /// The new value: the key's value for a set, or an entry's fields for an
83    /// add. Absent for a removal.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub value: Option<serde_json::Value>,
86}
87
88/// What the editor needs to draw a key's control and to know which entry it
89/// belongs to.
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct SettingsEdit {
92    pub kind: EditKind,
93    /// The accepted names for an `enum`, empty otherwise.
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub allowed: Vec<String>,
96    /// Whether adding the owning entry requires the key.
97    pub required: bool,
98    /// The repeated entry a key belongs to: `account`, `project`, `slack`,
99    /// `model` or `org`. Absent for a table's own keys.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub group: Option<String>,
102}
103
104/// One key's change in a held write.
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106pub struct SettingsWriteChange {
107    /// The concrete catalogue path the change names.
108    pub key: String,
109    /// The value the key stands at now; `null` when it is unset.
110    pub current: serde_json::Value,
111    /// The value the change would leave; `null` when it clears the key.
112    pub proposed: serde_json::Value,
113    /// Whether the change clears the key rather than setting it.
114    pub unset: bool,
115}
116
117/// One configuration write held for the reader's approval.
118///
119/// The blocked `settings__set` / `settings__unset` handler awaits the
120/// answer, and nothing reaches `forge.toml` unless it approved: this is the
121/// shape the dock draws while it waits, so it carries what a person decides
122/// on - every key in the save, what it holds now, and what the write would
123/// leave. A batch is one ask and one approval because a section's keys can
124/// be mutually required: neither half can land alone.
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126pub struct SettingsWriteAsk {
127    /// The write's own id, which is what an answer is addressed by.
128    pub id: uuid::Uuid,
129    /// The keys the one write changes, in the caller's order.
130    pub edits: Vec<SettingsWriteChange>,
131}
132
133/// What ended a held write, which a view that did not answer it has no other
134/// way to learn.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum SettingsWriteEnding {
138    /// The reader approved it and the edit was written.
139    Applied,
140    /// The reader declined it; nothing was written.
141    Declined,
142    /// The reader approved it and the write did not land, in the writer's
143    /// own words. The ending is emitted after the write, so `Applied` above
144    /// never stands for a failed one.
145    Refused { message: String },
146    /// The asking session went away before the write was settled.
147    Abandoned,
148}
149
150/// One area of the config, as the page's sub-nav lists it.
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
152pub struct SettingsArea {
153    pub id: String,
154    pub name: String,
155    pub note: String,
156    pub keys: Vec<SettingsKey>,
157}
158
159/// One key's effective value and the class that says when a change lands.
160///
161/// Hand-written `Debug`: a key's value can be an account token, and the frame
162/// is the page's read rather than a diagnostic. The class and path stay
163/// legible; the value never prints.
164#[derive(Clone, PartialEq, Serialize, Deserialize)]
165pub struct SettingsKey {
166    /// The concrete key path, with the instance where the table repeats:
167    /// `server.port`, `accounts.Subspace.token`.
168    pub path: String,
169    /// The key's own name as a row draws it: the catalogue key's leaf, or
170    /// the slug or flag a repeated key names. A name may contain dots, so
171    /// a reader cannot cut it from `path`.
172    pub leaf: String,
173    /// The instance names the row belongs to, from the table's repeat keys:
174    /// an account, an org and a project, a Slack table and workspace, a
175    /// model, a server table. Empty when the area's keys do not repeat.
176    pub group: Vec<String>,
177    /// The catalogue pattern the path fills, so the writer can locate the
178    /// TOML item without re-parsing a display path: `accounts[].token`.
179    pub pattern: String,
180    pub kind: SettingKind,
181    /// In the shape `kind` names: a JSON string, number, bool, array of
182    /// strings, or object of string values. `null` for a key the file does
183    /// not set.
184    pub value: serde_json::Value,
185    pub class: EffectClass,
186    pub edit: SettingsEdit,
187}
188
189impl std::fmt::Debug for SettingsKey {
190    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        f.debug_struct("SettingsKey")
192            .field("path", &self.path)
193            .field("leaf", &self.leaf)
194            .field("group", &self.group)
195            .field("pattern", &self.pattern)
196            .field("kind", &self.kind)
197            .field("value", &"[redacted]")
198            .field("class", &self.class)
199            .field("edit", &self.edit)
200            .finish()
201    }
202}
203
204/// The header's own totals, built with the document so a client does not
205/// count for itself.
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
207pub struct SettingsTotals {
208    pub keys: usize,
209    pub areas: usize,
210}
211
212/// The whole effective config, pushed on attach and after every applied
213/// reload.
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215pub struct SettingsDocument {
216    /// The forge version that built it.
217    pub version: String,
218    /// The resolved `forge.toml` path.
219    pub source: String,
220    /// The fingerprint of the raw file text this document was built from.
221    /// A save carries it, and a file that has moved since refuses the save.
222    pub fingerprint: String,
223    pub totals: SettingsTotals,
224    pub areas: Vec<SettingsArea>,
225}
226
227impl SettingsDocument {
228    /// A fresh total over the areas, so a builder cannot ship a count that
229    /// disagrees with what it drew.
230    pub fn with_totals(mut self) -> Self {
231        self.totals = SettingsTotals {
232            keys: self.areas.iter().map(|area| area.keys.len()).sum(),
233            areas: self.areas.len(),
234        };
235        self
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    /// The frame is the page's read, not a diagnostic: a token that rides in
244    /// it must not print through a debug log.
245    #[test]
246    fn a_keys_debug_never_prints_its_value() {
247        let key = SettingsKey {
248            path: "accounts.Primary.token".to_owned(),
249            leaf: "token".to_owned(),
250            group: vec!["Primary".to_owned()],
251            pattern: "accounts[].token".to_owned(),
252            kind: SettingKind::String,
253            value: serde_json::json!("sk-a-real-token"),
254            class: EffectClass::Live,
255            edit: SettingsEdit {
256                kind: EditKind::String,
257                allowed: Vec::new(),
258                required: true,
259                group: Some("account".to_owned()),
260            },
261        };
262        let shown = format!("{key:?}");
263        assert!(!shown.contains("sk-a-real-token"), "the value printed: {shown}");
264        assert!(shown.contains("accounts.Primary.token"), "the path stayed legible: {shown}");
265    }
266}