qualia_core_db/modalities/logic/shacl/
text_input.rs1use std::collections::HashMap;
15
16use super::shacl_types::{CompiledShape, ShaclConstraint, ShaclSeverity, ValidationReport};
17use super::validate::ShaclEngine;
18use crate::frame_layout::{pack_float_object, INLINE_TAG_INTEGER, INLINE_VALUE_MASK};
19use crate::modalities::logic::n3_parser::{N3Event, N3Parser, Term};
20use crate::{q_hash, NQuin};
21
22#[derive(Debug, Clone, serde::Deserialize)]
24#[serde(rename_all = "camelCase")]
25pub struct ShapeSpec {
26 pub target_class: String,
28 #[serde(default)]
31 pub path: String,
32 #[serde(default)]
34 pub severity: Option<String>,
35 pub constraints: Vec<ConstraintSpec>,
36}
37
38#[derive(Debug, Clone, serde::Deserialize)]
40pub struct ConstraintSpec {
41 pub kind: String,
45 #[serde(default)]
46 pub num: Option<f64>,
47 #[serde(default)]
48 pub text: Option<String>,
49 #[serde(default)]
50 pub list: Option<Vec<String>>,
51}
52
53fn term_str<'a>(t: &Term<'a>) -> &'a str {
54 match t {
55 Term::Uri(s) | Term::Variable(s) | Term::Literal(s) | Term::Formula(s) => s,
56 }
57}
58
59fn intern(s: &str, r: &mut HashMap<u64, String>) -> u64 {
60 let h = q_hash(s);
61 r.entry(h).or_insert_with(|| s.to_string());
62 h
63}
64
65fn encode_object(t: &Term, r: &mut HashMap<u64, String>) -> u64 {
68 let s = term_str(t);
69 if let Term::Literal(_) = t {
70 if let Ok(i) = s.parse::<i64>() {
71 return INLINE_TAG_INTEGER | ((i as u64) & INLINE_VALUE_MASK);
72 }
73 if let Ok(f) = s.parse::<f64>() {
74 return pack_float_object(f as f32);
75 }
76 }
77 intern(s, r)
78}
79
80pub fn build_graph(data: &str) -> (Vec<NQuin>, HashMap<u64, String>) {
82 let mut quins = Vec::new();
83 let mut resolver = HashMap::new();
84 let mut parser = N3Parser::new(data);
85 let _ = parser.parse_all(|ev| {
86 if let N3Event::StaticTriple(t) = ev {
87 let subject = intern(term_str(&t.subject), &mut resolver);
88 let predicate = intern(term_str(&t.predicate), &mut resolver);
89 let object = encode_object(&t.object, &mut resolver);
90 quins.push(NQuin {
91 subject,
92 predicate,
93 object,
94 context: 0,
95 metadata: 0,
96 parity: 0,
97 });
98 }
99 Ok(())
100 });
101 (quins, resolver)
102}
103
104fn severity_of(s: &Option<String>) -> ShaclSeverity {
105 match s.as_deref() {
106 Some("Warning") | Some("warning") => ShaclSeverity::Warning,
107 Some("Info") | Some("info") => ShaclSeverity::Info,
108 _ => ShaclSeverity::Violation,
109 }
110}
111
112fn constraint_of(c: &ConstraintSpec) -> Option<ShaclConstraint> {
113 let num = c.num.unwrap_or(0.0);
114 let u = num as u32;
115 let text = || c.text.clone().unwrap_or_default();
116 let list = || c.list.clone().unwrap_or_default();
117 Some(match c.kind.as_str() {
118 "minInclusive" => ShaclConstraint::MinInclusive(num),
119 "maxInclusive" => ShaclConstraint::MaxInclusive(num),
120 "minExclusive" => ShaclConstraint::MinExclusive(num),
121 "maxExclusive" => ShaclConstraint::MaxExclusive(num),
122 "minCount" => ShaclConstraint::MinCount(u),
123 "maxCount" => ShaclConstraint::MaxCount(u),
124 "minLength" => ShaclConstraint::MinLength(u),
125 "maxLength" => ShaclConstraint::MaxLength(u),
126 "pattern" => ShaclConstraint::Pattern(text()),
127 "class" => ShaclConstraint::Class(text()),
128 "datatype" => ShaclConstraint::DataType(text()),
129 "nodeKind" => ShaclConstraint::NodeKind(text()),
130 "hasValue" => ShaclConstraint::HasValue(text()),
131 "in" => ShaclConstraint::In(list()),
132 "equals" => ShaclConstraint::Equals(text()),
133 "lessThan" => ShaclConstraint::LessThan(text()),
134 "lessThanOrEquals" => ShaclConstraint::LessThanOrEquals(text()),
135 "greaterThan" => ShaclConstraint::GreaterThan(text()),
136 "greaterThanOrEquals" => ShaclConstraint::GreaterThanOrEquals(text()),
137 "node" => ShaclConstraint::Node(text()),
138 "not" => ShaclConstraint::Not(text()),
139 "and" => ShaclConstraint::And(list()),
140 "or" => ShaclConstraint::Or(list()),
141 "xone" => ShaclConstraint::Xone(list()),
142 "languageIn" => ShaclConstraint::LanguageIn(list()),
143 "uniqueLang" => ShaclConstraint::UniqueLang,
144 "closed" => ShaclConstraint::Closed {
145 ignored_properties: list(),
146 },
147 _ => return None,
148 })
149}
150
151pub fn shape_from_spec(spec: &ShapeSpec) -> CompiledShape {
153 let constraints: Vec<ShaclConstraint> =
154 spec.constraints.iter().filter_map(constraint_of).collect();
155 let mut shape = CompiledShape::new(
156 spec.target_class.clone(),
157 constraints,
158 severity_of(&spec.severity),
159 );
160 shape.property_path = spec.path.clone();
161 shape
162}
163
164pub fn validate_text(data: &str, specs: &[ShapeSpec]) -> ValidationReport {
166 let (quins, resolver) = build_graph(data);
167 let shapes: Vec<CompiledShape> = specs.iter().map(shape_from_spec).collect();
168 let engine = ShaclEngine::new(&quins, &shapes);
169 engine.validate(&|h| resolver.get(&h).cloned())
170}
171
172pub fn validate_json(data: &str, shapes_json: &str) -> Result<String, String> {
174 let specs: Vec<ShapeSpec> =
175 serde_json::from_str(shapes_json).map_err(|e| format!("invalid shapes JSON: {e}"))?;
176 let report = validate_text(data, &specs);
177 serde_json::to_string(&report).map_err(|e| format!("serialize report: {e}"))
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn end_to_end_age_mininclusive() {
186 let data = ":alice a :Person .\n:alice :age 15 .\n:bob a :Person .\n:bob :age 40 .";
187 let shapes = r#"[
188 {"targetClass":":Person","path":":age","severity":"Violation",
189 "constraints":[{"kind":"minInclusive","num":18}]}
190 ]"#;
191 let out = validate_json(data, shapes).unwrap();
192 let v: serde_json::Value = serde_json::from_str(&out).unwrap();
193 assert_eq!(
194 v["conforms"], false,
195 "alice (15) must violate minInclusive 18"
196 );
197 let results = v["results"].as_array().unwrap();
198 assert_eq!(results.len(), 1);
199 assert_eq!(
200 results[0]["source_constraint_component"],
201 "sh:MinInclusiveConstraintComponent"
202 );
203 }
204
205 #[test]
206 fn end_to_end_pattern_with_resolver() {
207 let data = r#":u a :User . :u :email "alice@example.org" ."#;
208 let shapes = r#"[
209 {"targetClass":":User","path":":email",
210 "constraints":[{"kind":"pattern","text":"^[^@]+@[^@]+\\.[a-z]+$"}]}
211 ]"#;
212 let out = validate_json(data, shapes).unwrap();
213 let v: serde_json::Value = serde_json::from_str(&out).unwrap();
214 assert_eq!(
215 v["conforms"], true,
216 "a well-formed email matches the pattern"
217 );
218 }
219
220 #[test]
221 fn end_to_end_class_and_node_kind() {
222 let data = ":o a :Owner . :o :pet :rex . :rex a :Cat .";
224 let shapes = r#"[
225 {"targetClass":":Owner","path":":pet",
226 "constraints":[{"kind":"class","text":":Dog"}]}
227 ]"#;
228 let v: serde_json::Value =
229 serde_json::from_str(&validate_json(data, shapes).unwrap()).unwrap();
230 assert_eq!(v["conforms"], false);
231 }
232
233 #[test]
234 fn invalid_json_is_reported() {
235 assert!(validate_json(":a :b :c .", "{not json").is_err());
236 }
237}