lattice_config/option.rs
1//! Typed [`Option<T>`] spec + the [`OptionHandle<T>`] consumers
2//! use for hot-path reads.
3//!
4//! Each option owns its current value behind an [`ArcSwap<T>`].
5//! Reads are wait-free pointer loads; writes go through
6//! [`Option::set`] (or, more commonly, via the registry's
7//! `parse_and_set` driven by `:set foo=value`).
8
9use std::borrow::Cow;
10use std::sync::Arc;
11
12use arc_swap::ArcSwap;
13
14use crate::option_type::OptionType;
15
16/// Post-parse validator. Runs after [`OptionType::parse`] succeeds
17/// and before commit; returning `Err(_)` cancels the set with
18/// that message.
19pub type ValidateFn<T> = fn(&T) -> Result<(), String>;
20
21/// A typed config option. The user-visible name + metadata,
22/// plus the cell holding the current value. Constructed once and
23/// handed to [`crate::ConfigRegistry::register`], which returns a
24/// typed [`OptionHandle`] consumers use to read / write.
25pub struct Option<T: OptionType> {
26 /// PL8.F: `Cow<'static, str>` so a builtin passes a zero-cost
27 /// `Cow::Borrowed` string literal while a plugin-contributed option passes a
28 /// `Cow::Owned(String)` that frees with the entry on
29 /// `ConfigRegistry::unregister` — replacing the old `Box::leak` intern.
30 pub(crate) name: Cow<'static, str>,
31 pub(crate) aliases: &'static [&'static str],
32 pub(crate) doc: Cow<'static, str>,
33 /// Optional post-parse validator. Runs *after* `T::parse`
34 /// succeeds and *before* the value is committed to the cell.
35 /// Returning `Err(_)` cancels the set with that message;
36 /// `Ok(())` commits. Used for range checks (`tabstop` 1..=32),
37 /// invariants between options, etc.
38 pub(crate) validate: std::option::Option<ValidateFn<T>>,
39 /// Pre-formatted default value. Captured at construction time
40 /// so `:describe-option` can show "default: X" without storing
41 /// `T` separately or re-running `format()` against the cell
42 /// (which holds the *current*, not the default, value).
43 pub(crate) default_formatted: String,
44 /// TC.3: a shape declared per OPTION rather than per type.
45 ///
46 /// `None` for every option whose type knows its own shape, which is all of
47 /// them that are written in Rust — `OptionType::schema()` answers and this
48 /// field stays empty. It exists for the case the type cannot cover: a
49 /// PLUGIN's structured option, whose shape arrives at registration as data
50 /// and therefore cannot be a static method on `ConfigValue`.
51 ///
52 /// The schema lives here rather than inside the value because a value that
53 /// carried its own shape could not survive `OptionType::from_value`, which
54 /// is a static function with no access to the option being set. Metadata
55 /// about an option belongs beside its doc and its default.
56 pub(crate) schema: std::option::Option<crate::ConfigSchema>,
57 pub(crate) cell: ArcSwap<T>,
58}
59
60impl<T: OptionType + std::fmt::Debug> std::fmt::Debug for Option<T> {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 f.debug_struct("Option")
63 .field("name", &self.name)
64 .field("aliases", &self.aliases)
65 .field("type_label", &T::type_label())
66 .field("current", &**self.cell.load())
67 .finish_non_exhaustive()
68 }
69}
70
71impl<T: OptionType> Option<T> {
72 /// Build a typed option with a default value. Most options use
73 /// the [`OptionBuilder`] (see [`Option::builder`]) instead so
74 /// optional fields don't need defaults restated.
75 pub fn new(
76 name: impl Into<Cow<'static, str>>,
77 default: T,
78 doc: impl Into<Cow<'static, str>>,
79 ) -> Self {
80 let default_formatted = default.format();
81 Self {
82 name: name.into(),
83 aliases: &[],
84 doc: doc.into(),
85 validate: None,
86 default_formatted,
87 schema: None,
88 cell: ArcSwap::from_pointee(default),
89 }
90 }
91
92 /// Builder entry point.
93 ///
94 /// # Examples
95 ///
96 /// ```
97 /// use lattice_config::ErasedOption;
98 /// use lattice_config::option::Option;
99 ///
100 /// let opt = Option::<i64>::builder("width", 8, "Tab visual width.")
101 /// .aliases(&["wd"])
102 /// .validate(|i| {
103 /// (1..=32).contains(i).then_some(()).ok_or_else(|| format!("out of range: {i}"))
104 /// })
105 /// .build();
106 ///
107 /// assert_eq!(*opt.get(), 8);
108 /// assert_eq!(opt.set(99), Err("out of range: 99".to_string()));
109 /// opt.set(2).unwrap();
110 /// // The erased view formats; the default is captured at build time.
111 /// assert_eq!(opt.get_formatted(), "2");
112 /// assert_eq!(opt.default_formatted(), "8");
113 /// ```
114 pub fn builder(
115 name: impl Into<Cow<'static, str>>,
116 default: T,
117 doc: impl Into<Cow<'static, str>>,
118 ) -> OptionBuilder<T> {
119 OptionBuilder {
120 name: name.into(),
121 aliases: &[],
122 doc: doc.into(),
123 default,
124 validate: None,
125 }
126 }
127
128 /// TC.3: an option whose shape is declared rather than derived — the
129 /// plugin-contributed structured option.
130 ///
131 /// `default` is NOT validated here; the caller
132 /// (`config_host::register_structured_option`) checks it against `schema`
133 /// first, so a declaration that does not fit registers nothing at all
134 /// rather than producing an option that exists and cannot hold a legal
135 /// value.
136 pub fn structured(
137 name: impl Into<Cow<'static, str>>,
138 schema: crate::ConfigSchema,
139 default: T,
140 doc: impl Into<Cow<'static, str>>,
141 ) -> Self {
142 let mut out = Self::new(name, default, doc);
143 out.schema = Some(schema);
144 out
145 }
146
147 /// The option's declared shape: what was given to [`Self::structured`], or
148 /// the type's own [`crate::OptionType::schema`].
149 pub fn declared_schema(&self) -> crate::ConfigSchema {
150 self.schema.clone().unwrap_or_else(T::schema)
151 }
152
153 /// Borrows the option's name. PL8.F: was `-> &'static str` when the field was
154 /// a leaked `&'static str`; a `Cow` field can only lend a `&str` bound to
155 /// `&self`, so callers that need an owned name `.to_owned()` it (registry).
156 pub fn name(&self) -> &str {
157 &self.name
158 }
159
160 /// Wait-free read of the current value. The returned `Arc<T>`
161 /// is stable for the caller's frame; concurrent writers may
162 /// publish a newer value, observed by the next call.
163 pub fn get(&self) -> Arc<T> {
164 self.cell.load_full()
165 }
166
167 /// Borrow the current value through a closure. Avoids the
168 /// `Arc::clone` cost of [`Self::get`] for one-shot reads --
169 /// useful in render hot paths that read+drop in the same
170 /// statement.
171 pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> R {
172 f(&self.cell.load())
173 }
174
175 /// Set the current value, running the (private) `validate` closure first.
176 /// Returns the validator's error verbatim on rejection;
177 /// otherwise commits and returns `Ok(())`.
178 pub fn set(&self, value: T) -> Result<(), String> {
179 if let Some(v) = self.validate
180 && let Err(e) = v(&value)
181 {
182 return Err(e);
183 }
184 self.cell.store(Arc::new(value));
185 Ok(())
186 }
187}
188
189/// Fluent constructor for [`Option<T>`]. See [`Option::builder`].
190pub struct OptionBuilder<T: OptionType> {
191 name: Cow<'static, str>,
192 aliases: &'static [&'static str],
193 doc: Cow<'static, str>,
194 default: T,
195 validate: std::option::Option<ValidateFn<T>>,
196}
197
198impl<T: OptionType> OptionBuilder<T> {
199 /// Extra names that resolve to this option (`ts` for `tabstop`).
200 /// Replaces any aliases set earlier. Each must be unique across the
201 /// registry, or registration fails with
202 /// [`crate::ConfigError::DuplicateName`].
203 pub fn aliases(mut self, aliases: &'static [&'static str]) -> Self {
204 self.aliases = aliases;
205 self
206 }
207
208 /// Install the post-parse validator run on every write (see
209 /// [`ValidateFn`]). Replaces any earlier one. The default passed to
210 /// [`Option::builder`] is NOT validated.
211 pub fn validate(mut self, f: ValidateFn<T>) -> Self {
212 self.validate = Some(f);
213 self
214 }
215
216 /// Finish: the [`Option<T>`] holding the default as its current
217 /// value, ready for [`crate::ConfigRegistry::register`].
218 pub fn build(self) -> Option<T> {
219 let default_formatted = self.default.format();
220 Option {
221 schema: None,
222 name: self.name,
223 aliases: self.aliases,
224 doc: self.doc,
225 validate: self.validate,
226 default_formatted,
227 cell: ArcSwap::from_pointee(self.default),
228 }
229 }
230}
231
232/// Typed pointer into the registry. Returned by
233/// [`crate::ConfigRegistry::register`]; passed back to
234/// [`crate::ConfigRegistry::get`] / [`crate::ConfigRegistry::set`]
235/// for type-safe access without string lookups.
236///
237/// `Copy` so callers can stash handles as plain fields and pass
238/// them around freely. Internally an opaque index +
239/// [`std::marker::PhantomData<T>`] -- the registry validates the
240/// index on each access.
241pub struct OptionHandle<T: OptionType> {
242 pub(crate) idx: usize,
243 pub(crate) _ty: std::marker::PhantomData<fn() -> T>,
244}
245
246impl<T: OptionType> OptionHandle<T> {
247 pub(crate) fn new(idx: usize) -> Self {
248 Self {
249 idx,
250 _ty: std::marker::PhantomData,
251 }
252 }
253
254 /// Raw index for telemetry / debugging only. Two handles of
255 /// the same `T` with the same `idx` refer to the same option.
256 pub fn raw(self) -> usize {
257 self.idx
258 }
259}
260
261impl<T: OptionType> Clone for OptionHandle<T> {
262 fn clone(&self) -> Self {
263 *self
264 }
265}
266
267impl<T: OptionType> Copy for OptionHandle<T> {}
268
269impl<T: OptionType> std::fmt::Debug for OptionHandle<T> {
270 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271 write!(f, "OptionHandle<{}>({})", T::type_label(), self.idx)
272 }
273}
274
275impl<T: OptionType> PartialEq for OptionHandle<T> {
276 fn eq(&self, other: &Self) -> bool {
277 self.idx == other.idx
278 }
279}
280
281impl<T: OptionType> Eq for OptionHandle<T> {}
282
283#[cfg(test)]
284mod tests {
285 #![allow(clippy::unwrap_used, clippy::panic)]
286 use super::*;
287
288 #[test]
289 fn option_builder_constructs_with_aliases_and_validator() {
290 let o: Option<i64> = Option::<i64>::builder("tabstop", 8, "tab width")
291 .aliases(&["ts"])
292 .validate(|i| {
293 if (1..=32).contains(i) {
294 Ok(())
295 } else {
296 Err(format!("out of range: {i}"))
297 }
298 })
299 .build();
300 assert_eq!(o.name, "tabstop");
301 assert_eq!(o.aliases, &["ts"]);
302 assert_eq!(*o.get(), 8);
303 assert!(o.set(4).is_ok());
304 assert_eq!(*o.get(), 4);
305 }
306
307 #[test]
308 fn validator_rejects_out_of_range() {
309 let o: Option<i64> = Option::<i64>::builder("ts", 8, "")
310 .validate(|i| {
311 if (1..=32).contains(i) {
312 Ok(())
313 } else {
314 Err(format!("out of range: {i}"))
315 }
316 })
317 .build();
318 let err = o.set(99).unwrap_err();
319 assert!(err.contains("out of range"), "got `{err}`");
320 assert_eq!(*o.get(), 8, "value must not change on validator reject");
321 }
322
323 #[test]
324 fn with_closure_borrows_without_clone() {
325 let o: Option<String> = Option::new("name", "hello".into(), "doc");
326 let len = o.with(|s| s.len());
327 assert_eq!(len, 5);
328 }
329
330 #[test]
331 fn option_handle_is_copy_and_eq() {
332 let h: OptionHandle<i64> = OptionHandle::new(7);
333 let h2 = h;
334 assert_eq!(h, h2);
335 assert_eq!(h.raw(), 7);
336 }
337}