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}