use crate::store::{InternalTriple, OxiRSStore};
use std::collections::HashMap;
const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
static DEFAULT_PREFIXES: &[(&str, &str)] = &[
("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"),
("rdfs", "http://www.w3.org/2000/01/rdf-schema#"),
("xsd", "http://www.w3.org/2001/XMLSchema#"),
("owl", "http://www.w3.org/2002/07/owl#"),
("schema", "https://schema.org/"),
("foaf", "http://xmlns.com/foaf/0.1/"),
("dc", "http://purl.org/dc/elements/1.1/"),
("dcterms", "http://purl.org/dc/terms/"),
];
pub fn serialize_jsonld(store: &OxiRSStore) -> String {
let triples: Vec<&InternalTriple> = store.all_triples().collect();
serialize_triples_jsonld(&triples, &[])
}
pub fn serialize_jsonld_with_prefixes(
store: &OxiRSStore,
extra_prefixes: &[(&str, &str)],
) -> String {
let triples: Vec<&InternalTriple> = store.all_triples().collect();
serialize_triples_jsonld(&triples, extra_prefixes)
}
pub(crate) fn serialize_triples_jsonld(
triples: &[&InternalTriple],
extra_prefixes: &[(&str, &str)],
) -> String {
let mut prefixes: HashMap<String, String> = DEFAULT_PREFIXES
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
for (k, v) in extra_prefixes {
prefixes.insert(k.to_string(), v.to_string());
}
let mut subject_map: HashMap<String, Vec<&InternalTriple>> = HashMap::new();
let mut subject_order: Vec<String> = Vec::new();
for triple in triples {
let entry = subject_map
.entry(triple.subject.clone())
.or_insert_with(|| {
subject_order.push(triple.subject.clone());
Vec::new()
});
entry.push(triple);
}
let context_str = build_context(&prefixes);
let mut graph_items: Vec<String> = Vec::new();
for subject in &subject_order {
if let Some(group) = subject_map.get(subject) {
let item = build_subject_object(subject, group, &prefixes);
graph_items.push(item);
}
}
if graph_items.is_empty() {
format!("{{\n \"@context\": {context_str},\n \"@graph\": []\n}}")
} else {
let graph_str = graph_items.join(",\n ");
format!("{{\n \"@context\": {context_str},\n \"@graph\": [\n {graph_str}\n ]\n}}")
}
}
fn build_context(prefixes: &HashMap<String, String>) -> String {
let mut pairs: Vec<String> = prefixes
.iter()
.map(|(k, v)| format!(" \"{k}\": \"{v}\""))
.collect();
pairs.sort(); format!("{{\n{}\n }}", pairs.join(",\n"))
}
fn build_subject_object(
subject: &str,
triples: &[&InternalTriple],
prefixes: &HashMap<String, String>,
) -> String {
let id = compact_iri(subject, prefixes);
let mut props: HashMap<String, Vec<String>> = HashMap::new();
let mut prop_order: Vec<String> = Vec::new();
for triple in triples {
let pred_compact = compact_iri(&triple.predicate, prefixes);
let obj_json = object_to_json(&triple.object, prefixes);
let entry = props.entry(pred_compact.clone()).or_insert_with(|| {
prop_order.push(pred_compact);
Vec::new()
});
entry.push(obj_json);
}
let mut lines: Vec<String> = Vec::new();
lines.push(format!("\"@id\": \"{id}\""));
for key in &prop_order {
if let Some(values) = props.get(key) {
let json_key = if key == "rdf:type" || key == "a" || key == RDF_TYPE {
"@type".to_string()
} else {
key.clone()
};
if values.len() == 1 {
lines.push(format!("\"{json_key}\": {}", values[0]));
} else {
let arr = values.join(", ");
lines.push(format!("\"{json_key}\": [{arr}]"));
}
}
}
let inner = lines.join(",\n ");
format!("{{\n {inner}\n }}")
}
pub(crate) fn compact_iri(iri: &str, prefixes: &HashMap<String, String>) -> String {
for (prefix, base) in prefixes {
if let Some(local) = iri.strip_prefix(base.as_str()) {
if !local.is_empty() && !local.contains('/') && !local.contains('#') {
return format!("{prefix}:{local}");
}
}
}
iri.to_string()
}
pub(crate) fn object_to_json(term: &str, prefixes: &HashMap<String, String>) -> String {
if term.starts_with('"') {
parse_literal_to_json(term)
} else if let Some(id) = term.strip_prefix("_:") {
format!("{{\"@id\": \"_:{id}\"}}")
} else {
let compact = compact_iri(term, prefixes);
format!("{{\"@id\": \"{compact}\"}}")
}
}
fn parse_literal_to_json(term: &str) -> String {
let chars: Vec<char> = term.chars().collect();
let mut pos = 1usize;
while pos < chars.len() && chars[pos] != '"' {
if chars[pos] == '\\' {
pos += 1; }
pos += 1;
}
let value: String = chars[1..pos].iter().collect();
let value_escaped = escape_json_string(&value);
if pos + 1 >= chars.len() {
return format!("{{\"@value\": \"{value_escaped}\"}}");
}
let rest: String = chars[pos + 1..].iter().collect();
if let Some(lang) = rest.strip_prefix('@') {
format!("{{\"@value\": \"{value_escaped}\", \"@language\": \"{lang}\"}}")
} else if let Some(dt_raw) = rest.strip_prefix("^^") {
let datatype = if dt_raw.starts_with('<') && dt_raw.ends_with('>') {
dt_raw[1..dt_raw.len() - 1].to_string()
} else {
dt_raw.to_string()
};
let short_dt = compact_xsd_type(&datatype);
format!("{{\"@value\": \"{value_escaped}\", \"@type\": \"{short_dt}\"}}")
} else {
format!("{{\"@value\": \"{value_escaped}\"}}")
}
}
fn compact_xsd_type(datatype: &str) -> String {
let xsd = "http://www.w3.org/2001/XMLSchema#";
if let Some(local) = datatype.strip_prefix(xsd) {
format!("xsd:{local}")
} else {
datatype.to_string()
}
}
fn escape_json_string(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => result.push_str("\\\""),
'\\' => result.push_str("\\\\"),
'\n' => result.push_str("\\n"),
'\r' => result.push_str("\\r"),
'\t' => result.push_str("\\t"),
c => result.push(c),
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::OxiRSStore;
fn make_store() -> OxiRSStore {
let mut store = OxiRSStore::new();
store.insert(
"http://example.org/alice",
"http://example.org/name",
"\"Alice\"",
);
store.insert(
"http://example.org/alice",
"http://example.org/knows",
"http://example.org/bob",
);
store.insert(
"http://example.org/bob",
"http://example.org/name",
"\"Bob\"",
);
store
}
#[test]
fn test_jsonld_contains_context() {
let store = make_store();
let output = serialize_jsonld(&store);
assert!(output.contains("\"@context\""));
assert!(output.contains("\"rdf\""));
}
#[test]
fn test_jsonld_contains_graph() {
let store = make_store();
let output = serialize_jsonld(&store);
assert!(output.contains("\"@graph\""));
}
#[test]
fn test_jsonld_contains_subject_ids() {
let store = make_store();
let output = serialize_jsonld(&store);
assert!(output.contains("alice"));
assert!(output.contains("bob"));
}
#[test]
fn test_jsonld_literal_value() {
let store = make_store();
let output = serialize_jsonld(&store);
assert!(output.contains("\"@value\""));
assert!(output.contains("Alice"));
}
#[test]
fn test_jsonld_iri_object() {
let store = make_store();
let output = serialize_jsonld(&store);
assert!(output.contains("\"@id\""));
}
#[test]
fn test_jsonld_lang_literal() {
let mut store = OxiRSStore::new();
store.insert(
"http://example.org/s",
"http://example.org/p",
"\"hello\"@en",
);
let output = serialize_jsonld(&store);
assert!(output.contains("\"@language\""));
assert!(output.contains("\"en\""));
}
#[test]
fn test_jsonld_typed_literal() {
let mut store = OxiRSStore::new();
store.insert(
"http://example.org/s",
"http://example.org/age",
"\"42\"^^<http://www.w3.org/2001/XMLSchema#integer>",
);
let output = serialize_jsonld(&store);
assert!(output.contains("\"@type\""));
assert!(output.contains("xsd:integer"));
}
#[test]
fn test_jsonld_empty_store() {
let store = OxiRSStore::new();
let output = serialize_jsonld(&store);
assert!(output.contains("\"@graph\""));
assert!(output.contains("[]"));
}
#[test]
fn test_jsonld_with_rdf_type() {
let mut store = OxiRSStore::new();
store.insert(
"http://example.org/alice",
"http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
"http://xmlns.com/foaf/0.1/Person",
);
let output = serialize_jsonld(&store);
assert!(output.contains("@type") || output.contains("rdf:type"));
}
#[test]
fn test_jsonld_blank_node() {
let mut store = OxiRSStore::new();
store.insert("http://example.org/s", "http://example.org/p", "_:b0");
let output = serialize_jsonld(&store);
assert!(output.contains("_:b0") || output.contains("b0"));
}
#[test]
fn test_jsonld_with_custom_prefixes() {
let mut store = OxiRSStore::new();
store.insert(
"http://example.org/alice",
"http://example.org/name",
"\"Alice\"",
);
let output = serialize_jsonld_with_prefixes(&store, &[("ex", "http://example.org/")]);
assert!(output.contains("\"ex\""));
}
#[test]
fn test_compact_iri_standard_prefix() {
let mut prefixes = HashMap::new();
prefixes.insert("ex".to_string(), "http://example.org/".to_string());
let compact = compact_iri("http://example.org/alice", &prefixes);
assert_eq!(compact, "ex:alice");
}
#[test]
fn test_compact_iri_no_match() {
let prefixes = HashMap::new();
let compact = compact_iri("http://unknown.org/foo", &prefixes);
assert_eq!(compact, "http://unknown.org/foo");
}
#[test]
fn test_escape_json_string() {
let s = "Hello \"World\"\nTab\there";
let escaped = escape_json_string(s);
assert!(escaped.contains("\\\""));
assert!(escaped.contains("\\n"));
assert!(escaped.contains("\\t"));
}
#[test]
fn test_jsonld_valid_json_structure() {
let store = make_store();
let output = serialize_jsonld(&store);
assert!(output.starts_with('{'));
assert!(output.ends_with('}'));
assert!(output.contains("@context"));
assert!(output.contains("@graph"));
}
#[test]
fn test_jsonld_multiple_objects_same_predicate() {
let mut store = OxiRSStore::new();
store.insert(
"http://example.org/alice",
"http://example.org/knows",
"http://example.org/bob",
);
store.insert(
"http://example.org/alice",
"http://example.org/knows",
"http://example.org/carol",
);
let output = serialize_jsonld(&store);
assert!(output.contains("bob"));
assert!(output.contains("carol"));
}
}