Skip to main content

wowlab_engine_domain/rotation/validate/
errors.rs

1use std::fmt::Write as _;
2
3use serde::{Deserialize, Serialize};
4#[cfg(feature = "wasm")]
5use tsify::Tsify;
6
7/// Aggregated outcome of rotation validation.
8#[derive(Clone, Debug, Deserialize, Serialize)]
9#[serde(rename_all = "camelCase")]
10#[cfg_attr(feature = "wasm", derive(Tsify))]
11#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
12pub struct ValidationResult {
13    pub valid: bool,
14    pub errors: Vec<ValidationError>,
15    pub warnings: Vec<ValidationWarning>,
16}
17
18impl ValidationResult {
19    /// Empty success result.
20    #[must_use]
21    pub fn ok() -> Self {
22        Self {
23            valid: true,
24            errors: Vec::new(),
25            warnings: Vec::new(),
26        }
27    }
28
29    /// Build a result from a list of errors.
30    #[must_use]
31    pub fn with_errors(errors: Vec<ValidationError>) -> Self {
32        Self {
33            valid: errors.is_empty(),
34            errors,
35            warnings: Vec::new(),
36        }
37    }
38}
39
40/// Hard validation error.
41#[derive(Clone, Debug, Deserialize, Serialize, thiserror::Error)]
42#[serde(tag = "type", rename_all = "camelCase")]
43#[cfg_attr(feature = "wasm", derive(Tsify))]
44#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
45#[non_exhaustive]
46// #t(rust_public_error_enum) serialized WASM contract uses stable tagged variants.
47pub enum ValidationError {
48    #[error("undefined variable '{name}'")]
49    UndefinedVariable { name: String },
50    #[error("undefined list '{name}'")]
51    UndefinedList { name: String },
52    #[error("circular list reference: {}", path.join(" -> "))]
53    CircularReference { path: Vec<String> },
54    #[error("action list '{list_name}' is empty")]
55    EmptyActionList { list_name: String },
56    #[error(
57        "invalid expression: {message}{context}",
58        context = invalid_expression_context(
59            list_name.as_deref(),
60            action_index.as_ref().copied(),
61            slug.as_deref()
62        )
63    )]
64    InvalidExpression {
65        message: String,
66        #[serde(default, skip_serializing_if = "Option::is_none")]
67        list_name: Option<String>,
68        #[serde(default, skip_serializing_if = "Option::is_none")]
69        action_index: Option<usize>,
70        #[serde(default, skip_serializing_if = "Option::is_none")]
71        slug: Option<String>,
72    },
73    #[error("duplicate variable '{name}'")]
74    DuplicateVariable { name: String },
75    #[error("duplicate list '{name}'")]
76    DuplicateList { name: String },
77    #[error(
78        "type mismatch on '{name}' ({op}): expected {expected}, got {got}{context}",
79        context = type_mismatch_context(list_name.as_deref(), action_index.as_ref().copied())
80    )]
81    TypeMismatch {
82        name: String,
83        op: String,
84        expected: String,
85        got: String,
86        #[serde(default, skip_serializing_if = "Option::is_none")]
87        list_name: Option<String>,
88        #[serde(default, skip_serializing_if = "Option::is_none")]
89        action_index: Option<usize>,
90    },
91    #[error("unknown field '{name}' in domain '{domain}'")]
92    UnknownField { domain: String, name: String },
93    #[error("condition nesting depth {depth} exceeds maximum {max}")]
94    MaxDepthExceeded { depth: usize, max: usize },
95    #[error("expanded action count {actions} exceeds maximum {max}")]
96    ActionExpansionLimitExceeded { actions: usize, max: usize },
97    #[error("unsupported rotation syntax: {construct}")]
98    UnsupportedSyntax { construct: String },
99}
100
101fn invalid_expression_context(
102    list_name: Option<&str>,
103    action_index: Option<usize>,
104    slug: Option<&str>,
105) -> String {
106    let Some(list_name) = list_name else {
107        return String::new();
108    };
109    let mut context = format!(" (list '{list_name}'");
110
111    if let Some(index) = action_index {
112        let _ = write!(context, ", action {index}");
113    }
114
115    if let Some(slug) = slug {
116        let _ = write!(context, ", spell '{slug}'");
117    }
118
119    context.push(')');
120
121    context
122}
123
124fn type_mismatch_context(list_name: Option<&str>, action_index: Option<usize>) -> String {
125    let Some(list_name) = list_name else {
126        return String::new();
127    };
128    let mut context = format!(" (list '{list_name}'");
129
130    if let Some(index) = action_index {
131        let _ = write!(context, ", action {index}");
132    }
133
134    context.push(')');
135
136    context
137}
138
139/// Non-fatal warning.
140#[derive(Clone, Debug, Deserialize, Serialize)]
141#[serde(tag = "type", rename_all = "camelCase")]
142#[cfg_attr(feature = "wasm", derive(Tsify))]
143#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
144#[non_exhaustive]
145pub enum ValidationWarning {
146    UnusedVariable { name: String },
147    UnusedList { name: String },
148    ConstantCondition { value: bool, location: String },
149}