Skip to main content

oxirs_shacl/
shape_map.rs

1//! W3C ShapeMap Validation
2//!
3//! Implements the W3C ShapeMap specification for associating RDF nodes with
4//! SHACL shapes and producing structured validation results.
5//!
6//! # ShapeMap Compact Syntax
7//!
8//! The compact syntax associates nodes with shapes using the `@` operator:
9//!
10//! ```text
11//! <http://example.org/alice>@<http://schema.org/PersonShape>
12//! <http://example.org/bob>@START
13//! *@<http://schema.org/EntityShape>
14//! ```
15//!
16//! # References
17//!
18//! - <https://shex.io/shape-map/>
19//! - <https://www.w3.org/TR/shacl/>
20
21use std::collections::HashMap;
22use std::fmt;
23
24// ─── Node selector ───────────────────────────────────────────────────────────
25
26/// Selects which RDF nodes an association applies to.
27#[derive(Debug, Clone, PartialEq, Eq, Hash)]
28pub enum NodeSelector {
29    /// A specific IRI node
30    NodeIri(String),
31    /// A regex pattern matched against node IRIs
32    Pattern(String),
33    /// Matches all nodes in the graph
34    Wildcard,
35    /// The focus node (used in validation contexts)
36    Focus,
37}
38
39impl fmt::Display for NodeSelector {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            Self::NodeIri(iri) => write!(f, "<{iri}>"),
43            Self::Pattern(p) => write!(f, "/{p}/"),
44            Self::Wildcard => write!(f, "*"),
45            Self::Focus => write!(f, "FOCUS"),
46        }
47    }
48}
49
50// ─── Shape label ─────────────────────────────────────────────────────────────
51
52/// Identifies which shape(s) a node should be validated against.
53#[derive(Debug, Clone, PartialEq, Eq, Hash)]
54pub enum ShapeLabel {
55    /// A specific shape identified by IRI
56    Iri(String),
57    /// The start shape of the schema
58    Start,
59    /// All shapes in the schema
60    All,
61}
62
63impl fmt::Display for ShapeLabel {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            Self::Iri(iri) => write!(f, "<{iri}>"),
67            Self::Start => write!(f, "START"),
68            Self::All => write!(f, "*"),
69        }
70    }
71}
72
73// ─── Association ─────────────────────────────────────────────────────────────
74
75/// A single node → shape association in a ShapeMap.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct ShapeAssociation {
78    /// The node selector
79    pub node: NodeSelector,
80    /// The shape label
81    pub shape: ShapeLabel,
82}
83
84impl fmt::Display for ShapeAssociation {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        write!(f, "{}@{}", self.node, self.shape)
87    }
88}
89
90// ─── ShapeMap ─────────────────────────────────────────────────────────────────
91
92/// A collection of node → shape associations.
93#[derive(Debug, Clone, Default)]
94pub struct ShapeMap {
95    /// The ordered list of associations
96    pub associations: Vec<ShapeAssociation>,
97}
98
99impl ShapeMap {
100    /// Create an empty ShapeMap.
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Add an association.
106    pub fn add(&mut self, node: NodeSelector, shape: ShapeLabel) {
107        self.associations.push(ShapeAssociation { node, shape });
108    }
109
110    /// Number of associations.
111    pub fn len(&self) -> usize {
112        self.associations.len()
113    }
114
115    /// Returns `true` if there are no associations.
116    pub fn is_empty(&self) -> bool {
117        self.associations.is_empty()
118    }
119}
120
121// ─── Validation status ────────────────────────────────────────────────────────
122
123/// Result of validating a node against a shape.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum ValidationStatus {
126    /// The node conforms to the shape
127    Conformant,
128    /// The node does not conform to the shape
129    NonConformant,
130    /// The validation could not be completed (e.g. missing data)
131    Incomplete,
132}
133
134impl fmt::Display for ValidationStatus {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        match self {
137            Self::Conformant => write!(f, "conformant"),
138            Self::NonConformant => write!(f, "nonConformant"),
139            Self::Incomplete => write!(f, "incomplete"),
140        }
141    }
142}
143
144// ─── Validation result ────────────────────────────────────────────────────────
145
146/// Result of validating a single node against a single shape.
147#[derive(Debug, Clone)]
148pub struct ValidationResult {
149    /// The node IRI that was validated
150    pub node: String,
151    /// The shape IRI that was applied
152    pub shape: String,
153    /// The conformance status
154    pub status: ValidationStatus,
155    /// Human-readable messages (empty if conformant)
156    pub messages: Vec<String>,
157}
158
159impl ValidationResult {
160    /// Create a conformant result.
161    pub fn conformant(node: impl Into<String>, shape: impl Into<String>) -> Self {
162        Self {
163            node: node.into(),
164            shape: shape.into(),
165            status: ValidationStatus::Conformant,
166            messages: Vec::new(),
167        }
168    }
169
170    /// Create a non-conformant result with messages.
171    pub fn non_conformant(
172        node: impl Into<String>,
173        shape: impl Into<String>,
174        messages: Vec<String>,
175    ) -> Self {
176        Self {
177            node: node.into(),
178            shape: shape.into(),
179            status: ValidationStatus::NonConformant,
180            messages,
181        }
182    }
183
184    /// Create an incomplete result.
185    pub fn incomplete(
186        node: impl Into<String>,
187        shape: impl Into<String>,
188        reason: impl Into<String>,
189    ) -> Self {
190        Self {
191            node: node.into(),
192            shape: shape.into(),
193            status: ValidationStatus::Incomplete,
194            messages: vec![reason.into()],
195        }
196    }
197}
198
199// ─── Shape-map parse error ────────────────────────────────────────────────────
200
201/// Error returned when parsing a ShapeMap compact syntax string.
202#[derive(Debug, Clone)]
203pub struct ShapeMapError(pub String);
204
205impl fmt::Display for ShapeMapError {
206    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207        write!(f, "ShapeMapError: {}", self.0)
208    }
209}
210
211impl std::error::Error for ShapeMapError {}
212
213// ─── Parser ───────────────────────────────────────────────────────────────────
214
215/// Parse a ShapeMap compact-syntax string into a `ShapeMap`.
216///
217/// Supported syntax per association (one per line or semicolon-separated):
218///
219/// ```text
220/// <nodeIRI>@<shapeIRI>
221/// <nodeIRI>@START
222/// <nodeIRI>@*
223/// *@<shapeIRI>
224/// FOCUS@<shapeIRI>
225/// /<regex>/@<shapeIRI>
226/// ```
227pub fn parse_shape_map(text: &str) -> Result<ShapeMap, ShapeMapError> {
228    let mut map = ShapeMap::new();
229
230    for raw_line in text.lines() {
231        // Also split on ';'
232        for part in raw_line.split(';') {
233            let part = part.trim();
234            if part.is_empty() || part.starts_with('#') {
235                continue;
236            }
237
238            let at_pos = find_at_separator(part)
239                .ok_or_else(|| ShapeMapError(format!("missing '@' separator in: '{part}'")))?;
240
241            let node_str = part[..at_pos].trim();
242            let shape_str = part[at_pos + 1..].trim();
243
244            let node = parse_node_selector(node_str)?;
245            let shape = parse_shape_label(shape_str)?;
246
247            map.add(node, shape);
248        }
249    }
250
251    Ok(map)
252}
253
254/// Find the position of the `@` separator that divides node from shape.
255///
256/// We need to skip `@` characters inside `<...>` and `/.../`.
257fn find_at_separator(s: &str) -> Option<usize> {
258    let bytes = s.as_bytes();
259    let mut i = 0;
260    while i < bytes.len() {
261        match bytes[i] {
262            b'<' => {
263                i += 1;
264                while i < bytes.len() && bytes[i] != b'>' {
265                    i += 1;
266                }
267            }
268            b'/' => {
269                i += 1;
270                while i < bytes.len() && bytes[i] != b'/' {
271                    if bytes[i] == b'\\' {
272                        i += 1;
273                    }
274                    i += 1;
275                }
276            }
277            b'@' => return Some(i),
278            _ => {}
279        }
280        i += 1;
281    }
282    None
283}
284
285fn parse_node_selector(s: &str) -> Result<NodeSelector, ShapeMapError> {
286    if s == "*" {
287        Ok(NodeSelector::Wildcard)
288    } else if s.eq_ignore_ascii_case("FOCUS") {
289        Ok(NodeSelector::Focus)
290    } else if s.starts_with('<') && s.ends_with('>') {
291        Ok(NodeSelector::NodeIri(s[1..s.len() - 1].to_string()))
292    } else if s.starts_with('/') && s.ends_with('/') && s.len() >= 2 {
293        Ok(NodeSelector::Pattern(s[1..s.len() - 1].to_string()))
294    } else {
295        Err(ShapeMapError(format!("unrecognised node selector: '{s}'")))
296    }
297}
298
299fn parse_shape_label(s: &str) -> Result<ShapeLabel, ShapeMapError> {
300    if s == "*" {
301        Ok(ShapeLabel::All)
302    } else if s.eq_ignore_ascii_case("START") {
303        Ok(ShapeLabel::Start)
304    } else if s.starts_with('<') && s.ends_with('>') {
305        Ok(ShapeLabel::Iri(s[1..s.len() - 1].to_string()))
306    } else {
307        Err(ShapeMapError(format!("unrecognised shape label: '{s}'")))
308    }
309}
310
311// ─── Validator ────────────────────────────────────────────────────────────────
312
313/// A simple ShapeMap-based validation engine.
314///
315/// Validation logic is necessarily lightweight here since full SHACL evaluation
316/// is provided by the rest of the `oxirs-shacl` crate. The validator applies
317/// built-in heuristics:
318///
319/// - Wildcard node selectors expand to all nodes in the graph
320/// - Shape conformance is checked by looking for a matching constraint triple
321/// - If a shape IRI is referenced but has no constraints in the graph, the
322///   result is `Incomplete`
323pub struct ShapeMapValidator {
324    /// Map from shape IRI → set of (property, value) constraints
325    /// (extracted from graph triples in SHACL vocabulary)
326    constraints: HashMap<String, Vec<(String, String)>>,
327}
328
329impl ShapeMapValidator {
330    /// Create a new validator.
331    pub fn new() -> Self {
332        Self {
333            constraints: HashMap::new(),
334        }
335    }
336
337    /// Register a simple property-value constraint for a shape.
338    ///
339    /// Used in tests / simple scenarios. For full SHACL support use the
340    /// `oxirs-shacl` validation engine directly.
341    pub fn add_constraint(
342        &mut self,
343        shape_iri: impl Into<String>,
344        property: impl Into<String>,
345        value: impl Into<String>,
346    ) {
347        self.constraints
348            .entry(shape_iri.into())
349            .or_default()
350            .push((property.into(), value.into()));
351    }
352
353    /// Validate a ShapeMap against a set of graph triples.
354    ///
355    /// Graph triples are passed as `(subject, predicate, object)` string tuples.
356    pub fn validate(
357        &self,
358        shape_map: &ShapeMap,
359        graph_triples: &[(String, String, String)],
360    ) -> Vec<ValidationResult> {
361        let mut results = Vec::new();
362
363        // Collect all distinct subjects in the graph
364        let all_nodes: Vec<String> = {
365            let mut nodes: Vec<String> = graph_triples.iter().map(|(s, _, _)| s.clone()).collect();
366            nodes.sort();
367            nodes.dedup();
368            nodes
369        };
370
371        // Build a per-subject property map for fast lookup
372        let mut subj_props: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
373        for (s, p, o) in graph_triples {
374            subj_props
375                .entry(s.as_str())
376                .or_default()
377                .push((p.as_str(), o.as_str()));
378        }
379
380        for assoc in &shape_map.associations {
381            // Expand node selector to concrete IRIs
382            let node_iris: Vec<String> = match &assoc.node {
383                NodeSelector::NodeIri(iri) => vec![iri.clone()],
384                NodeSelector::Wildcard => all_nodes.clone(),
385                NodeSelector::Focus => {
386                    // In a standalone validator there is no focus node context —
387                    // report Incomplete for each node in the graph
388                    for node in &all_nodes {
389                        results.push(ValidationResult::incomplete(
390                            node,
391                            assoc.shape.to_string(),
392                            "FOCUS selector requires a query context".to_string(),
393                        ));
394                    }
395                    continue;
396                }
397                NodeSelector::Pattern(pat) => {
398                    // Simple substring match for pattern
399                    all_nodes
400                        .iter()
401                        .filter(|n| n.contains(pat.as_str()))
402                        .cloned()
403                        .collect()
404                }
405            };
406
407            // Expand shape label
408            let shape_iris: Vec<String> = match &assoc.shape {
409                ShapeLabel::Iri(iri) => vec![iri.clone()],
410                ShapeLabel::Start => {
411                    // Use the first registered constraint shape as START, if any
412                    self.constraints.keys().take(1).cloned().collect()
413                }
414                ShapeLabel::All => self.constraints.keys().cloned().collect(),
415            };
416
417            for node_iri in &node_iris {
418                for shape_iri in &shape_iris {
419                    let result = self.validate_node_shape(node_iri, shape_iri, &subj_props);
420                    results.push(result);
421                }
422            }
423        }
424
425        results
426    }
427
428    fn validate_node_shape(
429        &self,
430        node: &str,
431        shape: &str,
432        subj_props: &HashMap<&str, Vec<(&str, &str)>>,
433    ) -> ValidationResult {
434        let Some(constraints) = self.constraints.get(shape) else {
435            return ValidationResult::incomplete(
436                node,
437                shape,
438                format!("shape '{shape}' has no registered constraints"),
439            );
440        };
441
442        let props = subj_props.get(node).cloned().unwrap_or_default();
443        let mut messages = Vec::new();
444
445        for (req_prop, req_val) in constraints {
446            let found = props.iter().any(|(p, o)| {
447                *p == req_prop.as_str() && (req_val.is_empty() || *o == req_val.as_str())
448            });
449            if !found {
450                messages.push(format!(
451                    "Node <{node}> missing required property <{req_prop}> = '{req_val}' for shape <{shape}>"
452                ));
453            }
454        }
455
456        if messages.is_empty() {
457            ValidationResult::conformant(node, shape)
458        } else {
459            ValidationResult::non_conformant(node, shape, messages)
460        }
461    }
462}
463
464impl Default for ShapeMapValidator {
465    fn default() -> Self {
466        Self::new()
467    }
468}
469
470// ─── JSON rendering ───────────────────────────────────────────────────────────
471
472/// Render validation results to a JSON string following the W3C ShapeMap spec.
473///
474/// Each result is an object with `node`, `shape`, `status`, and optionally `reason`.
475pub fn render_json(results: &[ValidationResult]) -> String {
476    let mut out = String::from("[\n");
477    for (i, r) in results.iter().enumerate() {
478        out.push_str("  {\n");
479        out.push_str(&format!("    \"node\": \"{}\",\n", json_escape(&r.node)));
480        out.push_str(&format!("    \"shape\": \"{}\",\n", json_escape(&r.shape)));
481        out.push_str(&format!("    \"status\": \"{}\"", r.status));
482        if !r.messages.is_empty() {
483            out.push_str(",\n    \"reason\": [");
484            for (j, msg) in r.messages.iter().enumerate() {
485                out.push_str(&format!("\"{}\"", json_escape(msg)));
486                if j + 1 < r.messages.len() {
487                    out.push_str(", ");
488                }
489            }
490            out.push(']');
491        }
492        out.push_str("\n  }");
493        if i + 1 < results.len() {
494            out.push(',');
495        }
496        out.push('\n');
497    }
498    out.push(']');
499    out
500}
501
502fn json_escape(s: &str) -> String {
503    s.replace('\\', "\\\\")
504        .replace('"', "\\\"")
505        .replace('\n', "\\n")
506        .replace('\r', "\\r")
507        .replace('\t', "\\t")
508}
509
510// ─── Tests ──────────────────────────────────────────────────────────────────
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    fn triples(v: &[(&str, &str, &str)]) -> Vec<(String, String, String)> {
517        v.iter()
518            .map(|(s, p, o)| (s.to_string(), p.to_string(), o.to_string()))
519            .collect()
520    }
521
522    // ── NodeSelector ────────────────────────────────────────────────────────
523
524    #[test]
525    fn test_node_selector_wildcard() {
526        assert_eq!(NodeSelector::Wildcard.to_string(), "*");
527    }
528
529    #[test]
530    fn test_node_selector_iri() {
531        let ns = NodeSelector::NodeIri("http://example.org/alice".to_string());
532        assert_eq!(ns.to_string(), "<http://example.org/alice>");
533    }
534
535    #[test]
536    fn test_node_selector_pattern() {
537        let ns = NodeSelector::Pattern("example".to_string());
538        assert_eq!(ns.to_string(), "/example/");
539    }
540
541    #[test]
542    fn test_node_selector_focus() {
543        assert_eq!(NodeSelector::Focus.to_string(), "FOCUS");
544    }
545
546    // ── ShapeLabel ───────────────────────────────────────────────────────────
547
548    #[test]
549    fn test_shape_label_iri() {
550        let sl = ShapeLabel::Iri("http://schema.org/PersonShape".to_string());
551        assert_eq!(sl.to_string(), "<http://schema.org/PersonShape>");
552    }
553
554    #[test]
555    fn test_shape_label_start() {
556        assert_eq!(ShapeLabel::Start.to_string(), "START");
557    }
558
559    #[test]
560    fn test_shape_label_all() {
561        assert_eq!(ShapeLabel::All.to_string(), "*");
562    }
563
564    // ── ShapeMap ─────────────────────────────────────────────────────────────
565
566    #[test]
567    fn test_shape_map_new_empty() {
568        let sm = ShapeMap::new();
569        assert!(sm.is_empty());
570        assert_eq!(sm.len(), 0);
571    }
572
573    #[test]
574    fn test_shape_map_add() {
575        let mut sm = ShapeMap::new();
576        sm.add(
577            NodeSelector::NodeIri("http://example.org/alice".to_string()),
578            ShapeLabel::Iri("http://schema.org/PersonShape".to_string()),
579        );
580        assert_eq!(sm.len(), 1);
581        assert!(!sm.is_empty());
582    }
583
584    #[test]
585    fn test_association_display() {
586        let assoc = ShapeAssociation {
587            node: NodeSelector::NodeIri("http://ex.org/n".to_string()),
588            shape: ShapeLabel::Iri("http://ex.org/s".to_string()),
589        };
590        assert!(assoc.to_string().contains('@'));
591    }
592
593    // ── Parsing ──────────────────────────────────────────────────────────────
594
595    #[test]
596    fn test_parse_simple_iri_association() {
597        let text = "<http://example.org/alice>@<http://schema.org/PersonShape>";
598        let sm = parse_shape_map(text).expect("parse ok");
599        assert_eq!(sm.len(), 1);
600        assert_eq!(
601            sm.associations[0].node,
602            NodeSelector::NodeIri("http://example.org/alice".to_string())
603        );
604        assert_eq!(
605            sm.associations[0].shape,
606            ShapeLabel::Iri("http://schema.org/PersonShape".to_string())
607        );
608    }
609
610    #[test]
611    fn test_parse_wildcard_node() {
612        let text = "*@<http://schema.org/EntityShape>";
613        let sm = parse_shape_map(text).expect("parse ok");
614        assert_eq!(sm.len(), 1);
615        assert_eq!(sm.associations[0].node, NodeSelector::Wildcard);
616    }
617
618    #[test]
619    fn test_parse_start_shape() {
620        let text = "<http://example.org/bob>@START";
621        let sm = parse_shape_map(text).expect("parse ok");
622        assert_eq!(sm.associations[0].shape, ShapeLabel::Start);
623    }
624
625    #[test]
626    fn test_parse_all_shape() {
627        let text = "<http://example.org/x>@*";
628        let sm = parse_shape_map(text).expect("parse ok");
629        assert_eq!(sm.associations[0].shape, ShapeLabel::All);
630    }
631
632    #[test]
633    fn test_parse_pattern_node() {
634        let text = "/alice/@<http://schema.org/PersonShape>";
635        let sm = parse_shape_map(text).expect("parse ok");
636        assert_eq!(
637            sm.associations[0].node,
638            NodeSelector::Pattern("alice".to_string())
639        );
640    }
641
642    #[test]
643    fn test_parse_focus_node() {
644        let text = "FOCUS@<http://schema.org/PersonShape>";
645        let sm = parse_shape_map(text).expect("parse ok");
646        assert_eq!(sm.associations[0].node, NodeSelector::Focus);
647    }
648
649    #[test]
650    fn test_parse_multiple_lines() {
651        let text = "<http://ex.org/a>@<http://ex.org/S>\n<http://ex.org/b>@START";
652        let sm = parse_shape_map(text).expect("parse ok");
653        assert_eq!(sm.len(), 2);
654    }
655
656    #[test]
657    fn test_parse_semicolon_separated() {
658        let text = "<http://ex.org/a>@<http://ex.org/S>;<http://ex.org/b>@START";
659        let sm = parse_shape_map(text).expect("parse ok");
660        assert_eq!(sm.len(), 2);
661    }
662
663    #[test]
664    fn test_parse_empty_string() {
665        let sm = parse_shape_map("").expect("parse ok");
666        assert!(sm.is_empty());
667    }
668
669    #[test]
670    fn test_parse_comments() {
671        let text = "# This is a comment\n<http://ex.org/a>@<http://ex.org/S>";
672        let sm = parse_shape_map(text).expect("parse ok");
673        assert_eq!(sm.len(), 1);
674    }
675
676    #[test]
677    fn test_parse_error_missing_at() {
678        let result = parse_shape_map("<http://ex.org/a><http://ex.org/S>");
679        assert!(result.is_err());
680    }
681
682    #[test]
683    fn test_parse_error_bad_node() {
684        let result = parse_shape_map("badnode@<http://ex.org/S>");
685        assert!(result.is_err());
686    }
687
688    // ── Validation ───────────────────────────────────────────────────────────
689
690    #[test]
691    fn test_validate_conformant() {
692        let mut validator = ShapeMapValidator::new();
693        validator.add_constraint(
694            "http://schema.org/PersonShape",
695            "http://schema.org/name",
696            "",
697        );
698        let graph = triples(&[(
699            "http://example.org/alice",
700            "http://schema.org/name",
701            "Alice",
702        )]);
703        let mut sm = ShapeMap::new();
704        sm.add(
705            NodeSelector::NodeIri("http://example.org/alice".to_string()),
706            ShapeLabel::Iri("http://schema.org/PersonShape".to_string()),
707        );
708        let results = validator.validate(&sm, &graph);
709        assert_eq!(results.len(), 1);
710        assert_eq!(results[0].status, ValidationStatus::Conformant);
711    }
712
713    #[test]
714    fn test_validate_non_conformant() {
715        let mut validator = ShapeMapValidator::new();
716        validator.add_constraint(
717            "http://schema.org/PersonShape",
718            "http://schema.org/name",
719            "",
720        );
721        let graph = triples(&[("http://example.org/bob", "http://schema.org/age", "30")]);
722        let mut sm = ShapeMap::new();
723        sm.add(
724            NodeSelector::NodeIri("http://example.org/bob".to_string()),
725            ShapeLabel::Iri("http://schema.org/PersonShape".to_string()),
726        );
727        let results = validator.validate(&sm, &graph);
728        assert_eq!(results[0].status, ValidationStatus::NonConformant);
729        assert!(!results[0].messages.is_empty());
730    }
731
732    #[test]
733    fn test_validate_incomplete_unknown_shape() {
734        let validator = ShapeMapValidator::new();
735        let graph = triples(&[("http://ex.org/a", "http://ex.org/p", "v")]);
736        let mut sm = ShapeMap::new();
737        sm.add(
738            NodeSelector::NodeIri("http://ex.org/a".to_string()),
739            ShapeLabel::Iri("http://ex.org/UnknownShape".to_string()),
740        );
741        let results = validator.validate(&sm, &graph);
742        assert_eq!(results[0].status, ValidationStatus::Incomplete);
743    }
744
745    #[test]
746    fn test_validate_wildcard_expands_to_all_nodes() {
747        let mut validator = ShapeMapValidator::new();
748        validator.add_constraint("http://ex.org/S", "http://ex.org/p", "");
749        let graph = triples(&[
750            ("http://ex.org/a", "http://ex.org/p", "x"),
751            ("http://ex.org/b", "http://ex.org/p", "y"),
752        ]);
753        let mut sm = ShapeMap::new();
754        sm.add(
755            NodeSelector::Wildcard,
756            ShapeLabel::Iri("http://ex.org/S".to_string()),
757        );
758        let results = validator.validate(&sm, &graph);
759        assert_eq!(results.len(), 2);
760    }
761
762    #[test]
763    fn test_validate_pattern_selector() {
764        let mut validator = ShapeMapValidator::new();
765        validator.add_constraint("http://ex.org/S", "http://ex.org/p", "");
766        let graph = triples(&[
767            ("http://ex.org/alice_1", "http://ex.org/p", "v"),
768            ("http://ex.org/bob_1", "http://ex.org/p", "v"),
769            ("http://other.org/charlie", "http://ex.org/p", "v"),
770        ]);
771        let mut sm = ShapeMap::new();
772        sm.add(
773            NodeSelector::Pattern("ex.org/alice".to_string()),
774            ShapeLabel::Iri("http://ex.org/S".to_string()),
775        );
776        let results = validator.validate(&sm, &graph);
777        // Only alice matches
778        assert_eq!(results.len(), 1);
779        assert_eq!(results[0].node, "http://ex.org/alice_1");
780        assert_eq!(results[0].status, ValidationStatus::Conformant);
781    }
782
783    #[test]
784    fn test_validate_multiple_constraints() {
785        let mut validator = ShapeMapValidator::new();
786        validator.add_constraint("http://ex.org/S", "http://ex.org/name", "");
787        validator.add_constraint("http://ex.org/S", "http://ex.org/age", "");
788        let graph = triples(&[("http://ex.org/n", "http://ex.org/name", "N")]);
789        let mut sm = ShapeMap::new();
790        sm.add(
791            NodeSelector::NodeIri("http://ex.org/n".to_string()),
792            ShapeLabel::Iri("http://ex.org/S".to_string()),
793        );
794        let results = validator.validate(&sm, &graph);
795        assert_eq!(results[0].status, ValidationStatus::NonConformant);
796        // Missing age
797        assert_eq!(results[0].messages.len(), 1);
798    }
799
800    #[test]
801    fn test_validate_focus_returns_incomplete() {
802        let validator = ShapeMapValidator::new();
803        let graph = triples(&[("http://ex.org/a", "http://ex.org/p", "v")]);
804        let mut sm = ShapeMap::new();
805        sm.add(
806            NodeSelector::Focus,
807            ShapeLabel::Iri("http://ex.org/S".to_string()),
808        );
809        let results = validator.validate(&sm, &graph);
810        for r in &results {
811            assert_eq!(r.status, ValidationStatus::Incomplete);
812        }
813    }
814
815    // ── JSON rendering ────────────────────────────────────────────────────────
816
817    #[test]
818    fn test_render_json_conformant() {
819        let results = vec![ValidationResult::conformant(
820            "http://ex.org/alice",
821            "http://ex.org/S",
822        )];
823        let json = render_json(&results);
824        assert!(json.contains("conformant"));
825        assert!(json.contains("alice"));
826        assert!(json.contains('['));
827        assert!(json.contains(']'));
828    }
829
830    #[test]
831    fn test_render_json_non_conformant() {
832        let results = vec![ValidationResult::non_conformant(
833            "http://ex.org/bob",
834            "http://ex.org/S",
835            vec!["Missing required property".to_string()],
836        )];
837        let json = render_json(&results);
838        assert!(json.contains("nonConformant"));
839        assert!(json.contains("reason"));
840    }
841
842    #[test]
843    fn test_render_json_empty() {
844        let json = render_json(&[]);
845        assert!(json.contains('['));
846        assert!(json.contains(']'));
847    }
848
849    #[test]
850    fn test_render_json_multiple() {
851        let results = vec![
852            ValidationResult::conformant("http://ex.org/a", "http://ex.org/S"),
853            ValidationResult::non_conformant(
854                "http://ex.org/b",
855                "http://ex.org/S",
856                vec!["err".to_string()],
857            ),
858        ];
859        let json = render_json(&results);
860        assert!(json.contains("conformant"));
861        assert!(json.contains("nonConformant"));
862    }
863
864    #[test]
865    fn test_render_json_escapes() {
866        let results = vec![ValidationResult::non_conformant(
867            r#"http://ex.org/n"ode"#,
868            "http://ex.org/S",
869            vec!["line1\nline2".to_string()],
870        )];
871        let json = render_json(&results);
872        // Quotes in IRI should be escaped
873        assert!(json.contains("\\\""));
874    }
875
876    #[test]
877    fn test_validation_status_display() {
878        assert_eq!(ValidationStatus::Conformant.to_string(), "conformant");
879        assert_eq!(ValidationStatus::NonConformant.to_string(), "nonConformant");
880        assert_eq!(ValidationStatus::Incomplete.to_string(), "incomplete");
881    }
882
883    #[test]
884    fn test_specific_value_constraint() {
885        let mut validator = ShapeMapValidator::new();
886        validator.add_constraint("http://ex.org/S", "http://ex.org/type", "Person");
887        let graph = triples(&[("http://ex.org/a", "http://ex.org/type", "Person")]);
888        let mut sm = ShapeMap::new();
889        sm.add(
890            NodeSelector::NodeIri("http://ex.org/a".to_string()),
891            ShapeLabel::Iri("http://ex.org/S".to_string()),
892        );
893        let results = validator.validate(&sm, &graph);
894        assert_eq!(results[0].status, ValidationStatus::Conformant);
895    }
896
897    #[test]
898    fn test_specific_value_constraint_wrong_value() {
899        let mut validator = ShapeMapValidator::new();
900        validator.add_constraint("http://ex.org/S", "http://ex.org/type", "Person");
901        let graph = triples(&[("http://ex.org/a", "http://ex.org/type", "Organization")]);
902        let mut sm = ShapeMap::new();
903        sm.add(
904            NodeSelector::NodeIri("http://ex.org/a".to_string()),
905            ShapeLabel::Iri("http://ex.org/S".to_string()),
906        );
907        let results = validator.validate(&sm, &graph);
908        assert_eq!(results[0].status, ValidationStatus::NonConformant);
909    }
910}