use std::sync::Arc;
use ikigai_core::{ArgRef, Capability, Error, Iri, Kernel, Request, Result, Verb};
use oxrdf::{NamedOrBlankNode, Term, Triple};
use serde_json::{json, Map, Value};
use crate::rdf;
pub const STAMP_CONTEXT: &str = r#"{ "@context": {
"Activity": "http://www.w3.org/ns/prov#Activity",
"associatedWith": { "@id": "http://www.w3.org/ns/prov#wasAssociatedWith", "@type": "@id" },
"attributedTo": { "@id": "http://www.w3.org/ns/prov#wasAttributedTo", "@type": "@id" },
"created": { "@id": "http://purl.org/dc/terms/created", "@type": "http://www.w3.org/2001/XMLSchema#dateTime" },
"endedAt": { "@id": "http://www.w3.org/ns/prov#endedAtTime", "@type": "http://www.w3.org/2001/XMLSchema#dateTime" },
"seq": { "@id": "https://ikigai-rs.dev/ns#seq", "@type": "http://www.w3.org/2001/XMLSchema#integer" },
"used": { "@id": "http://www.w3.org/ns/prov#used", "@type": "@id" }
} }"#;
const PRUNE: &str = "urn:jsonld:prune";
pub fn egress_context(layer_context: &str) -> std::result::Result<Value, String> {
let layer: Value = serde_json::from_str(layer_context).map_err(|e| format!("not JSON: {e}"))?;
let mut stamps = stamp_terms();
Ok(
match layer.get("@context").cloned().unwrap_or(Value::Null) {
Value::Object(terms) => {
stamps.extend(terms);
Value::Object(stamps)
}
Value::Array(items) => {
let mut merged = vec![Value::Object(stamps)];
merged.extend(items);
Value::Array(merged)
}
Value::Null => Value::Object(stamps),
_ => return Err("a context is an object, an array of objects, or null".into()),
},
)
}
fn stamp_terms() -> Map<String, Value> {
match serde_json::from_str::<Value>(STAMP_CONTEXT) {
Ok(Value::Object(mut doc)) => match doc.remove("@context") {
Some(Value::Object(terms)) => terms,
_ => unreachable!("STAMP_CONTEXT has an object @context"),
},
_ => unreachable!("STAMP_CONTEXT is a JSON object"),
}
}
pub fn expanded(triples: &[Triple]) -> Value {
let mut order: Vec<String> = Vec::new();
let mut nodes: std::collections::HashMap<String, Map<String, Value>> =
std::collections::HashMap::new();
for triple in triples {
let subject = match &triple.subject {
NamedOrBlankNode::NamedNode(n) => n.as_str().to_string(),
NamedOrBlankNode::BlankNode(b) => format!("_:{}", b.as_str()),
};
let node = nodes.entry(subject.clone()).or_insert_with(|| {
order.push(subject.clone());
let mut node = Map::new();
node.insert("@id".into(), Value::String(subject));
node
});
let predicate = triple.predicate.as_str();
let value = if let Term::NamedNode(n) = &triple.object {
if predicate == rdf::RDF_TYPE {
push(node, "@type", Value::String(n.as_str().to_string()));
continue;
}
json!({ "@id": n.as_str() })
} else if let Term::BlankNode(b) = &triple.object {
json!({ "@id": format!("_:{}", b.as_str()) })
} else if let Term::Literal(l) = &triple.object {
match (l.language(), l.datatype().as_str()) {
(Some(language), _) => json!({ "@value": l.value(), "@language": language }),
(None, rdf::XSD_STRING) => json!({ "@value": l.value() }),
(None, datatype) => json!({ "@value": l.value(), "@type": datatype }),
}
} else {
continue;
};
push(node, predicate, value);
}
Value::Array(
order
.into_iter()
.filter_map(|s| nodes.remove(&s))
.map(Value::Object)
.collect(),
)
}
fn push(node: &mut Map<String, Value>, key: &str, value: Value) {
match node
.entry(key.to_string())
.or_insert_with(|| Value::Array(Vec::new()))
{
Value::Array(values) => values.push(value),
_ => unreachable!("every member but @id is an array"),
}
}
pub fn shape(compacted: Value) -> Value {
let Value::Object(mut doc) = compacted else {
return json!({ "@context": {}, "@graph": [compacted] });
};
let context = doc.remove("@context").unwrap_or_else(|| json!({}));
let nodes = match doc.remove("@graph") {
Some(Value::Array(nodes)) if doc.is_empty() => nodes,
Some(other) => {
doc.insert("@graph".into(), other);
vec![Value::Object(doc)]
}
None if doc.is_empty() => Vec::new(),
None => vec![Value::Object(doc)],
};
json!({ "@context": context, "@graph": nodes })
}
pub struct Egress {
kernel: Kernel,
}
impl Default for Egress {
fn default() -> Self {
Egress::new()
}
}
impl Egress {
pub fn new() -> Egress {
Egress {
kernel: Kernel::new(Arc::new(ikigai_jsonld::space())),
}
}
pub async fn compact(&self, context: &Value, triples: &[Triple]) -> Result<Value> {
if triples.is_empty() {
return Ok(json!({ "@context": context, "@graph": [] }));
}
let request = Request::new(Verb::Source, Iri::parse(PRUNE).expect("a constant IRI"))
.with_arg(
"content",
ArgRef::Inline(expanded(triples).to_string().into_bytes()),
)
.with_arg(
"context",
ArgRef::Inline(json!({ "@context": context }).to_string().into_bytes()),
);
let answer = self
.kernel
.issue(request, &Capability::scoped(Vec::<String>::new()))
.await?;
let compacted: Value = serde_json::from_slice(&answer.bytes)
.map_err(|e| Error::Endpoint(format!("the JSON-LD prune answered no JSON: {e}")))?;
Ok(shape(compacted))
}
pub fn check(&self, layer: &str, context: &Value) -> std::result::Result<(), String> {
let node = |p: &str| oxrdf::NamedNode::new(p).expect("a constant IRI");
let probe = format!("urn:layer:{layer}:receipt:probe");
let used = format!("urn:layer:{layer}:entry:probe");
let who = "urn:collab:participant:probe";
let machine = "urn:iki:host:probe:client:probe";
let time = "2026-01-01T00:00:00.000Z";
let s = node(&probe);
let typed = |v: &str, dt: &str| oxrdf::Literal::new_typed_literal(v, node(dt));
let triples = vec![
Triple::new(s.clone(), node(rdf::RDF_TYPE), node(rdf::PROV_ACTIVITY)),
Triple::new(s.clone(), node(rdf::PROV_ATTRIBUTED), node(who)),
Triple::new(
s.clone(),
node(rdf::DCT_CREATED),
typed(time, rdf::XSD_DATE_TIME),
),
Triple::new(
s.clone(),
node(&rdf::ik_seq()),
typed("1", rdf::XSD_INTEGER),
),
Triple::new(s.clone(), node(rdf::PROV_USED), node(&used)),
Triple::new(s.clone(), node(rdf::PROV_ASSOCIATED), node(machine)),
Triple::new(s, node(rdf::PROV_ENDED), typed(time, rdf::XSD_DATE_TIME)),
];
let doc = futures::executor::block_on(self.compact(context, &triples))
.map_err(|e| format!("its context cannot be a JSON-LD egress context: {e}"))?;
let empty = Map::new();
let got = doc["@graph"]
.as_array()
.and_then(|nodes| nodes.first())
.and_then(Value::as_object)
.unwrap_or(&empty);
let expected = [
("@id", probe.as_str()),
("@type", "Activity"),
("attributedTo", who),
("created", time),
("seq", "1"),
("used", used.as_str()),
("associatedWith", machine),
("endedAt", time),
];
for (term, value) in expected {
if got.get(term) != Some(&Value::String(value.into())) {
let name = if term == "@type" { "Activity" } else { term };
return Err(format!(
"its context changes the host's term `{name}` (a stamp every JSON-LD face \
carries as {}): it defines that term, or its IRI under another name, \
differently, or a prefix of it shortens the IRIs the host names; the \
probe came out as {}",
serde_json::to_string(&json!({ term: value })).unwrap_or_default(),
Value::Object(got.clone())
));
}
}
if got.len() != expected.len() {
return Err(format!(
"its context renames one of the host's stamps (attributedTo, created, seq, \
Activity, used, associatedWith, endedAt); the probe came out as {}",
Value::Object(got.clone())
));
}
Ok(())
}
}