Skip to main content

panproto_gat/
refinement.rs

1//! Refinement types: sorts constrained by predicates.
2//!
3//! A `RefinedSort` pairs a base sort (e.g., "string") with constraints
4//! (e.g., `maxLength(300)`), creating a subsort. The subsort relationship
5//! determines whether constraint changes are breaking.
6
7use std::sync::Arc;
8
9use serde::{Deserialize, Serialize};
10
11/// A sort refined by constraints, creating a subsort.
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub struct RefinedSort {
14    /// The base sort name (e.g., "string", "int").
15    pub base: Arc<str>,
16    /// Constraints that narrow the sort.
17    pub constraints: Vec<RefinementConstraint>,
18}
19
20/// A single refinement constraint on a sort.
21#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
22pub struct RefinementConstraint {
23    /// The constraint kind (e.g., "maxLength", "minimum", "format").
24    pub kind: Arc<str>,
25    /// The constraint value as a string.
26    pub value: Arc<str>,
27}
28
29/// Error when a value fails refinement.
30#[derive(Debug, Clone, thiserror::Error)]
31pub enum RefinementError {
32    /// A numeric constraint was violated.
33    #[error("constraint {kind} violated: value {value} not in range")]
34    NumericViolation {
35        /// The constraint kind that was violated.
36        kind: String,
37        /// The value that violated the constraint.
38        value: String,
39    },
40    /// A pattern/format constraint was violated.
41    #[error("format constraint {kind} violated")]
42    FormatViolation {
43        /// The format constraint kind that was violated.
44        kind: String,
45    },
46}
47
48impl RefinedSort {
49    /// Build a refined sort from a base sort name and constraint pairs.
50    #[must_use]
51    pub fn from_constraints(base: &str, constraints: &[(String, String)]) -> Self {
52        Self {
53            base: Arc::from(base),
54            constraints: constraints
55                .iter()
56                .map(|(k, v)| RefinementConstraint {
57                    kind: Arc::from(k.as_str()),
58                    value: Arc::from(v.as_str()),
59                })
60                .collect(),
61        }
62    }
63
64    /// Returns true if `self`'s constraints are strictly tighter than `other`'s.
65    ///
66    /// For numeric constraints (`maxLength`, `minLength`, `maximum`, `minimum`),
67    /// this checks interval containment: every value satisfying `self` must
68    /// also satisfy `other`. Same base sort is required.
69    #[must_use]
70    pub fn subsort_of(&self, other: &Self) -> bool {
71        if self.base != other.base {
72            return false;
73        }
74
75        // Self is a subsort of other if for every constraint in other,
76        // self has a constraint of the same kind that is at least as tight.
77        for other_c in &other.constraints {
78            let dominated = self.constraints.iter().any(|self_c| {
79                self_c.kind == other_c.kind
80                    && constraint_tighter(&self_c.kind, &self_c.value, &other_c.value)
81            });
82            if !dominated {
83                return false;
84            }
85        }
86
87        // Also, self must actually be *strictly* tighter; it must have at
88        // least one constraint that is tighter or an additional constraint.
89        if self.constraints.len() == other.constraints.len()
90            && self.constraints.iter().all(|sc| {
91                other
92                    .constraints
93                    .iter()
94                    .any(|oc| sc.kind == oc.kind && sc.value == oc.value)
95            })
96        {
97            return false;
98        }
99
100        true
101    }
102}
103
104/// Check whether `self_val` is at least as tight as `other_val` for the
105/// given constraint kind. Returns true if self's constraint dominates other's.
106fn constraint_tighter(kind: &str, self_val: &str, other_val: &str) -> bool {
107    let parse_both = || -> Option<(f64, f64)> {
108        let s = self_val.parse::<f64>().ok()?;
109        let o = other_val.parse::<f64>().ok()?;
110        Some((s, o))
111    };
112
113    match kind {
114        // Upper-bound constraints: tighter means smaller or equal value.
115        "maxLength" | "maximum" | "exclusiveMaximum" | "maxItems" | "maxProperties" => {
116            parse_both().is_some_and(|(s, o)| s <= o)
117        }
118        // Lower-bound constraints: tighter means larger or equal value.
119        "minLength" | "minimum" | "exclusiveMinimum" | "minItems" | "minProperties" => {
120            parse_both().is_some_and(|(s, o)| s >= o)
121        }
122        // Non-numeric constraints: equal values are considered matching.
123        _ => self_val == other_val,
124    }
125}
126
127#[cfg(test)]
128#[allow(clippy::unwrap_used, clippy::expect_used)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn subsort_same_base_tighter_max() {
134        let narrow = RefinedSort::from_constraints("string", &[("maxLength".into(), "100".into())]);
135        let wide = RefinedSort::from_constraints("string", &[("maxLength".into(), "300".into())]);
136        assert!(narrow.subsort_of(&wide));
137        assert!(!wide.subsort_of(&narrow));
138    }
139
140    #[test]
141    fn subsort_same_base_tighter_min() {
142        let narrow = RefinedSort::from_constraints("int", &[("minimum".into(), "10".into())]);
143        let wide = RefinedSort::from_constraints("int", &[("minimum".into(), "0".into())]);
144        assert!(narrow.subsort_of(&wide));
145        assert!(!wide.subsort_of(&narrow));
146    }
147
148    #[test]
149    fn subsort_different_base_returns_false() {
150        let a = RefinedSort::from_constraints("string", &[("maxLength".into(), "100".into())]);
151        let b = RefinedSort::from_constraints("int", &[("maxLength".into(), "200".into())]);
152        assert!(!a.subsort_of(&b));
153    }
154
155    #[test]
156    fn identical_constraints_not_strict_subsort() {
157        let a = RefinedSort::from_constraints("string", &[("maxLength".into(), "100".into())]);
158        let b = RefinedSort::from_constraints("string", &[("maxLength".into(), "100".into())]);
159        assert!(!a.subsort_of(&b));
160    }
161
162    #[test]
163    fn additional_constraint_makes_subsort() {
164        let narrow = RefinedSort::from_constraints(
165            "string",
166            &[
167                ("maxLength".into(), "100".into()),
168                ("minLength".into(), "5".into()),
169            ],
170        );
171        let wide = RefinedSort::from_constraints("string", &[("maxLength".into(), "100".into())]);
172        assert!(narrow.subsort_of(&wide));
173    }
174
175    #[test]
176    fn from_constraints_round_trip() {
177        let sort = RefinedSort::from_constraints(
178            "string",
179            &[
180                ("maxLength".into(), "300".into()),
181                ("format".into(), "uri".into()),
182            ],
183        );
184        assert_eq!(&*sort.base, "string");
185        assert_eq!(sort.constraints.len(), 2);
186        assert_eq!(&*sort.constraints[0].kind, "maxLength");
187        assert_eq!(&*sort.constraints[0].value, "300");
188    }
189}