Skip to main content

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}