1use std::cmp::Ordering;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_app_i18n::{Language, Texts};
8use ravenrs_criteria::{Criteria, Environment, Scope, Value};
9
10use crate::{EntityInfo, ModelError, Object, ObjectSpace, Query};
11
12pub const CONTEXT_SAVE: &str = "Save";
14pub const CONTEXT_DELETE: &str = "Delete";
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19#[non_exhaustive]
20#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
21pub enum TextComparison {
22 Equals,
24 NotEquals,
26 Contains,
28 StartsWith,
30 EndsWith,
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36#[non_exhaustive]
37#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
38pub enum Comparison {
39 Equal,
41 NotEqual,
43 Less,
45 LessOrEqual,
47 Greater,
49 GreaterOrEqual,
51}
52
53#[derive(Clone, Debug, PartialEq)]
55#[non_exhaustive]
56#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
57pub enum Operand {
58 Value(Value),
60 Field(String),
62}
63
64#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
66#[non_exhaustive]
67#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
68pub enum Severity {
69 #[default]
71 Error,
72 Warning,
74 Information,
76}
77
78#[cfg_attr(feature = "live", ravenrs_live::expose)]
79impl Severity {
80 fn from_name(name: &str) -> Option<Self> {
81 match name {
82 "Error" => Some(Self::Error),
83 "Warning" => Some(Self::Warning),
84 "Information" => Some(Self::Information),
85 _ => None,
86 }
87 }
88}
89
90type CustomRule = Rc<dyn Fn(&Object, &dyn ObjectSpace) -> Result<(), String>>;
91
92#[derive(Clone)]
94#[non_exhaustive]
95#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
96pub enum RuleKind {
97 Required,
99 Range {
101 min: Option<f64>,
103 max: Option<f64>,
105 },
106 Matches(String),
108 Text {
110 comparison: TextComparison,
112 text: String,
114 ignore_case: bool,
116 },
117 Compare {
119 comparison: Comparison,
121 with: Operand,
123 },
124 Criteria(String),
126 IsTrue,
128 Unique,
130 UniqueTogether(Vec<String>),
132 Exists {
134 entity: String,
136 criteria: Option<String>,
138 },
139 Referenced {
141 entity: String,
143 field: String,
145 },
146 Custom(CustomRule),
148}
149
150impl fmt::Debug for RuleKind {
151 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152 f.write_str(match self {
153 Self::Required => "Required",
154 Self::Range { .. } => "Range",
155 Self::Matches(_) => "Matches",
156 Self::Text { .. } => "Text",
157 Self::Compare { .. } => "Compare",
158 Self::Criteria(_) => "Criteria",
159 Self::IsTrue => "IsTrue",
160 Self::Unique => "Unique",
161 Self::UniqueTogether(_) => "UniqueTogether",
162 Self::Exists { .. } => "Exists",
163 Self::Referenced { .. } => "Referenced",
164 Self::Custom(_) => "Custom",
165 })
166 }
167}
168
169#[derive(Clone, Debug)]
171#[non_exhaustive]
172#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
173pub struct Rule {
174 pub id: String,
176 pub field: Option<String>,
178 pub kind: RuleKind,
180 pub contexts: Vec<String>,
182 pub target_criteria: Option<String>,
184 pub message: Option<String>,
186 pub skip_empty: bool,
188 pub severity: Severity,
190}
191
192impl PartialEq for Rule {
193 fn eq(&self, other: &Self) -> bool {
194 self.id == other.id
195 }
196}
197
198#[cfg_attr(feature = "live", ravenrs_live::expose)]
199impl Rule {
200 pub(crate) fn from_model(node: &crate::ModelNode) -> Result<Self, crate::ModelError> {
204 let wrong = |what: &str| crate::ModelError(format!("the rule {} {what}", node.id));
205 let text = |name: &str| node.text(name).map(str::to_owned);
206 let field = || text("Field").ok_or_else(|| wrong("says nothing about its field"));
207 let kind = node.text("Kind").ok_or_else(|| wrong("says nothing about its kind"))?;
208 let mut rule = match kind {
209 "Required" => Self::on(field()?, RuleKind::Required),
210 "Unique" => Self::on(field()?, RuleKind::Unique),
211 "Range" => {
212 Self::on(field()?, RuleKind::Range { min: node.number("Min"), max: node.number("Max") })
213 }
214 "Matches" => {
215 let pattern = text("Pattern").ok_or_else(|| wrong("says nothing about its pattern"))?;
216 Self::on(field()?, RuleKind::Matches(pattern))
217 }
218 "Criteria" => {
219 let expression = text("Expression").ok_or_else(|| wrong("has no expression"))?;
220 Self::new(node.id.clone(), RuleKind::Criteria(expression))
221 }
222 other => return Err(wrong(&format!("is of an unknown kind: {other}"))),
223 };
224 rule.id = node.id.clone();
225 rule.message = text("Message");
226 rule.target_criteria = text("Target");
227 if let Some(name) = node.text("Severity") {
228 rule.severity = Severity::from_name(name)
229 .ok_or_else(|| wrong(&format!("has an unknown severity: {name}")))?;
230 }
231 if let Some(contexts) = node.text("Contexts") {
232 rule.contexts = contexts.split(',').map(|context| context.trim().to_owned()).collect();
233 }
234 Ok(rule)
235 }
236
237 pub fn new(id: impl Into<String>, kind: RuleKind) -> Self {
239 Self {
240 id: id.into(),
241 field: None,
242 kind,
243 contexts: vec![CONTEXT_SAVE.to_owned()],
244 target_criteria: None,
245 message: None,
246 skip_empty: true,
247 severity: Severity::Error,
248 }
249 }
250
251 pub fn on(field: impl Into<String>, kind: RuleKind) -> Self {
253 let field = field.into();
254 let skip_empty = !matches!(kind, RuleKind::Required | RuleKind::IsTrue);
255 Self { skip_empty, ..Self::new(format!("{field}.{kind:?}"), kind) }.about(field)
256 }
257
258 fn about(mut self, field: String) -> Self {
259 self.field = Some(field);
260 self
261 }
262
263 pub fn required(field: impl Into<String>) -> Self {
265 Self::on(field, RuleKind::Required)
266 }
267
268 pub fn unique(field: impl Into<String>) -> Self {
270 Self::on(field, RuleKind::Unique)
271 }
272
273 pub fn range(field: impl Into<String>, min: Option<f64>, max: Option<f64>) -> Self {
275 Self::on(field, RuleKind::Range { min, max })
276 }
277
278 pub fn matches(field: impl Into<String>, pattern: impl Into<String>) -> Self {
280 Self::on(field, RuleKind::Matches(pattern.into()))
281 }
282
283 pub fn criteria(id: impl Into<String>, expression: impl Into<String>) -> Self {
285 Self::new(id, RuleKind::Criteria(expression.into()))
286 }
287
288 pub fn contexts(mut self, contexts: impl IntoIterator<Item = impl Into<String>>) -> Self {
290 self.contexts = contexts.into_iter().map(Into::into).collect();
291 self
292 }
293
294 pub fn target_criteria(mut self, expression: impl Into<String>) -> Self {
296 self.target_criteria = Some(expression.into());
297 self
298 }
299
300 pub fn message(mut self, message: impl Into<String>) -> Self {
304 self.message = Some(message.into());
305 self
306 }
307
308 pub fn severity(mut self, severity: Severity) -> Self {
310 self.severity = severity;
311 self
312 }
313
314 pub fn warning(self) -> Self {
316 self.severity(Severity::Warning)
317 }
318
319 pub fn information(self) -> Self {
321 self.severity(Severity::Information)
322 }
323
324 fn reads_records(&self) -> bool {
326 matches!(
327 self.kind,
328 RuleKind::Unique
329 | RuleKind::UniqueTogether(_)
330 | RuleKind::Exists { .. }
331 | RuleKind::Referenced { .. }
332 | RuleKind::Custom(_)
333 )
334 }
335
336 pub fn skip_empty(mut self, skip: bool) -> Self {
338 self.skip_empty = skip;
339 self
340 }
341
342 pub(crate) fn check(&self, entity: &EntityInfo) -> Result<(), ModelError> {
344 let wrong = |what: String| Err(ModelError(format!("{}: the rule {}: {what}", entity.name, self.id)));
345 if let Some(field) = &self.field
346 && entity.field_named(field).is_none()
347 {
348 return wrong(format!("there is no field {field}"));
349 }
350 let parses = |text: &str| {
351 Criteria::parse(text)
352 .map(|_| ())
353 .map_err(|e| ModelError(format!("{}: the rule {}: {e}", entity.name, self.id)))
354 };
355 if let Some(text) = &self.target_criteria {
356 parses(text)?;
357 }
358 match &self.kind {
359 RuleKind::Criteria(text) => parses(text)?,
360 RuleKind::Exists { criteria: Some(text), .. } => parses(text)?,
361 RuleKind::Matches(pattern) => {
362 if let Err(e) = regex_lite::Regex::new(pattern) {
363 return wrong(e.to_string());
364 }
365 }
366 RuleKind::UniqueTogether(fields) => {
367 if let Some(missing) = fields.iter().find(|f| entity.field_named(f).is_none()) {
368 return wrong(format!("there is no field {missing}"));
369 }
370 }
371 RuleKind::Compare { with: Operand::Field(other), .. } if entity.field_named(other).is_none() => {
372 return wrong(format!("there is no field {other}"));
373 }
374 _ => {}
375 }
376 Ok(())
377 }
378}
379
380#[derive(Clone, Debug)]
382#[non_exhaustive]
383#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
384pub struct RuleWords {
385 pub required: String,
387 pub too_low: String,
389 pub too_high: String,
391 pub invalid: String,
393 pub comparison: String,
395 pub record: String,
397 pub must_be_true: String,
399 pub duplicate: String,
401 pub none_exists: String,
403 pub not_referenced: String,
405 pub decimal: char,
407}
408
409impl Default for RuleWords {
410 fn default() -> Self {
411 Self::from_texts(&Texts::new(Language::English))
412 }
413}
414
415#[cfg_attr(feature = "live", ravenrs_live::expose)]
416impl RuleWords {
417 pub fn from_texts(texts: &Texts) -> Self {
419 Self {
420 required: texts.get("rules.required"),
421 too_low: texts.get("rules.too_low"),
422 too_high: texts.get("rules.too_high"),
423 invalid: texts.get("rules.invalid"),
424 comparison: texts.get("rules.comparison"),
425 record: texts.get("rules.record"),
426 must_be_true: texts.get("rules.must_be_true"),
427 duplicate: texts.get("rules.duplicate"),
428 none_exists: texts.get("rules.none_exists"),
429 not_referenced: texts.get("rules.not_referenced"),
430 decimal: texts.char("format.decimal", '.'),
431 }
432 }
433
434 pub fn pt_br() -> Self {
436 Self::from_texts(&Texts::new(Language::BrazilianPortuguese))
437 }
438}
439
440#[derive(Clone, Debug, PartialEq, Eq)]
442#[non_exhaustive]
443#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
444pub struct Violation {
445 pub rule: String,
447 pub field: Option<String>,
449 pub message: String,
451 pub severity: Severity,
453}
454
455#[cfg_attr(feature = "live", ravenrs_live::expose)]
456impl Violation {
457 pub fn is_error(&self) -> bool {
459 self.severity == Severity::Error
460 }
461}
462
463fn is_empty(value: &Value) -> bool {
464 match value {
465 Value::Null => true,
466 Value::Text(text) => text.trim().is_empty(),
467 _ => false,
468 }
469}
470
471fn text_of(value: &Value) -> String {
472 match value {
473 Value::Text(text) => text.clone(),
474 Value::Number(number) if number.fract() == 0.0 => format!("{}", *number as i64),
475 Value::Number(number) => number.to_string(),
476 Value::Bool(on) => on.to_string(),
477 Value::Null => String::new(),
478 other => format!("{other:?}"),
479 }
480}
481
482fn same(a: &Value, b: &Value) -> bool {
483 text_of(a) == text_of(b)
484}
485
486fn compares(value: &Value, comparison: Comparison, other: &Value) -> bool {
487 let order = value.compare(other);
488 match comparison {
489 Comparison::Equal => order == Ordering::Equal,
490 Comparison::NotEqual => order != Ordering::Equal,
491 Comparison::Less => order == Ordering::Less,
492 Comparison::LessOrEqual => order != Ordering::Greater,
493 Comparison::Greater => order == Ordering::Greater,
494 Comparison::GreaterOrEqual => order != Ordering::Less,
495 }
496}
497
498fn meets(object: &Object, expression: &str, environment: &Environment) -> bool {
499 Criteria::parse(expression).is_ok_and(|parsed| Scope::new(environment).row(object).matches(&parsed))
500}
501
502fn duplicated(entity: &EntityInfo, object: &Object, fields: &[&str], space: &dyn ObjectSpace) -> bool {
505 let key = entity.key_field().map(|f| f.name.as_str()).unwrap_or_default();
506 let found = space.query(&entity.name, &Query::all()).unwrap_or_default();
507 found.iter().any(|other| {
508 !same(&other.get(key), &object.get(key))
509 && fields.iter().all(|field| same(&other.get(field), &object.get(field)))
510 })
511}
512
513fn shown(field: Option<&crate::FieldInfo>, value: &Value, words: &RuleWords) -> String {
515 match (field.map(|field| &field.kind), value) {
516 (Some(crate::FieldKind::Money), Value::Number(cents)) => {
517 format!("{:.2}", cents / 100.0).replace('.', &words.decimal.to_string())
518 }
519 (Some(crate::FieldKind::Enum(options)), Value::Text(code)) => options
520 .iter()
521 .find(|option| option.code == *code)
522 .map_or_else(|| code.clone(), |option| option.caption.clone()),
523 (_, Value::Date(moment)) => moment.to_string(),
524 _ => text_of(value),
525 }
526}
527
528fn message_part(
530 name: &str,
531 rule: &Rule,
532 entity: &EntityInfo,
533 object: &Object,
534 space: &dyn ObjectSpace,
535 words: &RuleWords,
536) -> Option<String> {
537 let number = |limit: Option<f64>| limit.map_or_else(String::new, |n| text_of(&Value::Number(n)));
538 match name {
539 "$entity" => return Some(entity.caption.clone()),
540 "$field" => {
541 let field = rule.field.as_deref().and_then(|field| entity.field_named(field));
542 return Some(field.map_or_else(String::new, |field| field.caption.clone()));
543 }
544 "$min" | "$max" => {
545 let RuleKind::Range { min, max } = &rule.kind else { return Some(String::new()) };
546 return Some(number(if name == "$min" { *min } else { *max }));
547 }
548 _ => {}
549 }
550 if let Some((reference, rest)) = name.split_once('.') {
551 let field = entity.field_named(reference)?;
552 let crate::FieldKind::Reference(target) = &field.kind else { return None };
553 let other = space.entities().into_iter().find(|e| e.name == *target)?;
554 let record = space.find(target, &object.get(reference)).ok().flatten();
555 let Some(record) = record else { return Some(String::new()) };
556 return message_part(rest, rule, &other, &record, space, words);
557 }
558 let field = entity.field_named(name)?;
559 Some(shown(Some(field), &object.get(name), words))
560}
561
562fn filled_message(
564 text: &str,
565 rule: &Rule,
566 entity: &EntityInfo,
567 object: &Object,
568 space: &dyn ObjectSpace,
569 words: &RuleWords,
570) -> String {
571 let mut filled = String::with_capacity(text.len());
572 let mut rest = text;
573 while let Some(start) = rest.find('{') {
574 filled.push_str(&rest[..start]);
575 let Some(end) = rest[start..].find('}') else {
576 rest = &rest[start..];
577 break;
578 };
579 let inside = &rest[start + 1..start + end];
580 match message_part(inside, rule, entity, object, space, words) {
581 Some(part) => filled.push_str(&part),
582 None => filled.push_str(&rest[start..=start + end]),
583 }
584 rest = &rest[start + end + 1..];
585 }
586 filled.push_str(rest);
587 filled
588}
589
590fn broken(
592 rule: &Rule,
593 entity: &EntityInfo,
594 object: &Object,
595 space: &dyn ObjectSpace,
596 words: &RuleWords,
597) -> Option<String> {
598 let environment = crate::environment();
599 let caption = rule
600 .field
601 .as_deref()
602 .and_then(|name| entity.field_named(name))
603 .map_or_else(String::new, |field| field.caption.clone());
604 let say = |text: &str, limit: Option<f64>| {
605 let limit = limit.map_or_else(String::new, |n| text_of(&Value::Number(n)));
606 text.replace("{0}", &caption).replace("{1}", &limit)
607 };
608 let value = rule.field.as_deref().map_or(Value::Null, |name| object.get(name));
609 if rule.field.is_some() && rule.skip_empty && is_empty(&value) {
610 return None;
611 }
612 let failed = match &rule.kind {
613 RuleKind::Required => is_empty(&value).then(|| say(&words.required, None)),
614 RuleKind::Range { min, max } => {
615 let number = value.as_number()?;
616 if min.is_some_and(|min| number < min) {
617 Some(say(&words.too_low, *min))
618 } else if max.is_some_and(|max| number > max) {
619 Some(say(&words.too_high, *max))
620 } else {
621 None
622 }
623 }
624 RuleKind::Matches(pattern) => {
625 let fits = regex_lite::Regex::new(pattern).is_ok_and(|regex| regex.is_match(&text_of(&value)));
626 (!fits).then(|| say(&words.invalid, None))
627 }
628 RuleKind::Text { comparison, text, ignore_case } => {
629 let fold = |s: &str| if *ignore_case { s.to_lowercase() } else { s.to_owned() };
630 let (held, wanted) = (fold(&text_of(&value)), fold(text));
631 let fits = match comparison {
632 TextComparison::NotEquals => held != wanted,
633 TextComparison::Contains => held.contains(&wanted),
634 TextComparison::StartsWith => held.starts_with(&wanted),
635 TextComparison::EndsWith => held.ends_with(&wanted),
636 _ => held == wanted,
637 };
638 (!fits).then(|| say(&words.invalid, None))
639 }
640 RuleKind::Compare { comparison, with } => {
641 let other = match with {
642 Operand::Value(other) => other.clone(),
643 Operand::Field(name) => object.get(name),
644 };
645 (!compares(&value, *comparison, &other)).then(|| say(&words.comparison, None))
646 }
647 RuleKind::Criteria(expression) => {
648 (!meets(object, expression, &environment)).then(|| words.record.clone())
649 }
650 RuleKind::IsTrue => (!matches!(value, Value::Bool(true))).then(|| say(&words.must_be_true, None)),
651 RuleKind::Unique => {
652 let field = rule.field.as_deref()?;
653 duplicated(entity, object, &[field], space).then(|| say(&words.duplicate, None))
654 }
655 RuleKind::UniqueTogether(fields) => {
656 let names: Vec<&str> = fields.iter().map(String::as_str).collect();
657 let captions: Vec<&str> = fields
658 .iter()
659 .filter_map(|name| entity.field_named(name))
660 .map(|field| field.caption.as_str())
661 .collect();
662 duplicated(entity, object, &names, space)
663 .then(|| words.duplicate.replace("{0}", &captions.join(", ")))
664 }
665 RuleKind::Exists { entity: other, criteria } => {
666 let found = space.query(other, &Query::all()).unwrap_or_default();
667 let any = found.iter().any(|record| {
668 criteria.as_deref().is_none_or(|expression| meets(record, expression, &environment))
669 });
670 (!any).then(|| words.none_exists.clone())
671 }
672 RuleKind::Referenced { entity: other, field } => {
673 let key = entity.key_field().map(|f| object.get(&f.name)).unwrap_or_default();
674 let found = space.query(other, &Query::all()).unwrap_or_default();
675 (!found.iter().any(|record| same(&record.get(field), &key))).then(|| words.not_referenced.clone())
676 }
677 RuleKind::Custom(check) => check(object, space).err(),
678 };
679 failed.map(|text| match &rule.message {
680 Some(message) => filled_message(message, rule, entity, object, space, words),
681 None => text,
682 })
683}
684
685pub fn validate(
688 entity: &EntityInfo,
689 object: &Object,
690 context: &str,
691 space: &dyn ObjectSpace,
692 words: &RuleWords,
693) -> Vec<Violation> {
694 checked(entity, object, context, space, words, false)
695}
696
697pub fn validate_in_place(
699 entity: &EntityInfo,
700 object: &Object,
701 context: &str,
702 space: &dyn ObjectSpace,
703 words: &RuleWords,
704) -> Vec<Violation> {
705 checked(entity, object, context, space, words, true)
706}
707
708fn checked(
709 entity: &EntityInfo,
710 object: &Object,
711 context: &str,
712 space: &dyn ObjectSpace,
713 words: &RuleWords,
714 in_place: bool,
715) -> Vec<Violation> {
716 let mut object = object.clone();
717 let environment = crate::environment();
718 entity.compute(&mut object, &environment);
719 entity
720 .rules
721 .iter()
722 .filter(|rule| rule.contexts.iter().any(|c| c == context))
723 .filter(|rule| !(in_place && rule.reads_records()))
724 .filter(|rule| {
725 rule.target_criteria.as_deref().is_none_or(|expression| meets(&object, expression, &environment))
726 })
727 .filter_map(|rule| {
728 broken(rule, entity, &object, space, words).map(|message| Violation {
729 rule: rule.id.clone(),
730 field: rule.field.clone(),
731 message,
732 severity: rule.severity,
733 })
734 })
735 .collect()
736}