Skip to main content

strixonomy_swrl/
turtle.rs

1//! Turtle / JSON helpers for SWRL rules.
2//!
3//! Full RDF SWRL vocabulary round-trip for complex blank-node shapes is handled
4//! primarily via horned load/save of `Component::Rule`. This module provides a
5//! compact JSON interchange for the IDE plus a simple Turtle annotation store
6//! (`strixonomy:swrlRule` JSON literal) so create/edit/save works in `.ttl` without
7//! requiring a full RDF list emitter for every atom shape.
8
9use crate::model::SwrlRule;
10use crate::{Result, SwrlError};
11
12const ONTOCORE_SWRL_PRED: &str = "http://ontocode.dev/ns#swrlRule";
13
14pub fn parse_swrl_rule_json(text: &str) -> Result<SwrlRule> {
15    Ok(serde_json::from_str(text)?)
16}
17
18fn escape_turtle_string(value: &str) -> String {
19    let mut out = String::with_capacity(value.len());
20    for ch in value.chars() {
21        match ch {
22            '\\' => out.push_str("\\\\"),
23            '"' => out.push_str("\\\""),
24            '\n' => out.push_str("\\n"),
25            '\r' => out.push_str("\\r"),
26            '\t' => out.push_str("\\t"),
27            c => out.push(c),
28        }
29    }
30    out
31}
32
33fn unescape_turtle_string(value: &str) -> String {
34    let mut out = String::with_capacity(value.len());
35    let mut chars = value.chars().peekable();
36    while let Some(ch) = chars.next() {
37        if ch == '\\' {
38            match chars.next() {
39                Some('n') => out.push('\n'),
40                Some('r') => out.push('\r'),
41                Some('t') => out.push('\t'),
42                Some('"') => out.push('"'),
43                Some('\\') => out.push('\\'),
44                Some(other) => {
45                    out.push('\\');
46                    out.push(other);
47                }
48                None => out.push('\\'),
49            }
50        } else {
51            out.push(ch);
52        }
53    }
54    out
55}
56
57fn compact_json(rule: &SwrlRule) -> Result<String> {
58    Ok(serde_json::to_string(rule)?)
59}
60
61/// Emit a Turtle snippet attaching a JSON-encoded rule to the ontology.
62pub fn rule_to_turtle_fragment(ontology_iri: &str, rule: &SwrlRule) -> Result<String> {
63    let json = compact_json(rule)?;
64    let escaped = escape_turtle_string(&json);
65    Ok(format!(
66        "<{ontology_iri}> <{ONTOCORE_SWRL_PRED}> \"{escaped}\"^^<http://www.w3.org/2001/XMLSchema#string> .\n"
67    ))
68}
69
70/// Extract rules stored as `strixonomy:swrlRule` JSON literals from Turtle text.
71pub fn rules_from_turtle_document(text: &str) -> Vec<SwrlRule> {
72    let mut rules = Vec::new();
73    let marker = ONTOCORE_SWRL_PRED;
74    for line in text.lines() {
75        if !line.contains(marker) {
76            continue;
77        }
78        // Crude extract of quoted JSON literal (supports escaped quotes / newlines).
79        if let Some(start) = line.find('"') {
80            let rest = &line[start + 1..];
81            let mut end = None;
82            let mut escaped = false;
83            for (i, ch) in rest.char_indices() {
84                if escaped {
85                    escaped = false;
86                    continue;
87                }
88                if ch == '\\' {
89                    escaped = true;
90                    continue;
91                }
92                if ch == '"' {
93                    end = Some(i);
94                    break;
95                }
96            }
97            if let Some(end) = end {
98                let raw = &rest[..end];
99                let unescaped = unescape_turtle_string(raw);
100                if let Ok(rule) = serde_json::from_str::<SwrlRule>(&unescaped) {
101                    rules.push(rule);
102                }
103            }
104        }
105    }
106    rules
107}
108
109/// Rewrite `from_iri` → `to_iri` inside `strixonomy:swrlRule` JSON string literals.
110///
111/// Returns updated text and number of rules remapped. Leaves non-SWRL content unchanged.
112pub fn rewrite_swrl_iris_in_turtle(text: &str, from_iri: &str, to_iri: &str) -> (String, usize) {
113    if from_iri == to_iri || !text.contains(ONTOCORE_SWRL_PRED) {
114        return (text.to_string(), 0);
115    }
116    let mut result = text.to_string();
117    let mut remapped = 0usize;
118    let marker = ONTOCORE_SWRL_PRED;
119    // Walk occurrences from the end so byte offsets stay valid.
120    let mut occ: Vec<(usize, usize, String)> = Vec::new();
121    let mut search_from = 0usize;
122    while let Some(rel) = result[search_from..].find(marker) {
123        let marker_pos = search_from + rel;
124        let after = &result[marker_pos..];
125        let Some(quote_rel) = after.find('"') else {
126            break;
127        };
128        let json_start = marker_pos + quote_rel + 1;
129        let rest = &result[json_start..];
130        let mut end = None;
131        let mut escaped = false;
132        for (i, ch) in rest.char_indices() {
133            if escaped {
134                escaped = false;
135                continue;
136            }
137            if ch == '\\' {
138                escaped = true;
139                continue;
140            }
141            if ch == '"' {
142                end = Some(i);
143                break;
144            }
145        }
146        let Some(json_len) = end else {
147            break;
148        };
149        let raw = &rest[..json_len];
150        let unescaped = unescape_turtle_string(raw);
151        if let Ok(mut rule) = serde_json::from_str::<SwrlRule>(&unescaped) {
152            if rule.remap_iri(from_iri, to_iri) {
153                if let Ok(new_json) = serde_json::to_string(&rule) {
154                    occ.push((json_start, json_start + json_len, escape_turtle_string(&new_json)));
155                    remapped += 1;
156                }
157            }
158        }
159        search_from = json_start + json_len + 1;
160    }
161    for (start, end, new_raw) in occ.into_iter().rev() {
162        result.replace_range(start..end, &new_raw);
163    }
164    (result, remapped)
165}
166
167#[allow(dead_code)]
168pub fn err_msg(msg: impl Into<String>) -> SwrlError {
169    SwrlError::Message(msg.into())
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::model::{SwrlAtom, SwrlIArg, SwrlRule};
176
177    #[test]
178    fn round_trip_preserves_newlines_in_json() {
179        let rule = SwrlRule {
180            id: Some("r1".into()),
181            body: vec![SwrlAtom::Class {
182                class: "http://ex#A".into(),
183                arg: SwrlIArg::Variable("x".into()),
184            }],
185            head: vec![SwrlAtom::Class {
186                class: "http://ex#B".into(),
187                arg: SwrlIArg::Variable("x".into()),
188            }],
189            enabled: true,
190        };
191        let frag = rule_to_turtle_fragment("http://ex#ont", &rule).expect("frag");
192        assert!(!frag.contains('\n') || frag.ends_with('\n'));
193        // JSON is compacted so no raw newlines inside the literal.
194        let inner = frag.lines().next().unwrap();
195        assert!(!inner.trim_end_matches(" .").contains("\n"));
196        let parsed = rules_from_turtle_document(&frag);
197        assert_eq!(parsed.len(), 1);
198        assert_eq!(parsed[0].id.as_deref(), Some("r1"));
199    }
200
201    #[test]
202    fn injection_payload_with_quotes_round_trips() {
203        let json = r#"{"id":"q","body":[{"kind":"class","class":"http://ex#A","arg":{"variable":"x"}}],"head":[{"kind":"class","class":"http://ex#B","arg":{"variable":"x"}}],"enabled":true}"#;
204        let rule: SwrlRule = serde_json::from_str(json).unwrap();
205        let frag = rule_to_turtle_fragment("http://ex#ont", &rule).unwrap();
206        assert_eq!(rules_from_turtle_document(&frag).len(), 1);
207    }
208}