Skip to main content

ferro_json_ui/
visibility.rs

1//! Conditional visibility rules for JSON-UI components.
2//!
3//! Visibility rules determine whether a component is rendered based
4//! on data conditions. Conditions reference data paths (JSONPath-style)
5//! and support logical composition with AND, OR, and NOT operators.
6
7use serde::{Deserialize, Serialize};
8
9/// Comparison operators for visibility conditions.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum VisibilityOperator {
13    Exists,
14    NotExists,
15    Eq,
16    NotEq,
17    Gt,
18    Lt,
19    Gte,
20    Lte,
21    Contains,
22    NotEmpty,
23    Empty,
24}
25
26/// A single visibility condition comparing a data path against a value.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct VisibilityCondition {
29    /// JSONPath-style reference to data.
30    pub path: String,
31    pub operator: VisibilityOperator,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub value: Option<serde_json::Value>,
34}
35
36/// Visibility rule with logical composition support.
37///
38/// Uses `#[serde(untagged)]` to support clean JSON:
39/// - Simple: `{"path": "/data/users", "operator": "not_empty"}`
40/// - Compound: `{"and": [...]}`
41/// - Nested: `{"not": {"path": ..., "operator": ...}}`
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43#[serde(untagged)]
44pub enum Visibility {
45    And { and: Vec<Visibility> },
46    Or { or: Vec<Visibility> },
47    Not { not: Box<Visibility> },
48    Condition(VisibilityCondition),
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54
55    #[test]
56    fn simple_condition_round_trips() {
57        let json = r#"{"path": "/data/users", "operator": "not_empty"}"#;
58        let vis: Visibility = serde_json::from_str(json).unwrap();
59        match &vis {
60            Visibility::Condition(c) => {
61                assert_eq!(c.path, "/data/users");
62                assert_eq!(c.operator, VisibilityOperator::NotEmpty);
63                assert!(c.value.is_none());
64            }
65            _ => panic!("expected Condition variant"),
66        }
67        let serialized = serde_json::to_string(&vis).unwrap();
68        let reparsed: Visibility = serde_json::from_str(&serialized).unwrap();
69        assert_eq!(vis, reparsed);
70    }
71
72    #[test]
73    fn condition_with_value() {
74        let json = r#"{"path": "/auth/user/role", "operator": "eq", "value": "admin"}"#;
75        let vis: Visibility = serde_json::from_str(json).unwrap();
76        match &vis {
77            Visibility::Condition(c) => {
78                assert_eq!(c.operator, VisibilityOperator::Eq);
79                assert_eq!(
80                    c.value,
81                    Some(serde_json::Value::String("admin".to_string()))
82                );
83            }
84            _ => panic!("expected Condition variant"),
85        }
86    }
87
88    #[test]
89    fn compound_and_condition() {
90        let json = r#"{
91            "and": [
92                {"path": "/auth/user", "operator": "exists"},
93                {"path": "/auth/user/role", "operator": "eq", "value": "admin"}
94            ]
95        }"#;
96        let vis: Visibility = serde_json::from_str(json).unwrap();
97        match &vis {
98            Visibility::And { and } => {
99                assert_eq!(and.len(), 2);
100            }
101            _ => panic!("expected And variant"),
102        }
103        let serialized = serde_json::to_string(&vis).unwrap();
104        let reparsed: Visibility = serde_json::from_str(&serialized).unwrap();
105        assert_eq!(vis, reparsed);
106    }
107
108    #[test]
109    fn compound_or_condition() {
110        let json = r#"{
111            "or": [
112                {"path": "/data/status", "operator": "eq", "value": "active"},
113                {"path": "/data/status", "operator": "eq", "value": "pending"}
114            ]
115        }"#;
116        let vis: Visibility = serde_json::from_str(json).unwrap();
117        assert!(matches!(vis, Visibility::Or { .. }));
118    }
119
120    #[test]
121    fn nested_not_condition() {
122        let json = r#"{"not": {"path": "/data/deleted", "operator": "exists"}}"#;
123        let vis: Visibility = serde_json::from_str(json).unwrap();
124        match &vis {
125            Visibility::Not { not } => match not.as_ref() {
126                Visibility::Condition(c) => {
127                    assert_eq!(c.path, "/data/deleted");
128                    assert_eq!(c.operator, VisibilityOperator::Exists);
129                }
130                _ => panic!("expected Condition inside Not"),
131            },
132            _ => panic!("expected Not variant"),
133        }
134    }
135
136    #[test]
137    fn all_operators_serialize() {
138        let operators = vec![
139            (VisibilityOperator::Exists, "exists"),
140            (VisibilityOperator::NotExists, "not_exists"),
141            (VisibilityOperator::Eq, "eq"),
142            (VisibilityOperator::NotEq, "not_eq"),
143            (VisibilityOperator::Gt, "gt"),
144            (VisibilityOperator::Lt, "lt"),
145            (VisibilityOperator::Gte, "gte"),
146            (VisibilityOperator::Lte, "lte"),
147            (VisibilityOperator::Contains, "contains"),
148            (VisibilityOperator::NotEmpty, "not_empty"),
149            (VisibilityOperator::Empty, "empty"),
150        ];
151        for (op, expected) in operators {
152            let json = serde_json::to_value(&op).unwrap();
153            assert_eq!(
154                json, expected,
155                "operator {op:?} should serialize to {expected}"
156            );
157        }
158    }
159}