Skip to main content

icydb_core/value/
mod.rs

1mod family;
2
3#[cfg(test)]
4mod tests;
5
6use crate::{
7    db::store::StorageKey,
8    prelude::*,
9    traits::{EnumValue, FieldValue, NumFromPrimitive},
10    types::*,
11};
12use candid::CandidType;
13use serde::{Deserialize, Serialize};
14use std::cmp::Ordering;
15
16// re-exports
17pub use family::{CoercionFamily, CoercionFamilyExt};
18
19///
20/// CONSTANTS
21///
22
23const F64_SAFE_I64: i64 = 1i64 << 53;
24const F64_SAFE_U64: u64 = 1u64 << 53;
25const F64_SAFE_I128: i128 = 1i128 << 53;
26const F64_SAFE_U128: u128 = 1u128 << 53;
27
28///
29/// NumericRepr
30///
31
32enum NumericRepr {
33    Decimal(Decimal),
34    F64(f64),
35    None,
36}
37
38///
39/// TextMode
40///
41
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
43pub enum TextMode {
44    Cs, // case-sensitive
45    Ci, // case-insensitive
46}
47
48///
49/// Value
50/// can be used in WHERE statements
51///
52/// None        → the field’s value is Option::None (i.e., SQL NULL).
53/// Unit        → internal placeholder for RHS; not a real value.
54/// Unsupported → the field exists but isn’t filterable/indexable.
55///
56
57#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
58pub enum Value {
59    Account(Account),
60    Blob(Vec<u8>),
61    Bool(bool),
62    Date(Date),
63    Decimal(Decimal),
64    Duration(Duration),
65    Enum(ValueEnum),
66    E8s(E8s),
67    E18s(E18s),
68    Float32(Float32),
69    Float64(Float64),
70    Int(i64),
71    Int128(Int128),
72    IntBig(Int),
73    /// Ordered list of values.
74    /// Used for many-cardinality transport and map-like encodings (`[key, value]` pairs).
75    /// List order is preserved for normalization and fingerprints.
76    List(Vec<Self>),
77    None,
78    Principal(Principal),
79    Subaccount(Subaccount),
80    Text(String),
81    Timestamp(Timestamp),
82    Uint(u64),
83    Uint128(Nat128),
84    UintBig(Nat),
85    Ulid(Ulid),
86    Unit,
87    Unsupported,
88}
89
90// Local helpers to expand the scalar registry into match arms.
91macro_rules! value_is_numeric_from_registry {
92    ( @args $value:expr; @entries $( ($scalar:ident, $coercion_family:expr, $value_pat:pat, is_numeric_value = $is_numeric:expr, supports_numeric_coercion = $supports_numeric_coercion:expr, supports_arithmetic = $supports_arithmetic:expr, supports_equality = $supports_equality:expr, supports_ordering = $supports_ordering:expr, is_keyable = $is_keyable:expr, is_storage_key_encodable = $is_storage_key_encodable:expr) ),* $(,)? ) => {
93        match $value {
94            $( $value_pat => $is_numeric, )*
95            _ => false,
96        }
97    };
98}
99
100macro_rules! value_supports_numeric_coercion_from_registry {
101    ( @args $value:expr; @entries $( ($scalar:ident, $coercion_family:expr, $value_pat:pat, is_numeric_value = $is_numeric:expr, supports_numeric_coercion = $supports_numeric_coercion:expr, supports_arithmetic = $supports_arithmetic:expr, supports_equality = $supports_equality:expr, supports_ordering = $supports_ordering:expr, is_keyable = $is_keyable:expr, is_storage_key_encodable = $is_storage_key_encodable:expr) ),* $(,)? ) => {
102        match $value {
103            $( $value_pat => $supports_numeric_coercion, )*
104            _ => false,
105        }
106    };
107}
108
109macro_rules! value_storage_key_case {
110    ( $value:expr, Unit, true ) => {
111        if let Value::Unit = $value {
112            Some(StorageKey::Unit)
113        } else {
114            None
115        }
116    };
117    ( $value:expr, $scalar:ident, true ) => {
118        if let Value::$scalar(v) = $value {
119            Some(StorageKey::$scalar(*v))
120        } else {
121            None
122        }
123    };
124    ( $value:expr, $scalar:ident, false ) => {
125        None
126    };
127}
128
129macro_rules! value_storage_key_from_registry {
130    ( @args $value:expr; @entries $( ($scalar:ident, $coercion_family:expr, $value_pat:pat, is_numeric_value = $is_numeric:expr, supports_numeric_coercion = $supports_numeric_coercion:expr, supports_arithmetic = $supports_arithmetic:expr, supports_equality = $supports_equality:expr, supports_ordering = $supports_ordering:expr, is_keyable = $is_keyable:tt, is_storage_key_encodable = $is_storage_key_encodable:expr) ),* $(,)? ) => {
131        {
132            let mut key = None;
133            $(
134                match key {
135                    Some(_) => {}
136                    None => {
137                        key = value_storage_key_case!($value, $scalar, $is_keyable);
138                    }
139                }
140            )*
141            key
142        }
143    };
144}
145
146macro_rules! value_coercion_family_from_registry {
147    ( @args $value:expr; @entries $( ($scalar:ident, $coercion_family:expr, $value_pat:pat, is_numeric_value = $is_numeric:expr, supports_numeric_coercion = $supports_numeric_coercion:expr, supports_arithmetic = $supports_arithmetic:expr, supports_equality = $supports_equality:expr, supports_ordering = $supports_ordering:expr, is_keyable = $is_keyable:expr, is_storage_key_encodable = $is_storage_key_encodable:expr) ),* $(,)? ) => {
148        match $value {
149            $( $value_pat => $coercion_family, )*
150            Value::List(_) => CoercionFamily::Collection,
151            Value::None => CoercionFamily::Null,
152            Value::Unsupported => CoercionFamily::Unsupported,
153        }
154    };
155}
156
157impl Value {
158    ///
159    /// CONSTRUCTION
160    ///
161
162    /// Build a `Value::List` from a list literal.
163    ///
164    /// Intended for tests and inline construction.
165    /// Requires `Clone` because items are borrowed.
166    pub fn from_slice<T>(items: &[T]) -> Self
167    where
168        T: Into<Self> + Clone,
169    {
170        Self::List(items.iter().cloned().map(Into::into).collect())
171    }
172
173    /// Build a `Value::List` from owned items.
174    ///
175    /// This is the canonical constructor for query / DTO boundaries.
176    pub fn from_list<T>(items: Vec<T>) -> Self
177    where
178        T: Into<Self>,
179    {
180        Self::List(items.into_iter().map(Into::into).collect())
181    }
182
183    /// Build a `Value::Enum` from a domain enum using its explicit mapping.
184    pub fn from_enum<E: EnumValue>(value: E) -> Self {
185        Self::Enum(value.to_value_enum())
186    }
187
188    /// Build a strict enum value using the canonical path of `E`.
189    #[must_use]
190    pub fn enum_strict<E: Path>(variant: &str) -> Self {
191        Self::Enum(ValueEnum::strict::<E>(variant))
192    }
193
194    ///
195    /// TYPES
196    ///
197
198    /// Returns true if the value is one of the numeric-like variants
199    /// supported by numeric comparison/ordering.
200    #[must_use]
201    pub const fn is_numeric(&self) -> bool {
202        scalar_registry!(value_is_numeric_from_registry, self)
203    }
204
205    /// Returns true when numeric coercion/comparison is explicitly allowed.
206    #[must_use]
207    pub const fn supports_numeric_coercion(&self) -> bool {
208        scalar_registry!(value_supports_numeric_coercion_from_registry, self)
209    }
210
211    /// Returns true if the value is Text.
212    #[must_use]
213    pub const fn is_text(&self) -> bool {
214        matches!(self, Self::Text(_))
215    }
216
217    /// Returns true if the value is Unit (used for presence/null comparators).
218    #[must_use]
219    pub const fn is_unit(&self) -> bool {
220        matches!(self, Self::Unit)
221    }
222
223    #[must_use]
224    pub const fn is_scalar(&self) -> bool {
225        match self {
226            // definitely not scalar:
227            Self::List(_) | Self::Unit => false,
228            _ => true,
229        }
230    }
231
232    fn numeric_repr(&self) -> NumericRepr {
233        // Numeric comparison eligibility is registry-authoritative.
234        if !self.supports_numeric_coercion() {
235            return NumericRepr::None;
236        }
237
238        if let Some(d) = self.to_decimal() {
239            return NumericRepr::Decimal(d);
240        }
241        if let Some(f) = self.to_f64_lossless() {
242            return NumericRepr::F64(f);
243        }
244        NumericRepr::None
245    }
246
247    ///
248    /// CONVERSION
249    ///
250
251    /// NOTE:
252    /// `Unit` is intentionally treated as a valid storage key and indexable,
253    /// used for singleton tables and synthetic identity entities.
254    /// Only `None` and `Unsupported` are non-indexable.
255    #[must_use]
256    pub const fn as_storage_key(&self) -> Option<StorageKey> {
257        scalar_registry!(value_storage_key_from_registry, self)
258    }
259
260    #[must_use]
261    pub const fn as_text(&self) -> Option<&str> {
262        if let Self::Text(s) = self {
263            Some(s.as_str())
264        } else {
265            None
266        }
267    }
268
269    #[must_use]
270    pub const fn as_list(&self) -> Option<&[Self]> {
271        if let Self::List(xs) = self {
272            Some(xs.as_slice())
273        } else {
274            None
275        }
276    }
277
278    fn to_decimal(&self) -> Option<Decimal> {
279        match self {
280            Self::Decimal(d) => Some(*d),
281            Self::Duration(d) => Decimal::from_u64(d.get()),
282            Self::E8s(v) => Some(v.to_decimal()),
283            Self::E18s(v) => v.to_decimal(),
284            Self::Float64(f) => Decimal::from_f64(f.get()),
285            Self::Float32(f) => Decimal::from_f32(f.get()),
286            Self::Int(i) => Decimal::from_i64(*i),
287            Self::Int128(i) => Decimal::from_i128(i.get()),
288            Self::IntBig(i) => i.to_i128().and_then(Decimal::from_i128),
289            Self::Timestamp(t) => Decimal::from_u64(t.get()),
290            Self::Uint(u) => Decimal::from_u64(*u),
291            Self::Uint128(u) => Decimal::from_u128(u.get()),
292            Self::UintBig(u) => u.to_u128().and_then(Decimal::from_u128),
293
294            _ => None,
295        }
296    }
297
298    // it's lossless, trust me bro
299    #[allow(clippy::cast_precision_loss)]
300    fn to_f64_lossless(&self) -> Option<f64> {
301        match self {
302            Self::Duration(d) if d.get() <= F64_SAFE_U64 => Some(d.get() as f64),
303            Self::Float64(f) => Some(f.get()),
304            Self::Float32(f) => Some(f64::from(f.get())),
305            Self::Int(i) if (-F64_SAFE_I64..=F64_SAFE_I64).contains(i) => Some(*i as f64),
306            Self::Int128(i) if (-F64_SAFE_I128..=F64_SAFE_I128).contains(&i.get()) => {
307                Some(i.get() as f64)
308            }
309            Self::IntBig(i) => i.to_i128().and_then(|v| {
310                (-F64_SAFE_I128..=F64_SAFE_I128)
311                    .contains(&v)
312                    .then_some(v as f64)
313            }),
314            Self::Timestamp(t) if t.get() <= F64_SAFE_U64 => Some(t.get() as f64),
315            Self::Uint(u) if *u <= F64_SAFE_U64 => Some(*u as f64),
316            Self::Uint128(u) if u.get() <= F64_SAFE_U128 => Some(u.get() as f64),
317            Self::UintBig(u) => u
318                .to_u128()
319                .and_then(|v| (v <= F64_SAFE_U128).then_some(v as f64)),
320
321            _ => None,
322        }
323    }
324
325    /// Cross-type numeric comparison; returns None if non-numeric.
326    #[must_use]
327    pub fn cmp_numeric(&self, other: &Self) -> Option<Ordering> {
328        if !self.supports_numeric_coercion() || !other.supports_numeric_coercion() {
329            return None;
330        }
331
332        match (self.numeric_repr(), other.numeric_repr()) {
333            (NumericRepr::Decimal(a), NumericRepr::Decimal(b)) => a.partial_cmp(&b),
334            (NumericRepr::F64(a), NumericRepr::F64(b)) => a.partial_cmp(&b),
335            _ => None,
336        }
337    }
338
339    ///
340    /// TEXT COMPARISON
341    ///
342
343    fn fold_ci(s: &str) -> std::borrow::Cow<'_, str> {
344        if s.is_ascii() {
345            return std::borrow::Cow::Owned(s.to_ascii_lowercase());
346        }
347        // NOTE: Unicode fallback — temporary to_lowercase for non‑ASCII.
348        // Future: replace with proper NFKC + full casefold when available.
349        std::borrow::Cow::Owned(s.to_lowercase())
350    }
351
352    fn text_with_mode(s: &'_ str, mode: TextMode) -> std::borrow::Cow<'_, str> {
353        match mode {
354            TextMode::Cs => std::borrow::Cow::Borrowed(s),
355            TextMode::Ci => Self::fold_ci(s),
356        }
357    }
358
359    fn text_op(
360        &self,
361        other: &Self,
362        mode: TextMode,
363        f: impl Fn(&str, &str) -> bool,
364    ) -> Option<bool> {
365        let (a, b) = (self.as_text()?, other.as_text()?);
366        let a = Self::text_with_mode(a, mode);
367        let b = Self::text_with_mode(b, mode);
368        Some(f(&a, &b))
369    }
370
371    fn ci_key(&self) -> Option<String> {
372        match self {
373            Self::Text(s) => Some(Self::fold_ci(s).into_owned()),
374            Self::Ulid(u) => Some(u.to_string().to_ascii_lowercase()),
375            Self::Principal(p) => Some(p.to_string().to_ascii_lowercase()),
376            Self::Account(a) => Some(a.to_string().to_ascii_lowercase()),
377            _ => None,
378        }
379    }
380
381    fn eq_ci(a: &Self, b: &Self) -> bool {
382        if let (Some(ak), Some(bk)) = (a.ci_key(), b.ci_key()) {
383            return ak == bk;
384        }
385
386        a == b
387    }
388
389    fn normalize_list_ref(v: &Self) -> Vec<&Self> {
390        match v {
391            Self::List(vs) => vs.iter().collect(),
392            v => vec![v],
393        }
394    }
395
396    fn contains_by<F>(&self, needle: &Self, eq: F) -> Option<bool>
397    where
398        F: Fn(&Self, &Self) -> bool,
399    {
400        self.as_list()
401            .map(|items| items.iter().any(|v| eq(v, needle)))
402    }
403
404    #[allow(clippy::unnecessary_wraps)]
405    fn contains_any_by<F>(&self, needles: &Self, eq: F) -> Option<bool>
406    where
407        F: Fn(&Self, &Self) -> bool,
408    {
409        let needles = Self::normalize_list_ref(needles);
410        match self {
411            Self::List(items) => Some(needles.iter().any(|n| items.iter().any(|v| eq(v, n)))),
412            scalar => Some(needles.iter().any(|n| eq(scalar, n))),
413        }
414    }
415
416    #[allow(clippy::unnecessary_wraps)]
417    fn contains_all_by<F>(&self, needles: &Self, eq: F) -> Option<bool>
418    where
419        F: Fn(&Self, &Self) -> bool,
420    {
421        let needles = Self::normalize_list_ref(needles);
422        match self {
423            Self::List(items) => Some(needles.iter().all(|n| items.iter().any(|v| eq(v, n)))),
424            scalar => Some(needles.len() == 1 && eq(scalar, needles[0])),
425        }
426    }
427
428    fn in_list_by<F>(&self, haystack: &Self, eq: F) -> Option<bool>
429    where
430        F: Fn(&Self, &Self) -> bool,
431    {
432        if let Self::List(items) = haystack {
433            Some(items.iter().any(|h| eq(h, self)))
434        } else {
435            None
436        }
437    }
438
439    #[must_use]
440    /// Case-sensitive/insensitive equality check for text-like values.
441    pub fn text_eq(&self, other: &Self, mode: TextMode) -> Option<bool> {
442        self.text_op(other, mode, |a, b| a == b)
443    }
444
445    #[must_use]
446    /// Check whether `other` is a substring of `self` under the given text mode.
447    pub fn text_contains(&self, needle: &Self, mode: TextMode) -> Option<bool> {
448        self.text_op(needle, mode, |a, b| a.contains(b))
449    }
450
451    #[must_use]
452    /// Check whether `self` starts with `other` under the given text mode.
453    pub fn text_starts_with(&self, needle: &Self, mode: TextMode) -> Option<bool> {
454        self.text_op(needle, mode, |a, b| a.starts_with(b))
455    }
456
457    #[must_use]
458    /// Check whether `self` ends with `other` under the given text mode.
459    pub fn text_ends_with(&self, needle: &Self, mode: TextMode) -> Option<bool> {
460        self.text_op(needle, mode, |a, b| a.ends_with(b))
461    }
462
463    ///
464    /// EMPTY
465    ///
466
467    #[must_use]
468    pub const fn is_empty(&self) -> Option<bool> {
469        match self {
470            Self::List(xs) => Some(xs.is_empty()),
471            Self::Text(s) => Some(s.is_empty()),
472            Self::Blob(b) => Some(b.is_empty()),
473
474            //  fields represented as Value::None:
475            Self::None => Some(true),
476
477            _ => None,
478        }
479    }
480
481    #[must_use]
482    /// Logical negation of [`is_empty`](Self::is_empty).
483    pub fn is_not_empty(&self) -> Option<bool> {
484        self.is_empty().map(|b| !b)
485    }
486
487    ///
488    /// COLLECTIONS
489    ///
490
491    #[must_use]
492    /// Returns true if `self` contains `needle` (or equals it for scalars).
493    pub fn contains(&self, needle: &Self) -> Option<bool> {
494        self.contains_by(needle, |a, b| a == b)
495    }
496
497    #[must_use]
498    /// Returns true if any item in `needles` matches a member of `self`.
499    pub fn contains_any(&self, needles: &Self) -> Option<bool> {
500        self.contains_any_by(needles, |a, b| a == b)
501    }
502
503    #[must_use]
504    /// Returns true if every item in `needles` matches a member of `self`.
505    pub fn contains_all(&self, needles: &Self) -> Option<bool> {
506        self.contains_all_by(needles, |a, b| a == b)
507    }
508
509    #[must_use]
510    /// Returns true if `self` exists inside the provided list.
511    pub fn in_list(&self, haystack: &Self) -> Option<bool> {
512        self.in_list_by(haystack, |a, b| a == b)
513    }
514
515    #[must_use]
516    /// Case-insensitive `contains` supporting text and identifier variants.
517    pub fn contains_ci(&self, needle: &Self) -> Option<bool> {
518        match self {
519            Self::List(_) => self.contains_by(needle, Self::eq_ci),
520            _ => Some(Self::eq_ci(self, needle)),
521        }
522    }
523
524    #[must_use]
525    /// Case-insensitive variant of [`contains_any`](Self::contains_any).
526    pub fn contains_any_ci(&self, needles: &Self) -> Option<bool> {
527        self.contains_any_by(needles, Self::eq_ci)
528    }
529
530    #[must_use]
531    /// Case-insensitive variant of [`contains_all`](Self::contains_all).
532    pub fn contains_all_ci(&self, needles: &Self) -> Option<bool> {
533        self.contains_all_by(needles, Self::eq_ci)
534    }
535
536    #[must_use]
537    /// Case-insensitive variant of [`in_list`](Self::in_list).
538    pub fn in_list_ci(&self, haystack: &Self) -> Option<bool> {
539        self.in_list_by(haystack, Self::eq_ci)
540    }
541}
542
543impl FieldValue for Value {
544    fn to_value(&self) -> Value {
545        self.clone()
546    }
547}
548
549#[macro_export]
550macro_rules! impl_from_for {
551    ( $( $type:ty => $variant:ident ),* $(,)? ) => {
552        $(
553            impl From<$type> for Value {
554                fn from(v: $type) -> Self {
555                    Self::$variant(v.into())
556                }
557            }
558        )*
559    };
560}
561
562impl_from_for! {
563    Account    => Account,
564    Date       => Date,
565    Decimal    => Decimal,
566    Duration   => Duration,
567    E8s        => E8s,
568    E18s       => E18s,
569    bool       => Bool,
570    i8         => Int,
571    i16        => Int,
572    i32        => Int,
573    i64        => Int,
574    i128       => Int128,
575    Int        => IntBig,
576    Principal  => Principal,
577    Subaccount => Subaccount,
578    &str       => Text,
579    String     => Text,
580    Timestamp  => Timestamp,
581    u8         => Uint,
582    u16        => Uint,
583    u32        => Uint,
584    u64        => Uint,
585    u128       => Uint128,
586    Nat        => UintBig,
587    Ulid       => Ulid,
588}
589
590impl CoercionFamilyExt for Value {
591    /// Returns the coercion-routing family for this value.
592    ///
593    /// NOTE:
594    /// This does NOT imply numeric, arithmetic, ordering, or keyability support.
595    /// All scalar capabilities are registry-driven.
596    fn coercion_family(&self) -> CoercionFamily {
597        scalar_registry!(value_coercion_family_from_registry, self)
598    }
599}
600
601impl<E: EntityIdentity> From<Ref<E>> for Value {
602    fn from(r: Ref<E>) -> Self {
603        r.to_value() // via FieldValue
604    }
605}
606
607impl From<Vec<Self>> for Value {
608    fn from(vec: Vec<Self>) -> Self {
609        Self::List(vec)
610    }
611}
612
613impl From<()> for Value {
614    fn from((): ()) -> Self {
615        Self::Unit
616    }
617}
618
619// NOTE:
620// Value::partial_cmp is NOT the canonical ordering for database semantics.
621// Some orderable scalar types (e.g. Account, Unit) intentionally do not
622// participate here. Use canonical_cmp / strict ordering for ORDER BY,
623// planning, and key-range validation.
624impl PartialOrd for Value {
625    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
626        match (self, other) {
627            (Self::Bool(a), Self::Bool(b)) => a.partial_cmp(b),
628            (Self::Date(a), Self::Date(b)) => a.partial_cmp(b),
629            (Self::Decimal(a), Self::Decimal(b)) => a.partial_cmp(b),
630            (Self::Duration(a), Self::Duration(b)) => a.partial_cmp(b),
631            (Self::E8s(a), Self::E8s(b)) => a.partial_cmp(b),
632            (Self::E18s(a), Self::E18s(b)) => a.partial_cmp(b),
633            (Self::Enum(a), Self::Enum(b)) => a.partial_cmp(b),
634            (Self::Float32(a), Self::Float32(b)) => a.partial_cmp(b),
635            (Self::Float64(a), Self::Float64(b)) => a.partial_cmp(b),
636            (Self::Int(a), Self::Int(b)) => a.partial_cmp(b),
637            (Self::Int128(a), Self::Int128(b)) => a.partial_cmp(b),
638            (Self::IntBig(a), Self::IntBig(b)) => a.partial_cmp(b),
639            (Self::Principal(a), Self::Principal(b)) => a.partial_cmp(b),
640            (Self::Subaccount(a), Self::Subaccount(b)) => a.partial_cmp(b),
641            (Self::Text(a), Self::Text(b)) => a.partial_cmp(b),
642            (Self::Timestamp(a), Self::Timestamp(b)) => a.partial_cmp(b),
643            (Self::Uint(a), Self::Uint(b)) => a.partial_cmp(b),
644            (Self::Uint128(a), Self::Uint128(b)) => a.partial_cmp(b),
645            (Self::UintBig(a), Self::UintBig(b)) => a.partial_cmp(b),
646            (Self::Ulid(a), Self::Ulid(b)) => a.partial_cmp(b),
647
648            // Cross-type comparisons: no ordering
649            _ => None,
650        }
651    }
652}
653
654///
655/// ValueEnum
656/// handles the Enum case; `path` is optional to allow strict (typed) or loose matching.
657///
658
659#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, PartialOrd, Serialize)]
660pub struct ValueEnum {
661    pub variant: String,
662    pub path: Option<String>,
663    pub payload: Option<Box<Value>>,
664}
665
666impl ValueEnum {
667    #[must_use]
668    /// Build a strict enum value matching the provided variant and path.
669    pub fn new(variant: &str, path: Option<&str>) -> Self {
670        Self {
671            variant: variant.to_string(),
672            path: path.map(ToString::to_string),
673            payload: None,
674        }
675    }
676
677    #[must_use]
678    /// Build a strict enum value using the canonical path of `E`.
679    pub fn strict<E: Path>(variant: &str) -> Self {
680        Self::new(variant, Some(E::PATH))
681    }
682
683    #[must_use]
684    /// Build a strict enum value from a domain enum using its explicit mapping.
685    pub fn from_enum<E: EnumValue>(value: E) -> Self {
686        value.to_value_enum()
687    }
688
689    #[must_use]
690    /// Build an enum value that ignores the path for loose matching.
691    pub fn loose(variant: &str) -> Self {
692        Self::new(variant, None)
693    }
694
695    #[must_use]
696    /// Attach an enum payload (used for data-carrying variants).
697    pub fn with_payload(mut self, payload: Value) -> Self {
698        self.payload = Some(Box::new(payload));
699        self
700    }
701}