Skip to main content

vasari_core/schema/
constraint.rs

1use serde::{Deserialize, Serialize};
2use serde_json::json;
3
4#[cfg(test)]
5mod tests {
6    use super::*;
7    use crate::schema::NodeId;
8
9    fn dummy_id() -> NodeId {
10        NodeId("cafebabe".repeat(8))
11    }
12
13    #[test]
14    fn polarity_is_included_in_hash() {
15        let text = "You must validate all inputs before writing.";
16        let derived = dummy_id();
17        let mandatory = Constraint::new(
18            text.into(),
19            derived.clone(),
20            ConstraintPolarity::Mandatory,
21            vec![],
22        );
23        let prohibitive = Constraint::new(
24            text.into(),
25            derived.clone(),
26            ConstraintPolarity::Prohibitive,
27            vec![],
28        );
29        assert_ne!(
30            mandatory.id, prohibitive.id,
31            "different polarities must produce different IDs"
32        );
33    }
34
35    #[test]
36    fn serde_round_trip() {
37        let c = Constraint::new(
38            "Never store tokens in plaintext.".into(),
39            dummy_id(),
40            ConstraintPolarity::Prohibitive,
41            vec![],
42        );
43        let json = serde_json::to_string(&c).unwrap();
44        let decoded: Constraint = serde_json::from_str(&json).unwrap();
45        assert_eq!(decoded.id, c.id);
46        assert_eq!(decoded.polarity, ConstraintPolarity::Prohibitive);
47        assert_eq!(decoded.text, c.text);
48    }
49
50    #[test]
51    fn schema_version_defaults_to_one_on_deserialize() {
52        let json = r#"{"id":"abcd1234","text":"must do","derived_from":"ef567890","polarity":"mandatory","parent_ids":[]}"#;
53        let c: Constraint = serde_json::from_str(json).unwrap();
54        assert_eq!(c.schema_version, "1");
55    }
56
57    #[test]
58    fn schema_version_excluded_from_hash() {
59        // Two constraints identical except schema_version should have the same ID
60        // since schema_version is excluded from hash_input.
61        let text = "You must always sanitize inputs.";
62        let derived = dummy_id();
63        let c1 = Constraint::new(
64            text.into(),
65            derived.clone(),
66            ConstraintPolarity::Mandatory,
67            vec![],
68        );
69        // Manually mutate schema_version — ID should remain unchanged.
70        let mut c2 = c1.clone();
71        c2.schema_version = "99".into();
72        assert_eq!(
73            c1.id, c2.id,
74            "schema_version must not affect the content hash"
75        );
76    }
77}
78
79use super::NodeId;
80use crate::hash::node_id;
81
82/// Whether a constraint must be satisfied (mandatory) or must not be violated (prohibitive).
83/// v0.1 `vasari violations` only checks mandatory constraints; prohibitive constraints are
84/// recorded but not evaluated (polarity detection requires semantics).
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
86#[serde(rename_all = "snake_case")]
87pub enum ConstraintPolarity {
88    #[default]
89    Mandatory,
90    Prohibitive,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct Constraint {
95    pub id: NodeId,
96    pub text: String,
97    /// The Intent or Plan this constraint was derived from.
98    pub derived_from: NodeId,
99    pub polarity: ConstraintPolarity,
100    pub parent_ids: Vec<NodeId>,
101    /// Schema version for this node type. Stored but excluded from content hash
102    /// (annotation, not identity — same pattern as confidence/result_summary).
103    #[serde(default = "default_schema_version")]
104    pub schema_version: String,
105}
106
107fn default_schema_version() -> String {
108    "1".to_string()
109}
110
111impl Constraint {
112    pub fn new(
113        text: String,
114        derived_from: NodeId,
115        polarity: ConstraintPolarity,
116        parent_ids: Vec<NodeId>,
117    ) -> Self {
118        let id = NodeId(node_id(&Self::hash_input(
119            &text,
120            &derived_from,
121            &polarity,
122            &parent_ids,
123        )));
124        Self {
125            id,
126            text,
127            derived_from,
128            polarity,
129            parent_ids,
130            schema_version: default_schema_version(),
131        }
132    }
133
134    fn hash_input(
135        text: &str,
136        derived_from: &NodeId,
137        polarity: &ConstraintPolarity,
138        parent_ids: &[NodeId],
139    ) -> serde_json::Value {
140        let polarity_str = match polarity {
141            ConstraintPolarity::Mandatory => "mandatory",
142            ConstraintPolarity::Prohibitive => "prohibitive",
143        };
144        json!({
145            "type": "constraint",
146            "text": text,
147            "derived_from": derived_from.as_str(),
148            "polarity": polarity_str,
149            "parent_ids": parent_ids.iter().map(|id| id.as_str()).collect::<Vec<_>>(),
150        })
151    }
152}