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